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

Size
11 KB
Lines
197
Language
javascript
SHA-256
3aa162bb624c…
View on GitHub

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.
5import test from 'node:test';
6import assert from 'node:assert/strict';
7import { createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
8import { CROSS_DEVICE, SAME_DEVICE, SafeActions, isSafeAction } from '../js/safe-actions.mjs';
9
10const CONTEXT = Buffer.concat([Buffer.from('CKey Safe Action v1'), Buffer.from([0])]);
11const SEED = Buffer.from('9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60', 'hex');
12const PRIV = createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), SEED]), format: 'der', type: 'pkcs8' });
13const PUB_DER = createPublicKey(PRIV).export({ format: 'der', type: 'spki' });
14const PUB_HEX = PUB_DER.subarray(12).toString('hex');
15const NOW = 1794000000;
16
17const str = (s) => Buffer.concat([Buffer.from([Buffer.byteLength(s)]), Buffer.from(s)]);
18const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(n)); return b; };
19function 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).
29function 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}
41const 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
53function 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
68test('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
75test('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
85test('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
106test('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
116test('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
141test('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
146test('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
158test('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
170test('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.
181function 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}