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.
DateKeys-App/src/lib/dkc/security.ts

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 };
}

Powered by TurnKey Linux.