Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

safe-actions.mjs

v0.1.0-build.85 Latest Commit ede38f5

Size
12 KB
Lines
235
Language
javascript
SHA-256
eb7a0d41989d…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1// CKey Safe Actions for the receiving app (spec/CKey-Safe-Actions.md).
2//
3// The SDK, not the app, decides whether a Safe Action runs:
4// - anything that is not a verified Safe Action for this app runs nothing;
5// - a verified same-device action runs its registered handler at once;
6// - a verified cross-device action runs only after the SDK's own confirmation,
7// which names the issuer and the requesting device (request.present()).
8// The app registers actions and pins issuer keys; it never sees a way to run an
9// action that skips these checks. Verification itself is the C core's.
10
11export const SAME_DEVICE = 'same-device';
12export const CROSS_DEVICE = 'cross-device';
13const PREFIX = 'ckey-action:1:';
14const MAX_LIFETIME_CAP = 3600;
15const KEY_FILE_TTL_MS = 3600 * 1000;
16const utf8 = new TextEncoder();
17const utf8d = new TextDecoder('utf-8', { fatal: true });
18
19const hexToBytes = (hex) => {
20 if (typeof hex !== 'string' || !/^[0-9a-fA-F]{64}$/.test(hex)) throw new TypeError('a public key is 64 hexadecimal digits (32 bytes)');
21 return Uint8Array.from(hex.match(/../g), (b) => parseInt(b, 16));
22};
23const keyBytes = (key) => {
24 if (key instanceof Uint8Array) { if (key.length !== 32) throw new TypeError('a public key is 32 bytes'); return key; }
25 return hexToBytes(key);
26};
27const shortText = (v, max = 120) => (typeof v === 'string' && v.length && v.length <= max ? v : null);
28
29/** True when text is a Safe Action payload (says nothing about whether it is valid). */
30export function isSafeAction(text) {
31 return typeof text === 'string' && text.startsWith(PREFIX) && text.length > PREFIX.length;
32}
33
34function requestInfoUrl(template, issuer, requestId) {
35 if (typeof template !== 'string') return null;
36 let url;
37 try { url = new URL(template.replace('{request_id}', encodeURIComponent(requestId))); } catch { return null; }
38 // Request information must come over HTTPS from the issuer's own domain.
39 if (url.protocol !== 'https:' || !(url.hostname === issuer || url.hostname.endsWith('.' + issuer))) return null;
40 return url.href;
41}
42
43/** A verified cross-device Safe Action waiting for the person to confirm it. */
44export class SafeActionRequest {
45 #approve;
46 #decided = false;
47 constructor(details, approve) {
48 Object.assign(this, details);
49 Object.freeze(this.requestInfo);
50 this.#approve = approve;
51 Object.freeze(this);
52 }
53
54 /**
55 * Shows the SDK's confirmation and resolves to { approved, result }. The
56 * handler runs only if the person approves here; there is no other way to
57 * run a cross-device action.
58 */
59 present({ document = globalThis.document } = {}) {
60 if (this.#decided) return Promise.resolve({ approved: false, reason: 'already decided' });
61 if (!document?.createElement) return Promise.resolve({ approved: false, reason: 'no document to show the confirmation in' });
62 return new Promise((resolve) => {
63 const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
64 const dialog = el('dialog', 'ckey-safe-action');
65 dialog.setAttribute('aria-labelledby', 'ckey-sa-title');
66 const title = el('h2', 'ckey-sa-title', this.title);
67 title.id = 'ckey-sa-title';
68 const info = this.requestInfo;
69 const facts = el('dl', 'ckey-sa-facts');
70 for (const [k, v] of [['Requested by', this.issuer + ' (verified)'], ['Device', info.device], ['Location', info.location], ['Requested', info.requestedAt]]) {
71 if (!v) continue;
72 facts.append(el('dt', null, k), el('dd', null, v));
73 }
74 const note = el('p', 'ckey-sa-note', 'Only continue if this is the device and place you are using right now. Someone may be trying to use a code meant for their own session.');
75 const approve = el('button', 'ckey-sa-approve', 'Continue');
76 const decline = el('button', 'ckey-sa-decline', 'Cancel');
77 approve.type = decline.type = 'button';
78 const finish = async (ok) => {
79 if (this.#decided) return;
80 this.#decided = true;
81 dialog.close?.();
82 dialog.remove();
83 if (!ok) { resolve({ approved: false }); return; }
84 try { resolve({ approved: true, result: await this.#approve() }); }
85 catch (error) { resolve({ approved: true, error }); }
86 };
87 approve.addEventListener('click', () => finish(true));
88 decline.addEventListener('click', () => finish(false));
89 dialog.addEventListener('cancel', (e) => { e.preventDefault(); finish(false); });
90 const row = el('div', 'ckey-sa-buttons');
91 row.append(decline, approve);
92 dialog.append(title, facts, note, row);
93 document.body.append(dialog);
94 if (dialog.showModal) dialog.showModal(); else dialog.setAttribute('open', '');
95 decline.focus?.();
96 });
97 }
98}
99
100export class SafeActions {
101 #core;
102 #appId;
103 #keys = new Map(); // `${issuer} ${keyId}` -> { key: Uint8Array, requestInfo }
104 #actions = new Map(); // action -> { issuers: Set, scope, handler, title }
105 #seen = new Map(); // requestId -> expiresAtMs
106 #keyFiles = new Map(); // issuer -> { at, file }
107 #fetch;
108 #now;
109 #maxLifetime;
110 #useKeyFiles;
111
112 /**
113 * @param core { verify(payloadBytes, publicKey, nowSeconds, maxLifetime) -> fields, parse(payloadBytes) -> fields }
114 * @param appId this app's ID, e.g. 'com.viadere'
115 */
116 constructor(core, appId, { maxLifetime = 600, keyFiles = false, fetch = globalThis.fetch?.bind(globalThis), now = () => Date.now() } = {}) {
117 if (!core?.verify || !core?.parse) throw new TypeError('SafeActions needs the CKey core');
118 if (typeof appId !== 'string' || !/^[A-Za-z0-9._-]{1,255}$/.test(appId)) throw new TypeError('appId must be an app ID such as com.example.app');
119 if (!Number.isInteger(maxLifetime) || maxLifetime < 1 || maxLifetime > MAX_LIFETIME_CAP) throw new RangeError('maxLifetime is 1 to 3600 seconds');
120 this.#core = core;
121 this.#appId = appId;
122 this.#maxLifetime = maxLifetime;
123 this.#useKeyFiles = Boolean(keyFiles);
124 this.#fetch = fetch;
125 this.#now = now;
126 }
127
128 /** Pins an issuer's public key (64 hex digits or 32 bytes). requestInfo is the issuer's request_info URL template. */
129 trust(issuer, keyId, publicKey, { requestInfo } = {}) {
130 if (typeof issuer !== 'string' || !/^[a-z0-9.-]+\.[a-z0-9-]+$/.test(issuer)) throw new TypeError('issuer is a lower-case domain name');
131 if (typeof keyId !== 'string' || !/^[A-Za-z0-9._-]{1,32}$/.test(keyId)) throw new TypeError('keyId is 1 to 32 letters, digits, . _ or -');
132 this.#keys.set(issuer + ' ' + keyId, { key: keyBytes(publicKey), requestInfo: requestInfo ?? null });
133 return this;
134 }
135
136 /**
137 * Registers an action this app accepts. scope is SAME_DEVICE (runs at once) or
138 * CROSS_DEVICE (runs after the SDK's confirmation). The stricter of this and
139 * the envelope's own scope applies.
140 */
141 register(action, { issuers, issuer, scope = SAME_DEVICE, title } = {}, handler) {
142 if (typeof action !== 'string' || !/^[a-z0-9._-]{1,64}$/.test(action)) throw new TypeError('action is 1 to 64 of a-z 0-9 . _ -');
143 if (scope !== SAME_DEVICE && scope !== CROSS_DEVICE) throw new TypeError('scope is SAME_DEVICE or CROSS_DEVICE');
144 if (typeof handler !== 'function') throw new TypeError('a handler function is required');
145 const list = issuers ?? (issuer ? [issuer] : []);
146 if (!list.length) throw new TypeError('name the issuer or issuers allowed to request this action');
147 this.#actions.set(action, { issuers: new Set(list), scope, handler, title: shortText(title, 80) });
148 return this;
149 }
150
151 async #keyFor(issuer, keyId) {
152 const pinned = this.#keys.get(issuer + ' ' + keyId);
153 if (pinned) return pinned;
154 if (!this.#useKeyFiles || !this.#fetch) return null;
155 const file = await this.#keyFile(issuer);
156 const entry = file?.keys?.find?.((k) => k && k.id === keyId && k.alg === 'Ed25519');
157 if (!entry) return null;
158 if (entry.not_after && Date.parse(entry.not_after) < this.#now()) return null;
159 try { return { key: hexToBytes(entry.public_key), requestInfo: file.request_info ?? null }; } catch { return null; }
160 }
161
162 async #keyFile(issuer) {
163 const cached = this.#keyFiles.get(issuer);
164 if (cached && this.#now() - cached.at < KEY_FILE_TTL_MS) return cached.file;
165 try {
166 const response = await this.#fetch('https://' + issuer + '/.well-known/ckey-safe-actions.json', { headers: { Accept: 'application/json' }, redirect: 'error' });
167 const file = response.ok ? await response.json() : null;
168 const valid = file && file.issuer === issuer && Array.isArray(file.keys) ? file : null;
169 this.#keyFiles.set(issuer, { at: this.#now(), file: valid });
170 return valid;
171 } catch {
172 return null;
173 }
174 }
175
176 async #requestInfo(template, issuer, requestId) {
177 const url = requestInfoUrl(template, issuer, requestId);
178 if (!url || !this.#fetch) return null;
179 try {
180 const response = await this.#fetch(url, { headers: { Accept: 'application/json' }, redirect: 'error' });
181 if (!response.ok) return null;
182 const info = await response.json();
183 if (!info || info.request_id !== requestId || info.status !== 'pending') return null;
184 return { device: shortText(info.device), location: shortText(info.location), requestedAt: shortText(info.requested_at, 40) };
185 } catch {
186 return null;
187 }
188 }
189
190 /**
191 * Handles a decoded payload. Resolves to one of:
192 * { kind: 'not-safe-action' } follow the ordinary payload rules
193 * { kind: 'rejected', reason, action? } claims to be a Safe Action but did not verify; run nothing
194 * { kind: 'ran', action, result } a same-device action that ran
195 * { kind: 'confirm', request } cross-device: call request.present()
196 */
197 async handle(text) {
198 if (!isSafeAction(text)) return { kind: 'not-safe-action' };
199 const bytes = utf8.encode(text);
200 let claimed;
201 try { claimed = this.#core.parse(bytes); }
202 catch (error) { return { kind: 'rejected', reason: error.message }; }
203 const reject = (reason) => ({ kind: 'rejected', reason, action: { issuer: claimed.issuer, app: claimed.app, action: claimed.action } });
204 if (claimed.app !== this.#appId) return reject('this code is for another app (' + claimed.app + ')');
205 const registered = this.#actions.get(claimed.action);
206 if (!registered) return reject('this app does not accept the action ' + claimed.action);
207 if (!registered.issuers.has(claimed.issuer)) return reject(claimed.issuer + ' may not request ' + claimed.action);
208 const trusted = await this.#keyFor(claimed.issuer, claimed.key_id);
209 if (!trusted) return reject('no trusted key ' + claimed.key_id + ' for ' + claimed.issuer);
210 let verified;
211 try { verified = this.#core.verify(bytes, trusted.key, Math.floor(this.#now() / 1000), this.#maxLifetime); }
212 catch (error) { return reject(error.message); }
213 const nowMs = this.#now();
214 for (const [id, until] of this.#seen) if (until < nowMs) this.#seen.delete(id);
215 if (this.#seen.has(verified.request_id)) return reject('this code has already been used');
216 this.#seen.set(verified.request_id, verified.expires_at * 1000 + 120000);
217 let paramsText = null;
218 let paramsJSON = null;
219 try { paramsText = verified.params.length ? utf8d.decode(verified.params) : ''; paramsJSON = paramsText ? JSON.parse(paramsText) : null; } catch {}
220 const action = Object.freeze({
221 issuer: verified.issuer, app: verified.app, action: verified.action, keyId: verified.key_id, requestId: verified.request_id,
222 scope: verified.scope, issuedAt: verified.issued_at, expiresAt: verified.expires_at, params: verified.params, paramsText, paramsJSON
223 });
224 const scope = verified.scope === CROSS_DEVICE || registered.scope === CROSS_DEVICE ? CROSS_DEVICE : SAME_DEVICE;
225 if (scope === SAME_DEVICE) {
226 return { kind: 'ran', action, result: await registered.handler(action) };
227 }
228 const info = await this.#requestInfo(trusted.requestInfo, verified.issuer, verified.request_id);
229 if (!info) return reject('the issuer did not confirm a pending request, so this cross-device action cannot run');
230 const request = new SafeActionRequest({
231 issuer: verified.issuer, action: verified.action, title: registered.title || 'Continue with ' + verified.issuer + '?', requestInfo: info, details: action
232 }, () => registered.handler(action));
233 return { kind: 'confirm', request };
234 }
235}