Compare commits

...

2 Commits
main ... v0.10

Author SHA1 Message Date
dev 709569aab9 /create: sign with a certificate, tried with test certificates
5 hours ago
dev f37da12504 /create: a reminder in the calendar, and 0.6.0-dev
5 hours ago

@ -2,6 +2,11 @@
Cambios notables de la librería TypeScript y de la página. El proyecto usa versionado semántico; mientras sea 0.x, no hay promesa de estabilidad. La sección «Versiones» del [README](README.md) explica qué cubre cada número.
## 0.6.0 — sin publicar
- **La firma con certificado en `/create`** (§29.10, §62.1 reglas 19 a 21), probada con certificados de prueba. «¿La firmas?» toma el certificado de quien firma de una firma de prueba de AutoFirma o del fichero del certificado, y cierra `SIGNERS` (regla 20). Al crear la cápsula, el sobre muestra el código del mensaje, lo da como `mensaje-para-firmar.txt` y espera la firma, hecha con AutoFirma en CAdES y con sello de tiempo. `create-sign.ts` la comprueba antes que el writer: le quita el contenido a una firma implícita (`detachSignature`, nuevo en `cms.ts`, regla 21) y dice en español qué falla; un sello sin precisión pasa con un aviso (regla 19). `securitycms.ts` exporta `signerLine` y `holderText`, y `createCapsule` acepta `cmsSigner` y devuelve los veredictos y sus líneas, que el resultado muestra. El sello lo tiene que poner AutoFirma: una página no puede pedirlo a una autoridad pública (CORS; FreeTSA responde 403 a la petición previa). `scripts/test-signature.mjs` firma como AutoFirma con certificados de prueba, para probarlo sin uno real. Comprobado en la página construida: sin sello, de otro certificado, correcta (F6) y sin precisión, con su aviso; y «Cancelar» durante la espera.
- **El recordatorio en el calendario** (§62.1, regla 26, MAY), como `encrypt -reminder` de `datekeys-go` (`7e6a03d`): tras crear la cápsula, «Descargar el recordatorio» da `<cápsula>.recordatorio.ics`, un evento de iCalendar (RFC 5545) a la hora de la ronda, con una alarma y lo que hará falta para abrirla. `reminder.ts` escribe los mismos bytes que Go, y su prueba los compara con un texto calculado aparte, a partir del RFC: CRLF, líneas plegadas a 75 octetos sin cortar un carácter y el nombre escapado. El UID es un UUID al azar, que no nombra la cápsula; la página dice que un calendario que se sincroniza con un servidor sabrá el nombre y la fecha.
## 0.5.0 — 7 de octubre de 2026
La especificación 0.16, el tag `spec-v0.16` de `datekeys-go`: la revisión de Astra de la v0.15, con el sello sin `accuracy` y el JSON de drand estricto; y las palabras al azar de la llave de palabras, de una lista inglesa y una española, con el ordenador o con dados, y lo que dice el SDK oficial al sellar. El autor la cerró el 7 de octubre de 2026, con el tag `v0.5.0`.

@ -26,6 +26,8 @@ Hay tres números de versión, cada uno con su significado, como en la referenci
`version.test.ts` comprueba que `VERSION` coincide con `package.json` y con su lockfile, y que `SPEC_VERSION` es la versión que nombran los vectores y fixtures compartidos; `vectors.test.ts` exige esa versión a cada fichero. El pie de la página muestra las dos.
En desarrollo está la `0.6.0` (`0.6.0-dev` en `package.json`): el recordatorio en el calendario de `/create` (§62.1, regla 26) y la firma con certificado en `/create` (§29.10), probada con certificados de prueba. Lo que cambia está en el [CHANGELOG](CHANGELOG.md).
La versión actual es la `0.5.0`, del 7 de octubre de 2026, con el tag `v0.5.0`. Cubre:
- la especificación 0.16 (el tag `spec-v0.16` de `datekeys-go`): lee los formatos de cápsula 1 a 3 y escribe el 3, y el 2 solo como generador de vectores (§62.1, regla 1);
- un sello sin `accuracy` no prueba nada antes de la fecha de apertura: los veredictos dicen por qué, y el escritor devuelve los del área que escribe (§29.7, §29.11, §62.1 regla 19);
@ -193,6 +195,7 @@ Medido en Chromium (el navegador de la app de escritorio) sobre la compilación
- **La `.dkk`** (152 bytes sin extensiones) vive solo en la memoria de la página: nunca en OPFS, y nunca se muestra como `AGE-SECRET-KEY-1…`. Sus bytes se borran al pulsar «Olvidar la clave», al crear otra cápsula y al salir, y con ellos las URL de sus descargas. Junto a ella va el aviso de §7.4. Si la cápsula solo se abre con su `.dkk` y no se ha descargado, la página pide confirmación antes de borrarla, al olvidarla o al crear otra, y avisa antes de salir: el navegador pregunta al cerrar o recargar, y la página, al seguir un enlace del sitio. Salir de la página también cancela una escritura en curso.
- **Las descargas** van por separado, con nombres que se pueden editar. Por defecto son `capsula-<apertura en UTC>.dkc` y `.dkk`, que no dicen cuándo se creó la cápsula. La URL `blob:` de cada descarga se revoca un minuto después del clic.
- **El resultado** muestra el `capsule_id`, la política, el contenido, el tamaño y el relleno, y el informe de los pasos 1 a 8 del `.dkc` escrito, con el componente del inspector.
- **La firma con certificado** (§29.10, §62.1 reglas 19 a 21), opcional, en «¿La firmas?». Primero, el certificado de quien firma, de una firma de prueba de AutoFirma o del fichero del certificado (`create-sign.ts`, bajo demanda): cierra `SIGNERS` antes de la firma (regla 20), y uno caducado o aún no válido no se admite. Al crear la cápsula, el writer la prepara y pide la firma de `AUTHOR_MESSAGE` con su gancho `cmsSigner`; la página muestra en el sobre el código del mensaje, lo da como `mensaje-para-firmar.txt` y espera la firma, hecha con AutoFirma en CAdES y con sello de tiempo. Antes de dársela al writer, la comprueba: le quita el contenido si es implícita (`detachSignature` de `cms.ts`, regla 21), y dice en español si es de otro fichero o de otro certificado, si no verifica, si no lleva sello, si el sello no vale o si el certificado no era válido al sellarse; un sello sin precisión pasa con un aviso, y la persona decide (regla 19). El writer lo comprueba todo otra vez. «Cancelar» detiene la espera. El resultado muestra las líneas de la firma, como las verá quien la abra. Una página no puede pedir el sello a una autoridad por su cuenta (CORS), así que lo tiene que poner AutoFirma. Se probó con los certificados de prueba de `scripts/test-signature.mjs`, sin AutoFirma ni certificados reales.
- **Lo que pide el SDK oficial al sellar** (§7.6, §62.1 reglas 26 y 27, §71). Con una fecha a más de un año y la política «solo fecha», la página recomienda la llave, y la opción «solo fecha» lo recuerda para algo valioso. Tras crear la cápsula, «Para abrirla más adelante» dice qué hará falta: la cápsula, una de sus llaves si la lleva, y la firma de drand de su ronda, que tendrá que guardar un archivo de firmas o un servicio de caché si drand ya no la sirve; y ofrece las instrucciones para abrirla sin DateKeys (`annex.ts`), el anexo del §79 que `datekeys-go` copia en `annex/`, con el nombre de la cápsula y `.recuperacion.txt`. `planCapsule` no escribe con un perfil que no esté activo en el registro de §71, y `/inspect` avisa si el de una cápsula está comprometido.
Comprobado el 30-09-2026 en Chromium, con el formato 3:
@ -229,9 +232,11 @@ Rendimiento, informativo, en ese navegador con la ventana en segundo plano: una
| `src/lib/inspector/files.ts` | Lo que la página muestra de los ficheros de una cápsula de formato 3: cada ruta como texto, sus avisos por la clave de R7 con los textos de la CLI de la referencia, y el nombre y el orden de las descargas. Se carga bajo demanda con la apertura, porque trae las tablas de las rutas |
| `src/lib/inspector/zipsink.ts` | El sumidero de la página para el formato 3 (apartado 5 del diseño): un fichero de un segmento va tal cual al fichero temporal, y los demás casos a un ZIP en ese fichero, con cada cabecera en su posición, el CRC-32 parcheado al cerrar cada fichero y el directorio central al hacer commit; cada fichero queda como un tramo continuo. `zipOf` hace el mismo ZIP en memoria, como un `Blob` de sus partes |
| `src/lib/inspector/localtime.ts` | Una fecha y una hora locales de una zona como instante UTC, con `Intl`: las horas que no existen y las repetidas; la lista de zonas |
| `src/lib/inspector/create-sign.ts` | La firma con certificado de `/create`, bajo demanda: `readSignerCertificate`, el certificado de quien firma desde una firma de prueba (implícita o separada) o desde el certificado, en DER, PEM o Base64; y `checkSignature`, la firma que trae la persona sobre `AUTHOR_MESSAGE`, comprobada como el writer, con el problema o el aviso en español |
| `src/lib/inspector/create-files.ts` | La lista de ficheros de `/create`, sin tablas: lo elegido y lo soltado, carpetas recorridas incluidas, los ficheros de un sistema fuera por defecto como en `collect.go`, las rutas editadas y lo que la cápsula guarda |
| `src/lib/inspector/create-check.ts` | Las reglas de las rutas y de los textos (§29.5, §29.6) como las explica `/create`: todos los problemas de todas las rutas, en español, con los de `pathrule.ts`. Se carga bajo demanda, con las tablas |
| `src/lib/inspector/create-input.ts` | El formulario de `/create`, sin DOM, reloj ni writer: `planCapsule` comprueba los campos en su orden y da lo que se muestra antes de cifrar, con los ficheros medidos una vez (`chooseFiles`); los destinatarios y los nombres de los ficheros |
| `src/lib/inspector/reminder.ts` | El recordatorio que `/create` ofrece junto a la cápsula (§62.1, regla 26, MAY): un evento de iCalendar (RFC 5545) a la hora de la ronda, con una alarma y lo que hará falta para abrirla, byte a byte como `encrypt -reminder` de Go; el nombre del fichero, el de la cápsula con `.recordatorio.ics` |
| `src/lib/inspector/annex.ts` | El anexo de recuperación que `/create` ofrece junto a la cápsula: `annex/recovery.md`, que Vite publica con un nombre con hash, y el nombre de su fichero, el de la cápsula con `.recuperacion.txt`, como `RecoveryAnnexSuffix` de Go |
| `src/lib/inspector/create-words.ts` | La lista de las palabras al azar de `/create`: `wordListLoader` descarga una vez `wordlists/es.txt`, que Vite publica con un nombre con hash, y la lee con `readWordList`, con su SHA-256 fijado; un fallo no se guarda y la siguiente llamada lo vuelve a intentar |
| `src/lib/inspector/creator.ts` | La escritura, cargada bajo demanda: `encryptFiles` con los ficheros, el comentario y el autor hacia el fichero temporal o la memoria, con el progreso de las dos lecturas, la cancelación y la cuota, y los pasos 1 a 8 de lo escrito |
@ -364,6 +369,7 @@ Umbrales de cobertura (`vitest.config.ts`), al 100 % en líneas, ramas, funcione
- las otras 15 fallan con el texto de Go: las exclusiones, el área de prueba con `largeArea`, una clave de 31 bytes, una firma de ceros (F2), una firma CMS sin un firmante exigido (F5, con el nombre), sin sellos o que no es una firma (F1), un área que no cabe en 32 KiB o en 64 KiB, `SIGNERS` vacío o repetido, y la aplicación de firma o la autoridad que fallan;
- `samples`: cinco cápsulas que `scripts/signing-ts-samples.mjs` escribe con `encryptFiles`, sus propios valores al azar, un `AuthorKey` nuevo y los certificados, firmas y tokens de `testing/cmsbuild.ts`. `capsule.Open` de Go las abre a sus ficheros y les da los mismos veredictos y las mismas líneas que esta librería.
- `scripts/test-signature.mjs`: firma como AutoFirma, con certificados de prueba, para probar la firma de `/create` sin un certificado real. `certificado DIR` crea una persona, otra y una autoridad de sellado de prueba, en `DIR/test-signer.json`, y la firma de prueba `DIR/firma-de-prueba.csig`; `firmar DIR MENSAJE` firma el mensaje que da la página y escribe `MENSAJE.csig`, implícita, con un sello de 1 s de precisión, o `sin-sello`, `sin-precision` u `otro-certificado`, para ver lo que dice la página.
Para regenerarlo: `node scripts/signing-ts-samples.mjs > ts-signing.json`, y la prueba de Go con `-samples ts-signing.json`; las órdenes están en la cabecera del script. Las cápsulas de Go salen igual en cada ejecución; las muestras son aleatorias y se congelan.
Se regeneró el 07-10-2026 en una exportación de `4f78854` (v0.16), con las mismas cinco muestras, cuyo `ts` se volvió a leer con esta librería. Las cápsulas y los errores salen byte a byte iguales; cambian lo que lee `capsule.Open` y un texto de error: los tokens del sellador de Go y los de las muestras no llevan `accuracy`, así que los cinco sellos válidos dan ahora S5, «el sello no dice su precisión», el posterior a la fecha da su motivo, `late`, y la firma de ceros con sello falla con «F2 and S5».

