import { AuthorAddress, Base32String, DocBase, DocInputBase, LocalIndex, Path, ShareAddress, Signature, Timestamp,} from "../util/doc-types.ts";import { EarthstarError, isErr, ValidationError } from "../util/errors.ts";import { FormatterGenerateOpts, IFormat } from "./format_types.ts";import { Crypto } from "../crypto/crypto.ts";
import { authorAddressChars, b32chars, pathChars, workspaceAddressChars,} from "../core-validators/characters.ts";import { checkInt, checkLiteral, checkObj, CheckObjOpts, checkString, isPlainObject,} from "../core-validators/checkers.ts";import { checkAuthorIsValid, checkShareIsValid,} from "../core-validators/addresses.ts";
import { Logger } from "../util/log.ts";import { AuthorKeypair } from "../crypto/crypto-types.ts";let logger = new Logger("validator es.5", "red");
export interface DocEs5 extends DocBase<"es.5"> { format: "es.5"; author: AuthorAddress; text: string; textHash: string; deleteAfter?: number; path: Path; signature: Signature; shareSignature: Signature; timestamp: Timestamp; share: ShareAddress; attachmentSize?: number; attachmentHash?: string;
_localIndex?: LocalIndex;}
export interface DocInputEs5 extends DocInputBase<"es.5"> { format: "es.5"; path: Path;
text?: string;
attachment?: Uint8Array | ReadableStream<Uint8Array>;
timestamp?: number; deleteAfter?: number | null;}
const FUTURE_CUTOFF_MINUTES = 10;const FUTURE_CUTOFF_MICROSECONDS = FUTURE_CUTOFF_MINUTES * 60 * 1000 * 1000;
const MIN_TIMESTAMP = 10000000000000; const MAX_TIMESTAMP = 9007199254740990;
const MAX_TEXT_LENGTH = 8000;
const HASH_STR_LEN = 53; const SIG_STR_LEN = 104;
const MIN_BLOB_SIZE = 0;const MAX_BLOB_SIZE = Number.MAX_SAFE_INTEGER;
const ES5_CORE_SCHEMA: CheckObjOpts = { objSchema: { format: checkLiteral("es.5"), author: checkString({ allowedChars: authorAddressChars }), text: checkString({ maxLen: MAX_TEXT_LENGTH }), textHash: checkString({ allowedChars: b32chars, len: HASH_STR_LEN }), deleteAfter: checkInt({ min: MIN_TIMESTAMP, max: MAX_TIMESTAMP, optional: true, }), path: checkString({ allowedChars: pathChars, minLen: 2, maxLen: 512 }), signature: checkString({ allowedChars: b32chars, len: SIG_STR_LEN }), shareSignature: checkString({ allowedChars: b32chars, len: SIG_STR_LEN }), timestamp: checkInt({ min: MIN_TIMESTAMP, max: MAX_TIMESTAMP }), share: checkString({ allowedChars: workspaceAddressChars }), attachmentSize: checkInt({ min: MIN_BLOB_SIZE, max: MAX_BLOB_SIZE, optional: true, }), attachmentHash: checkString({ allowedChars: b32chars, len: HASH_STR_LEN, optional: true, }), }, allowLiteralUndefined: false, allowExtraKeys: false,};
export type ConfigEs5 = { shareSecret: string | undefined;};
export const FormatEs5: IFormat< "es.5", DocInputEs5, DocEs5, ConfigEs5> = class { static id: "es.5" = "es.5";
static hashDocument( doc: DocEs5, ): Promise<Base32String | ValidationError> {
const docWithFakeSig = { ...doc, signature: "bthisisafakesignatureusedtofillintheobjectwhenvalidatingitforhashingaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", shareSignature: "bthisisafakesignatureusedtofillintheobjectwhenvalidatingitforhashingaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }; const err = this._checkBasicDocumentValidity(docWithFakeSig); if (isErr(err)) return Promise.resolve(err);
return Crypto.sha256base32( (doc.attachmentHash === undefined ? "" : `attachmentHash\t${doc.attachmentHash}\n`) + (doc.attachmentSize === undefined ? "" : `attachmentSize\t${doc.attachmentSize}\n`) + `author\t${doc.author}\n` + (doc.deleteAfter === undefined ? "" : `deleteAfter\t${doc.deleteAfter}\n`) + `format\t${doc.format}\n` + `path\t${doc.path}\n` + `textHash\t${doc.textHash}\n` + `timestamp\t${doc.timestamp}\n` + `share\t${doc.share}\n`, ); }
static async generateDocument( { input, keypair, share, timestamp, prevLatestDoc, config }: FormatterGenerateOpts< "es.5", DocInputEs5, DocEs5, ConfigEs5 >, ): Promise< | { doc: DocEs5; attachment?: ReadableStream<Uint8Array> | Uint8Array } | ValidationError > { if (input.text === undefined && prevLatestDoc?.text === undefined) { return new ValidationError( "Couldn't determine document text from given input or previous version's text.", ); }
const nextText = input.text !== undefined ? input.text : prevLatestDoc?.text as string;
const doc: DocEs5 = { ...prevLatestDoc, format: "es.5", author: keypair.address, text: nextText, textHash: await Crypto.sha256base32(nextText), path: input.path, timestamp, share, signature: "?", shareSignature: "?", };
if (input.deleteAfter) { doc["deleteAfter"] = input.deleteAfter; }
const signed = await this.signDocument(keypair, doc, config);
if (isErr(signed)) { return signed; }
if (input.attachment) { return { doc: signed, attachment: input.attachment }; }
return { doc: signed }; }
static async signDocument( credentials: AuthorKeypair, doc: DocEs5, config: ConfigEs5, ): Promise<DocEs5 | ValidationError> { const hash = await this.hashDocument(doc); if (isErr(hash)) return hash;
const sig = await Crypto.sign(credentials, hash);
if (config.shareSecret === undefined) { return new EarthstarError( `Tried to write a document to ${doc.share} without the secret.`, ); }
const shareSig = await Crypto.sign({ shareAddress: doc.share, secret: config.shareSecret, }, hash);
if (isErr(sig)) return sig; if (isErr(shareSig)) return shareSig;
return { ...doc, signature: sig, shareSignature: shareSig }; }
static async wipeDocument( credentials: AuthorKeypair, doc: DocEs5, config: ConfigEs5, ): Promise<DocEs5 | ValidationError> { if (doc.text.length === 0) { return doc; }
const cleanedResult = this.removeExtraFields(doc); if (isErr(cleanedResult)) return cleanedResult; const cleanedDoc = cleanedResult.doc;
if (cleanedDoc.attachmentHash) { const emptyDoc: DocEs5 = { ...cleanedDoc, text: "", textHash: await Crypto.sha256base32(""), signature: "?", shareSignature: "?", attachmentHash: await Crypto.sha256base32(""), attachmentSize: 0, };
return this.signDocument(credentials, emptyDoc, config); }
const emptyDoc: DocEs5 = { ...cleanedDoc, text: "", textHash: await Crypto.sha256base32(""), signature: "?", shareSignature: "?", };
return this.signDocument(credentials, emptyDoc, config); }
static removeExtraFields( doc: DocEs5, ): { doc: DocEs5; extras: Record<string, any> } | ValidationError { if (!isPlainObject(doc)) { return new ValidationError("doc is not a plain javascript object"); } const validKeys = new Set(Object.keys(ES5_CORE_SCHEMA.objSchema || {}));
const doc2: Record<string, any> = {}; const extras: Record<string, any> = {}; for (const [key, val] of Object.entries(doc)) { if (validKeys.has(key)) { doc2[key] = val; } else { if (!key.startsWith("_")) { return new ValidationError( "extra document fields must have names starting with an underscore", ); } extras[key] = val; } } return { doc: doc2 as DocEs5, extras, }; }
static async checkDocumentIsValid( doc: DocEs5, now?: number, ): Promise<true | ValidationError> { if (now === undefined) now = Date.now() * 1000;
const errBV = this._checkBasicDocumentValidity(doc); if (isErr(errBV)) return errBV;
const errT = this._checkTimestampIsOk( doc.timestamp, doc.deleteAfter ? doc.deleteAfter : null, now, ); if (isErr(errT)) return errT;
const errW = this._checkAuthorCanWriteToPath(doc.author, doc.path); if (isErr(errW)) return errW;
const errBFC = this._checkAttachmentFieldsConsistent(doc); if (isErr(errBFC)) return errBFC;
const errP = this._checkPathIsValid( doc.path, !!doc.attachmentHash, doc.deleteAfter, ); if (isErr(errP)) return errP;
const errAA = checkAuthorIsValid(doc.author); if (isErr(errAA)) return errAA;
const errWA = checkShareIsValid(doc.share); if (isErr(errWA)) return errWA;
const errS = await this._checkAuthorSignatureIsValid(doc); if (isErr(errS)) return errS;
const errSS = await this._checkShareSignatureIsValid(doc); if (isErr(errSS)) return errSS;
const errCH = await this._checkContentMatchesHash(doc.text, doc.textHash); if (isErr(errCH)) return errCH; return true; }
static _checkBasicDocumentValidity(doc: DocEs5): true | ValidationError { const err = checkObj(ES5_CORE_SCHEMA)(doc); if (err !== null) return new ValidationError(err);
return true; }
static _checkAttachmentFieldsConsistent( doc: DocEs5, ): true | ValidationError { if ( doc.text.length === 0 && doc.attachmentSize && doc.attachmentSize > 0 ) { return new ValidationError( "Documents with attachments must have text.", ); }
if ( doc.text.length > 0 && doc.attachmentSize && doc.attachmentSize === 0 ) { "Documents with deleted attachments must have no text."; }
if (doc.attachmentHash && doc.attachmentSize === undefined) { return new ValidationError( "Attachment size is undefined while attachment hash is defined", ); }
if (doc.attachmentSize && doc.attachmentHash === undefined) { return new ValidationError( "Attachment hash is undefined while attachment size is defined", ); }
return true; }
static _checkAuthorCanWriteToPath( author: AuthorAddress, path: Path, ): true | ValidationError {
if (path.indexOf("~") === -1) return true; if (path.indexOf("~" + author) !== -1) return true; return new ValidationError( `author ${author} can't write to path ${path}`, ); } static _checkTimestampIsOk( timestamp: number, deleteAfter: number | null, now: number, ): true | ValidationError {
if (timestamp > now + FUTURE_CUTOFF_MICROSECONDS) { return new ValidationError("timestamp too far in the future"); }
if (deleteAfter !== null) { if (now > deleteAfter) { return new ValidationError("ephemeral doc has expired"); } if (deleteAfter <= timestamp) { return new ValidationError( "ephemeral doc expired before it was created", ); } } return true; } static _checkPathIsValid( path: Path, hasAttachment: boolean, deleteAfter?: number, ): true | ValidationError {
if (!path.startsWith("/")) { return new ValidationError("invalid path: must start with /"); } if (path.endsWith("/")) { return new ValidationError("invalid path: must not end with /"); } if (path.startsWith("/@")) { return new ValidationError( 'invalid path: must not start with "/@"', ); } if (path.indexOf("//") !== -1) { return new ValidationError( "invalid path: must not contain two consecutive slashes", ); }
if (deleteAfter !== undefined) { if (path.indexOf("!") === -1 && deleteAfter !== null) { return new ValidationError( "when deleteAfter is set, path must contain '!'", ); } if (path.indexOf("!") !== -1 && deleteAfter === null) { return new ValidationError( "when deleteAfter is null, path must not contain '!'", ); } }
if (!pathHasFileExtension(path) && hasAttachment) { return new ValidationError( "when a attachment is provided, the path must end with a file extension", ); } if (pathHasFileExtension(path) && hasAttachment === false) { return new ValidationError( "when no attachment is provided, the path cannot end with a file extension", ); }
return true; } static async _checkAuthorSignatureIsValid( doc: DocEs5, ): Promise<true | ValidationError> { try { const hash = await this.hashDocument(doc); if (isErr(hash)) return hash; const verified = await Crypto.verify(doc.author, doc.signature, hash); if (verified !== true) { return new ValidationError("signature is invalid"); } return true; } catch { return new ValidationError( "signature is invalid (unexpected exception)", ); } }
static async _checkShareSignatureIsValid( doc: DocEs5, ): Promise<true | ValidationError> { try { const hash = await this.hashDocument(doc); if (isErr(hash)) return hash; const verified = await Crypto.verify( doc.share, doc.shareSignature, hash, ); if (verified !== true) { return new ValidationError("share signature is invalid"); } return true; } catch { return new ValidationError( "share signature is invalid (unexpected exception)", ); } }
static async _checkContentMatchesHash( content: string, contentHash: Base32String, ): Promise<true | ValidationError> {
if (await Crypto.sha256base32(content) !== contentHash) { return new ValidationError("content does not match contentHash"); } return true; }
static getAttachmentInfo( doc: DocEs5, ): { size: number; hash: string } | ValidationError { if (doc.attachmentHash && doc.attachmentSize === 0) { return new ValidationError( "This document has had its attachment wiped", ); }
if (!doc.attachmentHash || !doc.attachmentSize) { return new ValidationError("This document has no attachment."); }
return { size: doc.attachmentSize, hash: doc.attachmentHash, }; }
static updateAttachmentFields( credentials: AuthorKeypair, doc: DocEs5, size: number, hash: string, config: ConfigEs5, ): Promise<DocEs5 | ValidationError> { const updatedDoc = { ...doc, attachmentHash: hash, attachmentSize: size, };
return this.signDocument(credentials, updatedDoc, config); }
static authorFromCredentials(credentials: AuthorKeypair): AuthorAddress { return credentials.address; }};
const fileExtensionRegex = /^.*\.(\w+)$/;const endingWithKeypairAddrRegex = /^.*~@\w{4}\.\w{53}$/;
function pathHasFileExtension(path: string): boolean { if (path.indexOf(".") === -1) { return false; }
const matches = path.match(fileExtensionRegex);
if (matches === null) { return false; }
const extension = matches[1];
if (extension.length === 53 && path.match(endingWithKeypairAddrRegex)) { return false; }
return true;}