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
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 | |
| 11 | export const SAME_DEVICE = 'same-device'; |
| 12 | export const CROSS_DEVICE = 'cross-device'; |
| 13 | const PREFIX = 'ckey-action:1:'; |
| 14 | const MAX_LIFETIME_CAP = 3600; |
| 15 | const KEY_FILE_TTL_MS = 3600 * 1000; |
| 16 | const utf8 = new TextEncoder(); |
| 17 | const utf8d = new TextDecoder('utf-8', { fatal: true }); |
| 18 | |
| 19 | const 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 | }; |
| 23 | const 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 | }; |
| 27 | const 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). */ |
| 30 | export function isSafeAction(text) { |
| 31 | return typeof text === 'string' && text.startsWith(PREFIX) && text.length > PREFIX.length; |
| 32 | } |
| 33 | |
| 34 | function 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. */ |
| 44 | export 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 | |
| 100 | export 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 | } |