diff --git a/CHANGELOG.md b/CHANGELOG.md index 9509a7d..8402d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver - `testdata` se sincroniza con el tag `spec-v0.11` de `datekeys-go` (`ae33434`), y `SPEC_VERSION` pasa a `0.11`. Trae tres fixtures (`format3_signed`, con firma de clave propia; `format3_signed_cms`, con dos certificados sellados; `format3_sealed`, con firma y sello RFC 3161) y tres ficheros de vectores (`ed25519_strict.json`, `security_cms.json` y `locator.json`). El corpus de mutaciones pasa a 210 casos, con uno fuera del §64: una firma de `alg` 1 que no verifica, F2. `ibe-vectors.json` añade los tres fixtures y rehace los de `format3_signature_unsupported` y `format3_seal_unsupported`; `mutation-texts.json` se rehace con el `capsule.Open` de esa referencia. - **La firma de clave propia, `alg` 1** (§29.8, §29.9), portada: `ed25519strict.ts` comprueba las cuatro condiciones del perfil estricto con la aritmética de `@noble/curves` (que solo ofrece la ecuación con cofactor) y da la respuesta de Go en los 18 vectores de `ed25519_strict.json`; `author.ts` calcula `payload_commit`, `control_commit`, `head_digest`, `signers_digest`, `AUTHOR_MESSAGE` y su código, y los registros de `format3_signed` los confirman. `evaluateSecurity(área, contexto)` da F2, F3 y F4, `open` pasa el contexto del control y del head, y `OpenOptions.authorKeys` son las claves que la persona guardó. Los dos módulos usan solo `@noble/curves` y `@noble/hashes`, que ya iban en el bundle: ningún paquete nuevo, y entran en la lista de quien puede importar noble. - **La firma con certificados, `alg` 2, y el sello RFC 3161, `seal_type` 2** (§29.10, §29.11), portados sin dependencias nuevas: `der.ts` comprueba el DER byte a byte, `cms.ts` lee la firma CMS y el token con la tabla cerrada de algoritmos (RSA PKCS #1 y PSS en `BigInt`, síncrono, y ECDSA con la aritmética de `@noble/curves`) y `securitycms.ts` da F1, F2, F5 y F6 con los firmantes nombrados, y S1 a S5 con la autoridad del sello. `evaluateSecurity` los devuelve con su `detail`, `verdictLines` escribe las líneas de F6 y S4, y `open` pasa la hora de la ronda. Reproducen los 22 casos de `security_cms.json`, con los resultados de cada firmante, y los fixtures `format3_signed_cms` y `format3_sealed`. `testing/cmsbuild.ts` construye firmas y tokens de prueba con WebCrypto, y `cms.test.ts` porta los casos hostiles de Go. -- **Lo que esta biblioteca no hace todavía:** el localizador del §44.1 (la extensión `datekeys.capsule` de la `.dkk`, su sobre y su relleno) y la página de firma. `locator.json` solo se comprueba en su estructura. +- **El escritor de la v0.11** (§29.2, §24.1, §62.1 regla 13): `encryptFiles` escribe el área de seguridad de 32 KiB (`AREA_LEN` pasa de 512 a 32768) y acepta `publicNote`, la extensión `datekeys.note` de la cabecera pública (`note.ts`: `checkNote`, `newNote`, `publicNote`, con las reglas de texto del autor declarado). Otra área solo la escribe un generador de vectores, con `testVectors` y `areaLen`, y así los tests reproducen byte a byte los fixtures que escribió un escritor de la v0.10, de 512 bytes. `lengths.ts` y la página calculan L con el área nueva. Una nota cambiada después de escribir la cápsula falla en el paso 15. +- **Lo que esta biblioteca no hace todavía:** el escritor no firma ni pide sellos (`alg` 1, `alg` 2 y RFC 3161 solo se leen y verifican), la página no pide ni muestra la nota, y el localizador del §44.1 (la extensión `datekeys.capsule` de la `.dkk`, su sobre y su relleno) y la página de firma. `locator.json` solo se comprueba en su estructura. El formato 3 de la especificación 0.10, según `PLAN_formato3_ts.md` (en `../docs`). La versión que lo publique la decide el autor. diff --git a/README.md b/README.md index f093955..3ddbdc0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # datekeys-ts -Implementación en TypeScript del protocolo DateKeys (formato 3 de la v0.10; de la v0.11, la firma de clave propia, la firma con certificados y el sello de tiempo; el localizador está pendiente) y página de prueba en el navegador. Sustituye al prototipo, archivado en `../archive/prototype` (API Quicknet en Go, CLI tlock y cliente Svelte, commit `4d2b0a1`). +Implementación en TypeScript del protocolo DateKeys (formato 3 de la v0.10; de la v0.11, la firma de clave propia, la firma con certificados y el sello de tiempo, que lee y verifica, y el escritor con el área de 32 KiB y la nota pública; el localizador y firmar al escribir están pendientes) y página de prueba en el navegador. Sustituye al prototipo, archivado en `../archive/prototype` (API Quicknet en Go, CLI tlock y cliente Svelte, commit `4d2b0a1`). La implementación de referencia es la librería Go `g.activething.com/go/DateKeys`, en `../datekeys-go`. Los planes y el estado del trabajo están en `../docs`, el repositorio privado de documentación del proyecto. diff --git a/src/lib/dkc/body.ts b/src/lib/dkc/body.ts index bdf2e9d..a9e084a 100644 --- a/src/lib/dkc/body.ts +++ b/src/lib/dkc/body.ts @@ -20,9 +20,12 @@ export const AREA_UNIT = 512; export const MAX_AREA_LEN = 128 * AREA_UNIT; /** * The size of the security area that writers of this version write, always, - * whatever the capsule holds (spec §29.2, §62.1 rule 13). + * whatever the capsule holds (spec v0.11, §29.2, §62.1 rule 13): 32 KiB. A + * writer of v0.10 wrote AREA_UNIT, 512 bytes, which a reader still accepts. */ -export const AREA_LEN = 512; +export const AREA_LEN = 64 * AREA_UNIT; +/** The area of a capsule whose creator expressly asked for a larger one because the signatures do not fit in AREA_LEN: 64 KiB. */ +export const LARGE_AREA_LEN = MAX_AREA_LEN; /** The maximum of HEAD_LEN, 16 MiB. */ export const MAX_HEAD_LEN = 16 << 20; diff --git a/src/lib/dkc/encrypt.files.test.ts b/src/lib/dkc/encrypt.files.test.ts index a515be3..885afcf 100644 --- a/src/lib/dkc/encrypt.files.test.ts +++ b/src/lib/dkc/encrypt.files.test.ts @@ -17,6 +17,8 @@ import { type Extension, type ExtensionRegistry, ExtensionSet } from './extensio import { FORMAT_3 } from './framing.ts'; import { decodeHead, encodeHead, type Head, type HeadFile } from './head.ts'; import { TIME_AND_KEY, TIME_ONLY } from './header.ts'; +import { AREA_LEN, AREA_UNIT } from './body.ts'; +import { publicNote } from './note.ts'; import { bodyLength, capsuleLength, headComment, headLength, type HeadShape, mtimeSeconds } from './lengths.ts'; import { accessIdentity, open, type OpenOptions, payloadIdentity, timeIdentity } from './open.ts'; import { BLOQUE256, MAX_PAYLOAD_LENGTH, type Padding, REFORZADO } from './padding.ts'; @@ -151,6 +153,9 @@ describe('encryptFiles, the fixtures of format 3 of the Go reference', () => { policy: keyed ? TIME_AND_KEY : TIME_ONLY, newPortableKey: dkk !== undefined, padding: fx.padding, + // The fixtures of format 3 were written by a writer of v0.10, with the area of 512 bytes, which only a generator of test vectors writes now. + testVectors: true, + areaLen: AREA_UNIT, ...(fx.comment === undefined ? {} : { comment: fx.comment }), ...(fx.declared_author === undefined ? {} : { author: fx.declared_author }), }), @@ -185,6 +190,62 @@ describe('encryptFiles, the fixtures of format 3 of the Go reference', () => { }); }); +// Spec v0.11 §29.2, §62.1 rule 13: a writer writes the security area of 32 KiB, whatever the capsule holds, and only a generator of test +// vectors writes another; §24.1: the public note is an extension of PUBLIC_HEADER that header_binding ties to the control. +describe('encryptFiles, the writer of spec v0.11', () => { + it('writes the area of 32 KiB, and the capsule opens with the empty security area', async () => { + const res = await encryptFiles([source('nota.txt', 'Hola.\n', 1_790_000_000_000)], options()); + const { head } = await openFiles(res.dkc!, R1000); + expect(head.files).toHaveLength(1); + const sink = new MemorySink(); + const r = await open(res.dkc!, opening(R1000, { sink })); + expect([r.error, r.areaLen, r.verdicts?.signature, r.verdicts?.seal]).toEqual([undefined, AREA_LEN, 'F0', 'S0']); + expect(AREA_LEN).toBe(32768); + expect(res.length).toBeGreaterThan(AREA_LEN); + // The area of the writers of v0.10 is for a generator of test vectors, and only in multiples of its unit. + const old = await encryptFiles([source('a', 'x')], options({ testVectors: true, areaLen: AREA_UNIT })); + const oldOpened = await open(old.dkc!, opening(R1000, { sink: new MemorySink() })); + expect([oldOpened.error, oldOpened.areaLen]).toEqual([undefined, AREA_UNIT]); + for (const [name, extra] of [ + ['512 without testVectors', { areaLen: AREA_UNIT }], + ['an area that is not a multiple of 512', { testVectors: true, areaLen: 1000 }], + ['an area above 64 KiB', { testVectors: true, areaLen: 66_048 }], + ] as const) { + expect((await failure(encryptFiles([source('a', 'x')], options({ ...extra })))).message, name).toMatch(/the security area is 32768 bytes/); + } + }); + + it('writes the public note in PUBLIC_HEADER, which anyone reads and header_binding ties to the control', async () => { + const res = await encryptFiles([source('a', 'x')], options({ publicNote: 'Cartas del viaje a Lisboa' })); + const insp = await open(res.dkc!, opening(R1000, { sink: new MemorySink() })); + expect(insp.error).toBeUndefined(); + expect(publicNote(insp.inspection.header!.noncritical)).toBe('Cartas del viaje a Lisboa'); + // The same bytes with another letter in the note still inspect, and fail at step 15. + const changed = res.dkc!.slice(); + const at = indexOfText(changed, 'Lisboa'); + changed.set(new TextEncoder().encode('Lisbon'), at); + const bad = await open(changed, opening(R1000, { sink: new MemorySink() })); + expect(bad.error?.code).toBe('ERR_HEADER_BINDING'); + // A note that breaks the rules of text is refused before anything is written. + for (const text of ['a\tb', 'a\nb', ' a', 'a ', 'x'.repeat(1025)]) { + expect((await failure(encryptFiles([source('a', 'x')], options({ publicNote: text })))).message, JSON.stringify(text.slice(0, 8))).toMatch(/public note/); + } + // With no note, or an empty one, there is none. + const none = await encryptFiles([source('a', 'x')], options({ publicNote: '' })); + expect(none.dkc).toBeDefined(); + }); +}); + +function indexOfText(hay: Uint8Array, s: string): number { + const needle = new TextEncoder().encode(s); + for (let i = 0; i + needle.length <= hay.length; i++) { + let ok = true; + for (let j = 0; j < needle.length && ok; j++) ok = hay[i + j] === needle[j]; + if (ok) return i; + } + throw new Error('not found'); +} + describe('encryptFiles', () => { it('writes the files in the byte order of their paths, whatever the order given, and open gives them back', async () => { // UTF-16 puts U+10000 before U+FFFD; UTF-8 after. @@ -379,7 +440,7 @@ describe('encryptFiles, invalid inputs', () => { ['a negative size', [{ path: 'a', size: -1, open: a.open }], {}, 'capsule: file "a": negative size -1'], ['no open', [{ path: 'a', size: 1 } as FileSource], {}, 'capsule: file "a": Source.Open is nil'], ['files above L_MAX', [{ path: 'a', size: 2 ** 52, open: a.open }, { path: 'b', size: 2 ** 52, open: a.open }], {}, 'capsule: the files add up to more than 8936830510563328 bytes, the maximum of L'], - ['one file of L_MAX bytes', [{ path: 'a', size: MAX_PAYLOAD_LENGTH, open: a.open }], {}, 'capsule: content of 8936830510563968 bytes exceeds L_MAX = 8936830510563328'], + ['one file of L_MAX bytes', [{ path: 'a', size: MAX_PAYLOAD_LENGTH, open: a.open }], {}, 'capsule: content of 8936830510596224 bytes exceeds L_MAX = 8936830510563328'], ['path ..', [source('..', 'x')], {}, 'capsule: path "..": R3: segment 1: the segment is two dots'], ['path a/', [source('a/', 'x')], {}, 'capsule: path "a/": R2: segment 2 is empty'], ['path CON.txt', [source('CON.txt', 'x')], {}, 'capsule: path "CON.txt": R6: segment 1: CON is a reserved device name'], diff --git a/src/lib/dkc/lengths.ts b/src/lib/dkc/lengths.ts index 1293f46..9d28fb1 100644 --- a/src/lib/dkc/lengths.ts +++ b/src/lib/dkc/lengths.ts @@ -6,6 +6,7 @@ // with its first load. import { ACCESS_SLOTS } from './age.ts'; +import { AREA_LEN } from './body.ts'; import { compareBytes, utf8Bytes, utf8Length } from './bytes.ts'; import { encodeControl } from './control.ts'; import type { Extension } from './extension.ts'; @@ -87,8 +88,8 @@ export function capsuleLength(input: CapsuleLengthInput): number { // head.ts, which this module does not import: its rules of the paths bring // the Unicode tables). const LAST_MTIME = 253402300799; -// The frame of BODY and the security area that writers write (body.ts). -const FRAME_AND_AREA = 12 + 512; +// The frame of BODY and the security area that writers write, of 32 KiB (body.ts, spec v0.11 §29.2). +const FRAME_AND_AREA = 12 + AREA_LEN; /** * The comment as the writer of format 3 stores it: CR LF, and any lone CR, @@ -201,7 +202,7 @@ export function headLengthOf(files: MeasuredFiles, h: Omit): /** * L of a format 3 capsule: the length of its BODY, the frame, the security - * area of 512 bytes, the head and the files (spec §29.2). With it, + * area of 32 KiB, the head and the files (spec §29.2). With it, * capsuleLength gives the size of the .dkc, since the control of format 3 * has the length of the control of format 2. */ diff --git a/src/lib/dkc/note.test.ts b/src/lib/dkc/note.test.ts new file mode 100644 index 0000000..898a622 --- /dev/null +++ b/src/lib/dkc/note.test.ts @@ -0,0 +1,43 @@ +// Tests of note.ts, the public note of spec v0.11 §24.1: the same cases as the +// tests of extension.CheckNote, NewNote and Note of the Go reference. + +import { describe, expect, it } from 'vitest'; +import { checkNote, MAX_NOTE_LEN, newNote, NOTE_ID, publicNote } from './note.ts'; + +describe('checkNote', () => { + it('accepts a line of 1 to 1024 bytes that meets the rules of the declared author', () => { + expect(() => checkNote('Cartas del viaje a Lisboa')).not.toThrow(); + expect(() => checkNote('x'.repeat(MAX_NOTE_LEN))).not.toThrow(); + expect(() => checkNote('Ñandú, 日本')).not.toThrow(); + }); + + it.each([ + ['empty', ''], + ['a tab', 'a\tb'], + ['a line feed', 'a\nb'], + ['a space at the start', ' a'], + ['a space at the end', 'a '], + ['too long', 'x'.repeat(MAX_NOTE_LEN + 1)], + ['a right-to-left override', 'a\u202eb'], + ['a zero-width space', 'a\u200bb'], + ])('refuses %s', (_name, text) => { + expect(() => checkNote(text)).toThrow(/public note/); + }); +}); + +describe('newNote and publicNote', () => { + it('makes the extension, and reads it back among the noncritical ones', () => { + const e = newNote('Hola'); + expect([e.id, e.version, new TextDecoder().decode(e.data)]).toEqual([NOTE_ID, 1, 'Hola']); + expect(publicNote([{ id: 'x.other', version: 1, data: undefined }, e])).toBe('Hola'); + expect(publicNote([])).toBeUndefined(); + expect(() => newNote('a\nb')).toThrow(/public note/); + }); + + it('shows nothing for a note that is unusable: another version, no data, bad text or bad UTF-8', () => { + expect(publicNote([{ id: NOTE_ID, version: 2, data: new TextEncoder().encode('v2') }])).toBeUndefined(); + expect(publicNote([{ id: NOTE_ID, version: 1, data: undefined }])).toBeUndefined(); + expect(publicNote([{ id: NOTE_ID, version: 1, data: new TextEncoder().encode('a\nb') }])).toBeUndefined(); + expect(publicNote([{ id: NOTE_ID, version: 1, data: Uint8Array.of(0xff, 0xfe) }])).toBeUndefined(); + }); +}); diff --git a/src/lib/dkc/note.ts b/src/lib/dkc/note.ts new file mode 100644 index 0000000..08eb104 --- /dev/null +++ b/src/lib/dkc/note.ts @@ -0,0 +1,51 @@ +// The public note of a capsule (spec v0.11, §24.1): the extension +// datekeys.note, version 1, in the noncritical array of PUBLIC_HEADER. Its +// data is the text in UTF-8, from 1 to 1024 bytes, with no CBOR around it, and +// it meets the rules of text of the declared author of §29.6: one line, no +// tabs and no spaces at the ends. It is public: whoever holds the .dkc reads it +// before the date, and nobody can check who wrote it. As Go's extension.CheckNote, +// NewNote and Note. + +import { utf8Length } from './bytes.ts'; +import { DateKeysError } from './errors.ts'; +import type { Extension } from './extension.ts'; +import { checkAuthor } from './pathrule.ts'; + +/** The extension_id of the public note, and the longest note in bytes. */ +export const NOTE_ID = 'datekeys.note'; +export const MAX_NOTE_LEN = 1024; + +/** The text of a public note is from 1 to 1024 bytes of valid UTF-8 that meets the rules of the declared author (spec §24.1). Throws a DateKeysError when it is not. */ +export function checkNote(text: string): void { + const n = utf8Length(text); + if (n < 1 || n > MAX_NOTE_LEN) throw new DateKeysError('ERR_EXTENSION_DATA_INVALID', `a public note of ${n} bytes, not 1 to ${MAX_NOTE_LEN}`); + try { + checkAuthor(text); + } catch (err) { + throw new DateKeysError('ERR_EXTENSION_DATA_INVALID', `a public note that breaks the rules of text: ${(err as Error).message}`); + } +} + +/** The extension of a public note for `text`, which must pass checkNote. */ +export function newNote(text: string): Extension { + checkNote(text); + return { id: NOTE_ID, version: 1, data: new TextEncoder().encode(text) }; +} + +/** + * The text of the public note among the noncritical extensions of a + * PUBLIC_HEADER, and undefined when there is none that is usable: a note whose + * data breaks the rules of §24.1 is unusable, and shows nothing (spec §54). + */ +export function publicNote(noncritical: readonly Extension[]): string | undefined { + const e = noncritical.find((x) => x.id === NOTE_ID && x.version === 1); + if (e?.data === undefined) return undefined; + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(e.data); + checkNote(text); + } catch { + return undefined; + } + return text; +} diff --git a/src/lib/dkc/writer.ts b/src/lib/dkc/writer.ts index 8fb5bca..35c8528 100644 --- a/src/lib/dkc/writer.ts +++ b/src/lib/dkc/writer.ts @@ -18,7 +18,8 @@ import { Decrypter, Encrypter, type ReadableStreamWithSize } from 'age-encryptio import { ACCESS_TYPE_X25519, type AccessKey } from './accesskey.ts'; import { ACCESS_SLOTS, ageStanzas, checkAccessStanzas, checkTimeStanzas, parseAgeHeader } from './age.ts'; import { decryptAll } from './agefile.ts'; -import { AREA_LEN, BODY_FRAME_SIZE, bodyFrameBytes, contentLength, MAX_HEAD_LEN, parseBodyFrame } from './body.ts'; +import { AREA_LEN, AREA_UNIT, BODY_FRAME_SIZE, bodyFrameBytes, contentLength, MAX_AREA_LEN, MAX_HEAD_LEN, parseBodyFrame } from './body.ts'; +import { newNote } from './note.ts'; import { compareBytes, copyBytes, equalBytes, goQuote, toHex, utf8Bytes, utf8Length } from './bytes.ts'; import { decodeControl, encodeControl } from './control.ts'; import { compareInstants, type DateKey, formatRFC3339Nano, type Instant, isInstant, resolveDateKey, roundTime } from './datekey.ts'; @@ -110,11 +111,21 @@ export interface EncryptOptions { readonly headCritical?: readonly Extension[]; readonly headNoncritical?: readonly Extension[]; /** - * Lets encrypt write format 2, which only a generator of test vectors may - * write (spec §62.1 rule 1, §70). encryptFiles, which writes format 3, - * ignores it. + * The public note of the capsule (spec v0.11, §24.1): the extension + * datekeys.note in PUBLIC_HEADER, a line of text that anyone who holds the + * .dkc reads before the date and that nobody can check. It must pass + * checkNote. Absent or '' for none. The writer SHOULD warn the person that + * it is public and that, with the date, it can identify someone. + */ + readonly publicNote?: string; + /** + * Lets encrypt write format 2, and encryptFiles another area than the 32 KiB + * of AREA_LEN, which only a generator of test vectors may write (spec §62.1 + * rules 1 and 13, §70). */ readonly testVectors?: boolean; + /** The size of the security area, with testVectors only: 512 reproduces the fixtures of the writers of v0.10. */ + readonly areaLen?: number; /** The clock. Required, and called once. */ readonly now: () => Instant; /** @@ -204,6 +215,11 @@ export async function writeFiles(files: readonly FileSource[], opts: EncryptOpti if (typeof opts !== 'object' || opts === null) throw new TypeError('encrypt: options are required'); if (opts.length !== undefined) throw new Error('capsule: EncryptOptions.Length is for Encrypt: EncryptFiles computes L from the files'); checkOptions(opts); + // Spec §62.1 rule 13: the area is 32 KiB, and only a generator of test vectors writes another. + const areaLen = opts.areaLen ?? AREA_LEN; + if (areaLen !== AREA_LEN && (opts.testVectors !== true || !Number.isInteger(areaLen) || areaLen < AREA_UNIT || areaLen > MAX_AREA_LEN || areaLen % AREA_UNIT !== 0)) { + throw new Error(`capsule: the security area is ${AREA_LEN} bytes: another size is for a generator of test vectors, a multiple of ${AREA_UNIT} up to ${MAX_AREA_LEN} (spec §62.1 rule 13)`); + } const sources = copySources(files); const comment = checkText(opts.comment, 'comment'); const author = checkText(opts.author, 'author'); @@ -217,7 +233,7 @@ export async function writeFiles(files: readonly FileSource[], opts: EncryptOpti if (measured.length > MAX_HEAD_LEN) throw new Error(`capsule: the head is ${measured.length} bytes, more than ${MAX_HEAD_LEN}: fewer files or shorter paths`); selfCheckHead(measured); const content = head.files.at(-1)?.end ?? 0; - const length = BODY_FRAME_SIZE + AREA_LEN + measured.length + content; + const length = BODY_FRAME_SIZE + areaLen + measured.length + content; checkLength(length, s.code); // In memory, the limit is known before reading anything. if (opts.output === undefined) { @@ -253,11 +269,11 @@ export async function writeFiles(files: readonly FileSource[], opts: EncryptOpti if (v.signature !== 'F0' || v.seal !== 'S0') { throw new Error(`capsule: self-check: the reader finds the verdicts ${v.signature} and ${v.seal} in this security area`); } - const frame = bodyFrameBytes({ areaLen: AREA_LEN, securityLen: security.length, headLen: headBytes.length }); + const frame = bodyFrameBytes({ areaLen, securityLen: security.length, headLen: headBytes.length }); selfCheck('capsule: self-check', () => checkHeadEnd(final, contentLength(parseBodyFrame(frame, length), length))); // Step 16: BODY, and the second reading of each file. - const area = new Uint8Array(AREA_LEN); + const area = new Uint8Array(areaLen); area.set(security); const res = await seal(s, FORMAT_3, length, draws, state, () => bodyContent([frame, area, headBytes], final.files, sources, order)); return { ...res, head: final }; @@ -455,6 +471,7 @@ async function newSealer(opts: EncryptOptions, length: number): Promise const unlockAt: Instant = { seconds: opts.unlockAt.seconds, nanos: opts.unlockAt.nanos }; const critical = copyExtensions(opts.critical); const noncritical = copyExtensions(opts.noncritical); + if (opts.publicNote !== undefined && opts.publicNote !== '') noncritical.push(newNote(opts.publicNote)); const controlCritical = copyExtensions(opts.controlCritical); const controlNoncritical = copyExtensions(opts.controlNoncritical); const { policy, output, progress } = opts; diff --git a/src/lib/inspector/create-input.test.ts b/src/lib/inspector/create-input.test.ts index 7676b6f..1641403 100644 --- a/src/lib/inspector/create-input.test.ts +++ b/src/lib/inspector/create-input.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { parseDateKey } from '../dkc/datekey.ts'; import { TIME_AND_KEY, TIME_ONLY } from '../dkc/header.ts'; -import { MAX_HEAD_LEN } from '../dkc/body.ts'; +import { AREA_LEN, MAX_HEAD_LEN } from '../dkc/body.ts'; import { bodyLength, capsuleLength, headLength } from '../dkc/lengths.ts'; import { MAX_PAYLOAD_LENGTH, paddedLength, REFORZADO } from '../dkc/padding.ts'; import { formatX25519Recipient } from '../dkc/recipient.ts'; @@ -51,9 +51,9 @@ describe('planCapsule', () => { ]); expect(parseDateKey(p.dk1)).toEqual(p.dateKey); expect([p.policy, p.recipients, p.portable, p.ambiguous]).toEqual([TIME_ONLY, [], false, false]); - // Format 3: BODY holds the frame, the area of 512 bytes, the head of 111 + // Format 3: BODY holds the frame, the area of 32 KiB, the head of 111 // bytes with the path and no mtime, and the file. - const L = 12 + 512 + 111 + 1000; + const L = 12 + AREA_LEN + 111 + 1000; expect([p.files, p.comment, p.author, p.length, p.paddedLength]).toEqual([[{ path: 'nota.txt', size: 1000 }], '', '', L, paddedLength(L, REFORZADO)]); expect(p.length).toBe(bodyLength({ files: p.files })); expect(p.size).toBe(capsuleLength({ profileId: 'datekeys:quicknet:v1', round: 1000, policy: TIME_ONLY, length: L })); @@ -74,7 +74,7 @@ describe('planCapsule', () => { expect([p.files, p.comment, p.author]).toEqual([files, 'Feliz\ncumpleaños\n', 'Ana']); const shape = { files, comment: 'Feliz\r\ncumpleaños\r', author: 'Ana' }; expect(p.length).toBe(bodyLength(shape)); - expect(p.length).toBe(12 + 512 + headLength(shape) + 70_037); + expect(p.length).toBe(12 + AREA_LEN + headLength(shape) + 70_037); // A comment alone, with no files, is a capsule too (decision 9). const alone = planCapsule(input({ files: chooseFiles([]), comment: 'Solo esto.' }), GENESIS_MS); expect(alone.ok && [alone.plan.files, alone.plan.length]).toEqual([[], bodyLength({ files: [], comment: 'Solo esto.' })]); @@ -192,7 +192,7 @@ describe('planCapsule', () => { [x25519PublicKey(sampleIdentity(1)), x25519PublicKey(sampleIdentity(2))], true, ]); - expect(ok.ok && ok.plan.size).toBe(capsuleLength({ profileId: 'datekeys:quicknet:v1', round: 1000, policy: TIME_AND_KEY, length: 12 + 512 + 111 + 1000 })); + expect(ok.ok && ok.plan.size).toBe(capsuleLength({ profileId: 'datekeys:quicknet:v1', round: 1000, policy: TIME_AND_KEY, length: 12 + AREA_LEN + 111 + 1000 })); expect(keyed('', true).ok).toBe(true); expect(keyed(lines(15), true).ok).toBe(true); expect(keyed(lines(16), false).ok).toBe(true);