You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
389 lines
16 KiB
389 lines
16 KiB
// The security area of a format 3 capsule (spec §29.3, §29.7), as
|
|
// EncodeSecurity, EvaluateSecurityIn and the verdicts of the Go package
|
|
// capsule give them at the draft v0.12 (format3.go, signature.go), the texts
|
|
// of the verdicts of a certificate included. SECURITY_CBOR is the map
|
|
// {0: "datekeys-security", 1: 1, ? 2: author-signature, ? 3: seal}, whose
|
|
// keys 2 and 3 hold CBOR encoded apart. In the context of a capsule it checks
|
|
// the signature of alg 1 with the strict profile of ed25519strict.ts, and the
|
|
// signature of alg 2 and the seal of seal_type 2 with securitycms.ts; the
|
|
// writer of this library does not sign, and writes the area empty. Its
|
|
// evaluation never throws: the security area never decides the opening, and
|
|
// its verdicts carry no error code. Internal: index.ts does not re-export it.
|
|
|
|
import { ALG_CMS, ALG_ED25519, authorMessage, signersDigest } from './author.ts';
|
|
import { bech32Encode } from './bech32.ts';
|
|
import { utf8Length } from './bytes.ts';
|
|
import { type Decoder, Encoder, peek, unmarshal } from './cbor.ts';
|
|
import { verifyStrict } from './ed25519strict.ts';
|
|
import { DateKeysError } from './errors.ts';
|
|
import { formatRFC3339Nano, type Instant } from './datekey.ts';
|
|
import { fieldOf, requireKeys } from './schema.ts';
|
|
import { type Detail, evaluateCMS, evaluateSeal } from './securitycms.ts';
|
|
|
|
export const SECURITY_TYPE_TAG = 'datekeys-security';
|
|
export const SECURITY_VERSION = 1;
|
|
// The bound of the byte strings of keys 2 and 3, and that of alg and
|
|
// seal_type.
|
|
const MAX_SECURITY_ITEM = 65536;
|
|
const MAX_ALG = 2 ** 32 - 1;
|
|
/** The seal_type of an RFC 3161 token (spec v0.11, §29.3, §29.11). */
|
|
const SEAL_TYPE_RFC3161 = 2;
|
|
|
|
/**
|
|
* The verdict on the signature or on the seal of the security area (spec
|
|
* §29.7), which never prevents opening:
|
|
* - X: the area fails its layer 2 or 3; it stands for both;
|
|
* - F0: no signature (no key 2);
|
|
* - F1: a signature that is not checked: it does not decode, breaks its
|
|
* schema or has an alg this reader does not implement;
|
|
* - F2: a signature that does not correspond to this content: of alg 1, it
|
|
* does not verify; of alg 2, a required signer has an invalid one;
|
|
* - F3 and F4: a valid signature of alg 1, with a key that the person saved,
|
|
* whose label it names, or with another;
|
|
* - F5: a signature of alg 2 without a signature or a seal that it demands;
|
|
* - F6: a signature of alg 2 whose required signers are all valid and sealed;
|
|
* - S0: no seal (no key 3); nothing is shown about the date;
|
|
* - S1: a seal_type this reader does not implement, or a token with an
|
|
* algorithm outside the table;
|
|
* - S2: a seal that does not decode or breaks its schema;
|
|
* - S3: a seal that does not correspond to this content;
|
|
* - S4 and S5: a valid seal, from before the time of the round or not.
|
|
*/
|
|
export type Verdict = 'X' | 'F0' | 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | 'F6' | 'S0' | 'S1' | 'S2' | 'S3' | 'S4' | 'S5';
|
|
|
|
/** The verdicts of the security area of a format 3 capsule. */
|
|
export interface Verdicts {
|
|
readonly signature: Verdict;
|
|
readonly seal: Verdict;
|
|
/** The public key of a valid signature of alg 1 (F3, F4). */
|
|
readonly authorKey?: Uint8Array;
|
|
/** The label of the saved key that signed (F3). */
|
|
readonly authorLabel?: string;
|
|
/** The signers of a signature of alg 2 and the authority of a valid seal (F2, F5, F6, S4, S5). */
|
|
readonly detail?: Detail;
|
|
}
|
|
|
|
/**
|
|
* What the verdicts of a signature need besides SECURITY_CBOR (spec v0.11,
|
|
* §29.7): the commitments of the capsule, and the author keys that the person
|
|
* saved, by their dkauthor1… string, with the label she gave each (F3). A
|
|
* reader builds it at step 17.6. Without it the area is read as v0.10 reads
|
|
* it, and any signature is F1.
|
|
*/
|
|
export interface SecurityContext {
|
|
readonly controlCommit: Uint8Array;
|
|
readonly headDigest: Uint8Array;
|
|
/** round_time, the time of the round: a seal before it proves that the content existed before the capsule could open. */
|
|
readonly roundTime?: Instant;
|
|
readonly authorKeys?: ReadonlyMap<string, string>;
|
|
}
|
|
|
|
/** The text of a verdict that the official SDK shows, in Spanish (spec §29.7), and '' for S0, which shows nothing. */
|
|
export function verdictText(v: Verdict): string {
|
|
switch (v) {
|
|
case 'X':
|
|
return 'No se han podido comprobar la firma ni el sello: trátala como no firmada y sin fecha probada.';
|
|
case 'F0':
|
|
return 'Sin firma de autor.';
|
|
case 'F1':
|
|
return 'No se ha comprobado ninguna firma: trátala como no firmada.';
|
|
case 'S1':
|
|
return 'Lleva un sello de tiempo que esta versión no sabe comprobar: aquí no prueba nada.';
|
|
case 'S2':
|
|
return 'El sello de tiempo es ilegible: no prueba nada.';
|
|
case 'F2':
|
|
return 'La firma no corresponde a este contenido.';
|
|
case 'F5':
|
|
return 'Faltan firmas o sellos que la propia cápsula exige: trátala como no firmada.';
|
|
case 'S3':
|
|
return 'El sello no corresponde a este contenido.';
|
|
case 'S5':
|
|
return 'Sellado después de la fecha de apertura: no prueba nada anterior.';
|
|
// F3, F4, F6 and S4 name a key, a holder or a time: whoever shows them writes the text (spec §29.7).
|
|
case 'F3':
|
|
case 'F4':
|
|
case 'F6':
|
|
case 'S4':
|
|
case 'S0':
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// The result of a signer in the texts of §29.7.
|
|
const RESULT_TEXT: Readonly<Record<string, string>> = {
|
|
valid: 'válida',
|
|
invalid: 'inválida',
|
|
absent: 'ausente',
|
|
'not verifiable': 'no verificable',
|
|
'without seal': 'sin sello',
|
|
'invalid seal': 'con el sello inválido',
|
|
'out of validity': 'con el certificado fuera de validez',
|
|
};
|
|
|
|
// A name of a certificate between « and », as the texts of §29.7 write it, so
|
|
// that where it starts and where it ends is in view.
|
|
const quoted = (name: string): string => `«${name}»`;
|
|
|
|
/**
|
|
* The verdicts as the official SDK shows them, in order: X alone, or the
|
|
* signature and then the seal, when it shows something (spec §29.7). F6 is
|
|
* followed by a line for each required signer, which names the authority of
|
|
* its seal, by the warning that DateKeys does not check who issued the seals
|
|
* when one of them says that it is before the opening date, and by the
|
|
* signers who do not count. A time is in RFC 3339 with the fraction of the
|
|
* seal.
|
|
*/
|
|
export function verdictLines(v: Verdicts): string[] {
|
|
if (v.signature === 'X') return [verdictText('X')];
|
|
let signature = verdictText(v.signature);
|
|
// F3 and F4 name a key (spec §29.7).
|
|
if (v.signature === 'F3') signature = `Firmado con la clave que guardaste como ${v.authorLabel!}.`;
|
|
if (v.signature === 'F4') signature = `Firmado con la clave ${bech32Encode('dkauthor', v.authorKey!)}. No prueba quién la tiene.`;
|
|
const lines = [signature];
|
|
const d = v.detail;
|
|
if (v.signature === 'F6' && d !== undefined) {
|
|
lines[0] = `Firmado con un certificado a nombre de ${d.signers.map((s) => quoted(s.holder)).join(', ')}. DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial.`;
|
|
for (const s of d.signers) {
|
|
const when = s.before ? 'antes de la fecha de apertura' : 'no antes de la fecha de apertura';
|
|
lines.push(` ${quoted(s.holder)} (emisor según su certificado: ${quoted(s.issuer)}), sellado por ${quoted(s.sealHolder!)} el ${formatRFC3339Nano(s.sealTime!)}, ${when}.`);
|
|
}
|
|
// §29.7: whoever says that a capsule was signed before the date says that it does not check who issued the seal.
|
|
if (d.signers.some((s) => s.before)) lines.push(' DateKeys no comprueba quién emitió los sellos.');
|
|
}
|
|
for (const s of d?.foreign ?? []) lines.push(` Otro firmante, ${quoted(s.holder)}: ${RESULT_TEXT[s.result]!}. No cuenta.`);
|
|
if (v.seal === 'S4' && d?.sealHolder !== undefined) {
|
|
lines.push(`Según un sello a nombre de ${quoted(d.sealHolder)}, existía el ${formatRFC3339Nano(d.sealTime!)}, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.`);
|
|
} else if (verdictText(v.seal) !== '') {
|
|
lines.push(verdictText(v.seal));
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
// The outer map of SECURITY_CBOR: keys 2 and 3, undefined when absent.
|
|
interface SecurityWire {
|
|
signature: Uint8Array | undefined;
|
|
seal: Uint8Array | undefined;
|
|
}
|
|
|
|
function decodeWire(d: Decoder): SecurityWire {
|
|
const w: SecurityWire = { signature: undefined, seal: undefined };
|
|
const pairs = d.map(4);
|
|
const seen = new Set<number>();
|
|
for (let i = 0; i < pairs; i++) {
|
|
const k = d.key();
|
|
const field = fieldOf(k);
|
|
switch (k) {
|
|
case 0:
|
|
field(() => d.text(utf8Length(SECURITY_TYPE_TAG)));
|
|
break;
|
|
case 1:
|
|
field(() => d.uint(SECURITY_VERSION));
|
|
break;
|
|
case 2:
|
|
w.signature = field(() => d.bstr(1, MAX_SECURITY_ITEM));
|
|
break;
|
|
case 3:
|
|
w.seal = field(() => d.bstr(1, MAX_SECURITY_ITEM));
|
|
break;
|
|
default:
|
|
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is not defined`);
|
|
}
|
|
seen.add(Number(k));
|
|
}
|
|
requireKeys(seen, 2);
|
|
d.endMap();
|
|
return w;
|
|
}
|
|
|
|
function encodeWire(e: Encoder, w: SecurityWire): void {
|
|
e.map(2 + (w.signature === undefined ? 0 : 1) + (w.seal === undefined ? 0 : 1));
|
|
e.uint(0);
|
|
e.text(SECURITY_TYPE_TAG);
|
|
e.uint(1);
|
|
e.uint(SECURITY_VERSION);
|
|
if (w.signature !== undefined) {
|
|
e.uint(2);
|
|
e.bstr(w.signature);
|
|
}
|
|
if (w.seal !== undefined) {
|
|
e.uint(3);
|
|
e.bstr(w.seal);
|
|
}
|
|
}
|
|
|
|
/** SECURITY_CBOR as a writer of this version writes it: empty, {0: "datekeys-security", 1: 1}, 22 bytes (spec §29.3). */
|
|
export function encodeSecurity(): Uint8Array {
|
|
const e = new Encoder();
|
|
encodeWire(e, { signature: undefined, seal: undefined });
|
|
return e.out();
|
|
}
|
|
|
|
// The content of key 2: {0: alg, 1: key, 2: signature}.
|
|
interface AuthorSignature {
|
|
alg: number;
|
|
key: Uint8Array;
|
|
value: Uint8Array;
|
|
}
|
|
|
|
function decodeAuthorSignature(d: Decoder): AuthorSignature {
|
|
const a: AuthorSignature = { alg: 0, key: new Uint8Array(0), value: new Uint8Array(0) };
|
|
const pairs = d.map(3);
|
|
const seen = new Set<number>();
|
|
for (let i = 0; i < pairs; i++) {
|
|
const k = d.key();
|
|
fieldOf(k)(() => {
|
|
switch (k) {
|
|
case 0:
|
|
a.alg = decodeAlg(d);
|
|
break;
|
|
case 1:
|
|
a.key = d.bstr(0, MAX_SECURITY_ITEM);
|
|
break;
|
|
case 2:
|
|
a.value = d.bstr(0, MAX_SECURITY_ITEM);
|
|
break;
|
|
default:
|
|
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is not defined`);
|
|
}
|
|
});
|
|
seen.add(Number(k));
|
|
}
|
|
requireKeys(seen, 3);
|
|
d.endMap();
|
|
return a;
|
|
}
|
|
|
|
function encodeAuthorSignature(e: Encoder, a: AuthorSignature): void {
|
|
e.map(3);
|
|
e.uint(0);
|
|
e.uint(a.alg);
|
|
e.uint(1);
|
|
e.bstr(a.key);
|
|
e.uint(2);
|
|
e.bstr(a.value);
|
|
}
|
|
|
|
// The content of key 3: {0: seal_type, 1: token}.
|
|
interface Seal {
|
|
sealType: number;
|
|
token: Uint8Array;
|
|
}
|
|
|
|
function decodeSeal(d: Decoder): Seal {
|
|
const s: Seal = { sealType: 0, token: new Uint8Array(0) };
|
|
const pairs = d.map(2);
|
|
const seen = new Set<number>();
|
|
for (let i = 0; i < pairs; i++) {
|
|
const k = d.key();
|
|
fieldOf(k)(() => {
|
|
switch (k) {
|
|
case 0:
|
|
s.sealType = decodeAlg(d);
|
|
break;
|
|
case 1:
|
|
s.token = d.bstr(0, MAX_SECURITY_ITEM);
|
|
break;
|
|
default:
|
|
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is not defined`);
|
|
}
|
|
});
|
|
seen.add(Number(k));
|
|
}
|
|
requireKeys(seen, 2);
|
|
d.endMap();
|
|
return s;
|
|
}
|
|
|
|
function encodeSeal(e: Encoder, s: Seal): void {
|
|
e.map(2);
|
|
e.uint(0);
|
|
e.uint(s.sealType);
|
|
e.uint(1);
|
|
e.bstr(s.token);
|
|
}
|
|
|
|
// Reads alg or seal_type, from 1 to 2^32 - 1.
|
|
function decodeAlg(d: Decoder): number {
|
|
const v = d.uint(MAX_ALG);
|
|
if (v === 0) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', '0 is not defined');
|
|
return v;
|
|
}
|
|
|
|
// What the evaluation of the signature gives, and that of the seal.
|
|
type SignatureVerdicts = Pick<Verdicts, 'signature' | 'authorKey' | 'authorLabel' | 'detail'>;
|
|
type SealVerdicts = { seal: Verdict; sealHolder?: string; sealTime?: Instant };
|
|
|
|
// Runs an evaluation whose failure is a verdict, never an error: its result,
|
|
// or `failed` when it throws, whatever it throws. The decoders throw a
|
|
// DateKeysError for content that breaks its schema; anything else is a fault
|
|
// that no input should cause, of this library or of noble, and gives the
|
|
// same verdict, since the security area never decides the opening (spec
|
|
// §29.3).
|
|
function attempt<T, F>(evaluate: () => T, failed: F): T | F {
|
|
try {
|
|
return evaluate();
|
|
} catch {
|
|
return failed;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads SECURITY_CBOR and returns its verdicts (spec §29.3, §29.7). It never
|
|
* throws: the security area never decides the opening. For the signature and
|
|
* for the seal apart, the first row of the table of §29.7 that holds
|
|
* decides: alg and seal_type are read only from content that decodes and
|
|
* meets its schema. An exception while the signature is evaluated gives F1,
|
|
* and one while the seal is, S2, each without touching the other verdict.
|
|
*/
|
|
export function evaluateSecurity(b: Uint8Array, context?: SecurityContext): Verdicts {
|
|
const w = attempt(() => {
|
|
const h = peek(b);
|
|
return h.typeTag === SECURITY_TYPE_TAG && h.version === SECURITY_VERSION ? unmarshal(b, decodeWire, encodeWire) : undefined;
|
|
}, undefined);
|
|
if (w === undefined) return { signature: 'X', seal: 'X' };
|
|
const { signature, seal } = w;
|
|
const sig: SignatureVerdicts =
|
|
signature === undefined ? { signature: 'F0' } : attempt(() => evaluateSignature(signature, seal !== undefined, context), { signature: 'F1' as const });
|
|
const sealed: SealVerdicts = seal === undefined ? { seal: 'S0' } : attempt(() => evaluateSealArea(seal, signature, context), { seal: 'S2' as const });
|
|
const detail: Detail | undefined =
|
|
sig.detail === undefined && sealed.sealHolder === undefined
|
|
? undefined
|
|
: {
|
|
signers: sig.detail?.signers ?? [],
|
|
foreign: sig.detail?.foreign ?? [],
|
|
...(sealed.sealHolder === undefined ? {} : { sealHolder: sealed.sealHolder, sealTime: sealed.sealTime! }),
|
|
};
|
|
return { ...sig, seal: sealed.seal, ...(detail === undefined ? {} : { detail }) };
|
|
}
|
|
|
|
// The verdict of the content of key 3 (spec §29.7, §29.11): S2 for content that does not decode, S1 for a seal_type
|
|
// that is not 2 and, without the context of a capsule, as in v0.10, for seal_type 2 too; otherwise the token is checked.
|
|
function evaluateSealArea(seal: Uint8Array, signature: Uint8Array | undefined, context: SecurityContext | undefined): SealVerdicts {
|
|
const s = attempt(() => unmarshal(seal, decodeSeal, encodeSeal), undefined);
|
|
if (s === undefined) return { seal: 'S2' };
|
|
if (s.sealType !== SEAL_TYPE_RFC3161 || context === undefined) return { seal: 'S1' };
|
|
return evaluateSeal(s.token, signature, context.controlCommit, context.headDigest, context.roundTime);
|
|
}
|
|
|
|
// The verdict of the content of key 2 (spec §29.7, §29.9, §29.10): F1 for
|
|
// content that does not decode, an alg this reader does not implement, or a
|
|
// key or a signature of alg 1 of another length, and without the context of
|
|
// a capsule, as in v0.10; alg 2, a signature with certificates, goes to
|
|
// evaluateCMS, which gives F1, F2, F5 or F6; alg 1 is F2 when the signature
|
|
// does not verify with the strict profile, and F3 or F4 when it does.
|
|
function evaluateSignature(content: Uint8Array, hasSeal: boolean, context: SecurityContext | undefined): SignatureVerdicts {
|
|
const unchecked = { signature: 'F1' } as const;
|
|
if (context === undefined) return unchecked;
|
|
const a = attempt(() => unmarshal(content, decodeAuthorSignature, encodeAuthorSignature), undefined);
|
|
if (a === undefined) return unchecked;
|
|
if (a.alg === ALG_CMS) {
|
|
const r = evaluateCMS(a.key, a.value, hasSeal, context.controlCommit, context.headDigest, context.roundTime);
|
|
return r === undefined ? unchecked : r;
|
|
}
|
|
if (a.alg !== ALG_ED25519 || a.key.length !== 32 || a.value.length !== 64) return unchecked;
|
|
const message = authorMessage(context.controlCommit, context.headDigest, signersDigest(ALG_ED25519));
|
|
if (!verifyStrict(a.key, message, a.value)) return { signature: 'F2' };
|
|
const label = context.authorKeys?.get(bech32Encode('dkauthor', a.key));
|
|
return label === undefined ? { signature: 'F4', authorKey: a.key } : { signature: 'F3', authorKey: a.key, authorLabel: label };
|
|
}
|