Compare commits
2 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
709569aab9 | 5 hours ago |
|
|
f37da12504 | 5 hours ago |
@ -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'}`);
|
||||
}
|
||||
@ -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] };
|
||||
}
|
||||
@ -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('');
|
||||
}
|
||||
Loading…
Reference in new issue