- recipient.ts: age1… recipients as age 1.3.2 reads and writes them, the rules of spec §37 with the texts of agewrap.CheckX25519Recipient (the five low-order u checked by list, the twist accepted as in Go), and a recipient list read line by line. No noble. - random.ts: an index without bias and the Fisher-Yates permutation of the 16 slots, with Go's uniformity test. - agefile.ts: the whole-age-file helpers of the opening, shared with the writer's self-checks. - x25519.ts: newX25519Identity and x25519PublicKey (RFC 7748 vectors); digest.ts: sha256Hasher; datekey.ts: compareInstants, used by open.ts, and isInstant. - tempfile.ts: an area for the opening and one for creating capsules. - Guards: age-encryption and the writer core have import allowlists, and index.ts re-exports neither the opening nor the writer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
8c08c97040
commit
8838c7dd05
@ -0,0 +1,73 @@
|
||||
// Whole age files through the Decrypter of age-encryption, for the opening
|
||||
// (open.ts) and for the self-checks of the writer (phase 3): a failure that
|
||||
// an identity of this module reports keeps its normative error, and any other
|
||||
// failure of age is ERR_INTEGRITY with the fixed reason of its phase, the
|
||||
// header or the STREAM, never the text of age-encryption. Plaintexts read
|
||||
// into memory are wiped chunk by chunk as they are copied, and on failure.
|
||||
// Internal: index.ts does not re-export it.
|
||||
|
||||
import { Decrypter, type Identity } from 'age-encryption';
|
||||
import { DateKeysError } from './errors.ts';
|
||||
|
||||
// The failures of age that no identity reports, by phase: the header, with
|
||||
// its MAC checked once an identity unwrapped the file key, and the STREAM.
|
||||
export const HEADER_FAILURE = 'the age header is malformed or truncated, or its MAC does not verify';
|
||||
export const STREAM_FAILURE = 'the age payload is truncated, has trailing data or fails STREAM authentication';
|
||||
|
||||
/** A stream that yields `b` in one chunk. */
|
||||
export const streamOf = (b: Uint8Array): ReadableStream<Uint8Array> =>
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(b);
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The error of `what` for a failure of age: the normative error an identity
|
||||
* reported, prefixed, or ERR_INTEGRITY with `reason`.
|
||||
*/
|
||||
export function classify(what: string, reason: string, err: unknown): DateKeysError {
|
||||
if (err instanceof DateKeysError) return err.wrap(`capsule: ${what}`);
|
||||
return new DateKeysError('ERR_INTEGRITY', `capsule: ${what}: ${reason}`, err);
|
||||
}
|
||||
|
||||
/** The plaintext of an age file with one identity, as a stream, once its header opened. */
|
||||
export async function decrypt(file: ReadableStream<Uint8Array>, identity: Identity, what: string): Promise<ReadableStream<Uint8Array>> {
|
||||
const d = new Decrypter();
|
||||
d.addIdentity(identity);
|
||||
try {
|
||||
return await d.decrypt(file);
|
||||
} catch (err) {
|
||||
throw classify(what, HEADER_FAILURE, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a bounded age file in memory with one identity. The caller wipes the result. */
|
||||
export async function decryptAll(file: Uint8Array, identity: Identity, what: string): Promise<Uint8Array> {
|
||||
return readAll(await decrypt(streamOf(file), identity, what), file.length, what);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a plaintext of at most `max` bytes, the length of its ciphertext,
|
||||
* into one buffer, wiping every chunk it copies; on a failure of the STREAM
|
||||
* the buffer is wiped too.
|
||||
*/
|
||||
export async function readAll(plain: ReadableStream<Uint8Array>, max: number, what: string): Promise<Uint8Array> {
|
||||
const out = new Uint8Array(max);
|
||||
let n = 0;
|
||||
const reader = plain.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
out.set(value, n);
|
||||
n += value.length;
|
||||
value.fill(0);
|
||||
}
|
||||
} catch (err) {
|
||||
out.fill(0);
|
||||
throw classify(what, STREAM_FAILURE, err);
|
||||
}
|
||||
return out.subarray(0, n);
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
// Tests of random.ts: the index without bias and the uniform permutation of
|
||||
// the 16 slots of INNER_ACCESS_AGE (spec §39, §62.1 rule 4).
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cryptoWords, permute, randomIndex, type RandomWords } from './random.ts';
|
||||
|
||||
// A seeded source of 32-bit words (mulberry32), so that the statistical
|
||||
// test is deterministic; its seed is in the test name.
|
||||
function seeded(seed: number): RandomWords {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
};
|
||||
}
|
||||
const sequence = (words: number[]): RandomWords => {
|
||||
let i = 0;
|
||||
return () => {
|
||||
if (i >= words.length) throw new Error('sequence exhausted');
|
||||
return words[i++]!;
|
||||
};
|
||||
};
|
||||
|
||||
describe('randomIndex', () => {
|
||||
it('draws again exactly the words from ⌊2^32 / n⌋·n up', () => {
|
||||
// n = 3: 2^32 = 3·1431655765 + 1, so only 4294967295 is rejected.
|
||||
expect(randomIndex(3, sequence([4294967295, 7]))).toBe(1);
|
||||
expect(randomIndex(3, sequence([4294967294]))).toBe(4294967294 % 3);
|
||||
// n = 16 divides 2^32: nothing is rejected.
|
||||
expect(randomIndex(16, sequence([4294967295]))).toBe(15);
|
||||
// n = 10: the limit is 4294967290.
|
||||
expect(randomIndex(10, sequence([4294967290, 4294967295, 4294967289]))).toBe(9);
|
||||
expect(randomIndex(1, sequence([123]))).toBe(0);
|
||||
expect(randomIndex(2 ** 32, sequence([4294967295]))).toBe(4294967295);
|
||||
});
|
||||
|
||||
it('rejects an n without a uniform index, and words that are not 32-bit', () => {
|
||||
for (const n of [0, -1, 1.5, 2 ** 32 + 1, Number.NaN]) expect(() => randomIndex(n, sequence([0])), String(n)).toThrow(RangeError);
|
||||
for (const w of [-1, 2 ** 32, 0.5]) expect(() => randomIndex(5, sequence([w])), String(w)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('draws words from crypto.getRandomValues', () => {
|
||||
const words = cryptoWords();
|
||||
const drawn = Array.from({ length: 64 }, words);
|
||||
expect(drawn.every((w) => Number.isInteger(w) && w >= 0 && w < 2 ** 32)).toBe(true);
|
||||
expect(new Set(drawn).size).toBeGreaterThan(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permute', () => {
|
||||
it('permutes in place and keeps every element', () => {
|
||||
const items = Array.from({ length: 16 }, (_, i) => i);
|
||||
permute(items, seeded(1));
|
||||
expect([...items].sort((a, b) => a - b)).toEqual(Array.from({ length: 16 }, (_, i) => i));
|
||||
const one = ['a'];
|
||||
permute(one, sequence([]));
|
||||
expect(one).toEqual(['a']);
|
||||
const none: string[] = [];
|
||||
permute(none, sequence([]));
|
||||
expect(none).toEqual([]);
|
||||
});
|
||||
|
||||
// As TestStanzaOrderIsUniform of the Go reference: the positions of the
|
||||
// first and of the last element over 32 000 permutations of 16, each a
|
||||
// chi-square with 15 degrees of freedom under 60 (p ≈ 10⁻⁷ by chance).
|
||||
it.each([[20260929], [7]])('puts the first and the last element in every position uniformly (seed %i)', (seed) => {
|
||||
const words = seeded(seed);
|
||||
const n = 16;
|
||||
const rounds = 32000;
|
||||
const first = new Array<number>(n).fill(0);
|
||||
const last = new Array<number>(n).fill(0);
|
||||
for (let r = 0; r < rounds; r++) {
|
||||
const items = Array.from({ length: n }, (_, i) => i);
|
||||
permute(items, words);
|
||||
first[items.indexOf(0)]!++;
|
||||
last[items.indexOf(n - 1)]!++;
|
||||
}
|
||||
const chi2 = (counts: number[]): number => counts.reduce((s, c) => s + (c - rounds / n) ** 2 / (rounds / n), 0);
|
||||
expect(chi2(first)).toBeLessThan(60);
|
||||
expect(chi2(last)).toBeLessThan(60);
|
||||
});
|
||||
|
||||
// The modulo of a word alone would be biased: with words that favour
|
||||
// small values the frequencies drift, which the rejection prevents.
|
||||
it('does not take the modulo of a word that would bias the result', () => {
|
||||
// For n = 3 the only rejected word is 2^32 - 1, whose modulo would give 0.
|
||||
const draws = [4294967295, 1];
|
||||
expect(randomIndex(3, sequence(draws))).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,150 @@
|
||||
// Tests of recipient.ts: the age1… strings of age 1.3.2 and the rules of
|
||||
// spec §37 with the texts of agewrap.CheckX25519Recipient. noble is only the
|
||||
// oracle of the low-order list here; recipient.ts never imports it.
|
||||
|
||||
import { x25519 } from '@noble/curves/ed25519.js';
|
||||
import { generateX25519Identity, identityToRecipient } from 'age-encryption';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bech32Encode } from './bech32.ts';
|
||||
import {
|
||||
checkX25519Recipient,
|
||||
formatX25519Recipient,
|
||||
parseRecipientList,
|
||||
parseX25519Recipient,
|
||||
RecipientListError,
|
||||
recipientProblem,
|
||||
} from './recipient.ts';
|
||||
import { h, hx } from './testing/testdata.ts';
|
||||
import { newX25519Identity, parseX25519Identity, x25519PublicKey } from './x25519.ts';
|
||||
|
||||
const P = 2n ** 255n - 19n;
|
||||
const le = (n: bigint): Uint8Array => {
|
||||
const b = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) b[i] = Number((n >> BigInt(8 * i)) & 0xffn);
|
||||
return b;
|
||||
};
|
||||
const ORDER8A = 325606250916557431795983626356110631294008115727848805560023387167927233504n;
|
||||
const ORDER8B = 39382357235489614581723060781553021112529911719440698176882885853963445705823n;
|
||||
const failure = (fn: () => unknown): string => {
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
return (err as Error).message;
|
||||
}
|
||||
return 'no error';
|
||||
};
|
||||
|
||||
describe('age1… recipients', () => {
|
||||
it('formats and parses the recipient of an identity as age-encryption does', async () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const s = await generateX25519Identity();
|
||||
const r = await identityToRecipient(s);
|
||||
const raw = x25519PublicKey(parseX25519Identity(s));
|
||||
expect(formatX25519Recipient(raw)).toBe(r);
|
||||
expect(parseX25519Recipient(r)).toEqual(raw);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects what age.ParseX25519Recipient rejects, with its texts', () => {
|
||||
const raw = x25519PublicKey(newX25519Identity());
|
||||
const good = formatX25519Recipient(raw);
|
||||
expect(failure(() => parseX25519Recipient(good.toUpperCase()))).toBe(`malformed recipient "${good.toUpperCase()}": invalid type "AGE"`);
|
||||
const mixed = good.slice(0, 10) + good.slice(10).toUpperCase();
|
||||
expect(failure(() => parseX25519Recipient(mixed))).toBe(`malformed recipient "${mixed}": mixed case`);
|
||||
for (const hrp of ['age1pq', 'age1tag', 'age1tagpq']) {
|
||||
const other = bech32Encode(hrp, raw);
|
||||
expect(failure(() => parseX25519Recipient(other))).toBe(`malformed recipient "${other}": invalid type "${hrp}"`);
|
||||
}
|
||||
for (const n of [31, 33]) {
|
||||
const s = bech32Encode('age', new Uint8Array(n).fill(9));
|
||||
expect(failure(() => parseX25519Recipient(s))).toBe(`malformed recipient "${s}": invalid X25519 public key`);
|
||||
}
|
||||
const flipped = good.slice(0, -1) + (good.endsWith('q') ? 'p' : 'q');
|
||||
expect(failure(() => parseX25519Recipient(flipped))).toBe(`malformed recipient "${flipped}": invalid checksum`);
|
||||
expect(() => formatX25519Recipient(new Uint8Array(31))).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the rules of §37', () => {
|
||||
it('accepts fresh keys and rejects the non-canonical ones and those of low order, with the texts of Go', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const raw = x25519PublicKey(newX25519Identity());
|
||||
expect(recipientProblem(raw)).toBeUndefined();
|
||||
expect(() => checkX25519Recipient(raw)).not.toThrow();
|
||||
}
|
||||
const text = (raw: Uint8Array): string => failure(() => checkX25519Recipient(raw));
|
||||
const bit255 = le(9n);
|
||||
bit255[31]! |= 0x80;
|
||||
expect(text(bit255)).toBe(`agewrap: recipient ${formatX25519Recipient(bit255)} is not canonical: bit 255 is set`);
|
||||
for (const u of [P, P + 1n, P + 18n]) {
|
||||
expect(text(le(u)), String(u)).toBe(`agewrap: recipient ${formatX25519Recipient(le(u))} is not canonical: u is not below 2^255 - 19`);
|
||||
}
|
||||
for (const u of [0n, 1n, ORDER8A, ORDER8B, P - 1n]) {
|
||||
expect(text(le(u)), String(u)).toBe(`agewrap: recipient ${formatX25519Recipient(le(u))} is a point of low order: the shared secret would be zero`);
|
||||
}
|
||||
// p - 2, 2 (a point of the twist, accepted as Go does) and 9 are canonical and not of low order.
|
||||
for (const u of [P - 2n, 2n, 9n]) expect(recipientProblem(le(u)), String(u)).toBeUndefined();
|
||||
expect(() => checkX25519Recipient(new Uint8Array(33))).toThrow(TypeError);
|
||||
});
|
||||
|
||||
// The list is the probe of Go: X25519 of a clamped scalar and u is all zero
|
||||
// exactly for these five canonical u. noble rejects the zero secret.
|
||||
it('lists exactly the canonical u whose shared secret is zero', () => {
|
||||
const scalar = new Uint8Array(32);
|
||||
scalar[0] = 1;
|
||||
const zero = (u: bigint): boolean => {
|
||||
try {
|
||||
return x25519.scalarMult(scalar, le(u)).every((b) => b === 0);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
for (const u of [0n, 1n, ORDER8A, ORDER8B, P - 1n]) expect(zero(u), String(u)).toBe(true);
|
||||
let checked = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const raw = crypto.getRandomValues(new Uint8Array(32));
|
||||
raw[31]! &= 0x7f;
|
||||
const u = BigInt(`0x${hx(raw.slice().reverse())}`);
|
||||
if (u >= P) continue;
|
||||
expect(zero(u), hx(raw)).toBe(recipientProblem(raw) === 'low order');
|
||||
checked++;
|
||||
}
|
||||
expect(checked).toBeGreaterThan(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRecipientList', () => {
|
||||
const r1 = formatX25519Recipient(x25519PublicKey(h('77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a')));
|
||||
const r2 = formatX25519Recipient(x25519PublicKey(h('5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb')));
|
||||
|
||||
it('reads an age recipients file, trimmed, with comments and blank lines', () => {
|
||||
const list = parseRecipientList(`# friends\r\n ${r1} \n\n\t# not a recipient\n${r2}\n`);
|
||||
expect(list.map(formatX25519Recipient)).toEqual([r1, r2]);
|
||||
expect(parseRecipientList('')).toEqual([]);
|
||||
expect(parseRecipientList('# only a comment\n\n')).toEqual([]);
|
||||
});
|
||||
|
||||
it('names the first bad line and why, never its content', () => {
|
||||
const bad = (text: string): [number, string, string] => {
|
||||
try {
|
||||
parseRecipientList(text);
|
||||
} catch (err) {
|
||||
const e = err as RecipientListError;
|
||||
expect(e).toBeInstanceOf(RecipientListError);
|
||||
return [e.line, e.problem, e.message];
|
||||
}
|
||||
return [0, 'none', ''];
|
||||
};
|
||||
const secret = 'AGE-SECRET-KEY-1QQQ';
|
||||
expect(bad(`${r1}\n${secret}`)).toEqual([2, 'identity', 'recipient list: line 2: identity']);
|
||||
expect(bad(`\n\nage1nope`)).toEqual([3, 'malformed', 'recipient list: line 3: malformed']);
|
||||
expect(bad(formatX25519Recipient(le(P)))).toEqual([1, 'not canonical', 'recipient list: line 1: not canonical']);
|
||||
const high = le(9n);
|
||||
high[31]! |= 0x80;
|
||||
expect(bad(formatX25519Recipient(high))[1]).toBe('not canonical');
|
||||
expect(bad(`${r2}\n${formatX25519Recipient(le(1n))}`)).toEqual([2, 'low order', 'recipient list: line 2: low order']);
|
||||
expect(bad(`${r1}\n${r2}\n${r1}`)).toEqual([3, 'duplicate', 'recipient list: line 3: duplicate']);
|
||||
// The message never holds the line.
|
||||
expect(bad(secret)[2]).not.toContain('SECRET');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,144 @@
|
||||
// X25519 recipients of age written as strings, age1…, and the rules a writer
|
||||
// applies to them (spec §37, §62.1 rule 3), as ParseX25519Recipient and
|
||||
// X25519Recipient.String of age 1.3.2 and agewrap.CheckX25519Recipient of the
|
||||
// Go reference, with their texts.
|
||||
//
|
||||
// No noble: a page checks the lines of a list as they are typed, before it
|
||||
// loads the writer. Canonicity is a comparison of bytes, and the low order a
|
||||
// list of the five canonical u-coordinates whose shared secret is all zero.
|
||||
// A canonical point of the twist is accepted, as Go does: §37 leaves its
|
||||
// rejection to the writer (MAY), and nobody could open its stanza.
|
||||
|
||||
import { bech32Decode, bech32Encode } from './bech32.ts';
|
||||
import { equalBytes, goQuote } from './bytes.ts';
|
||||
|
||||
/** The size of an X25519 public key. */
|
||||
export const X25519_RECIPIENT_LEN = 32;
|
||||
const HRP = 'age';
|
||||
|
||||
const le32 = (n: bigint): Uint8Array => {
|
||||
const b = new Uint8Array(X25519_RECIPIENT_LEN);
|
||||
for (let i = 0; i < b.length; i++) b[i] = Number((n >> BigInt(8 * i)) & 0xffn);
|
||||
return b;
|
||||
};
|
||||
const P = 2n ** 255n - 19n;
|
||||
|
||||
// The canonical u-coordinates of the points of low order of Curve25519 and
|
||||
// its twist: 0, 1, the two points of order 8 and p - 1. The scalars of
|
||||
// X25519 are clamped to multiples of 8, so its result with one of these is
|
||||
// all zero whatever the scalar, and with any other canonical u it never is:
|
||||
// the probe of agewrap.CheckX25519Recipient, which uses the scalar 1.
|
||||
const LOW_ORDER: readonly Uint8Array[] = [
|
||||
0n,
|
||||
1n,
|
||||
325606250916557431795983626356110631294008115727848805560023387167927233504n,
|
||||
39382357235489614581723060781553021112529911719440698176882885853963445705823n,
|
||||
P - 1n,
|
||||
].map(le32);
|
||||
|
||||
function checkLength(raw: Uint8Array): void {
|
||||
if (!(raw instanceof Uint8Array) || raw.length !== X25519_RECIPIENT_LEN) {
|
||||
throw new TypeError(`recipient: an X25519 public key is ${X25519_RECIPIENT_LEN} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The age1… string of a raw X25519 public key, as X25519Recipient.String of age. */
|
||||
export function formatX25519Recipient(raw: Uint8Array): string {
|
||||
checkLength(raw);
|
||||
return bech32Encode(HRP, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw 32 bytes of an age1… recipient, as age.ParseX25519Recipient reads
|
||||
* it, with its texts: lowercase Bech32 with the HRP age. AGE1…, mixed case,
|
||||
* age1pq1…, age1tag1… and any other length are rejected. It does not check
|
||||
* the rules of §37: see checkX25519Recipient.
|
||||
*/
|
||||
export function parseX25519Recipient(s: string): Uint8Array {
|
||||
let decoded: { hrp: string; data: Uint8Array };
|
||||
try {
|
||||
decoded = bech32Decode(s);
|
||||
} catch (err) {
|
||||
throw new Error(`malformed recipient ${goQuote(s)}: ${(err as Error).message}`);
|
||||
}
|
||||
if (decoded.hrp !== HRP) throw new Error(`malformed recipient ${goQuote(s)}: invalid type ${goQuote(decoded.hrp)}`);
|
||||
if (decoded.data.length !== X25519_RECIPIENT_LEN) throw new Error(`malformed recipient ${goQuote(s)}: invalid X25519 public key`);
|
||||
return decoded.data;
|
||||
}
|
||||
|
||||
/** Why a raw X25519 public key is not a recipient a writer may use (spec §37). */
|
||||
export type RecipientProblem = 'bit 255' | 'not below p' | 'low order';
|
||||
|
||||
/** The problem of a raw X25519 public key under §37, or undefined when a writer may use it. */
|
||||
export function recipientProblem(raw: Uint8Array): RecipientProblem | undefined {
|
||||
checkLength(raw);
|
||||
if ((raw[31]! & 0x80) !== 0) return 'bit 255';
|
||||
// p = 2^255 - 19 is 0xed, then 30 bytes 0xff, then 0x7f, little-endian.
|
||||
if (raw[31] === 0x7f && raw[0]! >= 0xed && raw.subarray(1, 31).every((b) => b === 0xff)) return 'not below p';
|
||||
if (LOW_ORDER.some((u) => equalBytes(u, raw))) return 'low order';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a raw X25519 public key that a writer MUST NOT encrypt to (spec
|
||||
* §37, §62.1 rule 3), with the texts of agewrap.CheckX25519Recipient: a
|
||||
* non-canonical one, whose stanza no identity opens, and one of low order,
|
||||
* for which the shared secret is zero. A key that is not 32 bytes is a
|
||||
* TypeError.
|
||||
*/
|
||||
export function checkX25519Recipient(raw: Uint8Array): void {
|
||||
const problem = recipientProblem(raw);
|
||||
if (problem !== undefined) throw new Error(`agewrap: recipient ${formatX25519Recipient(raw)} ${PROBLEM_TEXTS[problem]}`);
|
||||
}
|
||||
|
||||
// The texts of agewrap.CheckX25519Recipient after "agewrap: recipient age1… ".
|
||||
const PROBLEM_TEXTS: Readonly<Record<RecipientProblem, string>> = {
|
||||
'bit 255': 'is not canonical: bit 255 is set',
|
||||
'not below p': 'is not canonical: u is not below 2^255 - 19',
|
||||
'low order': 'is a point of low order: the shared secret would be zero',
|
||||
};
|
||||
|
||||
/** Why a line of a recipient list was rejected. */
|
||||
export type RecipientLineProblem = 'malformed' | 'identity' | 'not canonical' | 'low order' | 'duplicate';
|
||||
|
||||
/** A rejected line of a recipient list: its number from 1 and why, never its content. */
|
||||
export class RecipientListError extends Error {
|
||||
readonly line: number;
|
||||
readonly problem: RecipientLineProblem;
|
||||
|
||||
constructor(line: number, problem: RecipientLineProblem) {
|
||||
super(`recipient list: line ${line}: ${problem}`);
|
||||
this.name = 'RecipientListError';
|
||||
this.line = line;
|
||||
this.problem = problem;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipients of a list written by a person, as an age recipients file
|
||||
* (age -R), a little more lenient: lines end in LF or CRLF, each line is
|
||||
* trimmed, and blank lines and lines starting with # are skipped. As age
|
||||
* does, a line starting with AGE- is rejected: it is a secret identity pasted
|
||||
* by mistake. Each line must be a recipient a writer may use (§37) and none
|
||||
* may repeat an earlier one. The first bad line throws a RecipientListError,
|
||||
* which names its number and never its content.
|
||||
*/
|
||||
export function parseRecipientList(text: string): Uint8Array[] {
|
||||
const out: Uint8Array[] = [];
|
||||
for (const [i, raw] of text.split(/\r?\n/).entries()) {
|
||||
const line = raw.trim();
|
||||
if (line === '' || line.startsWith('#')) continue;
|
||||
if (line.startsWith('AGE-')) throw new RecipientListError(i + 1, 'identity');
|
||||
let key: Uint8Array;
|
||||
try {
|
||||
key = parseX25519Recipient(line);
|
||||
} catch {
|
||||
throw new RecipientListError(i + 1, 'malformed');
|
||||
}
|
||||
const problem = recipientProblem(key);
|
||||
if (problem !== undefined) throw new RecipientListError(i + 1, problem === 'low order' ? 'low order' : 'not canonical');
|
||||
if (out.some((k) => equalBytes(k, key))) throw new RecipientListError(i + 1, 'duplicate');
|
||||
out.push(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Loading…
Reference in new issue