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.
262 lines
15 KiB
262 lines
15 KiB
import { describe, expect, it } from 'vitest';
|
|
import { ageStanzas, checkAccessStanzas, checkPayloadStanzas, checkTimeStanzas, parseAgeHeader, type Stanza } from './age.ts';
|
|
import { quicknet } from './profile.ts';
|
|
import { expectCode, hx, readBytes, readJSON } from './testing/testdata.ts';
|
|
|
|
const te = new TextEncoder();
|
|
const INTRO = 'age-encryption.org/v1';
|
|
const MAC = '--- ' + 'A'.repeat(43);
|
|
const age = (...lines: string[]): Uint8Array => te.encode(lines.join('\n') + '\n');
|
|
const X = '-> X25519 ' + 'A'.repeat(43);
|
|
const BODY = 'A'.repeat(43);
|
|
const FIXTURES = ['time_only', 'empty_payload', 'time_and_key_portable', 'time_and_key_recipients', 'time_only_extensions'];
|
|
|
|
interface FixtureJSON {
|
|
prelude: string;
|
|
outer_stanzas: { type: string; args: string[] }[];
|
|
payload_stanzas: { type: string; args: string[] }[];
|
|
}
|
|
|
|
// The error texts of the reference (agewrap.Stanzas over age v1.3.2) for the
|
|
// rejected headers below, in order, as Go printed them.
|
|
const GO_MESSAGES = [
|
|
"parsing age header: file is empty",
|
|
"parsing age header: unexpected EOF reading intro: \"age-encryption.org/v1\"",
|
|
"parsing age header: unexpected intro: \"age-encryption.org/v2\\n\"",
|
|
"parsing age header: unexpected intro: \"age-encryption.org/v1 \"",
|
|
"parsing age header: no recipient stanzas",
|
|
"parsing age header: failed to read header: EOF",
|
|
"parsing age header: failed to read header: EOF",
|
|
"failed to read header: EOF",
|
|
"parsing age header: malformed closing line: \"---\\n\"",
|
|
"parsing age header: malformed closing line: \"--- \\n\"",
|
|
"parsing age header: malformed closing line: \"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA x\\n\"",
|
|
"parsing age header: malformed closing line: \"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\n\"",
|
|
"parsing age header: malformed closing line: \"---\\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\n\"",
|
|
"parsing age header: malformed closing line \"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\n\": <nil>",
|
|
"parsing age header: malformed closing line \"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\n\": <nil>",
|
|
"parsing age header: malformed closing line \"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB\\n\": illegal base64 data at input byte 42",
|
|
"parsing age header: malformed closing line \"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n\": illegal base64 data at input byte 43",
|
|
"parsing age header: malformed closing line: \"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\r\\n\"",
|
|
"failed to parse header: malformed stanza opening line: \"-- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\n\"",
|
|
"failed to parse header: malformed stanza opening line: \"-->AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\n\"",
|
|
"failed to parse header: malformed stanza: \"->\\n\"",
|
|
"failed to parse header: malformed stanza: \"-> \\n\"",
|
|
"failed to parse header: malformed stanza: \"-> a\\n\"",
|
|
"failed to parse header: malformed stanza: \"-> a \\n\"",
|
|
"failed to parse header: malformed stanza: \"-> a b\\n\"",
|
|
"failed to parse header: malformed stanza: \"->\\ta\\n\"",
|
|
"failed to parse header: malformed stanza: \"-> é\\n\"",
|
|
"failed to parse header: malformed stanza: \"-> a\\x7f\\n\"",
|
|
"failed to parse header: malformed stanza: \"-> a\\r\\n\"",
|
|
"failed to parse header: malformed stanza opening line: \"-x a\\n\"",
|
|
"failed to parse header: malformed stanza: \"-> t a0 a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 a16 a17 a18 a19 a20 a21 a22 a23 a24 a25 a26 a27 a28 a29 a30 a31 a32 a33 a34 a35 a36 a37 a38 a39 a40 a41 a42 a43 a44 a45 a46 a47 a48 a49 a50 a51 a52 a53 a54 a55 a56 a57 a58 a59 a60 a61 a62 a63 a64 a65 a66 a67 a68 a69 a70 a71 a72 a73 a74 a75 a76 a77 a78 a79 a80 a81 a82 a83 a84 a85 a86 a87 a88 a89 a90 a91 a92 a93 a94 a95 a96 a97 a98 a99 a100 a101 a102 a103 a104 a105 a106 a107 a108 a109 a110 a111 a112 a113 a114 a115 a116 a117 a118 a119 a120 a121 a122 a123 a124 a125 a126 a127 a128\\n\"",
|
|
"failed to parse header: malformed body line \"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\n\": stanza ended without a short line\nnote: this might be a file encrypted with an old beta version of age or rage; use age v1.0.0-beta6 or rage to decrypt it",
|
|
"failed to parse header: malformed body line \"-> X25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\n\": stanza ended without a short line\nnote: this might be a file encrypted with an old beta version of age or rage; use age v1.0.0-beta6 or rage to decrypt it",
|
|
"failed to parse header: parsing age header: malformed body line \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\n\": too long",
|
|
"failed to parse header: parsing age header: malformed body line \"A\\n\": illegal base64 data at input byte 0",
|
|
"failed to parse header: parsing age header: malformed body line \"AR\\n\": illegal base64 data at input byte 0",
|
|
"failed to parse header: parsing age header: malformed body line \"AAA=\\n\": illegal base64 data at input byte 3",
|
|
"failed to parse header: parsing age header: malformed body line \"AA==\\n\": illegal base64 data at input byte 2",
|
|
"failed to parse header: parsing age header: malformed body line \"AA-_\\n\": illegal base64 data at input byte 2",
|
|
"failed to parse header: parsing age header: malformed body line \"AA A\\n\": illegal base64 data at input byte 2",
|
|
"failed to parse header: parsing age header: malformed body line \"AA\\r\\n\": unexpected newline character",
|
|
"failed to parse header: failed to read line: EOF",
|
|
"failed to parse header: failed to read line: EOF",
|
|
"parsing age header: header contains more than 1024 recipient stanzas",
|
|
];
|
|
|
|
describe('parseAgeHeader', () => {
|
|
it('parses the age headers of every fixture', () => {
|
|
for (const name of FIXTURES) {
|
|
const fx = readJSON<FixtureJSON>(`fixtures/${name}.json`);
|
|
const dkc = readBytes(`fixtures/${name}.dkc`);
|
|
const v = new DataView(dkc.buffer, dkc.byteOffset);
|
|
const hl = v.getUint32(8);
|
|
const sl = v.getUint32(12);
|
|
const sealed = parseAgeHeader(dkc.subarray(16 + hl, 16 + hl + sl));
|
|
expect(sealed.stanzas.map((s) => ({ type: s.type, args: s.args }))).toEqual(fx.outer_stanzas);
|
|
expect(sealed.mac).toHaveLength(32);
|
|
expect(sealed.stanzas[0]!.body.length).toBeGreaterThan(48);
|
|
const payload = parseAgeHeader(dkc.subarray(16 + hl + sl));
|
|
expect(payload.stanzas.map((s) => ({ type: s.type, args: s.args }))).toEqual(fx.payload_stanzas);
|
|
expect(payload.stanzas[0]!.body).toHaveLength(32);
|
|
// The payload header ends where the STREAM payload starts.
|
|
expect(new TextDecoder().decode(dkc.subarray(16 + hl + sl + payload.length - 1, 16 + hl + sl + payload.length))).toBe('\n');
|
|
}
|
|
});
|
|
|
|
it('reads bodies of full 64-column lines ended by a short line', () => {
|
|
const full = 'A'.repeat(64);
|
|
expect(parseAgeHeader(age(INTRO, '-> a', '', MAC)).stanzas[0]!.body).toHaveLength(0);
|
|
expect(parseAgeHeader(age(INTRO, '-> a', full, '', MAC)).stanzas[0]!.body).toHaveLength(48);
|
|
expect(parseAgeHeader(age(INTRO, '-> a', full, full, 'AA', MAC)).stanzas[0]!.body).toHaveLength(97);
|
|
const h = parseAgeHeader(age(INTRO, '-> a b c', 'AQ', '-> d', 'AAAA', MAC));
|
|
expect(h.stanzas.map((s) => [s.type, s.args, hx(s.body)])).toEqual([
|
|
['a', ['b', 'c'], '01'],
|
|
['d', [], '000000'],
|
|
]);
|
|
});
|
|
|
|
it('ignores the bytes after the header', () => {
|
|
const b = new Uint8Array([...age(INTRO, X, BODY, MAC), 0xff, 0x00]);
|
|
expect(parseAgeHeader(b).length).toBe(b.length - 2);
|
|
expect(ageStanzas(b)).toHaveLength(1);
|
|
});
|
|
|
|
it('accepts 128 arguments and 1024 stanzas', () => {
|
|
const args = Array.from({ length: 128 }, (_, i) => `a${i}`).join(' ');
|
|
expect(parseAgeHeader(age(INTRO, `-> t ${args}`, '', MAC)).stanzas[0]!.args).toHaveLength(128);
|
|
const many = Array.from({ length: 1024 }, () => ['-> a', '']).flat();
|
|
expect(parseAgeHeader(age(INTRO, ...many, MAC)).stanzas).toHaveLength(1024);
|
|
});
|
|
|
|
it('accepts a header of exactly 2 MiB and rejects one of 2 MiB + 1 byte, as filippo.io/age', () => {
|
|
// A header of exactly n bytes, MAC line included: the intro, "-> t <arg>",
|
|
// k full body lines of 64 columns, the final empty body line and the MAC
|
|
// line. The argument length absorbs what the full lines leave.
|
|
const header = (n: number): Uint8Array => {
|
|
const fixed = INTRO.length + 1 + '-> t '.length + 1 + 1 + MAC.length + 1;
|
|
const k = Math.floor((n - fixed - 1) / 65);
|
|
return age(INTRO, `-> t ${'a'.repeat(n - fixed - 65 * k)}`, ...Array<string>(k).fill('A'.repeat(64)), '', MAC);
|
|
};
|
|
const limit = 2 << 20;
|
|
const at = header(limit);
|
|
expect(at.length).toBe(limit);
|
|
expect(parseAgeHeader(at).length).toBe(limit);
|
|
const over = header(limit + 1);
|
|
expect(over.length).toBe(limit + 1);
|
|
expect(() => parseAgeHeader(over)).toThrow(/header exceeds 2 MiB/);
|
|
});
|
|
|
|
it('rejects everything age rejects', () => {
|
|
const cases: Uint8Array[] = [
|
|
new Uint8Array(0),
|
|
te.encode(INTRO),
|
|
age('age-encryption.org/v2', X, BODY, MAC),
|
|
age(INTRO + ' ', X, BODY, MAC),
|
|
age(INTRO, MAC),
|
|
age(INTRO),
|
|
te.encode(INTRO + '\n--'),
|
|
te.encode(INTRO + '\n' + X + '\n' + BODY + '\n' + MAC),
|
|
age(INTRO, X, BODY, '---'),
|
|
age(INTRO, X, BODY, '--- '),
|
|
age(INTRO, X, BODY, MAC + ' x'),
|
|
age(INTRO, X, BODY, '--- ' + 'A'.repeat(43)),
|
|
age(INTRO, X, BODY, '---\t' + 'A'.repeat(43)),
|
|
age(INTRO, X, BODY, '--- ' + 'A'.repeat(42)),
|
|
age(INTRO, X, BODY, '--- ' + 'A'.repeat(44)),
|
|
age(INTRO, X, BODY, '--- ' + 'A'.repeat(42) + 'B'),
|
|
age(INTRO, X, BODY, '--- ' + 'A'.repeat(42) + 'A='),
|
|
age(INTRO, X, BODY, MAC + '\r'),
|
|
age(INTRO, X, BODY, '-- ' + 'A'.repeat(43)),
|
|
age(INTRO, X, BODY, '-->' + 'A'.repeat(43)),
|
|
age(INTRO, '->', '', MAC),
|
|
age(INTRO, '-> ', '', MAC),
|
|
age(INTRO, '-> a', '', MAC),
|
|
age(INTRO, '-> a ', '', MAC),
|
|
age(INTRO, '-> a b', '', MAC),
|
|
age(INTRO, '->\ta', '', MAC),
|
|
age(INTRO, '-> é', '', MAC),
|
|
age(INTRO, '-> a\x7f', '', MAC),
|
|
age(INTRO, '-> a\r', '', MAC),
|
|
age(INTRO, '-x a', '', MAC),
|
|
age(INTRO, `-> t ${Array.from({ length: 129 }, (_, i) => `a${i}`).join(' ')}`, '', MAC),
|
|
age(INTRO, '-> a', 'A'.repeat(64), MAC),
|
|
age(INTRO, '-> a', 'A'.repeat(64), X, '', MAC),
|
|
age(INTRO, '-> a', 'A'.repeat(68), MAC),
|
|
age(INTRO, '-> a', 'A', MAC),
|
|
age(INTRO, '-> a', 'AR', MAC),
|
|
age(INTRO, '-> a', 'AAA=', MAC),
|
|
age(INTRO, '-> a', 'AA==', MAC),
|
|
age(INTRO, '-> a', 'AA-_', MAC),
|
|
age(INTRO, '-> a', 'AA A', MAC),
|
|
age(INTRO, '-> a', 'AA\r', MAC),
|
|
age(INTRO, '-> a'),
|
|
te.encode(INTRO + '\n-> a\nAA'),
|
|
age(INTRO, ...Array.from({ length: 1025 }, () => ['-> a', '']).flat(), MAC),
|
|
];
|
|
expect(cases).toHaveLength(GO_MESSAGES.length);
|
|
for (const [i, c] of cases.entries()) {
|
|
let msg = '';
|
|
try {
|
|
parseAgeHeader(c);
|
|
} catch (err) {
|
|
msg = (err as Error).message;
|
|
}
|
|
expect(msg, `case ${i}`).toBe(`agewrap: not a valid age file: failed to read header: ${GO_MESSAGES[i]!}: ERR_INTEGRITY`);
|
|
}
|
|
});
|
|
|
|
it('quotes runes as Go 1.26 does, whatever the Unicode version of the engine', () => {
|
|
// Texts printed by Go 1.26.8 (agewrap.Stanzas): U+31E4 is new in Unicode
|
|
// 16 and escaped by Go's Unicode 15 tables; U+31E3 is printed.
|
|
const B = String.fromCharCode(0x5c);
|
|
const lines: [Uint8Array, string][] = [
|
|
[Uint8Array.of(0xe3, 0x87, 0xa4), `${B}u31e4`],
|
|
[Uint8Array.of(0xe3, 0x87, 0xa3), '㇣'],
|
|
[Uint8Array.of(0xc2, 0xa0), `${B}u00a0`],
|
|
];
|
|
for (const [arg, quoted] of lines) {
|
|
const file = new Uint8Array([...te.encode(`${INTRO}\n-> X25519 `), ...arg, 0x0a]);
|
|
let msg = '';
|
|
try {
|
|
ageStanzas(file);
|
|
} catch (err) {
|
|
msg = (err as Error).message;
|
|
}
|
|
expect(msg).toBe(`agewrap: not a valid age file: failed to read header: failed to parse header: malformed stanza: "-> X25519 ${quoted}${B}n": ERR_INTEGRITY`);
|
|
}
|
|
});
|
|
|
|
it('bounds the header to 2 MiB', () => {
|
|
const line = 'A'.repeat(64);
|
|
const prefix = INTRO + '\n-> a\n';
|
|
const build = (lines: number): Uint8Array => te.encode(prefix + (line + '\n').repeat(lines) + '\n' + MAC + '\n');
|
|
const fixed = te.encode(prefix + '\n' + MAC + '\n').length;
|
|
const fit = Math.floor(((2 << 20) - fixed) / 65);
|
|
expect(build(fit).length).toBeLessThanOrEqual(2 << 20);
|
|
expect(parseAgeHeader(build(fit)).stanzas[0]!.body).toHaveLength(fit * 48);
|
|
expect(build(fit + 1).length).toBeGreaterThan(2 << 20);
|
|
expectCode(() => parseAgeHeader(build(fit + 1)), 'ERR_INTEGRITY', /: failed to read header: failed to read header: parsing age header: header exceeds 2 MiB: /);
|
|
// A line cut by the limit, in a larger file, and the three bytes of "---".
|
|
const cut = new Uint8Array((2 << 20) + 10).fill(0x41);
|
|
cut.set(te.encode(prefix));
|
|
expectCode(() => parseAgeHeader(cut), 'ERR_INTEGRITY', /: failed to parse header: failed to read line: parsing age header: header exceeds 2 MiB: /);
|
|
const edge = build(fit);
|
|
const tail = new Uint8Array(edge.length - MAC.length - 1);
|
|
tail.set(edge.subarray(0, tail.length));
|
|
expectCode(() => parseAgeHeader(new Uint8Array([...tail, 0x2d, 0x2d])), 'ERR_INTEGRITY', /: parsing age header: failed to read header: EOF: /);
|
|
});
|
|
});
|
|
|
|
describe('stanza rules', () => {
|
|
const p = quicknet();
|
|
const chain = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971';
|
|
const s = (type: string, ...args: string[]): Stanza => ({ type, args, body: new Uint8Array(0) });
|
|
|
|
it('OUTER_TIME_AGE holds one tlock stanza for the round and the pinned chain', () => {
|
|
checkTimeStanzas([s('tlock', '1000', chain)], p, 1000);
|
|
const PS = 'ERR_POLICY_STRUCTURE_MISMATCH';
|
|
expectCode(() => checkTimeStanzas([], p, 1000), PS, /has 0 stanzas/);
|
|
expectCode(() => checkTimeStanzas([s('tlock', '1000', chain), s('X25519', 'x')], p, 1000), PS, /has 2 stanzas/);
|
|
expectCode(() => checkTimeStanzas([s('TLOCK', '1000', chain)], p, 1000), PS, /stanza type "TLOCK"/);
|
|
expectCode(() => checkTimeStanzas([s('tlock', '1000')], p, 1000), PS, /has 1 arguments, want 2/);
|
|
expectCode(() => checkTimeStanzas([s('tlock', '01000', chain)], p, 1000), 'ERR_ROUND_MISMATCH', /round "01000", DateKey round 1000/);
|
|
expectCode(() => checkTimeStanzas([s('tlock', '1000', chain.toUpperCase())], p, 1000), 'ERR_PROFILE_MISMATCH', /uses 52db9ba7/);
|
|
});
|
|
|
|
it('PAYLOAD_AGE holds one X25519 stanza', () => {
|
|
checkPayloadStanzas([s('X25519', 'a')]);
|
|
expectCode(() => checkPayloadStanzas([s('X25519'), s('X25519')]), 'ERR_POLICY_STRUCTURE_MISMATCH', /has 2 stanzas/);
|
|
expectCode(() => checkPayloadStanzas([s('x25519')]), 'ERR_POLICY_STRUCTURE_MISMATCH', /stanza type "x25519"/);
|
|
});
|
|
|
|
it('INNER_ACCESS_AGE holds X25519 stanzas with distinct shares', () => {
|
|
checkAccessStanzas([s('X25519', 'a'), s('X25519', 'b'), s('X25519'), s('X25519')]);
|
|
expectCode(() => checkAccessStanzas([]), 'ERR_POLICY_STRUCTURE_MISMATCH', /no stanzas/);
|
|
expectCode(() => checkAccessStanzas([s('X25519', 'a'), s('scrypt', 'a')]), 'ERR_POLICY_STRUCTURE_MISMATCH', /stanza 1 has type "scrypt"/);
|
|
expectCode(() => checkAccessStanzas([s('X25519', 'a'), s('X25519', 'a')]), 'ERR_POLICY_STRUCTURE_MISMATCH', /stanza 1 repeats/);
|
|
});
|
|
});
|