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 | |
| 18 | import crypto from 'node:crypto'; |
| 19 | import { requireFreshGateway, SUITE_GW_LOG } from './gwbin.mjs'; |
| 20 | import { GW_URL as GW } from './ports.mjs'; |
| 21 | |
| 22 | let failures = 0; |
| 23 | const check = (cond, msg, detail) => { |
| 24 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 25 | if (!cond) failures++; |
| 26 | }; |
| 27 | |
| 28 | const 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 }. |
| 33 | async 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 | |
| 56 | const 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 | }); |
| 62 | const get = handle => fetch(`${GW}/api/passkey-blob?h=${encodeURIComponent(handle)}`, |
| 63 | { headers: { 'x-daimond-api': '1' } }); |
| 64 | const 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. |
| 68 | const 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 | }); |