Viewing is open to everyone. Downloading and starring need a GitHub sign-in.
Sign in with GitHub
safe-actions.test.mjs
v0.1.0-build.85
Latest
Commit ede38f5
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | // The Safe Actions policy layer (js/safe-actions.mjs) against real Ed25519 |
| 2 | // signatures. The core adapter here is a small reference verifier built on |
| 3 | // node:crypto (the wasm core is tested in test/native and wasm.test.mjs); |
| 4 | // what this file pins is who runs what, when. |
| 5 | import test from 'node:test'; |
| 6 | import assert from 'node:assert/strict'; |
| 7 | import { createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; |
| 8 | import { CROSS_DEVICE, SAME_DEVICE, SafeActions, isSafeAction } from '../js/safe-actions.mjs'; |
| 9 | |
| 10 | const CONTEXT = Buffer.concat([Buffer.from('CKey Safe Action v1'), Buffer.from([0])]); |
| 11 | const SEED = Buffer.from('9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60', 'hex'); |
| 12 | const PRIV = createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), SEED]), format: 'der', type: 'pkcs8' }); |
| 13 | const PUB_DER = createPublicKey(PRIV).export({ format: 'der', type: 'spki' }); |
| 14 | const PUB_HEX = PUB_DER.subarray(12).toString('hex'); |
| 15 | const NOW = 1794000000; |
| 16 | |
| 17 | const str = (s) => Buffer.concat([Buffer.from([Buffer.byteLength(s)]), Buffer.from(s)]); |
| 18 | const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(n)); return b; }; |
| 19 | function make(f) { |
| 20 | const params = Buffer.from(f.params ?? ''); |
| 21 | const len = Buffer.alloc(2); len.writeUInt16BE(params.length); |
| 22 | const body = Buffer.concat([Buffer.from([1, f.scope === CROSS_DEVICE ? 1 : 0]), str(f.issuer ?? 'citeory.com'), str(f.app ?? 'com.viadere'), |
| 23 | str(f.action ?? 'open.library'), str(f.key_id ?? 'k1'), str(f.request_id ?? 'req-' + Math.random().toString(36).slice(2, 12)), |
| 24 | u64(f.issued_at ?? NOW - 10), u64(f.expires_at ?? NOW + 60), len, params]); |
| 25 | return 'ckey-action:1:' + Buffer.concat([body, sign(null, Buffer.concat([CONTEXT, body]), PRIV)]).toString('base64url'); |
| 26 | } |
| 27 | |
| 28 | // Reference core: the same contract as the wasm bridge (fields, or throw with the reason). |
| 29 | function parseBytes(bytes) { |
| 30 | const text = Buffer.from(bytes).toString(); |
| 31 | if (!text.startsWith('ckey-action:1:')) throw new Error('not a Safe Action'); |
| 32 | const b = Buffer.from(text.slice(14), 'base64url'); |
| 33 | let at = 2; |
| 34 | const read = () => { const n = b[at++]; const s = b.subarray(at, at + n).toString('latin1'); at += n; return s; }; |
| 35 | const [issuer, app, action, key_id, request_id] = [read(), read(), read(), read(), read()]; |
| 36 | const issued_at = Number(b.readBigUInt64BE(at)); const expires_at = Number(b.readBigUInt64BE(at + 8)); at += 16; |
| 37 | const plen = b.readUInt16BE(at); at += 2; |
| 38 | const params = new Uint8Array(b.subarray(at, at + plen)); at += plen; |
| 39 | return { b, sigAt: at, fields: { scope: b[1] ? CROSS_DEVICE : SAME_DEVICE, issuer, app, action, key_id, request_id, issued_at, expires_at, params } }; |
| 40 | } |
| 41 | const core = { |
| 42 | parse: (bytes) => parseBytes(bytes).fields, |
| 43 | verify(bytes, key, now, maxLifetime) { |
| 44 | const { b, sigAt, fields } = parseBytes(bytes); |
| 45 | const spki = Buffer.concat([PUB_DER.subarray(0, 12), Buffer.from(key)]); |
| 46 | const ok = verify(null, Buffer.concat([CONTEXT, b.subarray(0, sigAt)]), createPublicKey({ key: spki, format: 'der', type: 'spki' }), b.subarray(sigAt)); |
| 47 | if (!ok) throw new Error('the signature does not match this key'); |
| 48 | if (fields.expires_at - fields.issued_at > maxLifetime || now >= fields.expires_at || now + 120 < fields.issued_at) throw new Error('Safe Action expired'); |
| 49 | return fields; |
| 50 | } |
| 51 | }; |
| 52 | |
| 53 | function setup({ requestInfo = { request_id: null, status: 'pending', device: 'Chrome on Windows', location: 'near Lagos', requested_at: '2026-11-07T20:14:03Z' } } = {}) { |
| 54 | const fetched = []; |
| 55 | const fetch = async (url) => { |
| 56 | fetched.push(url); |
| 57 | const id = decodeURIComponent(url.split('/').pop()); |
| 58 | return Response.json({ ...requestInfo, request_id: requestInfo.request_id ?? id }); |
| 59 | }; |
| 60 | const calls = []; |
| 61 | const sa = new SafeActions(core, 'com.viadere', { now: () => NOW * 1000, fetch }) |
| 62 | .trust('citeory.com', 'k1', PUB_HEX, { requestInfo: 'https://citeory.com/api/ckey/requests/{request_id}' }) |
| 63 | .register('open.library', { issuer: 'citeory.com' }, (a) => { calls.push(['open', a.paramsJSON]); return 'opened'; }) |
| 64 | .register('signin.request', { issuer: 'citeory.com', scope: CROSS_DEVICE, title: 'Sign in to citeory.com?' }, (a) => { calls.push(['signin', a.requestId]); return 'signed-in'; }); |
| 65 | return { sa, calls, fetched }; |
| 66 | } |
| 67 | |
| 68 | test('anything that is not a Safe Action is left to the ordinary rules', async () => { |
| 69 | const { sa, calls } = setup(); |
| 70 | for (const text of ['https://citeory.com', 'ckey-action:2:abc', 'hello', '']) assert.deepEqual(await sa.handle(text), { kind: 'not-safe-action' }); |
| 71 | assert.equal(isSafeAction('ckey-action:1:x'), true); |
| 72 | assert.equal(calls.length, 0); |
| 73 | }); |
| 74 | |
| 75 | test('a verified same-device action runs at once, with no prompt', async () => { |
| 76 | const { sa, calls } = setup(); |
| 77 | const out = await sa.handle(make({ params: '{"item":"apa-7"}' })); |
| 78 | assert.equal(out.kind, 'ran'); |
| 79 | assert.equal(out.result, 'opened'); |
| 80 | assert.deepEqual(calls, [['open', { item: 'apa-7' }]]); |
| 81 | assert.equal(out.action.issuer, 'citeory.com'); |
| 82 | assert.ok(Object.isFrozen(out.action)); |
| 83 | }); |
| 84 | |
| 85 | test('nothing runs unless every check passes', async () => { |
| 86 | const { sa, calls } = setup(); |
| 87 | const cases = [ |
| 88 | [make({ app: 'com.other' }), /another app/], |
| 89 | [make({ action: 'delete.everything' }), /does not accept/], |
| 90 | [make({ issuer: 'evil.example' }), /may not request/], |
| 91 | [make({ key_id: 'k2' }), /no trusted key/], |
| 92 | [make({ expires_at: NOW - 1, issued_at: NOW - 60 }), /expired/], |
| 93 | [make({ expires_at: NOW + 7200 }), /expired/], |
| 94 | ]; |
| 95 | for (const [payload, reason] of cases) { |
| 96 | const out = await sa.handle(payload); |
| 97 | assert.equal(out.kind, 'rejected', String(reason)); |
| 98 | assert.match(out.reason, reason); |
| 99 | } |
| 100 | const good = make({}); |
| 101 | const tampered = good.slice(0, -6) + (good.slice(-6, -5) === 'A' ? 'B' : 'A') + good.slice(-5); |
| 102 | assert.equal((await sa.handle(tampered)).kind, 'rejected'); |
| 103 | assert.equal(calls.length, 0); |
| 104 | }); |
| 105 | |
| 106 | test('a code works once', async () => { |
| 107 | const { sa, calls } = setup(); |
| 108 | const payload = make({ request_id: 'req-only-once' }); |
| 109 | assert.equal((await sa.handle(payload)).kind, 'ran'); |
| 110 | const again = await sa.handle(payload); |
| 111 | assert.equal(again.kind, 'rejected'); |
| 112 | assert.match(again.reason, /already been used/); |
| 113 | assert.equal(calls.length, 1); |
| 114 | }); |
| 115 | |
| 116 | test('a cross-device action waits for the SDK confirmation, which shows who is asking', async () => { |
| 117 | const { sa, calls, fetched } = setup(); |
| 118 | const out = await sa.handle(make({ action: 'signin.request', scope: CROSS_DEVICE, request_id: 'sess-0123456789' })); |
| 119 | assert.equal(out.kind, 'confirm'); |
| 120 | assert.equal(calls.length, 0, 'nothing ran before the confirmation'); |
| 121 | assert.deepEqual(fetched, ['https://citeory.com/api/ckey/requests/sess-0123456789']); |
| 122 | assert.equal(out.request.requestInfo.device, 'Chrome on Windows'); |
| 123 | assert.equal(typeof out.request.approve, 'undefined', 'there is no public way to approve'); |
| 124 | assert.ok(Object.isFrozen(out.request)); |
| 125 | // The SDK's own dialog: decline runs nothing; a second present() cannot approve after a decision. |
| 126 | const doc = fakeDocument(); |
| 127 | const declined = out.request.present({ document: doc }); |
| 128 | doc.click('Cancel'); |
| 129 | assert.deepEqual(await declined, { approved: false }); |
| 130 | assert.deepEqual(await out.request.present({ document: doc }), { approved: false, reason: 'already decided' }); |
| 131 | assert.equal(calls.length, 0); |
| 132 | const second = await sa.handle(make({ action: 'signin.request', scope: CROSS_DEVICE, request_id: 'sess-abcdefghij' })); |
| 133 | const approved = second.request.present({ document: doc }); |
| 134 | assert.match(doc.text(), /citeory\.com \(verified\)/); |
| 135 | assert.match(doc.text(), /Chrome on Windows/); |
| 136 | doc.click('Continue'); |
| 137 | assert.deepEqual(await approved, { approved: true, result: 'signed-in' }); |
| 138 | assert.deepEqual(calls, [['signin', 'sess-abcdefghij']]); |
| 139 | }); |
| 140 | |
| 141 | test('the stricter scope wins: a same-device envelope for a cross-device registration still needs confirmation', async () => { |
| 142 | const { sa } = setup(); |
| 143 | assert.equal((await sa.handle(make({ action: 'signin.request', scope: SAME_DEVICE }))).kind, 'confirm'); |
| 144 | }); |
| 145 | |
| 146 | test('a cross-device action cannot run without the issuer confirming a pending request', async () => { |
| 147 | const { sa, calls } = setup({ requestInfo: { status: 'used' } }); |
| 148 | const out = await sa.handle(make({ action: 'signin.request', scope: CROSS_DEVICE })); |
| 149 | assert.equal(out.kind, 'rejected'); |
| 150 | assert.match(out.reason, /did not confirm a pending request/); |
| 151 | const off = new SafeActions(core, 'com.viadere', { now: () => NOW * 1000, fetch: async () => Response.json({}) }) |
| 152 | .trust('citeory.com', 'k1', PUB_HEX, { requestInfo: 'http://citeory.com/{request_id}' }) |
| 153 | .register('signin.request', { issuer: 'citeory.com', scope: CROSS_DEVICE }, () => calls.push('x')); |
| 154 | assert.equal((await off.handle(make({ action: 'signin.request', scope: CROSS_DEVICE }))).kind, 'rejected', 'request information must be HTTPS from the issuer'); |
| 155 | assert.equal(calls.length, 0); |
| 156 | }); |
| 157 | |
| 158 | test('keys can come from the issuer key file when the app opts in', async () => { |
| 159 | const urls = []; |
| 160 | const fetch = async (url) => { urls.push(url); return Response.json({ issuer: 'citeory.com', keys: [{ id: 'k1', alg: 'Ed25519', public_key: PUB_HEX }] }); }; |
| 161 | const sa = new SafeActions(core, 'com.viadere', { now: () => NOW * 1000, fetch, keyFiles: true }) |
| 162 | .register('open.library', { issuer: 'citeory.com' }, () => 'ok'); |
| 163 | assert.equal((await sa.handle(make({}))).kind, 'ran'); |
| 164 | assert.equal((await sa.handle(make({}))).kind, 'ran'); |
| 165 | assert.deepEqual(urls, ['https://citeory.com/.well-known/ckey-safe-actions.json'], 'cached'); |
| 166 | const pinnedOnly = new SafeActions(core, 'com.viadere', { now: () => NOW * 1000, fetch }).register('open.library', { issuer: 'citeory.com' }, () => 'ok'); |
| 167 | assert.equal((await pinnedOnly.handle(make({}))).kind, 'rejected', 'pinned keys only by default'); |
| 168 | }); |
| 169 | |
| 170 | test('registration is validated', () => { |
| 171 | const sa = new SafeActions(core, 'com.viadere'); |
| 172 | assert.throws(() => sa.register('Bad Name', { issuer: 'citeory.com' }, () => {})); |
| 173 | assert.throws(() => sa.register('ok', {}, () => {}), /issuer/); |
| 174 | assert.throws(() => sa.register('ok', { issuer: 'citeory.com', scope: 'everywhere' }, () => {})); |
| 175 | assert.throws(() => sa.trust('Citeory.com', 'k1', PUB_HEX)); |
| 176 | assert.throws(() => sa.trust('citeory.com', 'k1', 'abcd')); |
| 177 | assert.throws(() => new SafeActions(core, 'com.viadere', { maxLifetime: 7200 })); |
| 178 | }); |
| 179 | |
| 180 | // A minimal DOM for the confirmation dialog. |
| 181 | function fakeDocument() { |
| 182 | const nodes = []; |
| 183 | const make = (tag) => { |
| 184 | const node = { tag, children: [], listeners: {}, attributes: {}, className: '', textContent: '', type: '', id: '', |
| 185 | append(...c) { this.children.push(...c); }, setAttribute(k, v) { this.attributes[k] = v; }, |
| 186 | addEventListener(k, fn) { this.listeners[k] = fn; }, remove() { const i = nodes.indexOf(this); if (i >= 0) nodes.splice(i, 1); }, |
| 187 | showModal() {}, close() {}, focus() {} }; |
| 188 | return node; |
| 189 | }; |
| 190 | const all = (n) => [n, ...n.children.flatMap(all)]; |
| 191 | return { |
| 192 | createElement: make, |
| 193 | body: { append: (n) => nodes.push(n) }, |
| 194 | text: () => nodes.flatMap(all).map((n) => n.textContent).join(' '), |
| 195 | click: (label) => nodes.flatMap(all).find((n) => n.tag === 'button' && n.textContent === label).listeners.click(), |
| 196 | }; |
| 197 | } |