ed25519strict.ts checks the four conditions of spec v0.11 29.9 on top of the arithmetic of @noble/curves and gives the answer of Go on the 18 vectors of ed25519_strict.json. author.ts computes payload_commit, control_commit, head_digest, signers_digest, AUTHOR_MESSAGE and its code, as the record of format3_signed says. evaluateSecurity takes the context of the capsule and gives F2, F3 or F4; open passes it, and OpenOptions.authorKeys are the keys the person saved. No new package: both modules use @noble/curves and @noble/hashes, which were already in the bundle. Alg 2 and the time seal are still read as v0.10 reads them, and the tests say so with testing/pending.ts. npm run verify passes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>main
parent
e3cf2409cd
commit
c31ab2d2e7
@ -0,0 +1,135 @@
|
||||
// Tests of author.ts and of the verdicts of a signature of alg 1 (spec v0.11,
|
||||
// §29.7 to §29.9) against format3_signed, the fixture of the Go reference whose
|
||||
// record gives every commitment, the message and the key.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ALG_CMS, ALG_ED25519, AUTHOR_MESSAGE_SIZE, authorCode, authorMessage, controlCommit, headDigest, payloadCommit, signersDigest } from './author.ts';
|
||||
import { bech32Decode, bech32Encode } from './bech32.ts';
|
||||
import { Encoder } from './cbor.ts';
|
||||
import { type Control, decodeControl, encodeControl } from './control.ts';
|
||||
import { FORMAT_3 } from './framing.ts';
|
||||
import { encodeSecurity, evaluateSecurity, type SecurityContext, verdictLines } from './security.ts';
|
||||
import { h, hx, readJSON } from './testing/testdata.ts';
|
||||
|
||||
interface Record {
|
||||
control_cbor: string;
|
||||
head_cbor: string;
|
||||
security_cbor: string;
|
||||
signature: {
|
||||
control_commit: string;
|
||||
head_digest: string;
|
||||
signers_digest: string;
|
||||
author_message: string;
|
||||
author_code: string;
|
||||
author_key: string;
|
||||
signature: string;
|
||||
};
|
||||
verdicts: { lines: string[] };
|
||||
}
|
||||
|
||||
const fx = readJSON<Record>('fixtures/format3_signed.json');
|
||||
const control = decodeControl(h(fx.control_cbor), FORMAT_3);
|
||||
const context = (): SecurityContext => ({ controlCommit: controlCommit(control, FORMAT_3), headDigest: headDigest(h(fx.head_cbor)) });
|
||||
const security = h(fx.security_cbor);
|
||||
const key = bech32Decode(fx.signature.author_key).data;
|
||||
const value = h(fx.signature.signature);
|
||||
|
||||
// SECURITY_CBOR with an author-signature of alg `alg`, the key and the signature value given.
|
||||
function area(alg: number, k: Uint8Array, v: Uint8Array): Uint8Array {
|
||||
const c = new Encoder();
|
||||
c.map(3);
|
||||
c.uint(0);
|
||||
c.uint(alg);
|
||||
c.uint(1);
|
||||
c.bstr(k);
|
||||
c.uint(2);
|
||||
c.bstr(v);
|
||||
return withKey2(c.out());
|
||||
}
|
||||
|
||||
function withKey2(content: Uint8Array): Uint8Array {
|
||||
const e = new Encoder();
|
||||
e.map(3);
|
||||
e.uint(0);
|
||||
e.text('datekeys-security');
|
||||
e.uint(1);
|
||||
e.uint(1);
|
||||
e.uint(2);
|
||||
e.bstr(content);
|
||||
return e.out();
|
||||
}
|
||||
|
||||
describe('what is signed', () => {
|
||||
it('computes the commitments, the message and the code that the reference records', () => {
|
||||
const cc = controlCommit(control, FORMAT_3);
|
||||
const hd = headDigest(h(fx.head_cbor));
|
||||
const sd = signersDigest(ALG_ED25519);
|
||||
const message = authorMessage(cc, hd, sd);
|
||||
expect([hx(cc), hx(hd), hx(sd), new TextDecoder().decode(message), authorCode(message)]).toEqual([
|
||||
fx.signature.control_commit,
|
||||
fx.signature.head_digest,
|
||||
fx.signature.signers_digest,
|
||||
fx.signature.author_message,
|
||||
fx.signature.author_code,
|
||||
]);
|
||||
expect(message.length).toBe(AUTHOR_MESSAGE_SIZE);
|
||||
expect(AUTHOR_MESSAGE_SIZE).toBe(99);
|
||||
expect(authorCode(message.subarray(1))).toBe('');
|
||||
});
|
||||
|
||||
it('binds alg and the list of signers, and leaves L out and I_PAYLOAD hidden', () => {
|
||||
expect(hx(signersDigest(ALG_ED25519))).not.toBe(hx(signersDigest(ALG_CMS)));
|
||||
expect(hx(signersDigest(ALG_CMS, Uint8Array.of(1)))).not.toBe(hx(signersDigest(ALG_CMS, Uint8Array.of(2))));
|
||||
const base = controlCommit(control, FORMAT_3);
|
||||
// L does not enter control_commit, so an area can grow after signing.
|
||||
expect(hx(controlCommit({ ...control, payloadLength: 1 << 30 }, FORMAT_3))).toBe(hx(base));
|
||||
// I_PAYLOAD does, through its commitment.
|
||||
const other: Control = { ...control, payloadIdentity: control.payloadIdentity.map((b) => b ^ 1) };
|
||||
expect(hx(controlCommit(other, FORMAT_3))).not.toBe(hx(base));
|
||||
expect(hx(payloadCommit(control.payloadIdentity))).not.toBe(hx(control.payloadIdentity));
|
||||
expect(encodeControl(control, FORMAT_3)).toHaveLength(encodeControl(other, FORMAT_3).length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the verdicts of a signature of alg 1', () => {
|
||||
it('gives F4 with the key of the signature, and F3 when the person saved it', () => {
|
||||
const v = evaluateSecurity(security, context());
|
||||
expect([v.signature, v.seal, hx(v.authorKey!)]).toEqual(['F4', 'S0', hx(key)]);
|
||||
expect(verdictLines(v)).toEqual(fx.verdicts.lines);
|
||||
|
||||
const saved = evaluateSecurity(security, { ...context(), authorKeys: new Map([[fx.signature.author_key, 'Ana']]) });
|
||||
expect([saved.signature, saved.authorLabel]).toEqual(['F3', 'Ana']);
|
||||
expect(verdictLines(saved)).toEqual(['Firmado con la clave que guardaste como Ana.']);
|
||||
// Another saved key is not this one.
|
||||
const luis = new Map([[bech32Encode('dkauthor', new Uint8Array(32).fill(7)), 'Luis']]);
|
||||
expect(evaluateSecurity(security, { ...context(), authorKeys: luis }).signature).toBe('F4');
|
||||
});
|
||||
|
||||
it('gives F2 in another capsule, and F1 without a context or for what it does not check', () => {
|
||||
expect(evaluateSecurity(security, { ...context(), headDigest: headDigest(Uint8Array.of(1)) }).signature).toBe('F2');
|
||||
expect(evaluateSecurity(security, { ...context(), controlCommit: new Uint8Array(32) }).signature).toBe('F2');
|
||||
expect(evaluateSecurity(security).signature).toBe('F1');
|
||||
|
||||
expect(evaluateSecurity(area(ALG_ED25519, key, value), context()).signature).toBe('F4');
|
||||
const flipped = value.slice();
|
||||
flipped[5] = flipped[5]! ^ 1;
|
||||
expect(evaluateSecurity(area(ALG_ED25519, key, flipped), context()).signature).toBe('F2');
|
||||
expect(evaluateSecurity(area(ALG_CMS, key, value), context()).signature).toBe('F1');
|
||||
expect(evaluateSecurity(area(4294967295, key, value), context()).signature).toBe('F1');
|
||||
expect(evaluateSecurity(area(ALG_ED25519, key.subarray(1), value), context()).signature).toBe('F1');
|
||||
expect(evaluateSecurity(area(ALG_ED25519, key, value.subarray(1)), context()).signature).toBe('F1');
|
||||
// Content that does not decode, or whose map has a key that no version defines.
|
||||
expect(evaluateSecurity(withKey2(Uint8Array.of(0xff)), context()).signature).toBe('F1');
|
||||
const unknown = new Encoder();
|
||||
unknown.map(3);
|
||||
unknown.uint(0);
|
||||
unknown.uint(ALG_ED25519);
|
||||
unknown.uint(1);
|
||||
unknown.bstr(key);
|
||||
unknown.uint(3);
|
||||
unknown.bstr(value);
|
||||
expect(evaluateSecurity(withKey2(unknown.out()), context()).signature).toBe('F1');
|
||||
// No signature: F0, whatever the context.
|
||||
expect(evaluateSecurity(encodeSecurity(), context()).signature).toBe('F0');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,77 @@
|
||||
// What an author signs and a seal seals (spec v0.11, §29.8, §29.11), as the Go
|
||||
// package capsule computes it (signature.go): payload_commit, control_commit
|
||||
// over CONTROL_SIG, head_digest, signers_digest, and AUTHOR_MESSAGE, the ASCII
|
||||
// text of 99 bytes that is signed, with its code. Every value is recomputed
|
||||
// from the capsule once it is open; none is stored. Internal: index.ts does
|
||||
// not re-export it.
|
||||
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { concatBytes, toHex } from './bytes.ts';
|
||||
import { type Control, encodeControl } from './control.ts';
|
||||
import type { Format } from './framing.ts';
|
||||
|
||||
const PAYLOAD_COMMIT_PREFIX = 'datekeys:dkc3:payload:v1';
|
||||
const CONTROL_COMMIT_PREFIX = 'datekeys:dkc3:control:v1';
|
||||
const HEAD_DIGEST_PREFIX = 'datekeys:dkc3:head:v1';
|
||||
const SIGNERS_DIGEST_PREFIX = 'datekeys:dkc3:signers:v1';
|
||||
/** The first line of AUTHOR_MESSAGE. */
|
||||
export const AUTHOR_MESSAGE_PREFIX = 'datekeys:dkc3:author-signature:v1';
|
||||
/** The length of AUTHOR_MESSAGE: the prefix, a line feed, the 64 hexadecimal digits of its digest and a line feed. */
|
||||
export const AUTHOR_MESSAGE_SIZE = AUTHOR_MESSAGE_PREFIX.length + 1 + 64 + 1;
|
||||
|
||||
/** The values of alg that spec v0.11 defines (§29.3). */
|
||||
export const ALG_ED25519 = 1;
|
||||
export const ALG_CMS = 2;
|
||||
|
||||
const text = new TextEncoder();
|
||||
|
||||
// SHA-256(prefix || 0x00 || parts...): each prefix is ASCII and is followed by a byte 0x00.
|
||||
function domainHash(prefix: string, ...parts: Uint8Array[]): Uint8Array {
|
||||
return sha256(concatBytes(text.encode(prefix), Uint8Array.of(0), ...parts));
|
||||
}
|
||||
|
||||
/** The commitment to I_PAYLOAD that replaces it in what is signed (spec §29.8). */
|
||||
export function payloadCommit(identity: Uint8Array): Uint8Array {
|
||||
return domainHash(PAYLOAD_COMMIT_PREFIX, identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* control_commit: the hash of CONTROL_SIG, the control of a capsule of format
|
||||
* 3 with payload_commit in place of I_PAYLOAD and the eight bytes of L at
|
||||
* zero (spec §29.8). It does not depend on L, so the area can grow after
|
||||
* signing. The encoded control holds the commitment and not I_PAYLOAD, and
|
||||
* is wiped.
|
||||
*/
|
||||
export function controlCommit(c: Control, format: Format): Uint8Array {
|
||||
const b = encodeControl({ ...c, payloadIdentity: payloadCommit(c.payloadIdentity), payloadLength: 0 }, format);
|
||||
try {
|
||||
return domainHash(CONTROL_COMMIT_PREFIX, b);
|
||||
} finally {
|
||||
b.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** head_digest, the hash of HEAD_CBOR (spec §29.8). The salt of the head makes it a commitment that hides the files. */
|
||||
export function headDigest(head: Uint8Array): Uint8Array {
|
||||
return domainHash(HEAD_DIGEST_PREFIX, head);
|
||||
}
|
||||
|
||||
/** signers_digest for alg and the exact content of key 1 of a signature of alg 2, `signers`; none with alg 1 (spec §29.8). */
|
||||
export function signersDigest(alg: number, signers?: Uint8Array): Uint8Array {
|
||||
const a = new Uint8Array(4);
|
||||
new DataView(a.buffer).setUint32(0, alg);
|
||||
return domainHash(SIGNERS_DIGEST_PREFIX, a, signers ?? new Uint8Array(0));
|
||||
}
|
||||
|
||||
/** AUTHOR_MESSAGE: the prefix, a line feed, the hexadecimal digest D of the three commitments and a line feed (spec §29.8). */
|
||||
export function authorMessage(controlCommitment: Uint8Array, headDigestValue: Uint8Array, signersDigestValue: Uint8Array): Uint8Array {
|
||||
const d = sha256(concatBytes(controlCommitment, headDigestValue, signersDigestValue));
|
||||
return text.encode(`${AUTHOR_MESSAGE_PREFIX}\n${toHex(d)}\n`);
|
||||
}
|
||||
|
||||
/** The code of AUTHOR_MESSAGE that a person compares before signing: the first 8 hexadecimal digits of its digest, in two groups of 4. */
|
||||
export function authorCode(message: Uint8Array): string {
|
||||
if (message.length !== AUTHOR_MESSAGE_SIZE) return '';
|
||||
const d = new TextDecoder().decode(message.subarray(AUTHOR_MESSAGE_PREFIX.length + 1));
|
||||
return `${d.slice(0, 4)}-${d.slice(4, 8)}`;
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
// Tests of ed25519strict.ts against the vectors of the Go reference: the
|
||||
// strict profile of the author signature (spec v0.11, §29.9).
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { verifyStrict } from './ed25519strict.ts';
|
||||
import { h, readJSON } from './testing/testdata.ts';
|
||||
|
||||
interface Vector {
|
||||
name: string;
|
||||
message: string;
|
||||
public_key: string;
|
||||
signature: string;
|
||||
valid: boolean;
|
||||
stdlib: boolean;
|
||||
}
|
||||
|
||||
describe('verifyStrict', () => {
|
||||
const file = readJSON<{ vectors: Vector[] }>('vectors/ed25519_strict.json');
|
||||
|
||||
it('gives the answer of the reference on every vector of ed25519_strict.json', () => {
|
||||
expect(file.vectors.length).toBeGreaterThanOrEqual(18);
|
||||
for (const v of file.vectors) {
|
||||
expect(verifyStrict(h(v.public_key), h(v.message), h(v.signature)), v.name).toBe(v.valid);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses what a looser verifier accepts: the cases where the standard library and the profile differ are in the file', () => {
|
||||
const loose = file.vectors.filter((v) => v.stdlib && !v.valid);
|
||||
expect(loose.length).toBeGreaterThan(0);
|
||||
for (const v of loose) expect(verifyStrict(h(v.public_key), h(v.message), h(v.signature)), v.name).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a key or a signature of another length', () => {
|
||||
const v = file.vectors[0]!;
|
||||
expect(verifyStrict(h(v.public_key).subarray(1), h(v.message), h(v.signature))).toBe(false);
|
||||
expect(verifyStrict(h(v.public_key), h(v.message), h(v.signature).subarray(1))).toBe(false);
|
||||
expect(verifyStrict(h(v.public_key), h(v.message), h(v.signature))).toBe(true);
|
||||
// A bit of the message, of R and of S.
|
||||
const bad = (i: number): Uint8Array => {
|
||||
const s = h(v.signature);
|
||||
s[i] = s[i]! ^ 1;
|
||||
return s;
|
||||
};
|
||||
expect(verifyStrict(h(v.public_key), Uint8Array.of(1), h(v.signature))).toBe(false);
|
||||
expect(verifyStrict(h(v.public_key), h(v.message), bad(0))).toBe(false);
|
||||
expect(verifyStrict(h(v.public_key), h(v.message), bad(40))).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,63 @@
|
||||
// The strict verification of the author signature of alg 1 (spec v0.11,
|
||||
// §29.9), as the Go package internal/ed25519strict does it. A signature is
|
||||
// valid if and only if all four conditions hold:
|
||||
//
|
||||
// 1. A is the canonical encoding of a point: its y is less than p, and if y
|
||||
// is 1 or p - 1 the sign bit is 0;
|
||||
// 2. A decodes to a point that is not of small order;
|
||||
// 3. sig[63] & 0xE0 is 0, and S, as a little-endian integer, is less than
|
||||
// the order of the group, l;
|
||||
// 4. [S]B - [k]A encodes exactly R, with k = SHA-512(R || A || message) mod
|
||||
// l: the equation of RFC 8032 without the cofactor.
|
||||
//
|
||||
// @noble/curves 2.4.0 `verify` always uses the equation with the cofactor and
|
||||
// accepts what condition 1 and the check of the encoding of R refuse, so the
|
||||
// checks are made here and noble only does the arithmetic of the group. The
|
||||
// vectors are testdata/vectors/ed25519_strict.json. Internal: index.ts does
|
||||
// not re-export it.
|
||||
|
||||
import { ed25519 } from '@noble/curves/ed25519.js';
|
||||
import { sha512 } from '@noble/hashes/sha2.js';
|
||||
import { concatBytes, equalBytes } from './bytes.ts';
|
||||
|
||||
const P = 2n ** 255n - 19n;
|
||||
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
|
||||
const MASK_255 = (1n << 255n) - 1n;
|
||||
|
||||
function leToBigint(b: Uint8Array): bigint {
|
||||
let n = 0n;
|
||||
for (let i = b.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(b[i]!);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Whether `sig` is a valid Ed25519 signature of `message` by the key `publicKey` under the strict profile of spec §29.9. */
|
||||
export function verifyStrict(publicKey: Uint8Array, message: Uint8Array, sig: Uint8Array): boolean {
|
||||
if (publicKey.length !== 32 || sig.length !== 64) return false;
|
||||
|
||||
// 1. A is canonical.
|
||||
const y = leToBigint(publicKey) & MASK_255;
|
||||
const sign = publicKey[31]! >> 7;
|
||||
if (y >= P) return false;
|
||||
if ((y === 1n || y === P - 1n) && sign !== 0) return false;
|
||||
|
||||
// 2. A decodes, and is not of small order.
|
||||
let a;
|
||||
try {
|
||||
a = ed25519.Point.fromBytes(publicKey, false);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (a.isSmallOrder()) return false;
|
||||
|
||||
// 3. S is canonical.
|
||||
if ((sig[63]! & 0xe0) !== 0) return false;
|
||||
const s = leToBigint(sig.subarray(32));
|
||||
if (s >= L) return false;
|
||||
|
||||
// 4. [S]B - [k]A encodes exactly R.
|
||||
const r = sig.subarray(0, 32);
|
||||
const k = leToBigint(sha512(concatBytes(r, publicKey, message))) % L;
|
||||
const sb = s === 0n ? ed25519.Point.ZERO : ed25519.Point.BASE.multiplyUnsafe(s);
|
||||
const ka = k === 0n ? ed25519.Point.ZERO : a.multiplyUnsafe(k);
|
||||
return equalBytes(sb.subtract(ka).toBytes(), r);
|
||||
}
|
||||
Loading…
Reference in new issue