oxedyne/daimond/www/js/curvefallback.test.mjs
8.2 KiB, 1 run
created by r2519314175:1357, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* ============================================================ |
| 2 | Test — crypto-capability fallback for Daimond identity. |
| 3 | ------------------------------------------------------------ |
| 4 | Drives the REAL www/js/identity.js in a simulated browser whose |
| 5 | WebCrypto can be made to lack Ed25519 and X25519 on demand, so |
| 6 | both halves of the fix are exercised end to end: |
| 7 | |
| 8 | (a) INTEROP: an account made on a full-WebCrypto engine unlocks |
| 9 | on a crippled one via the pure-JS fallback, and the |
| 10 | signature it makes there is bit-identical to WebCrypto's and |
| 11 | verifies under real WebCrypto; an X25519 shared secret it |
| 12 | computes equals the one a real-WebCrypto peer derives — so a |
| 13 | message sealed by one opens on the other. |
| 14 | |
| 15 | (b) HONEST ERROR: with the fallback ALSO removed, unlocking with |
| 16 | the RIGHT passphrase returns { ok:false, reason:'unsupported' } |
| 17 | (not "wrong passphrase"), while a genuinely WRONG passphrase |
| 18 | returns { ok:false } with NO 'unsupported' reason. |
| 19 | |
| 20 | Run: node www/js/curvefallback.test.mjs |
| 21 | (Node 20+, whose own WebCrypto implements Ed25519 and X25519 — |
| 22 | that is the "real" engine the fallback is checked against.) |
| 23 | ============================================================ */ |
| 24 | import { readFileSync } from 'node:fs'; |
| 25 | import { fileURLToPath } from 'node:url'; |
| 26 | import { dirname, join } from 'node:path'; |
| 27 | import { webcrypto } from 'node:crypto'; |
| 28 | |
| 29 | const HERE = dirname(fileURLToPath(import.meta.url)); |
| 30 | const real = webcrypto; |
| 31 | let failures = 0; |
| 32 | function check(name, cond) { |
| 33 | if (cond) { console.log(' ok ' + name); } |
| 34 | else { console.log(' FAIL ' + name); failures++; } |
| 35 | } |
| 36 | const eqBytes = (a, b) => { |
| 37 | const x = new Uint8Array(a), y = new Uint8Array(b); |
| 38 | if (x.length !== y.length) return false; |
| 39 | for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false; |
| 40 | return true; |
| 41 | }; |
| 42 | |
| 43 | // A WebCrypto proxy whose Ed25519/X25519 support is toggled by `flags`. |
| 44 | // Everything else (PBKDF2, AES-GCM, exportKey, verify) passes straight through. |
| 45 | const flags = { noEd: false, noX: false }; |
| 46 | function blocked(name) { |
| 47 | return (name === 'Ed25519' && flags.noEd) || (name === 'X25519' && flags.noX); |
| 48 | } |
| 49 | const cryptoShim = { |
| 50 | getRandomValues: (a) => real.getRandomValues(a), |
| 51 | subtle: { |
| 52 | generateKey: (algo, ex, u) => blocked(algo && algo.name) |
| 53 | ? Promise.reject(new Error('NotSupportedError')) : real.subtle.generateKey(algo, ex, u), |
| 54 | importKey: (fmt, data, algo, ex, u) => blocked(algo && algo.name) |
| 55 | ? Promise.reject(new Error('NotSupportedError')) : real.subtle.importKey(fmt, data, algo, ex, u), |
| 56 | deriveBits: (algo, key, len) => blocked(algo && algo.name) |
| 57 | ? Promise.reject(new Error('NotSupportedError')) : real.subtle.deriveBits(algo, key, len), |
| 58 | sign: (algo, key, data) => blocked(algo && algo.name) |
| 59 | ? Promise.reject(new Error('NotSupportedError')) : real.subtle.sign(algo, key, data), |
| 60 | deriveKey: (...a) => real.subtle.deriveKey(...a), |
| 61 | encrypt: (...a) => real.subtle.encrypt(...a), |
| 62 | decrypt: (...a) => real.subtle.decrypt(...a), |
| 63 | exportKey: (...a) => real.subtle.exportKey(...a), |
| 64 | verify: (...a) => real.subtle.verify(...a), |
| 65 | }, |
| 66 | }; |
| 67 | |
| 68 | // Minimal browser sandbox: a Map-backed localStorage, event stubs, the |
| 69 | // encoders, base64, and the toggleable crypto. The three app scripts attach |
| 70 | // their globals onto `window`. |
| 71 | const store = new Map(); |
| 72 | const localStorage = { |
| 73 | getItem: (k) => (store.has(k) ? store.get(k) : null), |
| 74 | setItem: (k, v) => store.set(k, String(v)), |
| 75 | removeItem: (k) => store.delete(k), |
| 76 | }; |
| 77 | // The three app scripts are classic-script IIFEs that read the bare globals |
| 78 | // `window`, `crypto`, `localStorage`, `btoa`, `atob`, `TextEncoder`, `Event`. |
| 79 | // Run each in the MAIN realm (not a vm sandbox) so every built-in — Uint8Array, |
| 80 | // BigInt, Object.prototype — is the real one and the vendored bundle's own |
| 81 | // type checks pass; the named parameters supply the browser host objects, with |
| 82 | // `crypto` pointed at the toggleable shim. |
| 83 | const win = {}; |
| 84 | win.dispatchEvent = () => true; |
| 85 | const btoa = (s) => Buffer.from(s, 'binary').toString('base64'); |
| 86 | const atob = (s) => Buffer.from(s, 'base64').toString('binary'); |
| 87 | function EventShim(t) { this.type = t; } |
| 88 | function loadScript(rel, extra) { |
| 89 | let body = readFileSync(join(HERE, rel), 'utf8'); |
| 90 | if (extra) body += extra; |
| 91 | const fn = new Function( |
| 92 | 'window', 'crypto', 'localStorage', 'btoa', 'atob', |
| 93 | 'TextEncoder', 'TextDecoder', 'Event', 'console', 'globalThis', |
| 94 | body); |
| 95 | fn(win, cryptoShim, localStorage, btoa, atob, |
| 96 | TextEncoder, TextDecoder, EventShim, console, globalThis); |
| 97 | } |
| 98 | // The bundle's top-level `var DaimondNoble` is a wrapper-local here (a real |
| 99 | // browser turns it into a window property), so publish it explicitly. |
| 100 | loadScript('vendor/noble-curves.min.js', '\n;window.DaimondNoble = DaimondNoble;'); |
| 101 | loadScript('curvefallback.js'); |
| 102 | loadScript('identity.js'); |
| 103 | const ID = win.DaimondIdentity; |
| 104 | const FB = win.DaimondCurveFallback; |
| 105 | |
| 106 | async function main() { |
| 107 | const PASS = 'correct horse battery staple frigate'; |
| 108 | |
| 109 | // ── Phase A — interop, fallback forced ───────────────────── |
| 110 | console.log('Phase A — pure-JS fallback interop'); |
| 111 | flags.noEd = false; flags.noX = false; // full engine to create. |
| 112 | check('fallback module loaded', !!FB && FB.available()); |
| 113 | check('WebCrypto Ed25519 present on this Node', await ID.signingAvailable()); |
| 114 | |
| 115 | await ID.create('Tester', PASS); |
| 116 | check('account created as Ed25519', localStorage.getItem('daimond-id-alg') === 'Ed25519'); |
| 117 | const pubRaw = await ID.publicKeyRaw(); |
| 118 | const sealPub = ID.sealingKeyRaw(); |
| 119 | check('sealing key present after create', !!sealPub && sealPub.length === 32); |
| 120 | |
| 121 | const MSG = new TextEncoder().encode('sign me across the two code paths'); |
| 122 | const sigWc = ID.sign ? await ID.sign(MSG) : null; // via WebCrypto Ed25519. |
| 123 | |
| 124 | ID.lock(); |
| 125 | // Cripple the engine: no Ed25519, no X25519. Unlock must use the fallback. |
| 126 | flags.noEd = true; flags.noX = true; |
| 127 | const r = await ID.unlock(PASS); |
| 128 | check('unlock succeeds on crippled engine via fallback', !!r && r.ok === true); |
| 129 | check('isUnlocked() true after fallback unlock', ID.isUnlocked()); |
| 130 | |
| 131 | const sigJs = await ID.sign(MSG); // via pure-JS Ed25519. |
| 132 | check('fallback signature is bit-identical to WebCrypto', sigJs === sigWc); |
| 133 | |
| 134 | // The fallback signature verifies under REAL WebCrypto. |
| 135 | const wcPub = await real.subtle.importKey('raw', pubRaw, { name: 'Ed25519' }, false, ['verify']); |
| 136 | const sigBytes = Uint8Array.from(Buffer.from(sigJs, 'base64')); |
| 137 | check('real WebCrypto verifies the fallback signature', |
| 138 | await real.subtle.verify({ name: 'Ed25519' }, wcPub, sigBytes, MSG)); |
| 139 | |
| 140 | // X25519: a real-WebCrypto peer and the crippled Daimond derive the same secret. |
| 141 | const peer = await real.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); |
| 142 | const peerPub = new Uint8Array(await real.subtle.exportKey('raw', peer.publicKey)); |
| 143 | const daimondSide = await ID.sharedSecret(peerPub); // fallback X25519. |
| 144 | const daimondPubWc = await real.subtle.importKey('raw', sealPub, { name: 'X25519' }, false, []); |
| 145 | const peerSide = new Uint8Array(await real.subtle.deriveBits( |
| 146 | { name: 'X25519', public: daimondPubWc }, peer.privateKey, 256)); |
| 147 | check('sealed-by-one opens-by-other (X25519 shared secret matches)', |
| 148 | eqBytes(daimondSide, peerSide)); |
| 149 | |
| 150 | ID.lock(); |
| 151 | |
| 152 | // ── Phase B — honest error, no fallback ──────────────────── |
| 153 | console.log('Phase B — honest unsupported-crypto error'); |
| 154 | // Remove the fallback entirely, keep the engine crippled for Ed25519. |
| 155 | const savedNoble = win.DaimondNoble; |
| 156 | win.DaimondNoble = undefined; // FB.available() now false. |
| 157 | check('fallback now reports unavailable', !FB.available()); |
| 158 | flags.noEd = true; flags.noX = true; |
| 159 | |
| 160 | const rRight = await ID.unlock(PASS); |
| 161 | check('right passphrase, no fallback -> reason "unsupported"', |
| 162 | !!rRight && rRight.ok === false && rRight.reason === 'unsupported'); |
| 163 | |
| 164 | const rWrong = await ID.unlock(PASS + ' wrong'); |
| 165 | check('wrong passphrase -> ok:false with NO "unsupported" reason', |
| 166 | !!rWrong && rWrong.ok === false && rWrong.reason !== 'unsupported'); |
| 167 | |
| 168 | win.DaimondNoble = savedNoble; |
| 169 | |
| 170 | console.log(failures === 0 ? '\nALL PASS' : ('\n' + failures + ' FAILURE(S)')); |
| 171 | if (failures) process.exitCode = 1; |
| 172 | } |
| 173 | main().catch((e) => { console.error('test crashed:', e); process.exitCode = 1; }); |