4
package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "datekeys-ts",
"version": "0.5.0",
"version": "0.6.0-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "datekeys-ts",
"version": "0.5.0",
"version": "0.6.0-dev",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "2.4.0",

@ -1,6 +1,6 @@
{
"name": "datekeys-ts",
"version": "0.5.0",
"version": "0.6.0-dev",
"private": true,
"description": "DateKeys in TypeScript: canonical CBOR codec, DKC1/DKK1 parsers, capsule inspector library and its static inspector page; later, browser encryption and decryption.",
"license": "Apache-2.0",

@ -0,0 +1,70 @@
#!/usr/bin/env node
// Signs as AutoFirma would, with test certificates, to try the signature with
// certificates of /create without a real one (spec §29.10, §62.1 rules 19 to
// 21). The certificates are those of src/lib/dkc/testing/cmsbuild.ts: a
// person, «Persona de Prueba», with an ECDSA P-256 key, and a test
// time-stamping authority, made once and kept in DIR/test-signer.json. Their
// signatures are implicit CAdES, the content inside, as AutoFirma delivers
// them, and the page takes the content out. Nobody trusts these
// certificates: DateKeys never checks who issued one. Node runs the
// TypeScript sources directly (type stripping, Node 22.6+):
//
// node scripts/test-signature.mjs certificado DIR
// makes the test signer and DIR/firma-de-prueba.csig, the test signature
// that gives the page the certificate;
// node scripts/test-signature.mjs firmar DIR MENSAJE [sin-sello|sin-precision|otro-certificado]
// signs the file MENSAJE that the page gives, and writes MENSAJE.csig:
// sealed with an accuracy of one second, or without a seal, or with a
// seal without accuracy, or with another certificate, to see what the
// page says.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import * as b from '../src/lib/dkc/testing/cmsbuild.ts';
const from = new Date(Date.UTC(2025, 0, 1));
const to = new Date(Date.UTC(2035, 0, 1));
const b64 = (x) => Buffer.from(x).toString('base64');
const unb64 = (s) => new Uint8Array(Buffer.from(s, 'base64'));
const save = (s) => ({ cert: b64(s.cert), issuer: b64(s.issuer), serial: b64(s.serial), ski: b64(s.ski), key: { kind: s.key.kind, pkcs8: b64(s.key.pkcs8), spki: b64(s.key.spki) } });
const load = (j) => ({ cert: unb64(j.cert), issuer: unb64(j.issuer), serial: unb64(j.serial), ski: unb64(j.ski), key: { kind: j.key.kind, pkcs8: unb64(j.key.pkcs8), spki: unb64(j.key.spki) } });
// The implicit signature: the detached one with the content in its encapContentInfo.
const implicit = (sig, content) => b.edit(sig, (encap) => b.seq(b.at(encap, 0), b.tlv(0xa0, b.octets(content))), ...b.SIGNED_DATA, 2);
const [cmd, dir, file, variant] = process.argv.slice(2);
if (!['certificado', 'firmar'].includes(cmd) || !dir || (cmd === 'firmar' && !file)) {
console.error('uso: node scripts/test-signature.mjs certificado DIR | firmar DIR MENSAJE [sin-sello|sin-precision|otro-certificado]');
process.exit(2);
}
const keys = join(dir, 'test-signer.json');
if (cmd === 'certificado') {
mkdirSync(dir, { recursive: true });
const person = await b.newECDSA('Persona de Prueba', 'P-256', from, to);
const other = await b.newECDSA('Otra Persona de Prueba', 'P-256', from, to);
const tsa = await b.newECDSA('Autoridad de Sellado de Prueba', 'P-256', from, to);
writeFileSync(keys, JSON.stringify({ person: save(person), other: save(other), tsa: save(tsa) }, null, 2));
const content = new TextEncoder().encode('Firma de prueba de DateKeys\n');
const sig = await b.signature(content, {}, person);
writeFileSync(join(dir, 'firma-de-prueba.csig'), implicit(sig, content));
console.log(`${keys}\n${join(dir, 'firma-de-prueba.csig')}: la firma de prueba de «Persona de Prueba», para el paso del certificado`);
} else {
if (!existsSync(keys)) {
console.error(`no existe ${keys}: ejecuta antes «certificado ${dir}»`);
process.exit(1);
}
const k = JSON.parse(readFileSync(keys, 'utf8'));
const tsa = load(k.tsa);
const who = variant === 'otro-certificado' ? load(k.other) : load(k.person);
const content = new Uint8Array(readFileSync(file));
const now = new Date();
const o =
variant === 'sin-sello'
? {}
: { token: (s) => b.token(s, now, variant === 'sin-precision' ? {} : { accuracy: b.accuracyOf(1) }, tsa) };
const sig = await b.signature(content, o, who);
writeFileSync(`${file}.csig`, implicit(sig, content));
console.log(`${file}.csig: firmado por «${variant === 'otro-certificado' ? 'Otra Persona de Prueba' : 'Persona de Prueba'}»${variant === 'sin-sello' ? ', sin sello' : variant === 'sin-precision' ? ', con un sello sin precisión' : ', con un sello de 1 s de precisión'}`);
}

@ -476,6 +476,49 @@ export function parseSignature(b: Uint8Array): SignedData {
return parse(b, false);
}
/**
* The signature `b` without the content it signed, and that content. A
* signing application such as AutoFirma may deliver an implicit signature,
* whose encapContentInfo holds the content: id-data with an eContent. Spec
* §62.1 rule 21 lets the writer remove it, which keeps the signature valid,
* since signedAttrs carry only the message-digest of the content. A detached
* signature comes back as it is, without content. It checks only the frame
* that it edits, the DER of a ContentInfo of id-signedData whose
* encapContentInfo is of id-data, and throws a CmsFormError otherwise;
* parseSignature checks the rest.
*/
export function detachSignature(b: Uint8Array): { signature: Uint8Array; content: Uint8Array | undefined } {
try {
checkDer(b);
} catch (err) {
throw form((err as DerError).message);
}
const ci = split(b);
if (ci === undefined || ci.id !== 0x30 || ci.children.length !== 2 || ci.children[1]![0] !== 0xa0) throw form('a ContentInfo');
if (oidOf(ci.children[0]!) !== OID.signedData) throw form('the content type is not id-signedData');
const inner = splitDer(ci.children[1]!).children;
if (inner.length !== 1 || inner[0]![0] !== 0x30) throw form('a SignedData');
const sd = splitDer(inner[0]!).children;
if (sd.length < 4 || sd[2]![0] !== 0x30) throw form('a SignedData');
const encap = splitDer(sd[2]!).children;
if (encap.length < 1 || encap.length > 2 || oidOf(encap[0]!) !== OID.data) throw form('an encapContentInfo of id-data');
if (encap.length === 1) return { signature: b, content: undefined };
const e = encap[1]![0] === 0xa0 ? splitDer(encap[1]!).children : [];
if (e.length !== 1 || e[0]![0] !== 0x04) throw form('eContent');
const signedData = derElement(0x30, ...sd.slice(0, 2), derElement(0x30, encap[0]!), ...sd.slice(3));
return { signature: derElement(0x30, ci.children[0]!, derElement(0xa0, signedData)), content: derContent(e[0]!) };
}
// The DER element of identifier `id` whose content is `parts`, one after
// another, with the length in its shortest form.
function derElement(id: number, ...parts: Uint8Array[]): Uint8Array {
const content = concatBytes(...parts);
const len: number[] = [];
for (let n = content.length; n > 0; n = Math.floor(n / 256)) len.unshift(n % 256);
const head = content.length < 0x80 ? [id, content.length] : [id, 0x80 | len.length, ...len];
return concatBytes(Uint8Array.from(head), content);
}
function parse(b: Uint8Array, token: boolean): SignedData {
try {
checkDer(b);

@ -135,7 +135,7 @@ function codePointCount(s: string): number {
* in a row, and the SHA-256 given otherwise. A name cannot then line up, with
* spaces, a text of its own where a terminal breaks the line.
*/
function holderText(name: string, hash: Uint8Array): string {
export function holderText(name: string, hash: Uint8Array): string {
if (name !== '' && name.isWellFormed() && codePointCount(name) <= MAX_NAME_LEN && !name.includes(' ')) {
try {
checkAuthor(name);
@ -157,10 +157,17 @@ function sealReason(tok: Token, roundTime: Instant | undefined): SealReason | un
return undefined;
}
// One SignerInfo as §29.10 orders: not verifiable, invalid, without seal, with an invalid seal, out of validity, or valid.
// The issuer is text of the certificate, as the holder is: it gets the same rules, and the SHA-256 of its Name when it
// fails them, so that no escape, no control and no bidirectional character reaches a line of the verdicts.
function signerLine(s: SignerInfo, msg: Uint8Array, roundTime: Instant | undefined): SignerLine {
/**
* The result of one SignerInfo over `msg`, in the order of §29.10: not
* verifiable, invalid, without seal, with an invalid seal, out of validity at
* t, or valid, with the authority of its seal, t, and whether its seal proves
* that it came before roundTime, or why not; as signerLine of Go. The issuer
* is text of the certificate, as the holder is: it gets the same rules, and
* the SHA-256 of its Name when it fails them, so that no escape, no control
* and no bidirectional character reaches a line of the verdicts. The create
* page checks with it a signature that a person brings, before the writer.
*/
export function signerLine(s: SignerInfo, msg: Uint8Array, roundTime: Instant | undefined): SignerLine {
const base = { holder: holderText(certHolder(s.cert), s.cert.hash), issuer: holderText(certIssuerName(s.cert), certIssuerHash(s.cert)), before: false };
const r = checkSigner(s, msg);
if (r !== 'valid') return { ...base, result: r };

@ -12,9 +12,10 @@
* 0.4.0 implements spec 0.15: the release object and a release in hand;
* 0.5.0 implements spec 0.16: a seal without accuracy proves nothing before
* the opening date and drand's JSON is read strictly; it also draws the
* random words of a key of words, from a computer or from dice.
* random words of a key of words, from a computer or from dice; 0.6.0-dev is
* what comes after it.
*/
export const VERSION = '0.5.0';
export const VERSION = '0.6.0-dev';
/**
* The version of the DateKeys Protocol Specification that this library

@ -99,7 +99,7 @@ export interface AuthorSigner {
* The signature of alg 2, a CMS signature with X.509 certificates (spec
* §29.10), as CMSSigner of Go: encryptFiles gives AUTHOR_MESSAGE to sign once
* the capsule is prepared, and waits while the person signs it outside, with
* her signing application.
* their signing application.
*/
export interface CmsSigner {
/** The SHA-256 of the certificate of each required signer, from 1 to 16. */
@ -456,7 +456,7 @@ async function securityArea(s: SealState, c: Control, head: Uint8Array): Promise
}
const list = encodeSigners(hashes as Uint8Array[]);
// AUTHOR_MESSAGE is what the person sees and signs elsewhere: the hook
// may take as long as she needs.
// may take as long as they need.
const msg = authorMessage(cc, hd, signersDigest(ALG_CMS, list));
let der: Uint8Array;
try {

@ -0,0 +1,153 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { equalBytes } from '../dkc/bytes.ts';
import { CmsFormError, detachSignature, parseSignature } from '../dkc/cms.ts';
import type { Instant } from '../dkc/datekey.ts';
import * as b from '../dkc/testing/cmsbuild.ts';
import { checkSignature, readSignerCertificate, SignInputError, type SignerCertificate } from './create-sign.ts';
// The signatures of a person with a certificate, as /create receives them
// (spec §29.10, §62.1 rules 19 to 21): test certificates and test
// time-stamping authorities, made field by field by cmsbuild.ts.
const from = new Date(Date.UTC(2025, 0, 1));
const to = new Date(Date.UTC(2030, 0, 1));
const signedAt = new Date(Date.UTC(2026, 9, 7, 12, 0, 0));
const roundTime: Instant = { seconds: Date.UTC(2028, 0, 1) / 1000, nanos: 0 };
const message = new TextEncoder().encode('the AUTHOR_MESSAGE of a capsule, as the person signs it');
// An implicit signature: the detached `sig` with `content` in its
// encapContentInfo, as AutoFirma may deliver it.
const implicit = (sig: Uint8Array, content: Uint8Array): Uint8Array =>
b.edit(sig, (encap) => b.seq(b.at(encap, 0), b.tlv(0xa0, b.octets(content))), ...b.SIGNED_DATA, 2);
let ana: b.Signer;
let luis: b.Signer;
let tsa: b.Signer;
let sealed: b.Options;
beforeAll(async () => {
ana = await b.newECDSA('Ana López', 'P-256', from, to);
luis = await b.newECDSA('Luis Gómez', 'P-256', from, to);
tsa = await b.newECDSA('Autoridad de Sellado de prueba', 'P-256', from, to);
sealed = { token: (sig) => b.token(sig, signedAt, { accuracy: b.accuracyOf(1) }, tsa) };
});
const base64 = (x: Uint8Array): string => btoa(String.fromCharCode(...x));
const text = (s: string): Uint8Array => new TextEncoder().encode(s);
describe('detachSignature', () => {
it('takes the content out of an implicit signature and leaves the signature of the detached one', async () => {
for (const size of [0, 100, 200, 300, 70_000]) {
const content = new Uint8Array(size).fill(0x61);
const sig = await b.signature(content, sealed, ana);
const out = detachSignature(implicit(sig, content));
expect(equalBytes(out.signature, sig)).toBe(true);
expect(equalBytes(out.content!, content)).toBe(true);
expect(parseSignature(out.signature).signers).toHaveLength(1);
}
const sig = await b.signature(message, {}, ana);
expect(detachSignature(sig)).toEqual({ signature: sig, content: undefined });
});
it('refuses what is not a ContentInfo of SignedData with id-data, with a CmsFormError', async () => {
const sig = await b.signature(message, {}, ana);
for (const bad of [
Uint8Array.of(0x30, 0x80, 0, 0),
b.octets(message),
b.seq(b.oid(b.OID.signedData)),
b.edit(sig, () => b.oid(b.OID.data), 0),
b.edit(sig, () => b.seq(), 1, 0),
b.edit(sig, () => b.int(1), ...b.SIGNED_DATA),
b.edit(sig, () => b.seq(b.int(1), b.set(0x31), b.int(2), b.set(0x31)), ...b.SIGNED_DATA),
b.edit(sig, () => b.seq(b.oid(b.OID.tstInfo)), ...b.SIGNED_DATA, 2),
b.edit(sig, () => b.seq(b.oid(b.OID.data), b.tlv(0xa0, b.int(1))), ...b.SIGNED_DATA, 2),
b.edit(sig, () => b.seq(b.oid(b.OID.data), b.octets(message)), ...b.SIGNED_DATA, 2),
]) {
expect(() => detachSignature(bad)).toThrow(CmsFormError);
}
});
});
describe('readSignerCertificate', () => {
it('reads the certificate, raw, in PEM or in Base64', () => {
for (const input of [ana.cert, text(`-----BEGIN CERTIFICATE-----\n${base64(ana.cert)}\n-----END CERTIFICATE-----\n`), text(base64(ana.cert))]) {
const c = readSignerCertificate(input);
expect(equalBytes(c.hash, c.cert.hash)).toBe(true);
expect(c.holder).toBe('Ana López');
expect(c.issuer).toBe('Ana López');
expect(c.notBeforeMs).toBe(from.getTime());
expect(c.notAfterMs).toBe(to.getTime());
}
});
it('takes it from a test signature of one signer, implicit or detached', async () => {
const test = text('una firma de prueba');
const sig = await b.signature(test, {}, ana);
for (const input of [sig, implicit(sig, test), text(base64(implicit(sig, test)))]) {
expect(readSignerCertificate(input).holder).toBe('Ana López');
}
});
it('refuses two signers, and what is neither a certificate nor a signature', async () => {
const two = await b.signature(message, {}, ana, luis);
expect(() => readSignerCertificate(two)).toThrow(/tiene 2 firmantes/);
for (const input of [text('hola'), text(''), text('====='), b.seq(b.int(1)), text(base64(b.seq(b.int(1))))]) {
expect(() => readSignerCertificate(input)).toThrow(SignInputError);
}
expect(() => readSignerCertificate(text('hola'))).toThrow(/firma de prueba que da AutoFirma/);
});
});
describe('checkSignature', () => {
let signer: SignerCertificate;
beforeAll(() => {
signer = readSignerCertificate(ana.cert);
});
it('takes a sealed signature, implicit or detached, and gives the detached one', async () => {
const sig = await b.signature(message, sealed, ana);
for (const input of [sig, implicit(sig, message), text(base64(implicit(sig, message)))]) {
const r = checkSignature(input, message, signer, roundTime);
expect(r).toEqual({ ok: true, signature: sig });
}
// Another signer who is not required does not count, and does not stop it.
const both = await b.signature(message, sealed, ana, luis);
expect(checkSignature(both, message, signer, roundTime).ok).toBe(true);
});
it('warns of a seal that proves nothing before the opening date, and takes it', async () => {
for (const [o, at, warning] of [
[{}, signedAt, /no dice su precisión/],
[{ policy: b.BTSP_POLICY }, signedAt, /no dice la precisión que exige su política/],
[{ accuracy: b.accuracyOf(1) }, new Date(Date.UTC(2028, 0, 2)), /no queda antes de la fecha de apertura/],
] as const) {
const sig = await b.signature(message, { token: (s) => b.token(s, at, o, tsa) }, ana);
const r = checkSignature(sig, message, signer, roundTime);
expect(r.ok).toBe(true);
expect(r.ok && r.warning).toMatch(warning);
}
});
it('says what is wrong with a signature that the writer would refuse', async () => {
const old = await b.newECDSA('Ana López', 'P-256', new Date(Date.UTC(2020, 0, 1)), new Date(Date.UTC(2021, 0, 1)));
const short = await b.newRSA('Ana López', 1024, from, to);
const cases: [Uint8Array, SignerCertificate, RegExp][] = [
[text('no es una firma'), signer, /No es una firma CMS/],
[b.seq(b.int(1)), signer, /No es una firma CMS/],
[implicit(await b.signature(text('otro fichero'), sealed, ana), text('otro fichero')), signer, /otro fichero/],
[await b.signature(message, { ...sealed, unsortedInfos: true }, ana, luis), signer, /no tiene la forma que DateKeys acepta/],
[await b.signature(message, { ...sealed, digestAlg: b.hashAlg('SHA-1') }, ana), signer, /algoritmo o una clave/],
[await b.signature(message, sealed, luis), signer, /no es del certificado de «Ana López»/],
[await b.signature(text('otro mensaje'), sealed, ana), signer, /no corresponde a este mensaje/],
[await b.signature(message, sealed, short), readSignerCertificate(short.cert), /algoritmo o una clave/],
[await b.signature(message, {}, ana), signer, /no lleva sello de tiempo/],
[await b.signature(message, { token: (s) => b.token(text('otra cosa'), signedAt, {}, tsa) }, ana), signer, /sello de tiempo de la firma no es válido/],
[await b.signature(message, sealed, old), readSignerCertificate(old.cert), /no era válido cuando se selló/],
];
for (const [input, who, problem] of cases) {
const r = checkSignature(input, message, who, roundTime);
expect(r.ok, String(problem)).toBe(false);
expect(!r.ok && r.problem).toMatch(problem);
}
});
});

@ -0,0 +1,158 @@
// What /create needs to sign a capsule with a certificate, alg 2 (spec
// §29.10, §62.1 rules 19 to 21): the certificate of the person, read from a
// test signature or from the certificate itself, which closes SIGNERS before
// the signature (rule 20); and the signature that the person brings, made
// over AUTHOR_MESSAGE with a signing application such as AutoFirma, checked
// here before the writer takes it, so that a missing seal or another
// certificate can be fixed without starting again. The writer checks it all
// again with the rules of the reader (rule 19). The page loads it with the
// writing, on demand. Its messages are in Spanish: the page shows them.
import { concatBytes, equalBytes } from '../dkc/bytes.ts';
import { type Cert, CertificateError, certHolder, certIssuerHash, certIssuerName, CmsFormError, detachSignature, parseCert, parseSignature } from '../dkc/cms.ts';
import type { Instant } from '../dkc/datekey.ts';
import { holderText, type SealReason, signerLine } from '../dkc/securitycms.ts';
// The code of AUTHOR_MESSAGE that the page shows (§62.1 rule 20), here so that the page loads it with the rest, on demand.
export { authorCode } from '../dkc/author.ts';
/** A certificate or a signature that the page cannot use; its message says why, in Spanish. */
export class SignInputError extends Error {
constructor(message: string) {
super(message);
this.name = 'SignInputError';
}
}
/** The certificate of the person who signs. */
export interface SignerCertificate {
readonly cert: Cert;
/** Its SHA-256, the entry of SIGNERS. */
readonly hash: Uint8Array;
/** Who it names and who issued it, as the verdicts show them (§29.7). */
readonly holder: string;
readonly issuer: string;
/** Its validity, in milliseconds since the epoch. */
readonly notBeforeMs: number;
readonly notAfterMs: number;
}
const notReadable = 'No es un certificado ni una firma que DateKeys sepa leer. Sirve la firma de prueba que da AutoFirma (.csig) o el certificado (.cer).';
// The DER that `bytes` holds: the bytes themselves, or the PEM or Base64
// text in which some applications save a certificate or a signature.
function derOf(bytes: Uint8Array): Uint8Array {
if (bytes[0] === 0x30) return bytes;
const text = new TextDecoder().decode(bytes);
const body = text.replace(/-----(BEGIN|END) [A-Z0-9 ]+-----/g, '').replace(/\s+/g, '');
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(body) || body === '') throw new SignInputError(notReadable);
const raw = atob(body);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
function msOf(t: Instant): number {
return t.seconds * 1000 + Math.floor(t.nanos / 1e6);
}
/**
* The certificate of the person who will sign, from `bytes`: a certificate,
* or a signature of one signer, such as the test signature of AutoFirma over
* any file, implicit or detached. It throws a SignInputError with the
* reason, in Spanish.
*/
export function readSignerCertificate(bytes: Uint8Array): SignerCertificate {
const der = derOf(bytes);
let cert: Cert | undefined;
try {
cert = parseCert(der);
} catch (err) {
/* v8 ignore next -- @preserve: parseCert throws only CertificateError */
if (!(err instanceof CertificateError)) throw err;
}
if (cert === undefined) {
let signers;
try {
signers = parseSignature(detachSignature(der).signature).signers;
} catch (err) {
/* v8 ignore next -- @preserve: detachSignature and parseSignature throw only CmsFormError */
if (!(err instanceof CmsFormError)) throw err;
throw new SignInputError(notReadable);
}
if (signers.length !== 1) throw new SignInputError(`La firma de prueba tiene ${signers.length} firmantes: usa una de una sola persona.`);
cert = signers[0]!.cert;
}
return {
cert,
hash: cert.hash,
holder: holderText(certHolder(cert), cert.hash),
issuer: holderText(certIssuerName(cert), certIssuerHash(cert)),
notBeforeMs: msOf(cert.notBefore),
notAfterMs: msOf(cert.notAfter),
};
}
/** A signature that the person brought: the detached signature that the writer takes, or the problem, in Spanish. */
export type SignatureCheck =
| { readonly ok: true; readonly signature: Uint8Array; readonly warning?: string }
| { readonly ok: false; readonly problem: string };
// What a seal that proves nothing before the opening date means for the
// person, so that they can ask another authority (§62.1 rule 19).
const reasonWarning: Record<SealReason, string> = {
late: 'El sello de tiempo no queda antes de la fecha de apertura: la cápsula no acreditará que se firmó antes.',
'no accuracy, BTSP':
'El sello de tiempo no dice la precisión que exige su política: la cápsula no acreditará que se firmó antes de la fecha de apertura. Si puedes, configura en AutoFirma otro servidor de sellado y vuelve a firmar.',
'no accuracy':
'El sello de tiempo no dice su precisión: la cápsula no acreditará que se firmó antes de la fecha de apertura. Si puedes, configura en AutoFirma otro servidor de sellado y vuelve a firmar.',
};
/**
* Checks the signature `bytes` that the person made over `message`,
* AUTHOR_MESSAGE, with the certificate `signer`, for a capsule that opens at
* `roundTime`: the form of §29.10 once its content is removed, its
* certificate, the signature over the message, its seal and the validity of
* the certificate at the instant of the seal, as the writer will (rule 19).
* An implicit signature must hold the message itself. A seal that proves
* nothing before the opening date passes, with a warning.
*/
export function checkSignature(bytes: Uint8Array, message: Uint8Array, signer: SignerCertificate, roundTime: Instant): SignatureCheck {
const problem = (text: string): SignatureCheck => ({ ok: false, problem: text });
let detached: { signature: Uint8Array; content: Uint8Array | undefined };
try {
detached = detachSignature(derOf(bytes));
} catch (err) {
/* v8 ignore next -- @preserve: derOf throws only SignInputError, and detachSignature only CmsFormError */
if (!(err instanceof SignInputError || err instanceof CmsFormError)) throw err;
return problem('No es una firma CMS. Firma el mensaje con AutoFirma, en formato CAdES.');
}
if (detached.content !== undefined && !equalBytes(detached.content, message)) {
return problem('Esta firma es de otro fichero. Firma el mensaje que da la página, sin cambiarlo.');
}
let signers;
try {
signers = parseSignature(detached.signature).signers;
} catch (err) {
// An algorithm outside the table is not an error of the form: the
// signer is not verifiable, below.
/* v8 ignore next -- @preserve: parseSignature throws only CmsFormError */
if (!(err instanceof CmsFormError)) throw err;
return problem(`La firma no tiene la forma que DateKeys acepta (${err.message}).`);
}
const s = signers.find((x) => equalBytes(x.cert.hash, signer.hash));
if (s === undefined) return problem(`Esta firma no es del certificado de «${signer.holder}». Firma con el mismo certificado que la firma de prueba.`);
const line = signerLine(s, message, roundTime);
switch (line.result) {
case 'invalid':
return problem('La firma no corresponde a este mensaje. Firma el mensaje que da la página, sin cambiarlo.');
case 'not verifiable':
return problem('La firma usa un algoritmo o una clave que DateKeys no admite.');
case 'without seal':
return problem('La firma no lleva sello de tiempo, y DateKeys lo exige. En AutoFirma, configura un servidor de sellado de tiempo y vuelve a firmar.');
case 'invalid seal':
return problem('El sello de tiempo de la firma no es válido.');
case 'out of validity':
return problem('Tu certificado no era válido cuando se selló la firma: está caducado o aún no había empezado.');
}
const signature = concatBytes(detached.signature);
return line.reason === undefined ? { ok: true, signature } : { ok: true, signature, warning: reasonWarning[line.reason] };
}

@ -18,6 +18,7 @@ import { createCapsule, CreateStopped } from './creator.ts';
import { normalizeWords, wordKey } from '../dkc/wordkey.ts';
import { quicknet } from '../dkc/profile.ts';
import type { TempFile } from './tempfile.ts';
import * as b from '../dkc/testing/cmsbuild.ts';
const encoded = vi.hoisted(() => [] as Uint8Array[]);
vi.mock('../dkc/index.ts', async (importOriginal) => {
@ -142,6 +143,33 @@ describe('createCapsule', () => {
expect(o.files).toEqual([bodies[2], bodies[1], bodies[0]]);
});
it('signs with a certificate: the signer gets AUTHOR_MESSAGE, and the verdicts are F6, with their lines', async () => {
const from = new Date(Date.UTC(2020, 0, 1));
const to = new Date(Date.UTC(2040, 0, 1));
const ana = await b.newECDSA('Ana López', 'P-256', from, to);
const tsa = await b.newECDSA('TSA de prueba', 'P-256', from, to);
const hash = await b.digest('SHA-256', ana.cert);
const asked: Uint8Array[] = [];
const p = plan({ files: chooseFiles([]), comment: 'Firmada.' });
const c = await createCapsule({
files: [],
plan: p,
cancelled: () => false,
now: genesis,
cmsSigner: {
signers: () => [hash],
sign: async (message) => {
asked.push(message);
return b.signature(message, { token: (sig) => b.token(sig, new Date(GENESIS_MS), { accuracy: b.accuracyOf(1) }, tsa) }, ana);
},
},
});
expect(asked.map((m) => m.length)).toEqual([99]);
expect([c.security.signature, c.security.seal, c.capsule.size]).toEqual(['F6', 'S0', p.size]);
expect(c.securityLines[0]).toContain('«Ana López»');
expect(c.securityLines[1]).toContain('antes de la fecha de apertura');
});
it('writes a capsule with a comment and no files', async () => {
const p = plan({ files: chooseFiles([]), comment: 'Solo un mensaje.' });
const c = await createCapsule({ files: [], plan: p, cancelled: () => false, now: genesis });

@ -9,6 +9,8 @@
import { encodeAccessKey, type Inspection, type Instant, inspect, quicknet, readCapsule, toHex, wipeAccessKey } from '../dkc/index.ts';
import { encryptFiles } from '../dkc/encrypt.ts';
import { type Verdicts, verdictLines } from '../dkc/security.ts';
import type { CmsSigner } from '../dkc/encrypt.ts';
import type { CapsulePlan } from './create-input.ts';
import { systemClock } from './opener.ts';
import type { TempFile } from './tempfile.ts';
@ -33,6 +35,12 @@ export interface CreateRequest {
readonly progress?: (pass: 1 | 2, done: number, total: number) => void;
/** The clock; the system clock when omitted. It must still be before the requested instant. */
readonly now?: () => Instant;
/**
* The signature with certificates of the capsule, alg 2 (spec §29.10): the
* writer asks it for AUTHOR_MESSAGE once the capsule is prepared, and
* waits while the person signs it elsewhere. Without it, no signature.
*/
readonly cmsSigner?: CmsSigner;
}
/** A capsule written and checked. */
@ -47,6 +55,10 @@ export interface Created {
readonly bytes: Uint8Array;
/** How long encryptFiles took, in milliseconds. */
readonly ms: number;
/** The verdicts of the security area written, as a reader finds them: F6 and its signers with a signature. */
readonly security: Verdicts;
/** Those verdicts as the official SDK shows them (§29.7). */
readonly securityLines: readonly string[];
}
/** The writing stopped because the person cancelled it, or before writing anything because the .dkc did not fit in `room`. */
@ -131,6 +143,7 @@ export async function createCapsule(req: CreateRequest): Promise<Created> {
...(words.length === 0 ? {} : { words }),
...(plan.comment === '' ? {} : { comment: plan.comment }),
...(plan.author === '' ? {} : { author: plan.author }),
...(req.cmsSigner === undefined ? {} : { cmsSigner: req.cmsSigner }),
now: req.now ?? systemClock,
...(req.output === undefined ? {} : { output: req.output.writable }),
progress: (written, total) => {
@ -163,7 +176,7 @@ export async function createCapsule(req: CreateRequest): Promise<Created> {
if (inspection.error !== undefined) {
throw new Error(`create: internal error: the .dkc written fails step ${inspection.checks.at(-1)!.step}: ${inspection.error.message}`);
}
return { capsule, ...(dkk === undefined ? {} : { dkk }), capsuleId: toHex(res.capsuleId), inspection, bytes, ms };
return { capsule, ...(dkk === undefined ? {} : { dkk }), capsuleId: toHex(res.capsuleId), inspection, bytes, ms, security: res.security!, securityLines: verdictLines(res.security!) };
} catch (err) {
dkk?.fill(0);
throw err;

@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { REMINDER_SUFFIX, newUID, reminderICS, reminderName } from './reminder.ts';
// The calendar file of the reminder of /create, byte for byte the one of
// datekeys encrypt -reminder (cmd/datekeys/reminder_test.go of datekeys-go):
// CRLF, lines folded at 75 octets without cutting a character, and a name
// whose semicolon, comma and backslash are escaped (spec §62.1 rule 26, RFC
// 5545). The expected text was computed apart from this code, from the RFC.
describe('reminderICS', () => {
const base = {
name: 'carta; de, mamá\\.dkc',
round: 1000,
unlockMs: Date.UTC(2023, 7, 23, 15, 59, 24),
timeAndKey: true,
uid: '0f1e2d3c-4b5a-4697-8877-665544332211',
stampMs: Date.UTC(2026, 9, 7, 12, 0, 0),
};
it('writes the event of Go byte for byte', () => {
expect(reminderICS(base)).toBe(
'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' +
'PRODID:-//DateKeys//datekeys-go//ES\r\n' +
'CALSCALE:GREGORIAN\r\n' +
'BEGIN:VEVENT\r\n' +
'UID:0f1e2d3c-4b5a-4697-8877-665544332211\r\n' +
'DTSTAMP:20261007T120000Z\r\n' +
'DTSTART:20230823T155924Z\r\n' +
'DTEND:20230823T162924Z\r\n' +
'SUMMARY:Ya se puede abrir la cápsula DateKeys «carta\\; de\\, mamá\\\\.dkc»\r\n' +
'DESCRIPTION:Desde este momento se puede abrir «carta\\; de\\, mamá\\\\.dkc».\r\n' +
' Hace falta el fichero .dkc y una de sus llaves\\, y la firma de drand de l\r\n' +
' a ronda 1000\\, que drand publica ahora: si un día ya no la sirve\\, un arc\r\n' +
' hivo de firmas o un servicio de caché tiene que haberla guardado. Las ins\r\n' +
' trucciones para abrirla sin DateKeys están en «carta\\; de\\, mamá\\\\.dkc.\r\n' +
' recuperacion.txt».\r\n' +
'TRANSP:TRANSPARENT\r\n' +
'BEGIN:VALARM\r\n' +
'ACTION:DISPLAY\r\n' +
'TRIGGER:PT0S\r\n' +
'DESCRIPTION:Ya se puede abrir la cápsula DateKeys «carta\\; de\\, mamá\\\\.d\r\n' +
' kc»\r\n' +
'END:VALARM\r\n' +
'END:VEVENT\r\n' +
'END:VCALENDAR\r\n',
);
});
it('folds every line at 75 octets', () => {
for (const line of reminderICS(base).split('\r\n')) {
expect(new TextEncoder().encode(line).length).toBeLessThanOrEqual(75);
}
});
it('asks for a credential only with a key', () => {
const ics = reminderICS({ ...base, timeAndKey: false, name: 'carta.dkc' });
// Unfolded, a line of RFC 5545 is its pieces without the CRLF and the space.
expect(ics.replaceAll('\r\n ', '')).toContain('Hace falta el fichero .dkc\\, y la firma');
expect(ics).not.toContain('una de sus llaves');
expect(ics).toContain('DTEND:20230823T162924Z\r\n');
});
it('escapes a line feed and drops a carriage return of a name', () => {
const ics = reminderICS({ ...base, name: 'a\r\nb.dkc' });
expect(ics).toContain('SUMMARY:Ya se puede abrir la cápsula DateKeys «a\\nb.dkc»\r\n');
});
it('folds before a character that the 75th octet would cut', () => {
// SUMMARY and the start of the text take 49 octets: with 25 more, ñ
// takes octets 75 and 76, and the line folds before it, at 74.
const ics = reminderICS({ ...base, name: `${'x'.repeat(25)}ñ.dkc` });
expect(ics).toContain(`SUMMARY:Ya se puede abrir la cápsula DateKeys «${'x'.repeat(25)}\r\n ñ.dkc»\r\n`);
});
it('names the file after the capsule', () => {
expect(reminderName('carta.dkc')).toBe('carta.dkc.recordatorio.ics');
expect(REMINDER_SUFFIX).toBe('.recordatorio.ics');
});
it('makes a random UUID of version 4', () => {
const a = newUID();
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(newUID()).not.toBe(a);
});
});

@ -0,0 +1,100 @@
// The local reminder that /create offers next to a capsule, the MAY of spec
// §62.1 rule 26: an iCalendar event (RFC 5545) at round_time, which any
// calendar imports, with what opening the capsule will take. It is
// cmd/datekeys/reminder.go of datekeys-go, byte for byte. It holds the name
// of the capsule and its date, and nothing secret; a calendar that syncs
// with a server learns both.
/** What is appended to the name of the .dkc to name its reminder: carta.dkc.recordatorio.ics. */
export const REMINDER_SUFFIX = '.recordatorio.ics';
/** The name of the reminder of the capsule saved as `dkcName`. */
export function reminderName(dkcName: string): string {
return `${dkcName}${REMINDER_SUFFIX}`;
}
/** A reminder: the capsule, its round and round_time, and the event. */
export interface Reminder {
/** The file name of the capsule. */
readonly name: string;
readonly round: number;
/** round_time, in milliseconds since the epoch. */
readonly unlockMs: number;
/** Whether opening needs one of its credentials too. */
readonly timeAndKey: boolean;
/** A random UUID, so that the event names no capsule. */
readonly uid: string;
/** When the event was made, in milliseconds since the epoch. */
readonly stampMs: number;
}
/** A random UUID of version 4 (RFC 9562). */
export function newUID(): string {
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6]! & 0x0f) | 0x40;
b[8] = (b[8]! & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
// A TEXT value of RFC 5545 (3.3.11): a backslash, a semicolon, a comma and
// a line feed escaped; a carriage return goes.
function calendarText(s: string): string {
return s.replace(/[\\;,\n\r]/g, (c) => (c === '\n' ? '\\n' : c === '\r' ? '' : `\\${c}`));
}
// A DATE-TIME of RFC 5545 in UTC, with a Z: 20230823T155924Z.
function calendarTime(ms: number): string {
return new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z').replace(/[-:]/g, '');
}
// A content line ended with CRLF and folded at 75 octets (RFC 5545, 3.1):
// each continuation starts with a space, and no UTF-8 sequence is cut.
function foldLine(line: string): string {
let rest = new TextEncoder().encode(line);
const parts: Uint8Array[] = [];
let limit = 75;
while (rest.length > limit) {
let cut = limit;
while (cut > 0 && (rest[cut]! & 0xc0) === 0x80) cut--;
parts.push(rest.subarray(0, cut));
rest = rest.subarray(cut);
limit = 74;
}
parts.push(rest);
const decoder = new TextDecoder();
return `${parts.map((p) => decoder.decode(p)).join('\r\n ')}\r\n`;
}
/** The calendar file of the reminder, in Spanish, as the annex is. */
export function reminderICS(r: Reminder): string {
const summary = `Ya se puede abrir la cápsula DateKeys «${r.name}»`;
const needs = r.timeAndKey ? 'el fichero .dkc y una de sus llaves' : 'el fichero .dkc';
const description =
`Desde este momento se puede abrir «${r.name}». Hace falta ${needs}, y la firma de drand de la ronda ${r.round}, ` +
'que drand publica ahora: si un día ya no la sirve, un archivo de firmas o un servicio de caché tiene que haberla guardado. ' +
`Las instrucciones para abrirla sin DateKeys están en «${r.name}.recuperacion.txt».`;
return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//DateKeys//datekeys-go//ES',
'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
`UID:${r.uid}`,
`DTSTAMP:${calendarTime(r.stampMs)}`,
`DTSTART:${calendarTime(r.unlockMs)}`,
`DTEND:${calendarTime(r.unlockMs + 30 * 60 * 1000)}`,
`SUMMARY:${calendarText(summary)}`,
`DESCRIPTION:${calendarText(description)}`,
'TRANSP:TRANSPARENT',
'BEGIN:VALARM',
'ACTION:DISPLAY',
'TRIGGER:PT0S',
`DESCRIPTION:${calendarText(summary)}`,
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR',
]
.map(foldLine)
.join('');
}

@ -44,6 +44,9 @@
} from '$lib/inspector/create-input.ts';
import { wordListLoader, WORDS_LANGUAGE } from '$lib/inspector/create-words.ts';
import { ANNEX_URL, annexName } from '$lib/inspector/annex.ts';
import { newUID, reminderICS, reminderName } from '$lib/inspector/reminder.ts';
import type { SignerCertificate } from '$lib/inspector/create-sign.ts';
import type { CmsSigner } from '$lib/dkc/encrypt.ts';
import { escapeInvisible, formatByteCount, formatDateTime, formatInteger, formatRelative, unexpectedProblem, viewerTimeZone } from '$lib/inspector/format.ts';
import { isTimeZone, localParts, supportedTimeZones, timeZoneList, UTC } from '$lib/inspector/localtime.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts';
@ -88,7 +91,39 @@
/** The clock and the zone of the report. */
readonly nowMs: number;
readonly timeZone: string | undefined;
}
/** The lines of the signature with a certificate, as a reader shows them (§29.7); undefined without one. */
readonly signatureLines?: readonly string[];
}
// The signature with a certificate, alg 2 (§29.10): optional. The
// certificate comes first, from a test signature or from its file, and
// closes SIGNERS before the signature (§62.1 rule 20). While the writing
// waits for the signature, signRequest holds the message to sign and what
// the person brought; the writer checks it all again before writing (rule
// 19). create-sign.ts, with the reader of CMS, loads on demand.
type Signing = typeof import('$lib/inspector/create-sign.ts');
interface SignRequest {
readonly message: Uint8Array;
readonly code: string;
readonly roundMs: number;
readonly problem: string;
/** A signature that passes with a warning (rule 19), until the person takes it. */
readonly warning: string;
readonly pending: Uint8Array | undefined;
readonly resolve: (signature: Uint8Array) => void;
/** Stops the writing: the person cancelled it. */
readonly cancel: () => void;
}
// A certificate or a signature is a few kilobytes: a bigger file is another thing.
const SIGN_FILE_LIMIT = 1 << 20;
let signWith: 'none' | 'certificate' = $state('none');
let signer: SignerCertificate | undefined = $state();
let signerProblem = $state('');
let signRequest: SignRequest | undefined = $state();
let signerInput: HTMLInputElement | undefined = $state();
let signatureInput: HTMLInputElement | undefined = $state();
let signingLoad: Promise<Signing> | undefined;
const signing = (): Promise<Signing> => (signingLoad ??= import('$lib/inspector/create-sign.ts'));
// The form: the files of the list, whether to keep their dates, and the
// texts of the head.
@ -413,6 +448,93 @@
function cancel(): void {
cancelled = true;
announcement = 'Cancelando.';
// A writing that waits for the signature stops at once.
const req = signRequest;
signRequest = undefined;
req?.cancel();
}
// Reads the certificate of the person: a test signature or its file.
async function readSigner(e: Event): Promise<void> {
const input = e.currentTarget as HTMLInputElement;
const f = input.files?.[0];
input.value = '';
if (f === undefined) return;
signer = undefined;
signerProblem = '';
if (f.size > SIGN_FILE_LIMIT) {
signerProblem = 'Ese fichero es demasiado grande para ser una firma o un certificado.';
return;
}
try {
const s = await signing();
const c = s.readSignerCertificate(new Uint8Array(await f.arrayBuffer()));
const now = Date.now();
if (c.notAfterMs <= now) {
signerProblem = `El certificado de «${c.holder}» caducó el ${formatDateTime(c.notAfterMs, timeZone)}: con él, la firma no valdría. Renuévalo y cárgalo de nuevo.`;
} else if (c.notBeforeMs > now) {
signerProblem = `El certificado de «${c.holder}» no es válido hasta el ${formatDateTime(c.notBeforeMs, timeZone)}.`;
} else {
signer = c;
}
} catch (err) {
signerProblem = err instanceof Error && err.name === 'SignInputError' ? err.message : unexpectedProblem('leer el certificado', err);
}
announcement = signer === undefined ? signerProblem : `Certificado de «${signer.holder}».`;
}
// The signer that the writer asks for the signature once the capsule is
// prepared: the page shows the message, and waits for the person.
function certificateSigner(c: SignerCertificate, roundMs: number, stop: () => Error): CmsSigner {
return {
signers: () => [c.hash],
sign: async (message) => {
const { authorCode } = await signing();
return new Promise<Uint8Array>((resolve, reject) => {
signRequest = { message, code: authorCode(message), roundMs, problem: '', warning: '', pending: undefined, resolve, cancel: () => reject(stop()) };
announcement = 'Falta tu firma: descarga el mensaje y fírmalo con AutoFirma.';
void tick().then(() => document.getElementById('sign-title')?.focus());
});
},
};
}
function saveMessage(): void {
if (signRequest !== undefined) save(new Blob([signRequest.message as Uint8Array<ArrayBuffer>], { type: 'text/plain' }), 'mensaje-para-firmar.txt');
}
// Checks the signature that the person brings, and gives it to the writer
// when it can take it; a warning waits for the person (rule 19).
async function takeSignature(e: Event): Promise<void> {
const input = e.currentTarget as HTMLInputElement;
const f = input.files?.[0];
input.value = '';
const req = signRequest;
if (f === undefined || req === undefined || signer === undefined) return;
if (f.size > SIGN_FILE_LIMIT) {
const problemText = 'Ese fichero es demasiado grande para ser una firma.';
signRequest = { ...req, problem: problemText, warning: '', pending: undefined };
announcement = problemText;
return;
}
const s = await signing();
const r = s.checkSignature(new Uint8Array(await f.arrayBuffer()), req.message, signer, { seconds: Math.floor(req.roundMs / 1000), nanos: 0 });
if (signRequest !== req) return;
if (!r.ok) {
signRequest = { ...req, problem: r.problem, warning: '', pending: undefined };
announcement = r.problem;
} else if (r.warning !== undefined) {
signRequest = { ...req, problem: '', warning: r.warning, pending: r.signature };
announcement = r.warning;
} else {
useSignature(req, r.signature);
}
}
function useSignature(req: SignRequest, signature: Uint8Array): void {
signRequest = undefined;
announcement = 'Firma recibida: cifrando la cápsula.';
req.resolve(signature);
}
async function forget(): Promise<void> {
@ -438,6 +560,23 @@
return url;
}
// The local reminder of spec §62.1 rule 26 (MAY): a calendar event at the
// round time, with what opening will take. Its UID is random, so that it
// names no capsule.
function saveReminder(): void {
if (result === undefined) return;
const name = downloadName(dkcName, '.dkc', result.plan.names.dkc);
const ics = reminderICS({
name,
round: result.plan.dateKey.round,
unlockMs: result.plan.effectiveMs,
timeAndKey: result.plan.policy === TIME_AND_KEY,
uid: newUID(),
stampMs: Date.now(),
});
save(new Blob([ics], { type: 'text/calendar' }), reminderName(name));
}
function saveCapsule(): void {
if (result === undefined || capsuleGone) return;
save(result.capsule, downloadName(dkcName, '.dkc', result.plan.names.dkc));
@ -608,6 +747,10 @@
await fail(p.problem, p.field);
return;
}
if (signWith === 'certificate' && signer === undefined) {
await fail(signerProblem || 'Falta tu certificado: carga una firma de prueba de AutoFirma o el fichero del certificado.', undefined, 'signer-button');
return;
}
if (!keyMayGo('creas otra')) return;
const id = ++createId;
const stale = (): boolean => id !== createId;
@ -664,6 +807,9 @@
plan: p.plan,
...(out === undefined ? {} : { output: out, ...(room === undefined ? {} : { room }) }),
cancelled: () => cancelled || stale(),
...(signWith === 'certificate' && signer !== undefined
? { cmsSigner: certificateSigner(signer, p.plan.effectiveMs, () => new creator.CreateStopped('cancelled', p.plan.size)) }
: {}),
progress: (step, done, all) => {
if (stale()) return;
pass = step;
@ -679,6 +825,8 @@
? 'Cancelado: no se ha guardado ninguna cápsula.'
: `La cápsula ocupará ${formatByteCount(err.total)} y el navegador deja ${formatByteCount(room ?? 0)} libres para esta página. Libera espacio y vuelve a intentarlo.`,
);
} else if (err instanceof Error && err.message.includes('does not fit in the area')) {
await fail('La firma, con sus certificados y su sello, no cabe en los 32 KiB que la cápsula guarda para firmas y sellos (§29.2).');
} else if (Date.now() >= p.plan.requestedMs) {
// The date came while the page got ready to write: encrypt refused
// it with its own clock (§62.1, rule 2).
@ -714,6 +862,7 @@
ms: made.ms,
nowMs: Date.now(),
timeZone: viewerTimeZone(),
...(made.security.signature === 'F6' ? { signatureLines: made.securityLines.map((l) => l.trim()) } : {}),
};
announcement = 'Cápsula creada.';
await tick();
@ -726,7 +875,10 @@
if (pending === t) pending = undefined;
await t.remove();
}
if (!stale()) busy = false;
if (!stale()) {
busy = false;
signRequest = undefined;
}
}
}
@ -1230,6 +1382,59 @@
{/if}
</fieldset>
<fieldset class="step" disabled={busy}>
<legend><span class="legend">¿La firmas?</span></legend>
<label class="choice">
<input type="radio" name="sign" value="none" bind:group={signWith} />
<span>
<span class="choice-title">Sin firma</span>
<span class="hint">El autor que escribas arriba es un texto: no prueba quién hizo la cápsula.</span>
</span>
</label>
<label class="choice">
<input type="radio" name="sign" value="certificate" bind:group={signWith} />
<span>
<span class="choice-title">Con tu certificado</span>
<span class="hint">DNIe, FNMT u otro, con AutoFirma. Quien la abra verá a nombre de quién está el certificado y cuándo se selló la firma.</span>
</span>
</label>
{#if signWith === 'certificate'}
<div class="keyed">
<input
bind:this={signerInput}
class="visually-hidden"
type="file"
accept=".csig,.p7s,.p7m,.cer,.crt,.pem,.der"
tabindex="-1"
aria-hidden="true"
onchange={readSigner}
/>
<div class="actions">
<button id="signer-button" class="button quiet" type="button" onclick={() => signerInput?.click()} aria-describedby="signer-hint">
{signer === undefined ? 'Cargar tu certificado' : 'Cargar otro certificado'}
</button>
</div>
{#if signer !== undefined}
<p class="signer">
Certificado de «{signer.holder}», emitido por «{signer.issuer}». Válido hasta el {formatDateTime(signer.notAfterMs, timeZone)}.
</p>
{:else if signerProblem !== ''}
<p class="inline-problem" role="alert">{signerProblem}</p>
{/if}
<p id="signer-hint" class="hint">
Sirve una firma de prueba: en AutoFirma, firma cualquier fichero con tu certificado y carga aquí el <code>.csig</code>. O el
fichero del certificado (<code>.cer</code>). La firma irá dentro de la cápsula, cifrada, con tu certificado: nadie lo verá hasta
la fecha, y desde entonces lo verá quien la abra, con tu nombre y el número de identidad que lleve.
</p>
<p class="hint">
Al crear la cápsula, la página te dará un mensaje para firmar con AutoFirma, en formato CAdES y con sello de tiempo: configura
antes en AutoFirma un servidor de sellado. Sin sello, la firma no se acepta.
</p>
</div>
{/if}
</fieldset>
<section class={['envelope', plan === undefined && 'pending']} aria-labelledby="summary-title">
<h2 id="summary-title" class="visually-hidden">Antes de crear</h2>
{#if plan}
@ -1248,7 +1453,34 @@
<button id="cancel-button" class="button quiet" type="button" onclick={cancel}>Cancelar</button>
{/if}
</div>
{#if busy}
{#if busy && signRequest !== undefined}
<div class="sign-request" role="group" aria-labelledby="sign-title">
<p id="sign-title" class="sign-title" tabindex="-1">Falta tu firma</p>
<p>
La cápsula está preparada. Descarga el mensaje y fírmalo con AutoFirma, con el certificado de «{signer?.holder}», en formato
CAdES y con sello de tiempo. Después, sube aquí la firma.
</p>
<p>
Su código es <strong class="mono">{signRequest.code}</strong>: son los ocho primeros caracteres de su segunda línea. Firma solo un
mensaje que lo lleve.
</p>
<input bind:this={signatureInput} class="visually-hidden" type="file" accept=".csig,.p7s,.p7m" tabindex="-1" aria-hidden="true" onchange={takeSignature} />
<div class="actions">
<button class="button" type="button" onclick={saveMessage}>Descargar el mensaje</button>
<button class="button quiet" type="button" onclick={() => signatureInput?.click()}>Subir la firma</button>
</div>
{#if signRequest.problem !== ''}
<p class="inline-problem" role="alert">{signRequest.problem}</p>
{/if}
{#if signRequest.warning !== ''}
{@const req = signRequest}
<p class="caution">{req.warning}</p>
<div class="actions">
<button class="button quiet" type="button" onclick={() => req.pending !== undefined && useSignature(req, req.pending)}>Usar esta firma igualmente</button>
</div>
{/if}
</div>
{:else if busy}
<div class="progress">
<progress max={total} value={written} aria-label={pass === 1 ? 'Ficheros leídos' : 'Cápsula escrita'}></progress>
<p class="hint">
@ -1408,6 +1640,15 @@
</section>
{/if}
{#if r.signatureLines !== undefined}
<section class="block" aria-labelledby="signature-title">
<h3 id="signature-title">La firma</h3>
{#each r.signatureLines as line, i (i)}
<p class={i === 0 ? undefined : 'hint'}>{line}</p>
{/each}
</section>
{/if}
<section class="block" aria-labelledby="later-title">
<h3 id="later-title">Para abrirla más adelante</h3>
<p>
@ -1417,10 +1658,12 @@
</p>
<p class="hint">
Guarda con la cápsula las instrucciones para abrirla sin DateKeys, por si ya no existe. Son las mismas para toda cápsula y
no dicen nada de la tuya.
no dicen nada de la tuya. El recordatorio es un evento para tu calendario a esa hora, con el nombre de la cápsula: si el
calendario se sincroniza con un servidor, este sabrá el nombre y la fecha.
</p>
<div class="actions">
<a class="button quiet" href={ANNEX_URL} download={annexName(downloadName(dkcName, '.dkc', r.plan.names.dkc))}>Descargar las instrucciones</a>
<button class="button quiet" type="button" onclick={saveReminder}>Descargar el recordatorio</button>
</div>
</section>
@ -1854,6 +2097,23 @@
font-size: var(--t-small);
color: var(--ink-muted);
}
/* The signature that the writing waits for: a slip in the envelope, under
the date, where the person is looking while the capsule is written. */
.sign-request {
display: grid;
gap: 0.6rem;
margin-top: 0.6rem;
padding-top: 0.9rem;
border-top: 1px solid var(--rule);
}
.sign-title {
font-family: var(--serif);
font-size: var(--t-lead);
color: var(--ink);
}
.signer {
color: var(--ink);
}
.caution {
padding: 0.7rem 0.9rem;
border-radius: 8px;

@ -51,6 +51,8 @@ export default defineConfig({
'src/lib/inspector/create-input.ts': { 100: true },
'src/lib/inspector/create-words.ts': { 100: true },
'src/lib/inspector/annex.ts': { 100: true },
'src/lib/inspector/reminder.ts': { 100: true },
'src/lib/inspector/create-sign.ts': { 100: true },
'src/lib/inspector/creator.ts': { 100: true },
// Format 3 (plan of format 3 in datekeys-ts): the rules of the paths
// and texts of the head, the codec of BODY, of the security area and of

Loading…
Cancel
Save

Powered by TurnKey Linux.