openvibers/openvibe.shared ov-locker.js
ov-locker.js
| 1 | /** |
| 2 | * openvibe-shared/ov-locker.js — Locker in the browser (plan track Q2; the owner, 2026-11-06: "encrypt it … set the |
| 3 | * encryption password … completely encrypted on our end … decrypted on the client's end, so we don't have access to |
| 4 | * any of the user data"). The look is ov-locker.css, linked from beside this file; docs/locker.md describes both. |
| 5 | * |
| 6 | * OVLocker.open(el, { bytes | url, title?, onOpen(bytes, info) }) the card a locked item shows: it opens with the |
| 7 | * link's key, a password, or your locker |
| 8 | * OVLocker.setup({ onDone }) make your locker: a passphrase, then a recovery kit (24 words), saved locked |
| 9 | * OVLocker.unlock({ onDone }) unlock it on this device (the passphrase, or the recovery kit) |
| 10 | * OVLocker.state() { unlocked, fingerprint, subject } |
| 11 | * OVLocker.forget() drop the unlocked keys from this tab |
| 12 | * OVLocker.keyFromLink(href?) the content key a link carries after #k= (link locks) |
| 13 | * OVLocker.ensure() resolves once the locker is unlocked here (the unlock sheet when it is not) |
| 14 | * OVLocker.keys(who) a person's public keys from Network: null when they have no locker yet |
| 15 | * OVLocker.publicKeys() my public keys, for locking something for myself as well as for others |
| 16 | * OVLocker.rewrap(bytes, { add, remove, keys }) a people-locked envelope re-wrapped, the content never leaving |
| 17 | * OVLocker.seal(content, { lock, keys, password }) seal content here: the lock is made in this tab |
| 18 | * OVLocker.inspect(bytes) the header of an envelope, without a key |
| 19 | * window.OpenVibeLockerConfig = { network: 'https://openvibe.network' } where the key directory is |
| 20 | * |
| 21 | * The cryptography is ov-locker-core.js beside this file (generated from openvibe-sdk/locker: WebCrypto only, the |
| 22 | * shared test vectors). This file never sends a password, a passphrase, the recovery words, a content key or a private |
| 23 | * key anywhere: they live in this tab's memory and go when it closes, or with forget(). What Network keeps is the |
| 24 | * public keys and two locked copies of the private keys (one opens with the passphrase, one with the recovery words). |
| 25 | * |
| 26 | * Every word on screen is text, never markup. Nothing spins: a wait is words. |
| 27 | */ |
| 28 | (function (root) { |
| 29 | ; |
| 30 | if (!root || !root.document || root.OVLocker) return; |
| 31 | const doc = root.document; |
| 32 | const cfg = () => Object.assign({ network: 'https://openvibe.network' }, root.OpenVibeLockerConfig || {}); |
| 33 | const RECOVERY_WORDS = 24; |
| 34 | const svg = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`; |
| 35 | const ICON = { |
| 36 | lock: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'), |
| 37 | open: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 6.8-1.2"/>'), |
| 38 | key: svg('<circle cx="8" cy="15" r="3.5"/><path d="m10.5 12.5 8-8M15.5 7.5l2 2M13.5 9.5l1.5 1.5"/>'), |
| 39 | eye: svg('<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/>'), |
| 40 | close: svg('<path d="m6.5 6.5 11 11M17.5 6.5l-11 11"/>'), |
| 41 | tick: svg('<path d="m5 12.5 4.5 4.5L19 7.5"/>'), |
| 42 | }; |
| 43 | |
| 44 | // ── Helpers ───────────────────────────────────────────────────────────────────────────────────────────── |
| 45 | const el = (tag, cls, attrs, text) => { |
| 46 | const n = doc.createElement(tag); |
| 47 | if (cls) n.className = cls; |
| 48 | if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null && attrs[k] !== false) n.setAttribute(k, attrs[k] === true ? '' : attrs[k]); |
| 49 | if (text != null) n.textContent = text; |
| 50 | return n; |
| 51 | }; |
| 52 | const icon = (name, cls) => { const s = el('span', cls || 'ovl-ico'); s.innerHTML = ICON[name]; return s; }; // constants only |
| 53 | // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it. |
| 54 | const reduce = () => document.documentElement.getAttribute('data-ov-motion') === 'off'; |
| 55 | const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return root.btoa(s); }; |
| 56 | const unb64 = (s) => Uint8Array.from(root.atob(String(s)), (c) => c.charCodeAt(0)); |
| 57 | const unb64url = (s) => unb64(String(s).replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((String(s).length + 3) % 4)); |
| 58 | const utf8 = (s) => new TextEncoder().encode(s); |
| 59 | const fromUtf8 = (u8) => new TextDecoder().decode(u8); |
| 60 | |
| 61 | function beside(name) { |
| 62 | const me = doc.currentScript || doc.querySelector('script[src*="ov-locker.js"]'); |
| 63 | return (me && me.src ? me.src : '/shared/ov-locker.js').replace(/ov-locker\.js(\?[^#]*)?.*$/, `${name}$1`); |
| 64 | } |
| 65 | function stylesheet() { |
| 66 | if (doc.querySelector('link[href*="ov-locker.css"]')) return; |
| 67 | doc.head.appendChild(el('link', null, { rel: 'stylesheet', href: beside('ov-locker.css') })); |
| 68 | } |
| 69 | stylesheet(); |
| 70 | let coreLoad = null; |
| 71 | /** The crypto core, once (ov-locker-core.js, generated from openvibe-sdk/locker). */ |
| 72 | function core() { |
| 73 | if (root.OVLockerCore) return Promise.resolve(root.OVLockerCore); |
| 74 | if (!coreLoad) { |
| 75 | coreLoad = new Promise((resolve, reject) => { |
| 76 | const sc = el('script', null, { src: beside('ov-locker-core.js'), async: true }); |
| 77 | sc.onload = () => (root.OVLockerCore ? resolve(root.OVLockerCore) : reject(new Error('The Locker could not start.'))); |
| 78 | sc.onerror = () => { coreLoad = null; reject(new Error('The Locker could not load. Check your connection and try again.')); }; |
| 79 | doc.head.appendChild(sc); |
| 80 | }); |
| 81 | } |
| 82 | return coreLoad; |
| 83 | } |
| 84 | async function network(method, path, body) { |
| 85 | const r = await fetch(cfg().network.replace(/\/+$/, '') + path, { |
| 86 | method, credentials: 'include', |
| 87 | headers: Object.assign({ accept: 'application/json', 'x-requested-with': 'ov-locker' }, body ? { 'content-type': 'application/json' } : {}), |
| 88 | body: body ? JSON.stringify(body) : undefined, |
| 89 | }); |
| 90 | let j = null; |
| 91 | try { j = await r.json(); } catch (_) { /* empty */ } |
| 92 | if (!r.ok) { const e = new Error((j && (j.error || j.message)) || `OpenVibe answered ${r.status}`); e.status = r.status; e.code = j && (j.code || j.error); throw e; } |
| 93 | return j || {}; |
| 94 | } |
| 95 | |
| 96 | // ── This tab's unlocked locker: memory only ───────────────────────────────────────────────────────────── |
| 97 | let session = null; // { person, subject, fingerprint } |
| 98 | const state = () => ({ unlocked: !!session, fingerprint: session ? session.fingerprint : null, subject: session ? session.subject : null }); |
| 99 | function forget() { session = null; doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); } |
| 100 | |
| 101 | /** The content key a link carries: #k=<base64url>, anywhere in the part after #. */ |
| 102 | function keyFromLink(href) { |
| 103 | const h = String(href == null ? root.location.hash : (String(href).split('#')[1] || '')).replace(/^#/, ''); |
| 104 | const m = h.match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/); |
| 105 | if (!m) return null; |
| 106 | try { const k = unb64url(m[1]); return k.length === 32 ? k : null; } catch (_) { return null; } |
| 107 | } |
| 108 | |
| 109 | // ── How strong a passphrase is, in plain words (no list to download: length, variety, and the obvious) ───── |
| 110 | const COMMON = /^(password|passw0rd|letmein|qwerty|iloveyou|admin|welcome|monkey|dragon|openvibe|123456|abc123)/i; |
| 111 | function strength(p) { |
| 112 | const s = String(p || ''); |
| 113 | if (!s) return { score: 0, words: '' }; |
| 114 | const words = s.trim().split(/[\s\-_.]+/).filter((w) => w.length >= 3).length; |
| 115 | const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9\s]/].filter((r) => r.test(s)).length; |
| 116 | let score = s.length >= 20 || words >= 4 ? 4 : s.length >= 16 || (words >= 3 && s.length >= 14) ? 3 : s.length >= 12 && classes >= 2 ? 2 : 1; |
| 117 | if (COMMON.test(s) || /^(.)\1+$/.test(s) || /^(0123|1234|abcd)/i.test(s)) score = 1; |
| 118 | return { score, words: ['', 'Too easy to guess', 'Getting there: add a word', 'Good', 'Strong'][score] }; |
| 119 | } |
| 120 | |
| 121 | // ── A sheet of our own (setup, unlock): a dialog, a bottom sheet on a phone ────────────────────────────── |
| 122 | function sheet(title) { |
| 123 | const dlg = el('dialog', 'ovl-sheet', { 'aria-labelledby': 'ovl-title' }); |
| 124 | const box = el('div', 'ovl-box'); |
| 125 | const head = el('div', 'ovl-head'); |
| 126 | head.appendChild(icon('lock', 'ovl-ico is-badge')); |
| 127 | head.appendChild(el('h2', 'ovl-title', { id: 'ovl-title' }, title)); |
| 128 | const x = el('button', 'ovl-iconbtn', { type: 'button', 'aria-label': 'Close' }); |
| 129 | x.appendChild(icon('close')); |
| 130 | head.appendChild(x); |
| 131 | const body = el('div', 'ovl-body'); |
| 132 | box.appendChild(head); box.appendChild(body); |
| 133 | dlg.appendChild(box); |
| 134 | doc.body.appendChild(dlg); |
| 135 | let done = null; |
| 136 | const finished = new Promise((r) => { done = r; }); |
| 137 | const close = (value) => { |
| 138 | if (!dlg.isConnected) return; |
| 139 | doc.documentElement.classList.remove('ovl-locked'); |
| 140 | const end = () => { try { dlg.close(); } catch (_) { /* closed */ } dlg.remove(); done(value); }; |
| 141 | if (reduce()) end(); else { dlg.classList.remove('is-open'); setTimeout(end, 220); } |
| 142 | }; |
| 143 | x.addEventListener('click', () => close(null)); |
| 144 | dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(null); }); |
| 145 | dlg.showModal(); |
| 146 | doc.documentElement.classList.add('ovl-locked'); |
| 147 | if (reduce()) dlg.classList.add('is-open'); else root.requestAnimationFrame(() => root.requestAnimationFrame(() => dlg.classList.add('is-open'))); |
| 148 | return { dlg, body, close, finished, setTitle: (t) => { head.querySelector('.ovl-title').textContent = t; } }; |
| 149 | } |
| 150 | function field(label, attrs, hint) { |
| 151 | const wrap = el('label', 'ovl-field'); |
| 152 | wrap.appendChild(el('span', 'ovl-label', null, label)); |
| 153 | const row = el('span', 'ovl-inputrow'); |
| 154 | const input = el('input', 'ovl-input', Object.assign({ autocomplete: 'off', spellcheck: 'false', autocapitalize: 'none' }, attrs)); |
| 155 | row.appendChild(input); |
| 156 | if (attrs.type === 'password') { |
| 157 | const eye = el('button', 'ovl-iconbtn is-small', { type: 'button', 'aria-label': 'Show', 'aria-pressed': 'false' }); |
| 158 | eye.appendChild(icon('eye')); |
| 159 | eye.addEventListener('click', () => { |
| 160 | const show = input.type === 'password'; |
| 161 | input.type = show ? 'text' : 'password'; |
| 162 | eye.setAttribute('aria-pressed', String(show)); eye.setAttribute('aria-label', show ? 'Hide' : 'Show'); |
| 163 | input.focus(); |
| 164 | }); |
| 165 | row.appendChild(eye); |
| 166 | } |
| 167 | wrap.appendChild(row); |
| 168 | if (hint) wrap.appendChild(el('span', 'ovl-hint', null, hint)); |
| 169 | return { wrap, input }; |
| 170 | } |
| 171 | const button = (label, kind, attrs) => el('button', `ovl-btn${kind ? ` is-${kind}` : ''}`, Object.assign({ type: 'button' }, attrs || {}), label); |
| 172 | const say = (box, words, bad) => { box.textContent = words || ''; box.className = `ovl-msg${bad ? ' is-bad' : ''}`; }; |
| 173 | |
| 174 | const needsReauth = (err) => /locker\.(reauth_required|proof_required)/.test(err.code || err.message) |
| 175 | || (err.status === 403 && /reauth|sign in again/i.test(err.message)); |
| 176 | function offerReauth(msg) { |
| 177 | say(msg, 'For your safety, sign in again, then return to your locker.', true); |
| 178 | const href = cfg().reauthUrl || `${cfg().network.replace(/\/+$/, '')}/login?reauth=1&return=${encodeURIComponent(root.location.href)}`; |
| 179 | msg.appendChild(doc.createTextNode(' ')); |
| 180 | msg.appendChild(el('a', 'ovl-btn is-quiet', { href }, 'Sign in again')); |
| 181 | } |
| 182 | |
| 183 | // Only the public credential id and PRF input are remembered on this origin. The PRF result, |
| 184 | // passphrase and private keys never enter localStorage or a network request. |
| 185 | const passkeyStorage = () => `ov-locker-passkey:${cfg().subject || 'device'}`; |
| 186 | const randomBytes = () => root.crypto.getRandomValues(new Uint8Array(32)); |
| 187 | function devicePasskey() { |
| 188 | try { return JSON.parse(root.localStorage.getItem(passkeyStorage()) || 'null'); } catch (_) { return null; } |
| 189 | } |
| 190 | async function passkeySecret(saved) { |
| 191 | const assertion = await root.navigator.credentials.get({ publicKey: { |
| 192 | challenge: randomBytes(), rpId: root.location.hostname, userVerification: 'required', |
| 193 | allowCredentials: [{ type: 'public-key', id: unb64(saved.id) }], |
| 194 | extensions: { prf: { eval: { first: unb64(saved.salt) } } }, |
| 195 | } }); |
| 196 | const first = assertion && assertion.getClientExtensionResults().prf?.results?.first; |
| 197 | if (!first || first.byteLength !== 32) throw new Error('This passkey cannot unlock a locker. Use your passphrase or recovery kit.'); |
| 198 | return b64(new Uint8Array(first)); |
| 199 | } |
| 200 | async function makePasskey() { |
| 201 | const salt = randomBytes(); |
| 202 | const credential = await root.navigator.credentials.create({ publicKey: { |
| 203 | challenge: randomBytes(), rp: { id: root.location.hostname, name: 'OpenVibe Locker' }, |
| 204 | user: { id: randomBytes(), name: cfg().subject || 'My locker', displayName: 'My OpenVibe locker' }, |
| 205 | pubKeyCredParams: [{ type: 'public-key', alg: -7 }], |
| 206 | authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, |
| 207 | extensions: { prf: { eval: { first: salt } } }, |
| 208 | } }); |
| 209 | if (!credential?.getClientExtensionResults().prf?.enabled) throw new Error('This passkey does not support Locker. Set up with a passphrase instead.'); |
| 210 | const saved = { id: b64(new Uint8Array(credential.rawId)), salt: b64(salt) }; |
| 211 | // Some authenticators only evaluate a PRF on get(), not on create(). Always confirm it here. |
| 212 | return { saved, secret: await passkeySecret(saved) }; |
| 213 | } |
| 214 | |
| 215 | // ── Make your locker ──────────────────────────────────────────────────────────────────────────────────── |
| 216 | async function setup(opts) { |
| 217 | const o = Object.assign({}, opts || {}); |
| 218 | const s = sheet('Make your locker'); |
| 219 | let device = null; |
| 220 | const step1 = () => { |
| 221 | s.body.replaceChildren(); |
| 222 | s.body.appendChild(el('p', 'ovl-lead', null, 'Your locker holds the keys that open what people lock for you. OpenVibe keeps only a locked copy: nobody here can open it, and nobody can reset it.')); |
| 223 | const f = el('form', 'ovl-form', { novalidate: true }); |
| 224 | const p1 = field('A passphrase', { type: 'password', autocomplete: 'new-password', 'data-k': 'pass' }, 'Four or more words you will remember. Not one you use anywhere else.'); |
| 225 | const meter = el('span', 'ovl-meter', { 'data-score': '0', 'aria-live': 'polite' }); |
| 226 | meter.appendChild(el('span', 'ovl-meterbar')); |
| 227 | meter.appendChild(el('span', 'ovl-meterwords')); |
| 228 | p1.wrap.appendChild(meter); |
| 229 | const p2 = field('The same again', { type: 'password', autocomplete: 'new-password', 'data-k': 'pass2' }); |
| 230 | const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); |
| 231 | const go = button('Next', 'primary', { type: 'submit', 'data-k': 'next' }); |
| 232 | p1.input.addEventListener('input', () => { const st = strength(p1.input.value); meter.dataset.score = String(st.score); meter.lastChild.textContent = st.words; }); |
| 233 | f.appendChild(p1.wrap); f.appendChild(p2.wrap); f.appendChild(msg); f.appendChild(go); |
| 234 | f.addEventListener('submit', (e) => { |
| 235 | e.preventDefault(); |
| 236 | if (strength(p1.input.value).score < 3) return say(msg, 'Make it longer: four words, or 16 characters or more.', true); |
| 237 | if (p1.input.value !== p2.input.value) return say(msg, 'The two do not match.', true); |
| 238 | step2(p1.input.value); |
| 239 | }); |
| 240 | s.body.appendChild(f); |
| 241 | s.body.appendChild(el('p', 'ovl-hint', null, 'If you lose the password (or your locker passphrase and recovery kit), nobody can open this, including OpenVibe.')); |
| 242 | if (root.PublicKeyCredential && root.navigator.credentials) { |
| 243 | const pk = button('Use a passkey on this device', 'quiet', { 'data-k': 'setup-passkey' }); |
| 244 | pk.addEventListener('click', async () => { |
| 245 | pk.disabled = true; |
| 246 | try { device = await makePasskey(); await step2(null); } |
| 247 | catch (e) { device = null; say(msg, e.message, true); pk.disabled = false; } |
| 248 | }); |
| 249 | s.body.appendChild(pk); |
| 250 | } |
| 251 | setTimeout(() => p1.input.focus(), 0); |
| 252 | }; |
| 253 | const step2 = async (passphrase) => { |
| 254 | s.body.replaceChildren(el('p', 'ovl-lead', { role: 'status' }, 'Making your keys on this device…')); |
| 255 | let c, person, words; |
| 256 | try { |
| 257 | c = await core(); |
| 258 | person = await c.generatePersonKeys(); |
| 259 | // The SDK answers the phrase as one string of words. |
| 260 | words = String(await c.recoveryWords(RECOVERY_WORDS)).trim().split(/\s+/); |
| 261 | } catch (e) { s.body.replaceChildren(el('p', 'ovl-msg is-bad', { role: 'alert' }, e.message)); return; } |
| 262 | step3(c, person, passphrase, words); |
| 263 | }; |
| 264 | const step3 = (c, person, passphrase, words) => { |
| 265 | s.setTitle('Your recovery kit'); |
| 266 | s.body.replaceChildren(); |
| 267 | s.body.appendChild(el('p', 'ovl-lead', null, 'If you forget your passphrase, these 24 words are the only other way into your locker. Write them down or save them somewhere only you can reach. They are shown once.')); |
| 268 | const grid = el('ol', 'ovl-words', { 'aria-label': 'Your 24 recovery words' }); |
| 269 | words.forEach((w) => grid.appendChild(el('li', null, null, w))); |
| 270 | s.body.appendChild(grid); |
| 271 | const acts = el('div', 'ovl-acts'); |
| 272 | const copy = button('Copy the words', 'quiet', { 'data-k': 'copy' }); |
| 273 | copy.addEventListener('click', async () => { |
| 274 | try { await root.navigator.clipboard.writeText(words.join(' ')); copy.textContent = 'Copied'; } catch (_) { copy.textContent = 'Select and copy them'; } |
| 275 | }); |
| 276 | const save = button('Save as a file', 'quiet', { 'data-k': 'save' }); |
| 277 | save.addEventListener('click', () => { |
| 278 | const text = `OpenVibe Locker recovery kit\n\nThese 24 words open your locker if you forget your passphrase.\nKeep them where only you can reach them. Anyone with them can open your locker.\n\n${words.map((w, i) => `${String(i + 1).padStart(2, ' ')}. ${w}`).join('\n')}\n`; |
| 279 | const a = el('a', null, { href: URL.createObjectURL(new Blob([text], { type: 'text/plain' })), download: 'openvibe-locker-recovery-kit.txt' }); |
| 280 | doc.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); |
| 281 | }); |
| 282 | const print = button('Print the kit', 'quiet', { 'data-k': 'print' }); |
| 283 | print.addEventListener('click', () => root.print()); |
| 284 | acts.appendChild(copy); acts.appendChild(save); acts.appendChild(print); |
| 285 | s.body.appendChild(acts); |
| 286 | // Two words back, so the kit was really kept. |
| 287 | const pick = [3 + Math.floor(Math.random() * 8), 13 + Math.floor(Math.random() * 10)]; |
| 288 | const f = el('form', 'ovl-form', { novalidate: true }); |
| 289 | const w1 = field(`Word ${pick[0] + 1}`, { type: 'text', 'data-k': 'w1', autocomplete: 'off' }); |
| 290 | const w2 = field(`Word ${pick[1] + 1}`, { type: 'text', 'data-k': 'w2', autocomplete: 'off' }); |
| 291 | const row = el('div', 'ovl-pair'); row.appendChild(w1.wrap); row.appendChild(w2.wrap); |
| 292 | f.appendChild(el('p', 'ovl-label', null, 'Check you kept them')); |
| 293 | f.appendChild(row); |
| 294 | const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); |
| 295 | f.appendChild(msg); |
| 296 | const go = button('Make my locker', 'primary', { type: 'submit', 'data-k': 'make' }); |
| 297 | f.appendChild(go); |
| 298 | f.addEventListener('submit', async (e) => { |
| 299 | e.preventDefault(); |
| 300 | const ok = [w1.input.value, w2.input.value].map((v) => v.trim().toLowerCase()); |
| 301 | if (ok[0] !== words[pick[0]] || ok[1] !== words[pick[1]]) return say(msg, 'Those are not the words in your kit. Look again.', true); |
| 302 | go.disabled = true; say(msg, 'Locking your keys and saving the locked copies…'); |
| 303 | try { |
| 304 | const pub = await c.exportPersonKeys(person); |
| 305 | const priv = utf8(JSON.stringify(await c.exportPrivateKeys(person))); |
| 306 | // seal() answers { header, bytes, … }: the bytes are the envelope that goes on the wire. |
| 307 | const byPass = (await c.seal(priv, { lock: 'password', password: device ? device.secret : passphrase })).bytes; |
| 308 | const byWords = (await c.seal(priv, { lock: 'password', password: words.join(' ') })).bytes; |
| 309 | await network('PUT', '/api/v1/me/keys', { |
| 310 | ecdh_public: pub.ecdh, signing_public: pub.signing, self_signature: pub.fingerprint_signature, fingerprint: pub.fingerprint, |
| 311 | ...(o.reset ? { reset: true } : {}), |
| 312 | backups: { [device ? 'passkey' : 'passphrase']: { format: 'locker.envelope.v1', envelope: b64(byPass) }, recovery: { format: 'locker.envelope.v1', envelope: b64(byWords) } }, |
| 313 | }); |
| 314 | if (device) { |
| 315 | root.localStorage.setItem(passkeyStorage(), JSON.stringify(device.saved)); |
| 316 | device.secret = null; |
| 317 | } |
| 318 | session = { person, subject: cfg().subject || null, fingerprint: pub.fingerprint }; |
| 319 | doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); |
| 320 | step4(pub.fingerprint); |
| 321 | } catch (err) { |
| 322 | go.disabled = false; |
| 323 | if (needsReauth(err)) offerReauth(msg); |
| 324 | else say(msg, err.status === 401 ? 'Sign in to OpenVibe first, then make your locker.' : `Could not save your locker: ${err.message}`, true); |
| 325 | } |
| 326 | }); |
| 327 | s.body.appendChild(f); |
| 328 | }; |
| 329 | const step4 = (fingerprint) => { |
| 330 | s.setTitle('Your locker is ready'); |
| 331 | s.body.replaceChildren(); |
| 332 | const ok = el('div', 'ovl-done'); |
| 333 | ok.appendChild(icon('tick', 'ovl-ico is-big')); |
| 334 | ok.appendChild(el('p', 'ovl-lead', null, 'People can now lock things for you, and only you can open them.')); |
| 335 | ok.appendChild(el('p', 'ovl-label', null, 'Your locker’s fingerprint')); |
| 336 | ok.appendChild(el('p', 'ovl-fp', { 'data-k': 'fingerprint' }, fingerprint)); |
| 337 | ok.appendChild(el('p', 'ovl-hint', null, 'Someone who wants to be sure a locked item is for you can compare these words with you, in person or on a call.')); |
| 338 | const d = button('Done', 'primary', { 'data-k': 'done' }); |
| 339 | d.addEventListener('click', () => s.close(state())); |
| 340 | ok.appendChild(d); |
| 341 | s.body.appendChild(ok); |
| 342 | if (typeof o.onDone === 'function') o.onDone(state()); |
| 343 | }; |
| 344 | step1(); |
| 345 | return s.finished; |
| 346 | } |
| 347 | |
| 348 | // ── Unlock your locker on this device ─────────────────────────────────────────────────────────────────── |
| 349 | async function unlock(opts) { |
| 350 | const o = Object.assign({}, opts || {}); |
| 351 | if (session) return state(); |
| 352 | const s = sheet('Unlock your locker'); |
| 353 | let backups = null; |
| 354 | const draw = (mode) => { |
| 355 | s.body.replaceChildren(); |
| 356 | s.body.appendChild(el('p', 'ovl-lead', null, mode === 'words' |
| 357 | ? 'Type your 24 recovery words, in order, with spaces between them.' |
| 358 | : 'Your passphrase opens your locker on this device. It stays here: OpenVibe never sees it.')); |
| 359 | const f = el('form', 'ovl-form', { novalidate: true }); |
| 360 | let input; |
| 361 | if (mode === 'words') { |
| 362 | const wrap = el('label', 'ovl-field'); |
| 363 | wrap.appendChild(el('span', 'ovl-label', null, 'Recovery words')); |
| 364 | input = el('textarea', 'ovl-input ovl-wordsin', { rows: '4', 'data-k': 'words', autocomplete: 'off', spellcheck: 'false', autocapitalize: 'none' }); |
| 365 | wrap.appendChild(input); |
| 366 | f.appendChild(wrap); |
| 367 | } else { |
| 368 | const p = field('Passphrase', { type: 'password', autocomplete: 'current-password', 'data-k': 'pass' }); |
| 369 | input = p.input; |
| 370 | f.appendChild(p.wrap); |
| 371 | } |
| 372 | const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); |
| 373 | f.appendChild(msg); |
| 374 | const go = button('Unlock', 'primary', { type: 'submit', 'data-k': 'unlock' }); |
| 375 | f.appendChild(go); |
| 376 | const other = el('button', 'ovl-link', { type: 'button', 'data-k': 'other' }, mode === 'words' ? 'Use my passphrase instead' : 'I forgot it: use my recovery kit'); |
| 377 | other.addEventListener('click', () => draw(mode === 'words' ? 'pass' : 'words')); |
| 378 | f.appendChild(other); |
| 379 | if (mode === 'words') { |
| 380 | const lost = el('button', 'ovl-link is-quiet', { type: 'button', 'data-k': 'lost' }, 'I lost both: start a new locker'); |
| 381 | lost.addEventListener('click', () => drawLost()); |
| 382 | f.appendChild(lost); |
| 383 | } |
| 384 | f.addEventListener('submit', async (e) => { |
| 385 | e.preventDefault(); |
| 386 | const secret = mode === 'words' ? input.value.trim().toLowerCase().split(/\s+/).join(' ') : input.value; |
| 387 | if (!secret) return say(msg, mode === 'words' ? 'Type the 24 words.' : 'Type your passphrase.', true); |
| 388 | go.disabled = true; say(msg, 'Unlocking…'); |
| 389 | try { |
| 390 | const c = await core(); |
| 391 | if (!backups) backups = await network('GET', '/api/v1/me/keys/backup'); |
| 392 | const b = (backups.backups || backups)[mode === 'words' ? 'recovery' : 'passphrase']; |
| 393 | if (!b || !b.envelope) { |
| 394 | if (mode !== 'words' && (backups.backups || backups).passkey) throw Object.assign(new Error('Use the passkey on its original device or choose your recovery kit.'), { code: 'locker.use_recovery' }); |
| 395 | throw Object.assign(new Error('nothing'), { status: 404 }); |
| 396 | } |
| 397 | const priv = JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret }))); |
| 398 | const person = await c.importPersonKeys(priv); |
| 399 | // The fingerprint from the public keys alone (an imported set signs nothing here). |
| 400 | const fingerprint = await c.fingerprintOf(person.ecdhPublic, person.signingPublic); |
| 401 | session = { person, subject: backups.subject || null, fingerprint }; |
| 402 | doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); |
| 403 | s.close(state()); |
| 404 | if (typeof o.onDone === 'function') o.onDone(state()); |
| 405 | } catch (err) { |
| 406 | go.disabled = false; |
| 407 | if (needsReauth(err)) offerReauth(msg); |
| 408 | else if (err.code === 'locker.use_recovery') say(msg, err.message, true); |
| 409 | else if (err.status === 404) { |
| 410 | say(msg, 'You have no locker yet.', true); |
| 411 | const mk = button('Make my locker', 'quiet', { 'data-k': 'make-instead' }); |
| 412 | mk.addEventListener('click', async () => { s.close(null); const st = await setup(); if (st && typeof o.onDone === 'function') o.onDone(st); }); |
| 413 | msg.appendChild(doc.createTextNode(' ')); msg.appendChild(mk); |
| 414 | } else if (err.status === 401) say(msg, 'Sign in to OpenVibe first.', true); |
| 415 | else if (err.code === 'locker.wrong_key') say(msg, mode === 'words' ? 'Those words do not open your locker. Check each one.' : 'That passphrase does not open your locker.', true); |
| 416 | else say(msg, `Could not unlock: ${err.message}`, true); |
| 417 | } |
| 418 | }); |
| 419 | s.body.appendChild(f); |
| 420 | setTimeout(() => input.focus(), 0); |
| 421 | }; |
| 422 | // Lost the passphrase and the kit: a new locker, said plainly. What was locked for the old keys stays locked. |
| 423 | const drawLost = () => { |
| 424 | s.setTitle('Start a new locker'); |
| 425 | s.body.replaceChildren(); |
| 426 | s.body.appendChild(el('p', 'ovl-lead', null, 'A new locker makes new keys. Anything locked for your old locker cannot be opened again, by you or by anyone at OpenVibe: ask the people who shared it to share it with you again.')); |
| 427 | s.body.appendChild(el('p', 'ovl-lead', null, 'For your safety, OpenVibe may ask you to sign in again first.')); |
| 428 | const l = el('label', 'ovl-check'); |
| 429 | const cb = el('input', null, { type: 'checkbox', 'data-k': 'lost-ok' }); |
| 430 | l.appendChild(cb); |
| 431 | l.appendChild(el('span', null, null, 'I understand that what was locked for my old locker is gone.')); |
| 432 | s.body.appendChild(l); |
| 433 | const go = button('Start a new locker', 'primary', { 'data-k': 'lost-go', disabled: true }); |
| 434 | cb.addEventListener('change', () => { go.disabled = !cb.checked; }); |
| 435 | go.addEventListener('click', async () => { s.close(null); const st = await setup({ reset: true }); if (st && typeof o.onDone === 'function') o.onDone(st); }); |
| 436 | s.body.appendChild(go); |
| 437 | }; |
| 438 | draw('pass'); |
| 439 | const saved = devicePasskey(); |
| 440 | if (saved && root.navigator.credentials) { |
| 441 | const pk = button('Unlock with this device’s passkey', 'quiet', { 'data-k': 'unlock-passkey' }); |
| 442 | const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); |
| 443 | pk.addEventListener('click', async () => { |
| 444 | pk.disabled = true; |
| 445 | try { |
| 446 | const secret = await passkeySecret(saved); |
| 447 | const c = await core(); |
| 448 | const backup = await network('GET', '/api/v1/me/keys/backup'); |
| 449 | const b = (backup.backups || backup).passkey; |
| 450 | if (!b) throw new Error('This locker has no passkey backup. Use the recovery kit.'); |
| 451 | const person = await c.importPersonKeys(JSON.parse(fromUtf8(await c.open(unb64(b.envelope), { password: secret })))); |
| 452 | session = { person, subject: backup.subject || null, fingerprint: await c.fingerprintOf(person.ecdhPublic, person.signingPublic) }; |
| 453 | doc.dispatchEvent(new CustomEvent('ov-locker:change', { detail: state() })); |
| 454 | s.close(state()); |
| 455 | if (typeof o.onDone === 'function') o.onDone(state()); |
| 456 | } catch (e) { pk.disabled = false; say(msg, e.message, true); } |
| 457 | }); |
| 458 | s.body.appendChild(pk); s.body.appendChild(msg); |
| 459 | } |
| 460 | return s.finished; |
| 461 | } |
| 462 | |
| 463 | // ── The locker as a tool: a public key, a lookup, and a re-wrap ───────────────────────────────────────── |
| 464 | /** The locker, unlocked on this device: unlocks it (the sheet) when it is not, and says so when that is declined. */ |
| 465 | async function ensure() { |
| 466 | if (session) return state(); |
| 467 | const st = await unlock(); |
| 468 | if (!st || !st.unlocked) throw Object.assign(new Error('Unlock your locker to do this.'), { code: 'locker.locked' }); |
| 469 | return st; |
| 470 | } |
| 471 | /** My public keys, as they are published: what a people lock wraps for me too. */ |
| 472 | async function publicKeys() { |
| 473 | if (!session) return null; |
| 474 | const c = await core(); |
| 475 | return await c.exportPersonKeys(session.person); |
| 476 | } |
| 477 | /** |
| 478 | * A people-locked envelope, its key slots wrapped again: `add` [{ subject, publicKey }] keeps the content and |
| 479 | * the chunks and adds their slots; `remove` [subject] seals the content under a fresh key for `keys` (the public |
| 480 | * keys of those who stay), so the person who left opens nothing. The content key and the plaintext stay here. |
| 481 | */ |
| 482 | async function rewrap(bytes, { add = [], remove = [], keys = [] } = {}) { |
| 483 | await ensure(); |
| 484 | const c = await core(); |
| 485 | const out = await c.rewrap(bytes, { add, remove, keys, credentials: { person: session.person } }); |
| 486 | return { bytes: out.bytes, header: out.header }; |
| 487 | } |
| 488 | /** A person's public keys from Network's directory (by subject or by @name): null when they have no locker yet. */ |
| 489 | async function keys(who) { |
| 490 | const id = who && typeof who === 'object' ? (who.subject && who.subject.id) || who.id : null; |
| 491 | const path = id |
| 492 | ? `/api/v1/people/${encodeURIComponent(id)}/keys` |
| 493 | : `/api/v1/people/@${encodeURIComponent(String(who || '').replace(/^@+/, ''))}/keys`; |
| 494 | try { return await network('GET', path); } catch (e) { if (e.status === 404) return null; throw e; } |
| 495 | } |
| 496 | /** |
| 497 | * Seal content on this device (a new locked item): `{ lock: 'people', keys: [{ subject, publicKey }] }`, |
| 498 | * `{ lock: 'password', password }`, or `{ lock: 'link' }`, whose key comes back in `linkKey` for the link's #k=. |
| 499 | * The content key is made and kept here; only the envelope leaves. |
| 500 | */ |
| 501 | async function seal(content, opts = {}) { |
| 502 | const c = await core(); |
| 503 | const out = await c.seal(content, opts); |
| 504 | return { bytes: out.bytes, header: out.header, linkKey: out.linkKey || null }; |
| 505 | } |
| 506 | /** An envelope's header, without any key. */ |
| 507 | async function inspectBytes(bytes) { |
| 508 | const c = await core(); |
| 509 | return c.inspect(bytes); |
| 510 | } |
| 511 | |
| 512 | // ── The card a locked item shows ──────────────────────────────────────────────────────────────────────── |
| 513 | async function open(container, opts) { |
| 514 | const o = Object.assign({}, opts || {}); |
| 515 | const card = el('div', 'ovl-card', { role: 'region', 'aria-label': 'Locked' }); |
| 516 | container.replaceChildren(card); |
| 517 | const top = (title, words, lockIcon) => { |
| 518 | card.replaceChildren(); |
| 519 | card.appendChild(icon(lockIcon || 'lock', 'ovl-ico is-big')); |
| 520 | card.appendChild(el('h2', 'ovl-cardtitle', null, title)); |
| 521 | if (words) card.appendChild(el('p', 'ovl-muted', null, words)); |
| 522 | }; |
| 523 | top('Opening…', ''); |
| 524 | let bytes = o.bytes, c, info; |
| 525 | try { |
| 526 | c = await core(); |
| 527 | if (!bytes && o.url) { |
| 528 | const r = await fetch(o.url, { credentials: 'include' }); |
| 529 | if (!r.ok) throw Object.assign(new Error(`The locked item answered ${r.status}`), { status: r.status }); |
| 530 | bytes = new Uint8Array(await r.arrayBuffer()); |
| 531 | } |
| 532 | info = c.inspect(bytes); |
| 533 | } catch (e) { top('This locked item cannot open', e.message); return null; } |
| 534 | const done = (plain) => { |
| 535 | card.replaceChildren(); |
| 536 | card.classList.add('is-opened'); |
| 537 | if (typeof o.onOpen === 'function') o.onOpen(plain, info); |
| 538 | doc.dispatchEvent(new CustomEvent('ov-locker:opened', { detail: { lock: info.lock } })); |
| 539 | return plain; |
| 540 | }; |
| 541 | const tryOpen = async (credentials) => done(await c.open(bytes, credentials)); |
| 542 | |
| 543 | if (info.lock === 'link') { |
| 544 | const key = keyFromLink(o.link); |
| 545 | if (key) { |
| 546 | try { return await tryOpen({ key }); } catch (_) { top('This link does not open it', 'The key in the link is not the one it was locked with. Ask for the link again.'); return null; } |
| 547 | } |
| 548 | top('This link is missing its key', 'A locked link carries its key after the # at the end. Ask for the whole link: OpenVibe never had the key, so it cannot send it.'); |
| 549 | return null; |
| 550 | } |
| 551 | |
| 552 | if (info.lock === 'password') { |
| 553 | top(o.title ? `“${o.title}” is locked` : 'This is locked with a password', 'Type the password you were given. It stays on this device: OpenVibe cannot read what is inside.'); |
| 554 | const f = el('form', 'ovl-form', { novalidate: true }); |
| 555 | const p = field('Password', { type: 'password', autocomplete: 'off', 'data-k': 'item-pass' }); |
| 556 | const msg = el('p', 'ovl-msg', { 'aria-live': 'polite' }); |
| 557 | const go = button('Open', 'primary', { type: 'submit', 'data-k': 'item-open' }); |
| 558 | f.appendChild(p.wrap); f.appendChild(msg); f.appendChild(go); |
| 559 | card.appendChild(f); |
| 560 | return new Promise((resolve) => { |
| 561 | f.addEventListener('submit', async (e) => { |
| 562 | e.preventDefault(); |
| 563 | if (!p.input.value) return say(msg, 'Type the password.', true); |
| 564 | go.disabled = true; say(msg, 'Opening…'); |
| 565 | try { resolve(await tryOpen({ password: p.input.value })); } |
| 566 | catch (err) { go.disabled = false; say(msg, err.code === 'locker.wrong_key' ? 'That password does not open it.' : `Could not open it: ${err.message}`, true); p.input.select(); } |
| 567 | }); |
| 568 | }); |
| 569 | } |
| 570 | |
| 571 | // people: your locker opens it, if it is for you. |
| 572 | const forMe = async () => { |
| 573 | try { return await tryOpen({ person: session.person }); } |
| 574 | catch (err) { |
| 575 | if (err.code === 'locker.no_slot' || err.code === 'locker.wrong_key') { |
| 576 | top('This is not locked for you', 'It is locked for specific people, and your locker is not one of them. Ask the person who shared it.'); |
| 577 | } else top('This locked item cannot open', err.message); |
| 578 | return null; |
| 579 | } |
| 580 | }; |
| 581 | if (session) return forMe(); |
| 582 | top(o.title ? `“${o.title}” is locked for you` : 'This is locked for specific people', 'Unlock your locker on this device to open it. OpenVibe cannot read what is inside.'); |
| 583 | const go = button('Unlock my locker', 'primary', { 'data-k': 'item-unlock' }); |
| 584 | card.appendChild(go); |
| 585 | return new Promise((resolve) => { |
| 586 | go.addEventListener('click', async () => { const st = await unlock(); if (st && st.unlocked) resolve(await forMe()); }); |
| 587 | }); |
| 588 | } |
| 589 | |
| 590 | /** Opens verified chunks with this tab's keys. No private key is exported to a service or caller. */ |
| 591 | async function openStream(stream, credentials) { |
| 592 | const c = await core(); |
| 593 | if (credentials) return c.openStream(stream, credentials); |
| 594 | await ensure(); |
| 595 | return c.openStream(stream, { person: session.person }); |
| 596 | } |
| 597 | |
| 598 | root.OVLocker = { |
| 599 | open, openStream, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes, |
| 600 | state, forget, keyFromLink, strength, version: 2, |
| 601 | }; |
| 602 | })(typeof window !== 'undefined' ? window : null); |
| 603 |