Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_passkey_blob.mjs

6.4 KiB, 1 run

created by r2519314175:575, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_passkey_blob.mjs — the /api/passkey-blob contract, from node.
2//
3// The sealed-bundle endpoint has an unusual shape: reading needs NO session,
4// because the device asking has none and getting one is what it is here to do.
5// That makes its access rules worth stating explicitly rather than inferring
6// from the browser flow, and two accounts cannot be driven from one browser
7// (one cookie jar), so this drives them from node instead.
8//
9// What must hold:
10// - GET is open, because the value is inert without the authenticator.
11// - POST needs a session, and binds the handle to that account.
12// - Another account cannot overwrite a handle that is already bound.
13// - DELETE needs a session, and only the owner's.
14// - Malformed handles and oversized blobs are refused.
15//
16// node dev/verify_passkey_blob.mjs (needs the gateway on :9002)
17
18import crypto from 'node:crypto';
19import { requireFreshGateway, SUITE_GW_LOG } from './gwbin.mjs';
20import { GW_URL as GW } from './ports.mjs';
21
22let failures = 0;
23const check = (cond, msg, detail) => {
24 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
25 if (!cond) failures++;
26};
27
28const b64url = b => Buffer.from(b).toString('base64')
29 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
30
31/// Register an account by proving a fresh Ed25519 key, as the browser does, and
32/// take a session. Returns { id, cookie }.
33async function account() {
34 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
35 const pub = publicKey.export({ format: 'jwk' }).x;
36 const sign = msg => b64url(crypto.sign(null, Buffer.from(msg), privateKey));
37 const ts = Math.floor(Date.now() / 1000);
38 const reg = await fetch(`${GW}/api/account`, {
39 method: 'POST', headers: { 'content-type': 'application/json', 'x-daimond-api': '1' },
40 body: JSON.stringify({ pubkey: pub, alg: 'Ed25519', ts,
41 sig: sign(`daimond-gw-account:v1:${pub}:${ts}`) }),
42 });
43 if (!reg.ok) return null;
44 const id = (await reg.json()).account_id;
45 const ch = await (await fetch(`${GW}/api/auth/challenge`, {
46 method: 'POST', headers: { 'content-type': 'application/json', 'x-daimond-api': '1' },
47 body: JSON.stringify({ pubkey: pub, alg: 'Ed25519' }),
48 })).json();
49 const ver = await fetch(`${GW}/api/auth/verify`, {
50 method: 'POST', headers: { 'content-type': 'application/json', 'x-daimond-api': '1' },
51 body: JSON.stringify({ challenge_id: ch.challenge_id, sig: sign(ch.challenge) }),
52 });
53 return { id, cookie: (ver.headers.get('set-cookie') || '').split(';')[0] };
54}
55
56const put = (cookie, handle, blob) => fetch(`${GW}/api/passkey-blob`, {
57 method: 'POST',
58 headers: Object.assign({ 'content-type': 'application/json', 'x-daimond-api': '1' },
59 cookie ? { cookie } : {}),
60 body: JSON.stringify({ handle, blob }),
61});
62const get = handle => fetch(`${GW}/api/passkey-blob?h=${encodeURIComponent(handle)}`,
63 { headers: { 'x-daimond-api': '1' } });
64const del = (cookie, handle) => fetch(`${GW}/api/passkey-blob?h=${encodeURIComponent(handle)}`,
65 { method: 'DELETE', headers: Object.assign({ 'x-daimond-api': '1' }, cookie ? { cookie } : {}) });
66
67/// A well-formed handle: 43 chars of base64url, as SHA-256 produces.
68const handleOf = seed => b64url(crypto.createHash('sha256').update(seed).digest());
69
70(async () => {
71 requireFreshGateway();
72 const health = await fetch(`${GW}/api/health`).then(r => r.ok).catch(() => false);
73 check(health, 'the gateway is up');
74 if (!health) { console.log('\nstart it: cd gateway && APP_MODE=sandbox ./target/release/daimond_gateway'); process.exit(1); }
75
76 const a = await account();
77 const b = await account();
78 check(!!(a && a.id && b && b.id), 'two accounts register and sign in');
79
80 const hA = handleOf('credential-of-a-' + a.id);
81 const blobA = 'sealed-bundle-for-a';
82
83 // ── Writing needs a session ──
84 check((await put(null, hA, blobA)).status === 401,
85 'storing a bundle without a session is refused (401)');
86
87 // ── The owner may store, and anyone may read ──
88 check((await put(a.cookie, hA, blobA)).status === 200, 'the account stores its sealed bundle');
89 const r1 = await get(hA);
90 const j1 = await r1.json();
91 check(r1.status === 200 && j1.blob === blobA,
92 'and ANY caller may read it back, with no session at all');
93
94 // ── The value is all a reader gets: nothing names the account ──
95 check(!JSON.stringify(j1).includes(a.id),
96 'the response names no account, so a reader learns nothing but the ciphertext');
97
98 // ── Another account cannot take over the handle ──
99 const steal = await put(b.cookie, hA, 'sealed-bundle-for-b');
100 check(steal.status === 409, 'another account cannot overwrite that handle (409)', 'HTTP ' + steal.status);
101 const j2 = await (await get(hA)).json();
102 check(j2.blob === blobA, 'and the original bundle is untouched');
103
104 // ── The owner may re-store: a passphrase change re-seals ──
105 check((await put(a.cookie, hA, 'resealed-bundle')).status === 200,
106 'the owner may re-seal the same handle');
107 check((await (await get(hA)).json()).blob === 'resealed-bundle', 'and the new bundle is served');
108
109 // ── Shape checks ──
110 check((await put(a.cookie, 'too-short', 'x')).status === 400, 'a malformed handle is refused (400)');
111 check((await put(a.cookie, handleOf('sized'), 'x'.repeat(9000))).status === 413,
112 'an oversized bundle is refused (413)');
113 check((await get('nonsense')).status === 400, 'a malformed handle on read is refused (400)');
114 check((await get(handleOf('never-stored'))).status === 404, 'an unknown handle reads 404');
115
116 // ── Deleting ──
117 check((await del(null, hA)).status === 401, 'deleting without a session is refused (401)');
118 check((await del(b.cookie, hA)).status === 401, 'deleting another account\'s bundle is refused (401)');
119 check((await del(a.cookie, hA)).status === 200, 'the owner may delete it');
120 check((await get(hA)).status === 404, 'and it is gone');
121
122 // This file starts no gateway of its own -- it is run against the one the
123 // suite brings up for phase 2 -- so it has no log to quote. It can still say
124 // WHERE the answer is: a 401 that should have been a 200 was explained by
125 // the gateway, in that file, and nowhere in this output.
126 if (failures) console.log(' ── what the gateway said is in ' + SUITE_GW_LOG + ' ──');
127 console.log(`\n${failures ? failures + ' FAILED' : 'all passed'}`);
128 process.exit(failures ? 1 : 0);
129})().catch(e => {
130 console.error(e);
131 console.log(' ── what the gateway said is in ' + SUITE_GW_LOG + ' ──');
132 process.exit(1);
133});