From 3c11a88a33dd7a3192faae57b6261441ee6145b2 Mon Sep 17 00:00:00 2001 From: Rasmus Neikes Date: Sun, 16 Aug 2026 22:00:08 +0200 Subject: [PATCH] work in progress --- .gitignore | 1 + crypto.md | 59 +- dev/certs/api.locqr.dev-key.pem | 28 + dev/certs/api.locqr.dev.pem | 27 + dev/certs/rootCA.pem | 30 + dev/certs/test-registration.b64 | 1 + dev/certs/test.locqr.dev-key.pem | 28 + dev/certs/test.locqr.dev.pem | 27 + dev/keys/ml-dsa-44-public.b64 | 1 + dev/keys/ml-dsa-44-secret.b64 | 1 + extension/build.js | 26 + extension/claude.md | 36 +- extension/gui.md | 24 +- extension/icons/icon-128.png | Bin 0 -> 686 bytes extension/icons/icon-16.png | Bin 0 -> 122 bytes extension/icons/icon-32.png | Bin 0 -> 184 bytes extension/icons/icon-48.png | Bin 0 -> 262 bytes extension/key.pem | 28 + extension/manifest.json | 31 + extension/package-lock.json | 984 ++++++++++++++++++ extension/package.json | 22 + extension/src/background/api.ts | 100 ++ extension/src/background/crypto.ts | 110 ++ extension/src/background/index.ts | 198 ++++ extension/src/background/relay.ts | 44 + extension/src/background/run-secrets.ts | 34 + extension/src/background/run.ts | 269 +++++ extension/src/background/sdk-ports.ts | 37 + extension/src/background/state.ts | 25 + extension/src/background/verify.ts | 111 ++ extension/src/base64url.ts | 26 + extension/src/constants.ts | 15 + extension/src/popup/popup.css | 196 ++++ extension/src/popup/popup.html | 19 + extension/src/popup/popup.ts | 356 +++++++ extension/src/types.ts | 158 +++ extension/tsconfig.json | 14 + interfaces.md | 47 + mobile/claude.md | 34 +- scripts/companion-harness.mjs | 238 +++++ scripts/package-lock.json | 135 +++ scripts/package.json | 18 + scripts/serve-website.js | 64 ++ scripts/setup-dev.js | 302 ++++++ sdk/package-lock.json | 65 ++ sdk/package.json | 15 + sdk/src/index.ts | 180 ++++ sdk/tsconfig.json | 13 + server/claude.md | 18 +- .../dev/locqr/server/config/CorsConfig.java | 39 + .../locqr/server/config/LocqrProperties.java | 3 + .../controller/PinConfirmationController.java | 39 + .../server/dto/PinConfirmationResponse.java | 8 + .../server/relay/RelaySessionRegistry.java | 36 +- server/src/main/resources/application.yml | 15 + .../locqr/server/RunFlowIntegrationTest.java | 44 + .../server/crypto/SigningServiceTest.java | 1 + .../relay/RelaySessionRegistryTest.java | 29 +- .../RegistrationCertificateStoreTest.java | 1 + website/index.html | 118 +++ 60 files changed, 4487 insertions(+), 41 deletions(-) create mode 100644 dev/certs/api.locqr.dev-key.pem create mode 100644 dev/certs/api.locqr.dev.pem create mode 100644 dev/certs/rootCA.pem create mode 100644 dev/certs/test-registration.b64 create mode 100644 dev/certs/test.locqr.dev-key.pem create mode 100644 dev/certs/test.locqr.dev.pem create mode 100644 dev/keys/ml-dsa-44-public.b64 create mode 100644 dev/keys/ml-dsa-44-secret.b64 create mode 100644 extension/build.js create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-32.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/key.pem create mode 100644 extension/manifest.json create mode 100644 extension/package-lock.json create mode 100644 extension/package.json create mode 100644 extension/src/background/api.ts create mode 100644 extension/src/background/crypto.ts create mode 100644 extension/src/background/index.ts create mode 100644 extension/src/background/relay.ts create mode 100644 extension/src/background/run-secrets.ts create mode 100644 extension/src/background/run.ts create mode 100644 extension/src/background/sdk-ports.ts create mode 100644 extension/src/background/state.ts create mode 100644 extension/src/background/verify.ts create mode 100644 extension/src/base64url.ts create mode 100644 extension/src/constants.ts create mode 100644 extension/src/popup/popup.css create mode 100644 extension/src/popup/popup.html create mode 100644 extension/src/popup/popup.ts create mode 100644 extension/src/types.ts create mode 100644 extension/tsconfig.json create mode 100644 scripts/companion-harness.mjs create mode 100644 scripts/package-lock.json create mode 100644 scripts/package.json create mode 100644 scripts/serve-website.js create mode 100644 scripts/setup-dev.js create mode 100644 sdk/package-lock.json create mode 100644 sdk/package.json create mode 100644 sdk/src/index.ts create mode 100644 sdk/tsconfig.json create mode 100644 server/src/main/java/dev/locqr/server/config/CorsConfig.java create mode 100644 server/src/main/java/dev/locqr/server/controller/PinConfirmationController.java create mode 100644 server/src/main/java/dev/locqr/server/dto/PinConfirmationResponse.java create mode 100644 website/index.html diff --git a/.gitignore b/.gitignore index 75bc39a..926b2c0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ test/tls/ node_modules/ companion/dist/ extension/dist/ +sdk/dist/ server/target/ .idea/ diff --git a/crypto.md b/crypto.md index b1e536b..e26159c 100644 --- a/crypto.md +++ b/crypto.md @@ -233,30 +233,65 @@ requires re-verifying interoperability end-to-end. | Concern | Runtime | Library | Version | |---|---|---|---| -| ML-KEM-768 + ML-DSA-44 | Browser / extension / web stub | `@oqs/liboqs-js` (npm) | 0.15.1 | +| ML-KEM-768 + ML-DSA-44 | Browser extension (MV3 service worker) | `@noble/post-quantum` (npm) | 0.6.1 | +| ML-KEM-768 + ML-DSA-44 | Web companion stub (normal page context) | `@oqs/liboqs-js` (npm) — unconfirmed, see below | 0.15.1 | | ML-KEM-768 + ML-DSA-44 | Node.js setup script | `@oqs/liboqs-js` (npm) | 0.15.1 | | ML-KEM-768 + ML-DSA-44 | Android / Kotlin | BouncyCastle (liboqs-java for production) | — | | ML-KEM-768 + ML-DSA-44 | Java/Spring (production) | liboqs-java | — | +| X25519 | Browser extension (MV3 service worker) | `@noble/curves` (npm) | 2.3.0 | +| X25519 | Node.js (companion test harness, setup script) | `@noble/curves` (npm) | 2.3.0 | | JCS (RFC 8785) | Node.js | `canonicalize` (npm) | 3.0.0 | | JCS (RFC 8785) | Browser | `canonicalize` (same package, ESM build) | 3.0.0 | | JCS (RFC 8785) | Android / Kotlin | `io.github.erdtman:java-json-canonicalization` (Maven Central) | — | | JCS (RFC 8785) | Java/Spring | `io.github.erdtman:java-json-canonicalization` (Maven Central) | — | +**X25519 had no pinned library at all until the run-flow slice.** Native +WebCrypto X25519 support is inconsistent enough across Chrome versions to be +a real risk, and untested here — rather than add a second unverified +platform dependency alongside ML-KEM/ML-DSA, `@noble/curves` was chosen +specifically because it's the same audited "noble" family as +`@noble/post-quantum`, already proven working in this exact service worker +context (pure JS, static imports, no WASM). Used on the Node side +(companion test harness, and available to the setup script) too, so the +X25519 implementation isn't duplicated across two different libraries for +one shared protocol. + +**Resolved 2026-08-16 — `@oqs/liboqs-js` does not work in the extension's +service worker, empirically, not just theoretically.** This was flagged +below as something to verify before writing any ML-KEM/ML-DSA code; it's now +verified. Built a minimal MV3 extension, loaded it in real Chrome via +Puppeteer/CDP (not a simulated environment), and read the actual service +worker console: `@oqs/liboqs-js` lazy-loads each algorithm's WASM via +`await import(...)` computed inside its own module, and Chrome threw +`TypeError: import() is disallowed on ServiceWorkerGlobalScope by the HTML +specification` — a hard platform restriction (referenced in the error: +[w3c/ServiceWorker#1356](https://github.com/w3c/ServiceWorker/issues/1356)), +not a bug or a config problem to work around. + +**The extension now uses `@noble/post-quantum@0.6.1`** (npm, pure +TypeScript, audited by Trail of Bits, static imports only, no WASM) — this +was already named below as the fallback for exactly this scenario. Verified +working the same way: real ML-DSA-44 signature from `dev/certs/test-registration.b64` +verified correctly inside the actual service worker, plus a tampered-payload +negative control confirmed to fail. One API difference from `@oqs/liboqs-js` +worth flagging for implementers: argument order is +`verify(signature, message, publicKey)`, not `verify(message, signature, publicKey)`. + +The web companion stub row above is **not yet tested** — it runs in a normal +page context (`mobile/claude.md`: "single-page web application," no service +worker), where dynamic `import()` is allowed, so `@oqs/liboqs-js` should +still work there in principle. Whether to keep two different PQ libraries +across the browser-side components, or standardize on `@noble/post-quantum` +everywhere for consistency (simpler, no WASM asset handling anywhere), is an +open call for whoever builds the companion — not yet decided. + **`@oqs/liboqs-js`:** Published by PQCA (Post-Quantum Cryptography Alliance), WASM bindings to the reference liboqs implementation, zero npm dependencies, MIT licence. Covers both ML-KEM-768 and ML-DSA-44 in a single package. Tracks the NIST reference implementation directly, reducing the risk of subtle -algorithm deviations. Unpacked size is ~17 MB; the WASM binary will be large in -the extension bundle. Before writing any ML-KEM or ML-DSA code, verify that the -async `init()` call is compatible with the Manifest V3 service worker -environment: no DOM dependency, compatible with `import()` or `importScripts`. - -**Fallback:** If `@oqs/liboqs-js` proves incompatible with the service worker -context, `@noble/post-quantum@0.6.1` (npm, pure TypeScript, audited by Trail -of Bits) covers both algorithms and is unconditionally service-worker safe. It -is not WASM; use it only if the WASM init path cannot be made to work. Any -switch from `@oqs/liboqs-js` to `@noble/post-quantum` requires a full -cross-component interoperability test before adoption. +algorithm deviations. Unpacked size is ~17 MB. Still the right choice for the +Node.js setup script (confirmed working — see `scripts/setup-dev.js`) and +possibly the web companion stub (unconfirmed); ruled out for the extension. **JCS consistency:** All four runtimes use libraries from the same author (Samuel Erdtman, editor of RFC 8785). This maximises the likelihood of diff --git a/dev/certs/api.locqr.dev-key.pem b/dev/certs/api.locqr.dev-key.pem new file mode 100644 index 0000000..254a817 --- /dev/null +++ b/dev/certs/api.locqr.dev-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDYx2NG9DFd2Od3 +XzAptZ8KDce3mPVUayVY3M6Q0xA2HKZSGpJlZpwZo/eCWsoWg8lwijd2RzYmP2Fl +a9NGKo1wJNT7MP3uVl55kVBsTtUQI4sXO/Gdpgzv1Z9MWPSDDboLuf6yNmnhIBIT +BYT6AN020dR/V0+JNImQxhgIfpSORbEek86NgWJ07NuaLYxJF13IlkWc8dhDqR0/ +J7nHul4R7Jd5E/I2LqNSJb2t8ax6kW6nlxObjbhX2GwOEW+E8T0OesCAuWn+34rR +MexUK/V+DQoTcfCIzOMWgZY4SwVKaUQplbBk9nDGqs9QGbGRfS862rdntjLDWmdA +N6k15oPfAgMBAAECggEAXCPjvrTshsc7GzIKdrAL+WoCCbeqD0e7iBnScxxDN7zV +tPA2aYNDtmJLQcz1OLyrUnptOIjsKzWlYEaEdr5/f7soC9y3EicD4Qgy9tZLGaEB +7c481JVPymEyZHEq/MlI2tMjvvwB5P6MsCkYbGsRm8t0Vuv1kowSLMcGUBX9brrH +8gylUo60ogZJBW0I9WT75L5a0AwPTxiOP67QvIDuD7+eT2agrJ9v3XEz1JFRRP1Y +KpPD6+NtG6G9gHAPYFr3xFNWIBHoKCqCLz1zfv+giNGdxmaC5dvOyO+/Z6/GhAKE +fiEgmshEE4QaAT1t+REOdqv/HKv9/6mY57GONZt1gQKBgQD7JXyZtdPbuiBm4WgL +WRxn+TEgo4wmHLmx3cuQMe7Pm2YvaiQd5IKBOIf4DMA2wxcy4lsylvah31JQBuzX +Y+pXcLVJ5ypnaaMBrylTRW+yT7mSzfbJ/2s3Vw/XTiaw4KKKnQ5YFrwGewpdvPBz +5wr4V9pQJafp4D8tm1vioVdE2wKBgQDc999DQopRMyArF9oauJB9cMDBcQa2dfi1 +HqFErxIvwwIWKj3Cyooy5Dryt+/495m6AbSdPBrBMfROlx6RNtdT53v+ScnAUk7S +H4mHTFE4Xok4/75pCHAJz6HmG0LuVNA0UrCHP+IzVyr63OhnEmnpLXSpDZtYzr6o +NShUjpbKTQKBgEvF0qMZrfLkZDRGG7sYxq5EC+N6FKXHEuusLGez+QZgL2Ns1brD +H/DW2ocnabLcB5rNmpBX5c+O7mnAvSJ7Pc/l7HyAp1WOFKVEcOZz07Brx2SYibYG +PxXySZA/PwMssz25BwPi0BXwd38yqyV89t1YvEBkLBYvF0CuV/m3jfnzAoGASZtM +RPR6bNgSBCja34HRp+eSXh3PdaJQqcy9PcrvzPcxz75cenHLnW5HcKjzCEU6cSq2 +RpZJ90czsaZHaWoSSoHW3PspeKYyWW5l+qrid1uObG1MuWI1KB+BN8ym/AtGm9db +tIIEUMPdrlk+FW+d1i3tKY89y1R7UD7840XoVxUCgYEAlQb+x0piMD8OjyDmdNEL +8x5IQb1Tw4hL8EOtbgIlFFFv4np55uPVNjjO5bNQIkCYytG6cZAGIfwYs+MaSlHj +FrhucXd8IOEOD7yypQVcogdgrr50vDN+EIvlx4uWli1ACMV44IL0XUG2P/eTthHW +aBq66Lg7dtOdf+JZAlGhImI= +-----END PRIVATE KEY----- diff --git a/dev/certs/api.locqr.dev.pem b/dev/certs/api.locqr.dev.pem new file mode 100644 index 0000000..8b35f1e --- /dev/null +++ b/dev/certs/api.locqr.dev.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEfTCCAuWgAwIBAgIQXtvGLwBin70KN5DZPlu50jANBgkqhkiG9w0BAQsFADCB +pzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMT4wPAYDVQQLDDVyYXNt +dXNAcmFzbXVzLUluc3Bpcm9uLTE2LTc2MjAtMi1pbi0xIChSYXNtdXMgTmVpa2Vz +KTFFMEMGA1UEAww8bWtjZXJ0IHJhc211c0ByYXNtdXMtSW5zcGlyb24tMTYtNzYy +MC0yLWluLTEgKFJhc211cyBOZWlrZXMpMB4XDTI2MDgxNjE0MzIyNloXDTI4MTEx +NjE1MzIyNlowaTEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNlcnRpZmlj +YXRlMT4wPAYDVQQLDDVyYXNtdXNAcmFzbXVzLUluc3Bpcm9uLTE2LTc2MjAtMi1p +bi0xIChSYXNtdXMgTmVpa2VzKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBANjHY0b0MV3Y53dfMCm1nwoNx7eY9VRrJVjczpDTEDYcplIakmVmnBmj94Ja +yhaDyXCKN3ZHNiY/YWVr00YqjXAk1Psw/e5WXnmRUGxO1RAjixc78Z2mDO/Vn0xY +9IMNugu5/rI2aeEgEhMFhPoA3TbR1H9XT4k0iZDGGAh+lI5FsR6Tzo2BYnTs25ot +jEkXXciWRZzx2EOpHT8nuce6XhHsl3kT8jYuo1Ilva3xrHqRbqeXE5uNuFfYbA4R +b4TxPQ56wIC5af7fitEx7FQr9X4NChNx8IjM4xaBljhLBUppRCmVsGT2cMaqz1AZ +sZF9Lzrat2e2MsNaZ0A3qTXmg98CAwEAAaNiMGAwDgYDVR0PAQH/BAQDAgWgMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFJOhUTjJSp0SHygdwvL+uc1c +NqDOMBgGA1UdEQQRMA+CDWFwaS5sb2Nxci5kZXYwDQYJKoZIhvcNAQELBQADggGB +AE91pYcMSWx0Q8HirVgoLEmymAfEErCHpdWR9Kjq+pHYL8V14CJpuZaN6PPcFdwj +/QT0f0G4GYoiyUITT+DxvaAAoSGpb+ZGq8Uk0DruRgrcFwAR8/84/xUWbEUW8twn +EaaNDeT8rs3pY85dzhGm3ACA+Jqbwntzo2RWioK+EKIKgyNBTU0gMhnKET01mUxC +ZSv6QT4/XB2RYZwV6H97TmoT38GKRAQ3efNJEoF9mYbXTYRUtBVE4ANesfn0urTB +Jnh5ZSN9cJdGq0lo6PpTCBSeCMkGSUX+PRl46njFSbiu7dpcKPryafz2UbJlt2MP +idj+56y9dzSSnpeCyWAhjdzbqwxpApxdRVXHo+NWcq/LmL/BpSm/Vo3kYvc5oAWH +VeYLFjkW8YfuxTg+8C/cQg/bx4V+6ajPJz4ahgoP9QRAk4kvdqwp1GUpCpAsEMgk +LzpGyUhUCBscWbdPFWVBFpAaPJa2u0HQrJIeCEYB+j5BL/XN+dyFpOWORJQuuej0 +SQ== +-----END CERTIFICATE----- diff --git a/dev/certs/rootCA.pem b/dev/certs/rootCA.pem new file mode 100644 index 0000000..a36be91 --- /dev/null +++ b/dev/certs/rootCA.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFIDCCA4igAwIBAgIRAK8+0i2pGi9CTW0wPsSj2I4wDQYJKoZIhvcNAQELBQAw +gacxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE+MDwGA1UECww1cmFz +bXVzQHJhc211cy1JbnNwaXJvbi0xNi03NjIwLTItaW4tMSAoUmFzbXVzIE5laWtl +cykxRTBDBgNVBAMMPG1rY2VydCByYXNtdXNAcmFzbXVzLUluc3Bpcm9uLTE2LTc2 +MjAtMi1pbi0xIChSYXNtdXMgTmVpa2VzKTAeFw0yNjA4MTYxNDI1MzlaFw0zNjA4 +MTYxNDI1MzlaMIGnMR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExPjA8 +BgNVBAsMNXJhc211c0ByYXNtdXMtSW5zcGlyb24tMTYtNzYyMC0yLWluLTEgKFJh +c211cyBOZWlrZXMpMUUwQwYDVQQDDDxta2NlcnQgcmFzbXVzQHJhc211cy1JbnNw +aXJvbi0xNi03NjIwLTItaW4tMSAoUmFzbXVzIE5laWtlcykwggGiMA0GCSqGSIb3 +DQEBAQUAA4IBjwAwggGKAoIBgQCsWHtE3dM4GQPuJ83CXEukj9DVKOXgdQOrQYmd +pDFxBGfkZiStwia/yIYyoGrXfdtdrVTT/g7WSRbQpaLgwgv3C4T9Le/1udq1bi1/ +G8s7XrbQOF+YKw85DhOdWGCfv8lSVMhMMchzZm6uDXnau9cY6L6iADbJT9j2tZXv +YZxeACC+D+NKKeA0n8ryhdeHXcVlPAw0pBk9SvtprMT95iIziTfm88iB1bFGeC31 +Q8m7ZvTAM55JLBiyrP5SCS2F7RH74moetfZd2G7gWAnfuSeRrNtliRs7D8LtkR0r +W5evbrugDRSLJKtAhwsNdn8DEkopbKBqpnVGFiZTbK2rXzLObMTVrtuE/Flfmk/n +EppyjRh0B2GiK0jOh7zMaWYKXxG0Xo4AAtSaoXjqu56AvCjIiVMfIuTvYSjlngI1 +35blQXRy89kPKM8fCvc7ooubPDYm9XgJmj+/dhlNZliqu9wtGrq2yOqMnADoA3bw +STfQHca7t/0J1cwoATBKAnK1W3sCAwEAAaNFMEMwDgYDVR0PAQH/BAQDAgIEMBIG +A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFJOhUTjJSp0SHygdwvL+uc1cNqDO +MA0GCSqGSIb3DQEBCwUAA4IBgQCTmuCEvLUsgYWCAf1+uynWNQ+p+RNjelbbIGsk +FX4FXFxp6eBJWCRjH6qtQNazi+aM0rv2Gqy7R+hcBkwTDB4QEizR4F0j2VqFJnRW +nBAbiIgLzTCc/MB3eYs8R7TA20OPX8077n5vqCouV7XB9C41snDS6cprKM3V9AtB +nTdJ8bhQojNiwVJRwtT0YcKlJc0y49YJ3jb1s4OUEKtTZgr1Cbs2/7XcCwTwTKMz +ht8Katk2xtPP+r+oVV9cvFQIotHz9a/SjjG2b6F2Y6XeHp5TMmtna5CPjA5njeXE +Tm4bj2gwn2st+QWl6RPJ3fQz3T/8N4xOJzflZEGAw5UPbzuMyg8qVLkLAk2i/O4r +jdcV0mmYZtOyL5oEaDXpdE/P7l9MpEuExygbbFn6Ntd5n/vfJGkL4lu7GLxhGO6a +fKDsPCK8ygatT3BsVG6mLoCEX9cWtPrhO572q+VadgAdzo3Z7hKcqS+eQPONRR/e +/+tAU4VwZG113FW15IohT9+kNhM= +-----END CERTIFICATE----- diff --git a/dev/certs/test-registration.b64 b/dev/certs/test-registration.b64 new file mode 100644 index 0000000..c575460 --- /dev/null +++ b/dev/certs/test-registration.b64 @@ -0,0 +1 @@ +eyJ2IjoxLCJkb21haW4iOiJ0ZXN0LmxvY3FyLmRldiIsImlzc3VlZF9hdCI6MTc4Njg4NDY2MiwiZXhwaXJlc19hdCI6MjEwMjI0NDY2MiwiZmVhdHVyZXMiOlsibG9naW4iXSwic2lnIjoiMks1RXNBemZWWHVJR3l3a2pBZVBDMXRNdXJiMVNQNVRLcDNQX0laYXloaHhWZExjWFM1TUllLTFURVB3VS1hUUt1MDdVa3JOM3E1bjhGZmVacGFYM0JRbG55cExGTjlTbVRxTGkwcTE0TWZCSTRzX0o1QzRUckxVaXdZQnBPRVpMV2ZYQVE1SUtrc0hQTjBfWnFyaEloY3FPdlFwS2tNcUNtdUpkVmZNQXJpVTZnelplOHZyTmp4YUFacFpiNEd1YWF3UVdUUkpWOExrektzejJxQlYwMnV0YlNmejBqWHVaOG5qQ08xcDZhMGFEQkZHeEY4cVRnR1dheVl3MUloNGQ2NC00QTQ5RVpzT3hwZU8zT1luQTktWmd2cV9hc3J2aThUX3dTd3dtWDRCSWJFMUVyc0I1c25tcEZYRGo3SWJQOHZUM21JdXBHMG1uMkNlcDhTMkRVaGdEM2lSMnZYTjNyNE5ZSnVUVDZiWmRrVlNRd1BuUjdZVGFvUW0wbzh3TnVxeUlhX2tPcTQ2NGRHQ2VyMzNJazdZOE85YXZFV3ExUG9XSy03SF9JT0lOUzc4TGIwVzJwM0xUb2xvZ0p0YjVtRzgxZFNpNG9IUF9BVE9hSVNrN1g1S0FsZjNYUUl5X1ItR0tYZlBWODJYQlBCd2pUb0s5VWliVjhDV1FXUHB5enhZb1pjWU9NcUFUcFYwWE1FUjhROU1fTXVpS0pZTEg0cmVDMmI3aXpJV3N6c1hvOWxNTnBtbnVyT19veUx0NW5DendiZTM5VkI0RTlwQzJFTEdQdXRYM0dyVFJiRC1HbVVobF8tQlpfS1g2bkh1WHR2Ukc1eDhLSkdxVm5oZ0c0cVA5QWxRNTd5U3VQMzVLajktUHRUR3UwRnFRQnE1c1JYMFJfbUVCamJwOHZUVGJCelJrX2tCYy1scmtmZTBxQlFXUzEwQWVkYnRnZWhTcXljSUtKZnFzTFZ4TXJ3Y3BmZVFSeHdsTUJVYjE5TDRBRm5YZGRXZWtUbzdLM2dwam9EMm9QSW1Oa2NDTHdJejRJd0hiOUhXRUdWOGc1b09MNmhBdXBBR3gwLV9SaEthcEdqdUs2RDJpdmNGd19YMm9OVnNmaTlEdVhVampZM3BVVENzaWFyb3FCeDFMQmIwN29PWmdiRUFJdkJKVXBXYjI2Ml82OW5BdHozaTVvQTVBNHcyN1N1YjF1SXhBcnRfVUItaEtCYy1GVEJCeExROFVpX014WjVoNlA2dW9lREY2X3VTQXRBYXh3elhEenpEVXowR1dsbkNVZkVrd1A5My1CMm85Mm9mRjVqc010d0hwalFwR0lhdTFMQ0tOX21IU3NqMUlGN3d0dDFFRVJ2bWhHcmRmRHhhd2VFMkg2RjVsZFgtN1lFem1US1VPWW9QYWhFaHNULUM3LXVSay1ybnBxWXZYRTN1aG5FQnlhVS1qZ3NQS1IxTDJsQ01paW00UnhrdzduZUNaNHYycXcyTC03WllGektFVU1ubzA4MHhlcm9wMGtRMnhtVmMxUWJoUHFDalRyYVpGSFRQZ0pEVzJKQk5kbzJRaWpIaTRYeVcyRGRkMUdlX19Xcy1aUXN1ZmQzbU1haVlqTHBzUVd2STZHa3BLZ0xSZmIxMTJraUJfcGlDWU5nNDRhOUhrTkZ1NjRMbjdYUVhvOGlydWRFNk1vV1VVci1zR3dTeU81UFZPOFhmM3hveUJ6d01XdGVKNjJvSkM3cWV3QkgxcHZyUzR2WXRMVkZrS2lLMWZfMERTTlpob3k1NjBYSlJuZjd5U1RMbU5kMkFXVkJ0emFXN0lJbUdHbDhHU1lmVlpQYVRjZWtZZDNTUTBOY3FzMVJzcWlCRlBqLXhWOW52OXd1eEJMNjg3Z2QxRGFqZ1pVN2VfUGN1dkltdnR6aFZISkFsYjNxcmJTTk9CaFlabnFXSC1ZZF9BSmZ0dzhObGJucjVUZlc3RDEtS19ZYkRzZVpscmpzbFF5cU5BcVhIMmlfa2NZV0dqd0VvcU1MVE14bzdIeGozd2t3M2VNNmFmc3BqVHhrN01CZk9zQk84SERfSVNiSnA5SHdWQ3BPWHNwY0kyQTVXc3k0REVoemtSU05UQ0ZfSWhfZmRlNTRZRmRnNS05bHFKZUpwUTEwUjEtUXBCazR2OVNxc0kyYVJNVEtDMXFCQ3QwN2ppTnlCOVZFSDlsOWNHMmtBdmR0OGZIVC1TUlcteFdPcHlSb2k1dnRHbjQ0RzdtUnhGejJIMU5UVWhabmJqVjhUcVpZV2hoV1E2UDg0SHZHN0J2bUp0eC1jczBibnpBbTllWk13SXBpWDBQM04wSzlSeUh2QjZvNkNOTVhLemNNTHFOUG9iLUs3TW8wX1VaQ1BIT3FZNTNlTmthS0taSnlhaUxqeHpFRWlOT2hmSTc1RUtRSGJMY0t1WlM5dG1XTXdiVVItb2p0djBNeDVqTFVsaHNaTndXc1NKY1M3ZnVxZUI4ak5MMXVLcG1PTUd2TU4wOGVPb0VhVkhyZlN3cWZ4UVctel85bEpPRWo4VVRqMVpJVkRhNWhTTnZSMnh6c3FUcVIxaGM1emtxbi1TTkFreWhtaFhKVlB3aERJdzFfbDdXdkV3WVJTOUYxWFFiMTBudkRhaUwzVHFLcXhzYkNuV0ZvaEQ5WkRJQjlZbVRra2VMV1BsOEVUbXZ3Rm0wUjNpUngzV3cxLTdCNVFwdDhEWjhUcm94eE1aZzF2TXFZU2w1bnQyUDVESHV3SUNIZ0lWR0EzOFRpajhSTmJxWnZaWGZHWmNmeHg5WHdZSGtycXpodjIxMzFpa0lvSVloOGtBc25UcDBOTzJhbGl0TDlRN1J1eDR6eHhFWWV1VDBDZFFMU29zRXNpQTNEdnlSSWJBdkdfMDNNMXZXWVBfbWs0dDZ4bl80N3lydlk5azd1T0VEMkdFcnVoT0ZHaEkyek45N24ybGh4eGdXanhVVW9XT094LVVJY01pakw0R0VGcVY2ZHcxRXN4RWd2Z1V6SU51a2VKT1MzcHNOaThhbW1RX01TSlVsUWJtc0czQU1wSS1DS185anpyTFlnTGNXQnVKVWtyQlg1dGs4T3NnYXFQcTZXT013eGNaekp1eFBoWFZ2T2FKb2FQZjNvNGcxU25aLTNoLWxQcUZQTFNHVkRpbmRBVzZvOGJDMVhvTGZmR0F1ckhxQTlTdDNEMlhrVUlGVjVuV29zdjJkcDM1MnFOZUxPbnNWYUZvemN5QjRRV2htODBTWV9QYmtxTTl5bUNtN2Y2VjRDRnFaYWp6TWpFU2FpRzBmSkNDZGdteWF3Z1lTdnFCWGtwYUhqN0dHeXdQSFNGbkpldnRIWWtGVVVVdGNNT196YnFvSjk0aDlrUEdrdUtYY0pnTGFuRGFjTkRMc3VYaXdfajBfTld2MkR0Z2VFQUk0Q3pKNHFKVk5DYi1ENnc4SC1DRDZPX1RObm9sejJCZXh1WWxkUTdLdDZQYVZzOXZZNEVCS3Vydmdvdm1VSGdpOGNzRDh2NUdMWWNVb2JpUVlRQWszTzZEaEJ0MWVBbDhJeXpOMU56MXoxWTcwaDN2MHlpUnlzV09oXzBIYVBfR0JOc1JXNmZsYjE4dldNRlRtQU1LVnRaTlg3MHY2MVF1MGphem9WY1hoS1N0WUY5WjF1ZDRveWdSODluUy1zTWJnVjk3X2hzWVV4cDhZVkhWLV92OV94Wi0wcEtCZExuajlra2xsVU5ZckhPSjBfekViZXBnYWJrWGY5MkFuMDBrRmtmOHRIWDNvelNURmtMNEM0cWcwY0xSdUF1enIycGJxR0ZvTnlWZXM2N3k5TDk4Z3VxRnl2NGpWQ2ROaUZpOV9NQ1ZscG8zRF90OW5nUXg1NnV5YV83ZGQ1dEdod01kVExhMEFEa2RLTl9YSWstYWU2eGNDdFJMalNzSUR2cGdZM1ZVckJ5TEZVUGg1MEZsN3NRbmFFUTgydGRZbmFSV2tFc0FSSHUyRWVlOFM5RXdvaGg3TFRhM05IVkpqMUZoS2RmWVk4Tzc0U1VXVHRSTVFKVHhBckItd0Utd1gwNlh2N1pFQWhKWVo3ZEZpdENWWTBjRjZ3RjJ2WnhtYmVibW9XaFgwTGlCaV9vSmlFOHcxZHE2NEZ0RlJjaHlLY0ZFU1VyTnprN1AwVktYWFotcEsyeXM3dS14QXNORVRnOFVYUE8wOVRjOF9uOEVoZ2pMQzB1T0ZoaWpyTEcyZHZlNERSTlVscDE1X2dBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFCUWlNamsifQ diff --git a/dev/certs/test.locqr.dev-key.pem b/dev/certs/test.locqr.dev-key.pem new file mode 100644 index 0000000..f75f446 --- /dev/null +++ b/dev/certs/test.locqr.dev-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDTi9lsZbut+gC4 +dYUdftpgfPnhnubMDtuMmBYpFwDFVCnaPjaPqpde3S3WA0pt43iyz9zLn0UJaaBh +HaBKmokmoAxy/2ETaaYNFl99AgCDCUpxYJ1CUymnb8G1ADy5bZ6KusNzQGR/5KTq +UF4DC6XIMjij/roep8sU1lOtxGTz6anuMDcr2Uon6bTpb/5LVkhwT946qsBoDgFl +PCGmQKDOiNQYfh2p8+wEnut7/DW5Bj2BaUSpVgBVPYl6QS6Ze0xXWTgKpwAT5//U +cYMg1v7+znSEsq5/WoS50wBa9lG/Wf894HWXVQ4d9rFcoUx3auRCdopgYsfIBASe +tVCnnR9/AgMBAAECggEBAL4pWKk4Sh16PmuSOLbq8SMLNRS74nxMXs9jZ4hIRUkc +/wJQKnu6vZdo+2sGSkG92SHj+HaSFV0AtkqWdyCZCfDOrmIxbvIkZkAdfHjC8/LW +xzRjxPl6CFea/mXWcL++6mWKvWc82xqcTiLTW68hKUVs372LnYMxEj0I7O+nO5iF +MLyanXRkJHZVn0iYdnhh5B3UmswTv67i6jksrOyf9VdyBm/vjPzfIf90UPjmUVOP +Phr9BbWl7Ry+XPcUqPd12/7+J4ATdncv2kHA8XfNTXwGi/Yt3hnpsnJdMaDxe9is +5XBMaeKxs7OH97mv3BgsCiNSEi0yR2zeThU9hWlligECgYEA4QFGaX2QLIgMwj6X +gqES4hYN9FaknILaV5PMDs02RRyg9M2kmdmb6Z27p/j6Hj9cNldLvHWjdyKxz8Bu +d4K9GxZK6aLTTsuOu7QbQqzazlOq7EPCq04EPSy1wGJzS43TXwYcEbzN6RnyuzQ3 +CTjlkIujeHPeW/CUStKCG3DOBHkCgYEA8K/1O1RzAJYEEURl5+pmmI12F1vTeR/f +Jd8+Z4+z24YSSw0nHvHxQLRiJkC3g4wn71d36ieMj9xILBMopqCwwjKenfPghtMZ +FgXMXWhy64SIlyWSD/Ozq7AWjtKs5MaxmhyS6WtxNzWJrvRgJ/RehUo9NuppfnqF +24DIKKtKFbcCgYAQBQE4XYI4SW9vHPm4iTNI+X0A2nJZ2k8lURaEL0Qf44vqIgII +GiApn9tOeEGGichM7iYsQsvinYu/WNoElEBWf13SCI+22nNNFeOi+Z+SdQ6ER8bC +X4mZuWcvTVMcG/rilxLEiIa4g+puPad1dqGRiv+Wgdlg/l3wfdBZl8xzMQKBgQCl +o5KTSCT07EjUUUwIdMoyhngUzz8UBpkdiSt7Pew7UWNZfy3DICI1s24wgS1KPLRn +BL0jyh/0CVcp7e117vR2UTvT3DhS0QhcnZTtW6pq5wTRcCu7Cq5Fo6OCmv4dW5hy +ROPd+/EoW5Hrc4aROJ2sAVFrb5s2Tb+9Nj2Jr4gGtQKBgQCruodX1M4CSjKauTZC +bgXN3a5eEwYRdW80mCIv7DW+ff9KmKXUsUUv3VWdHYjFYt+Xa/qtQ20PWA+swoxp +ArPLmiFAzudh55VNQ+qGsKDsp1izqOUAE5JoMQXEyVanHXmV0f6Bj7MeGepe0BLF +IVa1f+4NUHiX5qY+lBHmUKWXqg== +-----END PRIVATE KEY----- diff --git a/dev/certs/test.locqr.dev.pem b/dev/certs/test.locqr.dev.pem new file mode 100644 index 0000000..472c5b5 --- /dev/null +++ b/dev/certs/test.locqr.dev.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEfjCCAuagAwIBAgIQI6o5V7+pV6vxWffkW3mgyzANBgkqhkiG9w0BAQsFADCB +pzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMT4wPAYDVQQLDDVyYXNt +dXNAcmFzbXVzLUluc3Bpcm9uLTE2LTc2MjAtMi1pbi0xIChSYXNtdXMgTmVpa2Vz +KTFFMEMGA1UEAww8bWtjZXJ0IHJhc211c0ByYXNtdXMtSW5zcGlyb24tMTYtNzYy +MC0yLWluLTEgKFJhc211cyBOZWlrZXMpMB4XDTI2MDgxNjE0MzIyNloXDTI4MTEx +NjE1MzIyNlowaTEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNlcnRpZmlj +YXRlMT4wPAYDVQQLDDVyYXNtdXNAcmFzbXVzLUluc3Bpcm9uLTE2LTc2MjAtMi1p +bi0xIChSYXNtdXMgTmVpa2VzKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBANOL2Wxlu636ALh1hR1+2mB8+eGe5swO24yYFikXAMVUKdo+No+ql17dLdYD +Sm3jeLLP3MufRQlpoGEdoEqaiSagDHL/YRNppg0WX30CAIMJSnFgnUJTKadvwbUA +PLltnoq6w3NAZH/kpOpQXgMLpcgyOKP+uh6nyxTWU63EZPPpqe4wNyvZSifptOlv +/ktWSHBP3jqqwGgOAWU8IaZAoM6I1Bh+Hanz7ASe63v8NbkGPYFpRKlWAFU9iXpB +Lpl7TFdZOAqnABPn/9RxgyDW/v7OdISyrn9ahLnTAFr2Ub9Z/z3gdZdVDh32sVyh +THdq5EJ2imBix8gEBJ61UKedH38CAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgWgMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFJOhUTjJSp0SHygdwvL+uc1c +NqDOMBkGA1UdEQQSMBCCDnRlc3QubG9jcXIuZGV2MA0GCSqGSIb3DQEBCwUAA4IB +gQAx5iPz55W1gNp+px3mxv86k0TUsNMOU/oefz3HlU0Mm4ZjXgRZla0znn3aMmNw +cns2+RdF7jewirF3WT0oUy2oHdfZjWSiebWtKz2ejOAYykNrL2viz1BdYGLnizo1 +7goBABWsf88tltfPPwKnXQ7sg6eZlAl17/v3jxREirD7zC8pmiKahX3KrwLJhFYB +m+wIR+VYMMludqIfpwlHuo8vXqsImfs93HXw3lljAq9fP1L9SlQydOkVcwEswjYO +0rWJw2YMyZ4FkVQATihT+NGH1Kgw9Ff4PEO+IMnVv8Hm//vZWtRBITfdNzTgD3xJ +3qG6XC++bNcrEivN+CtipQsRFTlC5Hg4VtLSVwxyJQdZqvdM7xH0XfPiaLzgb3ys +lB+Sy+mLBLeb57yek8IYGtsQUQvVBLMHAcmciTJaAVrLQUnF6MULm9Tyehk9i1jI +ynILFIBUJAI2gyHVLFwzyKofT0yzIOXS8nrfA3Mu6chBcjs6N0zPAI4wkW4MIOqo +jJo= +-----END CERTIFICATE----- diff --git a/dev/keys/ml-dsa-44-public.b64 b/dev/keys/ml-dsa-44-public.b64 new file mode 100644 index 0000000..d556e3e --- /dev/null +++ b/dev/keys/ml-dsa-44-public.b64 @@ -0,0 +1 @@ +VkwMOKMIP1wRds9rvkfothm_h_YVMND-nZW2hhGlhTrkapzOcmV2WkhELuGdDnzb7dn40I89r1nh53s3NOXRHK9Cg0SoWmh3KvDCOCrZcy67lizJ2YfNbc6-8mwRzmSWzBUt2nw3eqjoxYHWsDj4RT_OYBO7iExgzdumQkXxox8-QxHdg6lrFzDjtmyf3xNlI85x2WXHqISh9QZ8Hh7RzHuL9kJ0_n8jkP83SJYU0priPyPmxYjG7sjzikXNRsoW4nYmRhkY4LuD1ygzuHS3ZV90pjN_Id8ttLEUT3RAdMvlk_mUiG4le3oxwrhoypfeOECq80olooMcrnZJSD_m3PyDbuxIPstVrrQraLp-IgHNPameWATyr22c_-AI2qtdK6aVWVb-W9D7x8hooEQV2TQLXzKSxzpwpcey3-T1PWb0cwICCjq3MbBA9yCcwqeCjCG-FY2Vu4HQCIWPyJDB-5RAjVmyKntNX4v1ska7aXlNYj5mUYY5vSQRYfW4oY7eSARoeLpueEkoEPeKgRAFK0_MqafkDLgXTVx9ZGYN0QTQwfwTjoPi3BRHcJDMsILvD65E9jrKx6Bc5aaPlux9naMGSwRtKRIteEjfqKXcZ8Dtm-MGbNNoAiDHqkqsROa-DST9WNOY57SHZF2uXc7ZbXixXbcuj6lN_vrB0pl1BaiNQzoxciePbgvocUNQHa9Jqhs-D5NIdrK4BGaBItYoeD2WdynfMnE9wttXDin5LJyU5zqQksWhxYIM4LPd4I2_Fiqpw8EdYuIgipfwWZiTDjDu9SsLt9kwkyjADdVTWYiB_AvNPqmniJyTTYJNhPWxbZ2fLDRsRNNq55pZf2PhRQiaBX_-6KKVUgir3DfJf7WFS9FnDYLtBRwc7cobagmtwH6QMzN2uEuqpTmyNBP6N7_2SD6LBvBlQ1f9qdQMb63q_waoPJ76OQOOQa1qQ3wfFCaGbZm1N_61uGpxPENLv2ZczBIRiViPcpPDHxDLGy4nYlDQ7Uk3yhD-urFGO63LnHrG69dDDrMc38-xT6Aaq5J6tx7qp20Ggao5O-gJckHKkfCsFNllelu_BmZIqRjrBR6tJYeZBKFuFO34BaUlxoG8AYk9eza5lsJzgjobL89Ngn9r-GqeRSMEBZw-YIwIx8Bn-yy3PCZcROkXqCSXlJ-9cc7z2dcbMg1Sg0nWGW_i73CGoGSl2eLjVJBpTWfnWvDPVLL6EjlPlfSlguw1xlQkxpW2f6MdAdpA9kwew389x058NLrgig2gIRL0Xu565zaC6KVUUd3cf-isnpByffZbKRn1kTspJaXTUXIV_0g_qJ12qaiK5LKqbQ77TJ16cheC5z_h6Ig5hoBXCgEplmYKE5rbBkvqjeSpWemciQx6kMySUygNjOmYAzTk5atOgYUgw5aLx2d_9AYDq4zu0MczLOiVzfLIe2Qon3Ng2BtzPx0PJMqnI9elxdAmDVJNkew3RuQE_vuJbBL2Dw0jYDzkYBuCcLLSUmbshfe6P8IJwjT115DDxzRXK634EHkh1fNWLcAa4GtyGywHXlM4IrwrsENHSGbpG5ONp4vJBCgackRjuQhBRZXLmOqmTj27n_OiUqH2VKIm4ZyFWeBBaXXeXEyhjtUsN16CkaaNFciuCwmcYO-v95g2TK9-lw9wHOF9TQfq3US4iSO48RNMX8H-yLgq_6iSvgD2hU_V-oKeui0UoXEZiI-42Dv3fm5XCvgknubie78x3JdDm8CXQw diff --git a/dev/keys/ml-dsa-44-secret.b64 b/dev/keys/ml-dsa-44-secret.b64 new file mode 100644 index 0000000..f347140 --- /dev/null +++ b/dev/keys/ml-dsa-44-secret.b64 @@ -0,0 +1 @@ +VkwMOKMIP1wRds9rvkfothm_h_YVMND-nZW2hhGlhTpzi7xYlnVJtqLrnJ4SLBLXbm7ZbOZ5nqih9H-Qw9-DaNZVTQAcBCxSdraUFkF5P9JfdHXOLifittWpfZPhfn7fVlj_gHa7h39mjn39N6_cTZ5j7fKbnNVAVBuWVcegeZjRKAECMDAYF0URIFGhookSJ03TJBGcJIAkgkgZB2ghg1CiEiyRhmBgIjKigIjIpE3iGBDQwokEBi7iMiLkuFGhCCQjplEMhSXKhohBkkHYxknLhmiLIkgJGUjIRE0IEZJcgGGKsIBLMIoSRwAgsGUjpQVLSJAhp4UIEySgMmiUyJBTlEzLqBHUhmgAhoUbJ4BUqEzLFC4EglERGYUItRETFWTCFEEAQCgBxJChoIkjRU7cCExcpiwZAGCKGEAEKRETlkAbNolZSEUkFIFYmGkZhUUKxYmbQkSSiEyJsgQJogGIyHDTmAhZgixIIlAQBiSagIBDpHAgEGEEBVHkwCggsm0IJQbkgnFJoFAbBmrRBBBJOHAjxiQMJowLRoAZE0SihgGLNoKQOJCaACYKQZBgCCYLGVEDRxBiOIITRWSAklCcomETsklTNAhhMg0ExozJwAREFkZQFiGJoA3UFExRhghgsg0EFIKiIkoIkSAYNA6gOAAYom1KhEQckgASE2EJGYbQlG0gCA0KMmAZFQ1MgEFKsmhAJAXgMikaFIXQRibbkiSUJk0SljFaQI0kxwURg0iIxC1EAi6TsBDBQiAEQEHAFEKRJookIEHUEgRCOEJSuIQBKQKSwokCsQQhiDCCMmohyGWjtIjBRCQAmGShJJFhtnDRhJDLRiKRwIjcQiCjREUMtDAMAoIBwYSZgo3MRC6cmESAyGCYlmhgwDAKkkRbJGEgN0KaNJFRhCATFCnTFiHUkGTasGSbMgxMtGSiSG6CskWJSCLhtAHYwIQAlEACFywCSCQCFYRQuGXQkgUTN0wkJlHkppEjRJIEkWgIh4gYNjAcJUQTIFJhSEoRMoSAOGLYAmBQEEXgJlGblgDcsgSjCIRRxkhBNG0UJSURMWoBFmBbwCGUAIZYmAziBmYiNyiCFIiSgHAColDgmAkLCA1MCAXRNGoDpGHbRmXRkIALxwDRKAnZgCzZgJAiBACSJA4ApjFjBiaQpjEZBATZKG4KBQhTQm2aFElQKJAvnMkP-uAGvYdunfphHbDPedn1qHSXF2mD_S0Hsj3Eee9cXvwRMm2k09q8Sb1-pQKInjxsDAp_JNaeOtB0OXzz5ESf8Xs1q4ieuUILUYNfLk5B-3BdO5Cso0mE7vS574xLU9P_MNCSwZu3jtF_djo-73q7y3aA2ecKjnMztS3jTDBlGH-yZATSX1k8cc6tDzJ0EhjbIDXFs0gV9xhFZK_mmsML-GLxS56-k3C6t7BJnGZ-ZmicPYvFwCQ1QwjgcqWcUKhPC3INP7OuLoxxX9fIfbeJi5RDw_FEV1P7vzNmWHVoUxjtoIy1XNO7c1GNBB0hseJCa6zB8KDM2scBbGS9RExX_FbuHP4qtswBiV-ETX49TALiwADF99u3T8gaFSxDF6I93Gk2AjLxihzN0mzcBP19S6irTkQy9MspVm0lTmDUfZ7amHY0_NoMDr0MKoNWiB_ZeRWDvnaIqgov4pZCgwEGP6CErhY67OrbOP-TfAZuyejNlbSLKUeq3wBrDGTNl6oe0pdqSRSF5KxfFjpTMAIMZ763XC5gbnA4FkoxNBHFayHB8eRQuPDMgaky2kA-4pLibxdi1p-PTAYH2SDOAva5FgQ2McRaTJcQ-zmf40ridJoxuLP_uDHJHFfltY9qrovZit84N1-7cR1Ra3aEN5l7YGsMD8nOGAewpm63rsinPcgZeG6tIkhsjOGGP2-NfQmcCpirWcAAn04O_1TYu1cWmGDwrsiqiJkgaVqwP0fqMUuoLy7NnNrBfO3WzjbQe2nC00q6vK59ef7T5MWp3kCeAzRcHnvyvIt03XuZthaHstxwbPRIw3QDEw4xFzV6WHxGt2nRwBtPmiUeIaw8fxhEReQLiUrswoizQRbGfxa1LN_ZskouHdYvuZ_s9mIBX2y_t6uiUDJHd3grRDFC7mEryryzA3bogo4fzT6JO1y3BDKtdyr4auNZU2TXrNYlXPWlz41y3FxKOGrNQ5B48VbBL0ey0DNt2WwjumC9s73ygc4cH-RnraYQXnnx34iV4H-WqzIRQLCUG7pa9xkTsDXK-d5L8hZREqREOS_BGtg1Ks0o_KSlji5BhTDmsMl68zfqGsHGY5BgqjBSpj_TLChVe8ncw2oX_QDNK0IkrKQ2_KCZFO9Rmf8I07zR3879xRnTRN6vG2CBoplWG1_4KJ-lYra5KMOQESIGxCG6MjdawEOIPF92L4SE0AGElDewn8nhZlErked88XsWvhphTGQG4tgO1xV21NMutmIWObTj61wBEBmB-wiKTEvZknCi4Np-_M6wX-0LgWnh9eIoxnIs3aNaNNUKdjOGQPXCbxpFhwbmPmrcz0_pzoNUd93ZrKXPlECKpnl1BddyqsHUXWOS0UlPimLUQGWQcdfdE3ivA4_4I2F1tA56ynYzsMvQ1CpIC_WWG2KIdq8-1y-oKpPIK1Kv10sCXmx-iqDL3jU_pdGa61m6x0jtIOi-oajOQxTJMZilICiY2A2aJnEqSvyBT5DSimIM3RGaypVoslhNr2cVc1HDmYvMf0Cn3tQGbA249kWzFa69H5VI6OqOkuc0y_i5NIlj63xpjPWu9UisdSKMk_DbEgNYlQBHn03E5v9YGt1zMIRaOlZnPcCrwsRKYGi77yePcsgn-mGGNXS9a7HUsbPEeWn-_uA3STVHRnv71-Jdb8uC1PvaRQ0VtqL_uXRgINC1q70fZsix4qgNmKDt_DTZcaxnCMlEflg2xtot9Y8cPKzN_6mP3MAGuhh1Td_7ISt0j4XMzaSih3Hm1rbBRD6gZpH-zMSNt-NRkAx3rD-BlbCsDJswoKWYQqAJjuBx8fcqiniJb7_wD491t7u7EndEcF7DNWYQmFYChuqRTcvczXDwXOuGcXQ2y5EMARJxSLnzCJDwuZ6g_px3Xto5vsNn7v2P81dy4x2ED2RxhFm9ESbQF-uPYvWADYTEGSwaqNgBZHSoAj2Vf2a3xsUddh0HNGjxieZc4MmMUkiqym5oDDnVCxjZHxUemdMrxcD0QKc51Uel-cGbZNYhsrrq5kaRbVMMKUsZXhIM9kC_es_GssrG1vSMX5ObRcJ4N-kby-AIG14ddNAw5gNxPzAgoSIExZWdC66XMw-PTFipg2_oJvqYnIkd116L06Wm1cef20Ei-MECXe-_MqVLRUt6Wc-lX89S_xSc9QexPsNPhi-qbFnjXSMHPnUAtPs3AjJhgMrcEGOd-Q3fYg diff --git a/extension/build.js b/extension/build.js new file mode 100644 index 0000000..7894d5b --- /dev/null +++ b/extension/build.js @@ -0,0 +1,26 @@ +import * as esbuild from 'esbuild'; +import { cpSync, mkdirSync, existsSync } from 'node:fs'; + +const outdir = 'dist'; +mkdirSync(outdir, { recursive: true }); + +await esbuild.build({ + entryPoints: { + background: 'src/background/index.ts', + popup: 'src/popup/popup.ts', + }, + bundle: true, + format: 'esm', + target: 'chrome120', + outdir, + logLevel: 'info', +}); + +cpSync('manifest.json', `${outdir}/manifest.json`); +cpSync('src/popup/popup.html', `${outdir}/popup.html`); +cpSync('src/popup/popup.css', `${outdir}/popup.css`); +if (existsSync('icons')) { + cpSync('icons', `${outdir}/icons`, { recursive: true }); +} + +console.log(`Built -> ${outdir}/`); diff --git a/extension/claude.md b/extension/claude.md index a98558c..239b1c6 100644 --- a/extension/claude.md +++ b/extension/claude.md @@ -106,18 +106,14 @@ machine. The popup reflects the state of the currently active tab. - `phase_3` — run key derived; PIN displayed with a countdown derived from `pin_ttl`. Awaiting user confirmation on the popup. On TTL expiry: transition to `run_error / pin_timeout`. -- `phase_4` — user confirmed PIN; awaiting encrypted credential from relay. +- `phase_4` — user confirmed PIN (`POST /run/relay/:runId/confirm` sent to + unlock the companion, interfaces.md's "PIN confirmation" — fire-and-forget; + this transition itself is the extension's own real local gate and doesn't + wait on that call); awaiting encrypted credential from relay. - `delivered` — credential decrypted and handed to the JS SDK; transitions automatically to `idle` after brief display. - `run_error` — run failed or aborted at any phase; reason and class - held for display. Three classes: - - *Security-class* (extension notifies backend; user sees explicit security - warning; no retry offered): - - `alpha_hash_mismatch` — key bundle received from backend does not match the - commitment in the QR; indicates tampering between extension and backend. - - `pin_mismatch` — run keys diverged; should not occur if all prior - verifications passed. + held for display. Two classes currently apply to the extension: *Timeout / user class* (expected; user offered retry): - `ttl_exhausted` — QR auto-refreshes used up; user must manually retry. @@ -130,6 +126,21 @@ machine. The popup reflects the state of the currently active tab. - `relay_timeout` — no message received from relay within expected window. - `relay_error` — relay returned an error or malformed response. + A *Security-class* (extension notifies backend; user sees an explicit + security warning; no retry offered) also exists in the shared + `RunErrorReason` vocabulary — `gui.md`'s "Security warning — mid-run" + template is built for it — but the extension has no member of it today. + `alpha_hash_mismatch` and `pin_mismatch` were listed here in an earlier + draft; both turned out to be companion-only detections copied across by + sharing one `RunErrorReason` enum with `mobile/claude.md` (see that + file's near-identical wording) — the extension never receives a second, + independent value to compare against its own alpha_hash or PIN, so + neither is actually detectable on this side. A human comparing the two + devices' screens and declining to confirm is exactly what `user_abort` + already covers. `bundle_consumed` (see the design note below) is the one + concrete candidate for a real extension-side security-class reason, if + the server is ever extended to push it over the relay. + ### Transitions ``` @@ -163,8 +174,9 @@ phase_2 phase_3 → phase_4 (user confirms PIN) → run_error/pin_timeout (pin_ttl expired) - → run_error/pin_mismatch (security: run keys diverged) - → run_error/user_abort (user rejects or cancels) + → run_error/user_abort (user rejects or cancels — the mechanism + for a PIN the user sees not matching; + see the run_error note above) phase_4 → delivered (credential received; GCM auth passes) @@ -341,6 +353,8 @@ The extension communicates with the backend over HTTPS. Endpoints used: - Open and maintain a WebSocket connection to `/run/relay/:runId` for incoming relay messages (kem_ciphertext in Phase 2, encrypted credential in Phase 4). +- Post PIN confirmation to `/run/relay/:runId/confirm` (phase_3 → phase_4, + unlocks the companion — interfaces.md's "PIN confirmation"). - Query account status for a domain (site verification, step 3). - Report security-class errors to `/security/report`. diff --git a/extension/gui.md b/extension/gui.md index 7411295..45ad0b6 100644 --- a/extension/gui.md +++ b/extension/gui.md @@ -64,7 +64,12 @@ Dark mode follows the user's OS/browser theme (`prefers-color-scheme`). requirement, not a nice-to-have, since older scanners are the ones most likely to reject reversed polarity. -- Display size 190×190px in the card body. +- Display size 260×260px — fills the card body's content width edge to + edge (300px popup − 20px padding × 2), so the QR's outer edge lines up + with the countdown bar below it. No margin beyond that shared 20px card + padding; a smaller fixed size (190px, tried initially) left extra + whitespace around the code and made it read as unnecessarily dense for + its size. - Caption: "Scan with the companion app." - Countdown bound to `qr_ttl`. @@ -132,11 +137,18 @@ which are ambient page state rather than part of an active run — nothing to retry or dismiss; it persists until the cert or page changes. **Security warning — mid-run** — same red badge and "Security warning" -heading, but with a single **Dismiss** button. Used for `run_error` -security-class reasons (`alpha_hash_mismatch`, `pin_mismatch`), which -interrupt something the user started. Dismiss uses the outlined/ghost button -style, never the filled accent button — nothing about a blocked state should -visually read as "the expected path," the way Confirm does in phase 3. +heading, but with a single **Dismiss** button. Built for a `run_error` +security-class reason that interrupts something the user started, the same +way `cert_invalid`'s security variant interrupts before one starts. No +concrete extension-side reason uses this template today — `claude.md`'s +`run_error` note explains why `alpha_hash_mismatch`/`pin_mismatch` (this +template's original motivating examples) turned out to be companion-only +detections; `bundle_consumed` is the one real candidate, if the server is +ever extended to signal it. The template stays ready either way: the popup +code renders any `errorClass: 'security'` reason through it generically. +Dismiss uses the outlined/ghost button style, never the filled accent +button — nothing about a blocked state should visually read as "the +expected path," the way Confirm does in phase 3. **Placeholder copy — needs a plain-language pass.** The reason strings used while designing this ("relay timed out," "run has been stopped and diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..32e62aa4ced8964dc53a985a1dca29c042e7d4e9 GIT binary patch literal 686 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU@G-=aSW-L^Y+Hw!dC_itckaE z12~ojaxV?!FrC28rq=L3#dF%yyYK%xsio__UiO~LD*j#nr2F+E+h4EL|9W?K?Z)Kx zo7!D#;vc=peA1(Mn%%la&eF-_S0ms4{wazoKNZ^bm0dZU->_TMS!&jO4`j5ctK2A^ zW920CZ^pJgLQVb*$L-hY-ezHa@Z-4nu8Xf2WcJG`Mz7^M@bUHLvdu?<+_>y*-T$l` zUYSWZ_}BlPW;TcCz{lH$+n;7I^xdDg(swWOfxj#dF5h3bG1r#yfgRI_m-+X=3NMx1!v3#;#a{tOF|;d4Ry@gk*Dw!`wX$XB5?~nHd({5S+|# S!mJT!7=x#)pUXO@geCxhFC#1f literal 0 HcmV?d00001 diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..6a619fb58cf4d76c661d2a66dcf3f18fd8a659dd GIT binary patch literal 184 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJdQTU}kcv5Pr*GtKFyLXyT|dE5 zqBqhgiY<|Cg$^srdXXc3ALEZreD`N=sKd6u78h$w){6a|wU_T7Q-8-DxwOWK9j=Gx z>p06RCn;^bT|H;!y@s@$`|5icxdIxPL>4fxYB(@DyB~hDCCX$!qj@Krb#X77OH%ZU h$ZXl=n_U8Siyxh*xlu3gGYilu44$rjF6*2UngG^?LD2vJ literal 0 HcmV?d00001 diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..54f8f1dc39295c84ec4067d8020e65c69401ad04 GIT binary patch literal 262 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtvz{)FAr*7p&N|3@SV4g0VN`*l zrta1qCgP8zCW#9jlCe7(SmuALlBr)Z@yCOxrakwbtMB?IF7rD~`SrB!f4Xlsb{Xb3 zx$c{?^|VQY=e~!Qu}l(ArH##ZM;|!$BI6#5@!r+F%#j)A-sw(h{laMa`1$3@b?hPy zZj2dth+~V^H}(Ln`23zB>VamM*PpEkD literal 0 HcmV?d00001 diff --git a/extension/key.pem b/extension/key.pem new file mode 100644 index 0000000..c398fcf --- /dev/null +++ b/extension/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCb0SsqFKFIHxUZ +ts1WnIMzlSisNs+d8ZnAmmyYboY1ATtfkQM3mgQRUMqm6VgBhX6bz6sYgBs/Ilsc +SflPDWhhHNGy6VQz4IpM79F9uxAW5jhc47QccGCZZ43UrlvVg7gwjPKN5eShi0bB +qL/BHRdDZ6YBwkV3LCl1HW6xSiHTZYGwM3nLklSqFlHwE5TyCCoEBUmw2GA51Wy0 +MpGCusC1JpNaz1k18RTfxDQrILGwiKg9c0ICFsIkUyQPhNNeGTlTwPLgNgJBZiyw +OJy22X8hQjB7lePJkMBauvpvGKR0Pmz2stdKpisa9/RoAY6d4Ykj35F2S9ixR+Nh +dF0OcE/fAgMBAAECggEAEz44JCCbcR+9kwkQv43hZNZv88y/w+lu1pV7rIZBzCap +zCSe5BgA6+6GsymwNQflL0hCAMBEHSjuCEaueRqBIIqBmfnXl0yMvUcEyo/2mGK4 +OpM4eXv7bMFC3S2R/oLKQDINUS7qFx/vOMLRY6pnEWup2cpsmnTOwz2Y9QuLCo3r +cdRybLfj36mUDevxhT5Qa8xh6JNBv5ljgv2L2lOfZUHzCTpvkklVWnMXsidwOo+1 +9Or43QE/ATFcmX1DzvhYenL8IvnBNTSxsmoyIC8bL+6Sd5CF6aK5R6qBeHG2OY4N +vjVy1vfliOwwpmtJlrvo1Lu2CHnpjqtnbV4FBY6RmQKBgQDbrVJ3C1vBZ5P+NXi3 +R6u9VyNR8n+6EhR3AO+01TzT7wWOlLKDRSdiID0S6L8SV6Qd5Q8QO6qi0FhkafI5 +6E1HDZbGFaN6LdKaip51n2OP7Wcgiu8cIA6+3BmA1t/XSz/odSyVg+aZHkjPxjS2 +Dks2TFfVEPeFUrWlcnXIfSIDpQKBgQC1lLoOV9it/ZAt5QKF/h8jBwB30DuftReE +II+Zvif+8ZZ4rjI79zDNH6WYYR8CQNMQf5qYBiRiz9cZFvK+hc6UG8bhpqeyUFnG +qMNkKBHIeeGAiY9mq4XFvpJXVKpqRE2KSS+ZxHgCDoLKbRCgyVvdWVeidBzbUQDb +vcEG+ZdeMwKBgBpUYNDx5JJ+xqcuY4ScK4JrIkmdJh/4MMm+q/xhnoPMXz8ipW7E +aB8DvC47BUO+i2Yl4TNR43bWP/HxHN5B8Jk2kL63MzveqCJrwOhaLUW/759f557Y +XMwVd10Q5A1a8JL9EFXvVEE/9vwoRoMXnX4pklFwUiqBqlHlMSbRkLUdAoGADm37 +OxiQB4OW6Y7BXJSNNONhjiPmGRLzA3Ty9GzAEOpx4rU1GL7UCfjx1+If7LXqD+2U +A3C5g4CwvzWrpKhNekeazmPH/8uS1s6ieFsWzq+g0+4ajzYiM0yppHb98+PppYub +VMTgJImyqxNvttdJjZWD+Uzw3hzZzJyPslYzSTECgYEAu4ecu1DAXUVria/nXEyh +/jqRjDRk20Gur/s9/ZgphcdjG8km8pR2xmGjzcBIT3ScDCe3EQaqNgekG2gyd+dr +g8fXq+VqGIpqgy2shTfa7I8UCbhegRagACq57r5ld7JneOIuSTxLaIRkrtOQIxcQ +LLVPG9JN3MGtp2t+Qb1S1SI= +-----END PRIVATE KEY----- diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..792111a --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "LOCQR (dev)", + "version": "0.1.0", + "description": "LOCQR browser extension — development build.", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm9ErKhShSB8VGbbNVpyDM5UorDbPnfGZwJpsmG6GNQE7X5EDN5oEEVDKpulYAYV+m8+rGIAbPyJbHEn5Tw1oYRzRsulUM+CKTO/RfbsQFuY4XOO0HHBgmWeN1K5b1YO4MIzyjeXkoYtGwai/wR0XQ2emAcJFdywpdR1usUoh02WBsDN5y5JUqhZR8BOU8ggqBAVJsNhgOdVstDKRgrrAtSaTWs9ZNfEU38Q0KyCxsIioPXNCAhbCJFMkD4TTXhk5U8Dy4DYCQWYssDicttl/IUIwe5XjyZDAWrr6bxikdD5s9rLXSqYrGvf0aAGOneGJI9+RdkvYsUfjYXRdDnBP3wIDAQAB", + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "externally_connectable": { + "matches": ["https://test.locqr.dev/*"] + }, + "host_permissions": ["https://api.locqr.dev:3000/*"], + "permissions": ["storage", "tabs", "alarms"] +} diff --git a/extension/package-lock.json b/extension/package-lock.json new file mode 100644 index 0000000..1e05e8a --- /dev/null +++ b/extension/package-lock.json @@ -0,0 +1,984 @@ +{ + "name": "locqr-extension", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "locqr-extension", + "version": "0.1.0", + "dependencies": { + "@noble/curves": "^2.3.0", + "@noble/post-quantum": "0.6.1", + "canonicalize": "3.0.0", + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@types/chrome": "^0.0.280", + "@types/qrcode": "^1.5.6", + "esbuild": "^0.28.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", + "integrity": "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.3.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/chrome": { + "version": "0.0.280", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.280.tgz", + "integrity": "sha512-AotSmZrL9bcZDDmSI1D9dE7PGbhOur5L0cKxXd7IqbVizQWCY4gcvupPUVsQ4FfDj3V2tt/iOpomT9EY0s+w1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/canonicalize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz", + "integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..f60fbca --- /dev/null +++ b/extension/package.json @@ -0,0 +1,22 @@ +{ + "name": "locqr-extension", + "private": true, + "version": "0.1.0", + "description": "LOCQR browser extension (Manifest V3). See claude.md / gui.md.", + "type": "module", + "scripts": { + "build": "node build.js" + }, + "dependencies": { + "@noble/curves": "^2.3.0", + "@noble/post-quantum": "0.6.1", + "canonicalize": "3.0.0", + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@types/chrome": "^0.0.280", + "@types/qrcode": "^1.5.6", + "esbuild": "^0.28.0", + "typescript": "^5.6.0" + } +} diff --git a/extension/src/background/api.ts b/extension/src/background/api.ts new file mode 100644 index 0000000..c6263a6 --- /dev/null +++ b/extension/src/background/api.ts @@ -0,0 +1,100 @@ +// Backend calls the service worker makes directly (extension/claude.md, +// "Backend API surface"). `host_permissions` in manifest.json grants these +// fetch() calls cross-origin access without needing CORS headers from the +// server. + +import type { RunBundleUploadRequest, RunBundleUploadResponse } from '../types.js'; + +const API_BASE = 'https://api.locqr.dev:3000'; + +export type DomainStatus = 'valid' | 'rejected' | 'suspended'; + +export interface DomainStatusResult { + reachable: true; + status: DomainStatus; +} + +export interface DomainUnreachable { + reachable: false; +} + +export interface BundleUploaded { + ok: true; + response: RunBundleUploadResponse; +} + +export interface BundleUploadFailed { + ok: false; +} + +/** POST /run/bundle, Phase 1 (interfaces.md, "Run bundle upload"). */ +export async function uploadRunBundle(request: RunBundleUploadRequest): Promise { + try { + const response = await fetch(`${API_BASE}/run/bundle`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + if (!response.ok) { + return { ok: false }; + } + return { ok: true, response: (await response.json()) as RunBundleUploadResponse }; + } catch { + return { ok: false }; + } +} + +/** + * PIN confirmation handshake (interfaces.md, "PIN confirmation") — tells the + * companion it's cleared to proceed past its own local pin_display gate. + * Best-effort, same as reportSecurityError: a failed POST here doesn't + * block this side's own phase_4 transition (that's already fully gated + * locally), it just means the companion never gets unlocked and the run + * times out via pin_timeout — logged so that failure mode is diagnosable. + */ +export async function postPinConfirmation(runId: string): Promise { + try { + const response = await fetch(`${API_BASE}/run/relay/${runId}/confirm`, { method: 'POST' }); + if (!response.ok) { + console.error(`[locqr] pin confirmation post rejected: ${response.status}`); + } + } catch (err) { + console.error('[locqr] pin confirmation post failed:', err); + } +} + +export async function queryDomainStatus(domain: string): Promise { + try { + const response = await fetch(`${API_BASE}/domain/status?domain=${encodeURIComponent(domain)}`); + if (!response.ok) { + return { reachable: false }; + } + const body = (await response.json()) as { status: DomainStatus }; + return { reachable: true, status: body.status }; + } catch { + return { reachable: false }; + } +} + +/** + * Security-class errors are reported immediately (extension/claude.md, + * "Error escalation"). `runId` is omitted for pre-run cert errors, per + * interfaces.md — there is no run yet at the verification stage. + */ +export async function reportSecurityError(errorType: string): Promise { + try { + const response = await fetch(`${API_BASE}/security/report`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error_type: errorType, timestamp: Math.floor(Date.now() / 1000) }), + }); + if (!response.ok) { + console.error(`[locqr] security report rejected: ${response.status}`); + } + } catch (err) { + // Best-effort — a failed security report must not block the user-facing + // outcome, which is already determined by this point. Still logged: + // silently swallowing this with no trace at all makes it undebuggable. + console.error('[locqr] security report failed to send:', err); + } +} diff --git a/extension/src/background/crypto.ts b/extension/src/background/crypto.ts new file mode 100644 index 0000000..c1c70c3 --- /dev/null +++ b/extension/src/background/crypto.ts @@ -0,0 +1,110 @@ +// Run-flow cryptography (crypto.md, extension/claude.md "Cryptographic +// responsibilities"). Pure functions — no chrome.* APIs — so they're usable +// unchanged from the companion test harness (scripts/) for cross-checking, +// and testable in isolation. + +import { x25519 } from '@noble/curves/ed25519.js'; +import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; + +export interface X25519Keypair { + secretKey: Uint8Array; + publicKey: Uint8Array; +} + +export interface MlKem768Keypair { + secretKey: Uint8Array; + publicKey: Uint8Array; +} + +export function generateX25519Keypair(): X25519Keypair { + return x25519.keygen(); +} + +export function generateMlKem768Keypair(): MlKem768Keypair { + return ml_kem768.keygen(); +} + +export function x25519SharedSecret(secretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + return x25519.getSharedSecret(secretKey, peerPublicKey); +} + +export function mlKem768Encapsulate(publicKey: Uint8Array): { cipherText: Uint8Array; sharedSecret: Uint8Array } { + return ml_kem768.encapsulate(publicKey); +} + +export function mlKem768Decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array { + return ml_kem768.decapsulate(cipherText, secretKey); +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +/** + * alpha_hash = SHA-256(x25519_pub || kem_pub) — classical precedes + * post-quantum per crypto.md's normative hybrid-construction-ordering rule. + */ +export async function alphaHash(x25519Pub: Uint8Array, kemPub: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', concat(x25519Pub, kemPub) as BufferSource); + return new Uint8Array(digest); +} + +async function hkdfSha256(ikm: Uint8Array, salt: Uint8Array, info: string, lengthBytes: number): Promise { + const key = await crypto.subtle.importKey('raw', ikm as BufferSource, 'HKDF', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: salt as BufferSource, info: new TextEncoder().encode(info) }, + key, + lengthBytes * 8, + ); + return new Uint8Array(bits); +} + +/** + * run_key = HKDF-SHA256(ikm = x25519_shared || kem_shared_secret, + * salt = runId_utf8, info = "locqr-run-key-v1", length = 32) — crypto.md. + */ +export async function deriveRunKey(x25519Shared: Uint8Array, kemSharedSecret: Uint8Array, runId: string): Promise { + const ikm = concat(x25519Shared, kemSharedSecret); + const salt = new TextEncoder().encode(runId); + return hkdfSha256(ikm, salt, 'locqr-run-key-v1', 32); +} + +/** + * PIN = HKDF-SHA256(ikm=run_key, salt=[], info="locqr-pin-v1", length=4) + * (mobile/claude.md — not restated in crypto.md itself), interpreted as a + * big-endian uint32, mod 1,000,000, zero-padded to 6 digits. Both the + * extension and the companion (here, the test harness) must implement this + * identically or the two sides' PINs will never match. + */ +export async function derivePin(runKey: Uint8Array): Promise { + const bits = await hkdfSha256(runKey, new Uint8Array(0), 'locqr-pin-v1', 4); + const value = new DataView(bits.buffer, bits.byteOffset, bits.byteLength).getUint32(0, false); + return (value % 1_000_000).toString().padStart(6, '0'); +} + +export interface Credential { + username: string; + password: string; +} + +/** + * Wire framing (interfaces.md): nonce[12] || ciphertext[N] || tag[16]. + * WebCrypto's AES-GCM expects ciphertext+tag concatenated as one buffer + * (tag last, tagLength defaults to 128 bits) — which is exactly this wire + * format already, once the nonce is split off. No manual tag separation + * needed. + */ +export async function decryptCredential(runKey: Uint8Array, payload: Uint8Array): Promise { + const nonce = payload.slice(0, 12); + const ciphertextAndTag = payload.slice(12); + const key = await crypto.subtle.importKey('raw', runKey as BufferSource, 'AES-GCM', false, ['decrypt']); + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce as BufferSource }, key, ciphertextAndTag as BufferSource); + return JSON.parse(new TextDecoder().decode(plaintext)) as Credential; +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts new file mode 100644 index 0000000..720f7b1 --- /dev/null +++ b/extension/src/background/index.ts @@ -0,0 +1,198 @@ +// LOCQR extension — background service worker. +// +// Covers both the verification-state machine (extension/claude.md: cert +// check, idle/error popup states) and, as of this slice, the run flow +// (idle -> phase_1..4 -> delivered) defined in run.ts. This file wires +// external SDK Port messages and internal popup actions to those two +// state machines; the actual run logic lives in run.ts. + +import { queryDomainStatus, reportSecurityError } from './api.js'; +import { abortRun, cleanupRun, confirmPin, dismissRunError, startRun } from './run.js'; +import { registerSdkPort, unregisterSdkPort } from './sdk-ports.js'; +import { clearTabState, getTabState, setTabState } from './state.js'; +import { verifyRegistrationCert } from './verify.js'; +import type { CertInvalidReason, PopupActionMessage, SdkMessage, TabState, VerificationMessage } from '../types.js'; + +/** Every state past initial verification carries the origin/features an active or just-ended run needs. */ +function originAndFeatures(state: TabState): { origin: string; features: string[] } | undefined { + switch (state.kind) { + case 'unverified': + case 'not_registered': + case 'cert_invalid': + case 'account_error': + return undefined; + default: + return { origin: state.origin, features: state.features }; + } +} + +const OPERATIONAL_ERROR_TO_REASON: Record, string> = + { + insecure_origin: 'insecure_origin', + expired: 'expired', + not_yet_valid: 'not_yet_valid', + }; + +const SECURITY_ERROR_TYPE: Record<'domain_mismatch' | 'signature_invalid' | 'malformed', string> = { + domain_mismatch: 'domain_mismatch', + signature_invalid: 'cert_signature_invalid', + malformed: 'cert_malformed', +}; + +function tabIdOf(port: chrome.runtime.Port): number | undefined { + return port.sender?.tab?.id; +} + +async function handleInit(port: chrome.runtime.Port, tabId: number, originUrl: string, cert: string | undefined): Promise { + if (!cert) { + await setTabState(tabId, { kind: 'not_registered' }); + respond(port, { type: 'verification', status: 'error', error: { code: 'NOT_REGISTERED' } }); + return; + } + + const result = verifyRegistrationCert(cert, originUrl); + + if (!result.ok) { + const state: TabState = { kind: 'cert_invalid', reason: result.reason, security: result.security }; + await setTabState(tabId, state); + + if (result.security) { + const errorType = SECURITY_ERROR_TYPE[result.reason as keyof typeof SECURITY_ERROR_TYPE]; + await reportSecurityError(errorType); + } + + respond(port, { + type: 'verification', + status: 'error', + error: { code: 'CERT_INVALID', reason: result.reason, security: result.security }, + }); + return; + } + + const domainStatus = await queryDomainStatus(result.domain); + + if (!domainStatus.reachable) { + await setTabState(tabId, { kind: 'account_error', reason: 'unreachable' }); + respond(port, { + type: 'verification', + status: 'error', + error: { code: 'ACCOUNT_ERROR', reason: 'unreachable' }, + }); + return; + } + + if (domainStatus.status !== 'valid') { + await setTabState(tabId, { kind: 'account_error', reason: 'rejected' }); + respond(port, { + type: 'verification', + status: 'error', + error: { code: 'ACCOUNT_ERROR', reason: 'rejected' }, + }); + return; + } + + await setTabState(tabId, { kind: 'idle', origin: result.domain, features: result.features }); + respond(port, { type: 'verification', status: 'valid', features: result.features }); +} + +function respond(port: chrome.runtime.Port, message: VerificationMessage): void { + try { + port.postMessage(message); + } catch { + // Port may already be disconnected (page navigated away mid-verification). + } +} + +/** SDK `request_credential` — only valid from `idle` per claude.md's transition table. */ +async function handleRequestCredential(tabId: number, windowId: number | undefined): Promise { + const state = await getTabState(tabId); + if (state.kind !== 'idle') return; + + // Nothing else makes the popup actually appear for an SDK-triggered run + // (unlike the popup's own start_run action, where it's already open). + // Best-effort: openPopup() can reject if the window isn't focused: the + // run still proceeds either way, the user just has to open it manually. + try { + await chrome.action.openPopup(windowId === undefined ? undefined : { windowId }); + } catch { + // ignored — see above + } + + await startRun(tabId, state.origin, state.features); +} + +/** SDK `abort` — valid from any active run phase; a no-op outside one. */ +async function handleAbort(tabId: number): Promise { + const oaf = originAndFeatures(await getTabState(tabId)); + if (!oaf) return; + await abortRun(tabId, oaf.origin, oaf.features); +} + +chrome.runtime.onConnectExternal.addListener((port) => { + const tabId = tabIdOf(port); + const originUrl = port.sender?.tab?.url; + const windowId = port.sender?.tab?.windowId; + if (tabId === undefined || !originUrl) { + port.disconnect(); + return; + } + + registerSdkPort(tabId, port); + port.onDisconnect.addListener(() => unregisterSdkPort(tabId, port)); + + void setTabState(tabId, { kind: 'unverified' }); + + port.onMessage.addListener((raw: unknown) => { + const message = raw as SdkMessage; + if (message.type === 'init') { + void handleInit(port, tabId, originUrl, message.cert); + } else if (message.type === 'request_credential') { + void handleRequestCredential(tabId, windowId); + } else if (message.type === 'abort') { + void handleAbort(tabId); + } + }); +}); + +/** + * Internal popup actions (extension/claude.md's UI action column) — a + * same-extension chrome.runtime.sendMessage channel, distinct from the + * external SDK Port above. `retry` re-enters phase_1 from a dismissable + * run_error (claude.md: security-class errors don't offer retry, but this + * slice only wires user_abort, which does). + */ +chrome.runtime.onMessage.addListener((raw: unknown) => { + const message = raw as PopupActionMessage; + if (message.type !== 'popup_action') return; + void handlePopupAction(message); +}); + +async function handlePopupAction(message: PopupActionMessage): Promise { + const { tabId, action } = message; + const state = await getTabState(tabId); + + switch (action) { + case 'start_run': + if (state.kind === 'idle') await startRun(tabId, state.origin, state.features); + return; + case 'confirm_pin': + if (state.kind === 'phase_3') await confirmPin(tabId, state.origin, state.features); + return; + case 'abort': { + const oaf = originAndFeatures(state); + if (oaf) await abortRun(tabId, oaf.origin, oaf.features); + return; + } + case 'retry': + if (state.kind === 'run_error') await startRun(tabId, state.origin, state.features); + return; + case 'dismiss': + if (state.kind === 'run_error') await dismissRunError(tabId, state.origin, state.features); + return; + } +} + +chrome.tabs.onRemoved.addListener((tabId) => { + cleanupRun(tabId); + void clearTabState(tabId); +}); diff --git a/extension/src/background/relay.ts b/extension/src/background/relay.ts new file mode 100644 index 0000000..1ecc276 --- /dev/null +++ b/extension/src/background/relay.ts @@ -0,0 +1,44 @@ +// WebSocket relay connection (interfaces.md, "Relay messages" — "Backend -> +// Extension (WebSocket push)"). Opened after Phase 1 completes, closed on +// run end. Confirmed working from the MV3 service worker context via the +// Step 0 spike (crypto.md) — this is that finding becoming real code. + +import type { RelayMessage } from '../types.js'; + +const API_WS_BASE = 'wss://api.locqr.dev:3000'; + +const socketsByTab = new Map(); + +export interface RelayHandlers { + onKemCiphertext: (payloadB64: string) => void; + onCredential: (payloadB64: string) => void; +} + +export function openRelay(tabId: number, runId: string, handlers: RelayHandlers): void { + closeRelay(tabId); // at most one active run per tab + + const ws = new WebSocket(`${API_WS_BASE}/run/relay/${runId}`); + socketsByTab.set(tabId, ws); + + ws.addEventListener('message', (event) => { + let message: RelayMessage; + try { + message = JSON.parse(event.data as string) as RelayMessage; + } catch { + return; // malformed relay message — ignored, not a protocol error worth surfacing + } + if (message.type === 'kem_ciphertext') { + handlers.onKemCiphertext(message.payload_b64); + } else if (message.type === 'credential') { + handlers.onCredential(message.payload_b64); + } + }); +} + +export function closeRelay(tabId: number): void { + const existing = socketsByTab.get(tabId); + if (existing) { + existing.close(); + socketsByTab.delete(tabId); + } +} diff --git a/extension/src/background/run-secrets.ts b/extension/src/background/run-secrets.ts new file mode 100644 index 0000000..5d88297 --- /dev/null +++ b/extension/src/background/run-secrets.ts @@ -0,0 +1,34 @@ +// In-memory-only store for a run's actual key material — never written to +// chrome.storage.session or anywhere else (extension/claude.md's Data +// storage table lists these separately from general per-tab state, and the +// security invariants are explicit: private key material never leaves the +// service worker). Lost on service worker suspension, same as +// extension/claude.md documents for ephemeral keypairs and the run key — +// an in-flight run doesn't survive that, and isn't expected to. + +import type { MlKem768Keypair, X25519Keypair } from './crypto.js'; + +export interface RunSecrets { + runId: string; + x25519: X25519Keypair; + kem: MlKem768Keypair; + runKey?: Uint8Array; // set once the key exchange completes (phase_2 -> phase_3) + qrTtlSeconds: number; + maxAutoRefresh: number; + pinTtlSeconds: number; + autoRefreshRemaining: number; // claude.md: "the extension tracks auto_refresh_remaining" +} + +const secretsByTab = new Map(); + +export function setRunSecrets(tabId: number, secrets: RunSecrets): void { + secretsByTab.set(tabId, secrets); +} + +export function getRunSecrets(tabId: number): RunSecrets | undefined { + return secretsByTab.get(tabId); +} + +export function clearRunSecrets(tabId: number): void { + secretsByTab.delete(tabId); +} diff --git a/extension/src/background/run.ts b/extension/src/background/run.ts new file mode 100644 index 0000000..87e2e77 --- /dev/null +++ b/extension/src/background/run.ts @@ -0,0 +1,269 @@ +// Run state machine (extension/claude.md's run states). Scope: the happy +// path (phase_1 -> phase_2 -> phase_3 -> phase_4 -> delivered), user_abort, +// QR auto-refresh / ttl_exhausted, and pin_timeout. Still deferred: +// backend_unreachable/relay_error are only wired for the two call sites +// below, not every conceivable network failure point. + +import { base64UrlDecode, base64UrlEncode } from '../base64url.js'; +import type { RunErrorClass, RunErrorReason } from '../types.js'; +import { postPinConfirmation, uploadRunBundle } from './api.js'; +import { + decryptCredential, + deriveRunKey, + derivePin, + generateMlKem768Keypair, + generateX25519Keypair, + mlKem768Decapsulate, + x25519SharedSecret, +} from './crypto.js'; +import { closeRelay, openRelay } from './relay.js'; +import { clearRunSecrets, getRunSecrets, setRunSecrets, type RunSecrets } from './run-secrets.js'; +import { sendToSdk } from './sdk-ports.js'; +import { getTabState, setTabState } from './state.js'; + +// ML-KEM-768 ciphertext (crypto.md's reference table) and the companion's +// ephemeral X25519 pubkey are both fixed-size — interfaces.md's +// kem_ciphertext relay payload is a fixed-offset split, not length-prefixed. +const X25519_PUBKEY_BYTES = 32; + +const DELIVERED_DISPLAY_MS = 2000; + +// chrome.alarms, not setTimeout: a service worker can be suspended between +// events, which would silently drop a pending setTimeout. Alarms survive +// that (extension/claude.md's timers are load-bearing security/UX +// deadlines, not decorative). Named per-tab, one of each kind at a time — +// scheduling always clears any prior alarm of the same kind first. +const QR_ALARM_PREFIX = 'locqr-qr-ttl'; +const PIN_ALARM_PREFIX = 'locqr-pin-ttl'; + +function qrAlarmName(tabId: number): string { + return `${QR_ALARM_PREFIX}:${tabId}`; +} +function pinAlarmName(tabId: number): string { + return `${PIN_ALARM_PREFIX}:${tabId}`; +} + +function scheduleQrTtlAlarm(tabId: number, qrTtlSeconds: number): void { + const name = qrAlarmName(tabId); + chrome.alarms.clear(name); + chrome.alarms.create(name, { delayInMinutes: qrTtlSeconds / 60 }); +} +function schedulePinTtlAlarm(tabId: number, pinTtlSeconds: number): void { + const name = pinAlarmName(tabId); + chrome.alarms.clear(name); + chrome.alarms.create(name, { delayInMinutes: pinTtlSeconds / 60 }); +} +function clearRunAlarms(tabId: number): void { + chrome.alarms.clear(qrAlarmName(tabId)); + chrome.alarms.clear(pinAlarmName(tabId)); +} + +export async function startRun(tabId: number, origin: string, features: string[]): Promise { + await setTabState(tabId, { kind: 'phase_1', origin, features }); + + const runId = crypto.randomUUID(); + const ok = await uploadBundleAndDisplayQr(tabId, origin, features, runId); + if (!ok) { + await failRun(tabId, origin, features, 'backend_unreachable', 'network'); + return; + } + + // "run:started | Phase 1 complete; QR displayed" (sdk/claude.md) — fires + // here; phase_1 itself has no dedicated SDK event. + sendToSdk(tabId, { type: 'run_started' }); + + openRelay(tabId, runId, { + onKemCiphertext: (payloadB64) => { + void handleKemCiphertext(tabId, origin, features, payloadB64); + }, + onCredential: (payloadB64) => { + void handleCredential(tabId, origin, features, payloadB64); + }, + }); +} + +/** + * Generates a fresh ephemeral keypair, uploads it under `runId`, and shows + * the resulting QR — the whole of Phase 1's work. Used both for the + * initial phase_1 -> phase_2 transition and for each QR auto-refresh + * (claude.md: "new keypairs are generated, a new key bundle is uploaded, + * and a new QR is displayed automatically... the old key bundle is + * abandoned and will expire server-side"). Reuses `runId` across refreshes + * — the server's key bundle store is keyed by runId and a re-`put` cleanly + * overwrites the old bundle, so the relay WebSocket (also keyed by runId, + * opened once in startRun) never needs to be reopened. + */ +async function uploadBundleAndDisplayQr(tabId: number, origin: string, features: string[], runId: string): Promise { + const x25519Keys = generateX25519Keypair(); + const kemKeys = generateMlKem768Keypair(); + + const uploadResult = await uploadRunBundle({ + runId, + origin, + x25519_pubkey: base64UrlEncode(x25519Keys.publicKey), + kem_pubkey: base64UrlEncode(kemKeys.publicKey), + }); + if (!uploadResult.ok) return false; + + const { signed_token: qrContent, qr_ttl: qrTtlSeconds, max_auto_refresh: maxAutoRefresh, pin_ttl: pinTtlSeconds } = uploadResult.response; + const existing = getRunSecrets(tabId); + const secrets: RunSecrets = { + runId, + x25519: x25519Keys, + kem: kemKeys, + qrTtlSeconds, + maxAutoRefresh, + pinTtlSeconds, + autoRefreshRemaining: existing?.autoRefreshRemaining ?? maxAutoRefresh, + }; + setRunSecrets(tabId, secrets); + + await setTabState(tabId, { kind: 'phase_2', origin, features, qrContent, qrTtlSeconds, phaseStartedAt: Date.now() }); + scheduleQrTtlAlarm(tabId, qrTtlSeconds); + return true; +} + +/** qr_ttl expired (claude.md's phase_2 transitions). */ +async function handleQrTtlExpiry(tabId: number): Promise { + const state = await getTabState(tabId); + if (state.kind !== 'phase_2') return; // stale alarm — run already moved on + + const secrets = getRunSecrets(tabId); + if (!secrets) return; + + if (secrets.autoRefreshRemaining <= 0) { + await failRun(tabId, state.origin, state.features, 'ttl_exhausted', 'timeout_user'); + return; + } + + setRunSecrets(tabId, { ...secrets, autoRefreshRemaining: secrets.autoRefreshRemaining - 1 }); + const ok = await uploadBundleAndDisplayQr(tabId, state.origin, state.features, secrets.runId); + if (!ok) { + await failRun(tabId, state.origin, state.features, 'backend_unreachable', 'network'); + } +} + +/** pin_ttl expired (claude.md's phase_3 transitions). */ +async function handlePinTtlExpiry(tabId: number): Promise { + const state = await getTabState(tabId); + if (state.kind !== 'phase_3') return; // stale alarm — user already confirmed or aborted + await failRun(tabId, state.origin, state.features, 'pin_timeout', 'timeout_user'); +} + +chrome.alarms.onAlarm.addListener((alarm) => { + const [prefix, tabIdStr] = alarm.name.split(':'); + const tabId = Number(tabIdStr); + if (prefix === QR_ALARM_PREFIX) void handleQrTtlExpiry(tabId); + else if (prefix === PIN_ALARM_PREFIX) void handlePinTtlExpiry(tabId); +}); + +async function handleKemCiphertext(tabId: number, origin: string, features: string[], payloadB64: string): Promise { + const secrets = getRunSecrets(tabId); + if (!secrets) return; // run already ended (aborted/errored) — message arrived too late + + // interfaces.md: "kem_ciphertext is valid only in phase_2 ... Messages of + // the wrong type are discarded." The companion proceeds independently of + // the extension's own pace once it reaches pin_display (mobile/claude.md), + // so a stray/duplicate message here is a real possibility, not just theory. + const state = await getTabState(tabId); + if (state.kind !== 'phase_2') return; + + const bytes = base64UrlDecode(payloadB64); + const companionX25519Pub = bytes.slice(0, X25519_PUBKEY_BYTES); + const kemCiphertext = bytes.slice(X25519_PUBKEY_BYTES); + + const x25519Shared = x25519SharedSecret(secrets.x25519.secretKey, companionX25519Pub); + const kemSharedSecret = mlKem768Decapsulate(kemCiphertext, secrets.kem.secretKey); + const runKey = await deriveRunKey(x25519Shared, kemSharedSecret, secrets.runId); + const pin = await derivePin(runKey); + + setRunSecrets(tabId, { ...secrets, runKey }); + chrome.alarms.clear(qrAlarmName(tabId)); + schedulePinTtlAlarm(tabId, secrets.pinTtlSeconds); + await setTabState(tabId, { kind: 'phase_3', origin, features, pin, pinTtlSeconds: secrets.pinTtlSeconds, phaseStartedAt: Date.now() }); +} + +/** Decrypts and delivers a credential — only ever called once the user has confirmed the PIN. */ +async function processCredential(tabId: number, origin: string, features: string[], payloadB64: string): Promise { + const secrets = getRunSecrets(tabId); + if (!secrets?.runKey) return; // run already ended + + let credential; + try { + credential = await decryptCredential(secrets.runKey, base64UrlDecode(payloadB64)); + } catch { + await failRun(tabId, origin, features, 'relay_error', 'network'); // GCM auth failure — interfaces.md + return; + } + + closeRelay(tabId); + clearRunSecrets(tabId); + await setTabState(tabId, { kind: 'delivered', origin, features }); + sendToSdk(tabId, { type: 'run_delivered', credential }); + + setTimeout(() => { + void setTabState(tabId, { kind: 'idle', origin, features }); + }, DELIVERED_DISPLAY_MS); +} + +async function handleCredential(tabId: number, origin: string, features: string[], payloadB64: string): Promise { + const secrets = getRunSecrets(tabId); + if (!secrets?.runKey) return; // run already ended, or kem_ciphertext hasn't landed yet + + // interfaces.md: "credential [is valid] only in phase_4" — deliberately + // absolute. A credential that arrives before the user's own Confirm + // click is discarded, never held for later use — the alternative (queue + // it, apply it whenever confirm eventually happens) would decouple + // "the user confirmed" from "what they're confirming was actually + // waited on," which is exactly the invariant this gate exists to keep. + // If Confirm's click isn't reaching the background reliably, that's the + // bug to fix directly, not something to route around here. + const state = await getTabState(tabId); + if (state.kind !== 'phase_4') return; + + await processCredential(tabId, origin, features, payloadB64); +} + +/** Popup action: user tapped Confirm on the PIN screen (phase_3 -> phase_4). */ +export async function confirmPin(tabId: number, origin: string, features: string[]): Promise { + chrome.alarms.clear(pinAlarmName(tabId)); + await setTabState(tabId, { kind: 'phase_4', origin, features }); + + // Unlocks the companion, which is gated on this arriving before it will + // proceed past its own local pin_display (interfaces.md, "PIN + // confirmation"). Fire-and-forget: this side's own phase_4 transition + // above is already the real local gate and doesn't depend on this call. + const runId = getRunSecrets(tabId)?.runId; + if (runId) void postPinConfirmation(runId); +} + +/** Popup action: user tapped Abort at any run phase, or the SDK sent `abort`. */ +export async function abortRun(tabId: number, origin: string, features: string[]): Promise { + await failRun(tabId, origin, features, 'user_abort', 'timeout_user'); +} + +/** Popup action: user dismissed a run_error screen — clears back to idle. */ +export async function dismissRunError(tabId: number, origin: string, features: string[]): Promise { + await setTabState(tabId, { kind: 'idle', origin, features }); +} + +/** Tab closed mid-run (index.ts's chrome.tabs.onRemoved) — release everything a run held. */ +export function cleanupRun(tabId: number): void { + closeRelay(tabId); + clearRunSecrets(tabId); + clearRunAlarms(tabId); +} + +async function failRun( + tabId: number, + origin: string, + features: string[], + reason: RunErrorReason, + errorClass: RunErrorClass, +): Promise { + closeRelay(tabId); + clearRunSecrets(tabId); + clearRunAlarms(tabId); + await setTabState(tabId, { kind: 'run_error', origin, features, reason, errorClass }); + sendToSdk(tabId, { type: 'run_error', error: { code: 'RUN_FAILED', reason, security: errorClass === 'security' } }); +} diff --git a/extension/src/background/sdk-ports.ts b/extension/src/background/sdk-ports.ts new file mode 100644 index 0000000..6ef789c --- /dev/null +++ b/extension/src/background/sdk-ports.ts @@ -0,0 +1,37 @@ +// Tracks each tab's active SDK Port so the run state machine can push +// unsolicited lifecycle events (run_started, run_delivered, run_error) at +// arbitrary later times — not just as an immediate reply to a message, the +// way the verification slice's respond() was used. + +import type { ExtensionToSdkMessage } from '../types.js'; + +const portsByTab = new Map(); + +export function registerSdkPort(tabId: number, port: chrome.runtime.Port): void { + portsByTab.set(tabId, port); +} + +export function unregisterSdkPort(tabId: number, port: chrome.runtime.Port): void { + // Only clear if this is still the current port — a stale disconnect + // event for an old port must not clobber a newer connection. + if (portsByTab.get(tabId) === port) { + portsByTab.delete(tabId); + } +} + +export function sendToSdk(tabId: number, message: ExtensionToSdkMessage): void { + const port = portsByTab.get(tabId); + if (!port) { + // Was silent before — logged now because a lost run_delivered/run_error + // is otherwise indistinguishable from "the site never called init()." + // A likely cause: the service worker was suspended and restarted + // between connect and now, which drops this in-memory Map. + console.warn(`[locqr] sendToSdk(${message.type}): no SDK port registered for tab ${tabId}`); + return; + } + try { + port.postMessage(message); + } catch (err) { + console.warn(`[locqr] sendToSdk(${message.type}): postMessage failed`, err); + } +} diff --git a/extension/src/background/state.ts b/extension/src/background/state.ts new file mode 100644 index 0000000..e314778 --- /dev/null +++ b/extension/src/background/state.ts @@ -0,0 +1,25 @@ +// Per-tab verification state, held in chrome.storage.session +// (extension/claude.md, "Data storage": "Site verification result | +// chrome.storage.session | Browser session") — survives service worker +// suspension, cleared when the browser closes. Never chrome.storage.local: +// nothing here is meant to outlive the browser session. + +import type { TabState } from '../types.js'; + +function storageKey(tabId: number): string { + return `tabState:${tabId}`; +} + +export async function getTabState(tabId: number): Promise { + const key = storageKey(tabId); + const stored = await chrome.storage.session.get(key); + return (stored[key] as TabState | undefined) ?? { kind: 'unverified' }; +} + +export async function setTabState(tabId: number, state: TabState): Promise { + await chrome.storage.session.set({ [storageKey(tabId)]: state }); +} + +export async function clearTabState(tabId: number): Promise { + await chrome.storage.session.remove(storageKey(tabId)); +} diff --git a/extension/src/background/verify.ts b/extension/src/background/verify.ts new file mode 100644 index 0000000..0777dd9 --- /dev/null +++ b/extension/src/background/verify.ts @@ -0,0 +1,111 @@ +// Registration certificate verification — interfaces.md, "Registration +// certificate" / "Extension verification steps". Five checks, in the +// documented order; the first one to fail determines the outcome. + +import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js'; +import canonicalize from 'canonicalize'; +import { base64UrlDecode } from '../base64url.js'; +import { BACKEND_PUBLIC_KEY_B64 } from '../constants.js'; +import type { CertInvalidReason } from '../types.js'; + +export interface CertVerified { + ok: true; + domain: string; + features: string[]; +} + +export interface CertRejected { + ok: false; + reason: CertInvalidReason; + security: boolean; // true for security-class reasons (extension/claude.md) +} + +export type CertVerificationResult = CertVerified | CertRejected; + +const SECURITY_CLASS_REASONS: ReadonlySet = new Set(['domain_mismatch', 'signature_invalid', 'malformed']); + +function rejected(reason: CertInvalidReason): CertRejected { + return { ok: false, reason, security: SECURITY_CLASS_REASONS.has(reason) }; +} + +/** + * @param cert base64url-encoded envelope string, as passed to `locqr.init({ cert })`. + * @param originUrl the real page URL, read from `sender.tab.url` — never + * trust a URL supplied by the page itself. + */ +export function verifyRegistrationCert(cert: string, originUrl: string): CertVerificationResult { + // Step 1: HTTPS is checked before any cert logic runs at all — "no + // certificate is inspected, no backend call is made" (extension/claude.md). + // + // In practice this branch is currently unreachable through the SDK's + // Port: manifest.json's externally_connectable.matches is https-only + // (`https://test.locqr.dev/*`), so Chrome refuses the connection at the + // platform level before any `init` message can arrive — confirmed + // empirically (a plain-HTTP page's chrome.runtime.connect() never + // reaches the service worker at all; the SDK sees NOT_INSTALLED, not + // CERT_INVALID/insecure_origin). Kept anyway as real defense-in-depth + // per extension/claude.md's documented intent — e.g. if the match + // pattern were ever loosened to cover both schemes, this is what would + // still catch it. + let origin: URL; + try { + origin = new URL(originUrl); + } catch { + return rejected('malformed'); + } + if (origin.protocol !== 'https:') { + return rejected('insecure_origin'); + } + + // Step 2: decode and parse the envelope. + let envelope: Record; + try { + const bytes = base64UrlDecode(cert); + envelope = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return rejected('malformed'); + } + + const { sig, ...payload } = envelope; + if ( + typeof sig !== 'string' || + typeof payload.v !== 'number' || + typeof payload.domain !== 'string' || + typeof payload.issued_at !== 'number' || + typeof payload.expires_at !== 'number' || + !Array.isArray(payload.features) + ) { + return rejected('malformed'); + } + + // Step 3: signature, over the JCS-canonicalized payload (sig excluded). + let signatureValid: boolean; + try { + const canonical = canonicalize(payload); + const message = new TextEncoder().encode(canonical as string); + const signature = base64UrlDecode(sig); + const publicKey = base64UrlDecode(BACKEND_PUBLIC_KEY_B64); + signatureValid = ml_dsa44.verify(signature, message, publicKey); + } catch { + return rejected('malformed'); + } + if (!signatureValid) { + return rejected('signature_invalid'); + } + + // Step 4: validity window. + const now = Math.floor(Date.now() / 1000); + if (now < payload.issued_at) { + return rejected('not_yet_valid'); + } + if (now > payload.expires_at) { + return rejected('expired'); + } + + // Step 5: domain match. Subdomains are not implicitly covered. + if (payload.domain !== origin.hostname) { + return rejected('domain_mismatch'); + } + + return { ok: true, domain: payload.domain, features: payload.features as string[] }; +} diff --git a/extension/src/base64url.ts b/extension/src/base64url.ts new file mode 100644 index 0000000..f37fa9e --- /dev/null +++ b/extension/src/base64url.ts @@ -0,0 +1,26 @@ +// base64url (RFC 4648 §5), no padding — interfaces.md's shared conventions, +// "shared conventions" table. +// +// Padding must be restored before atob() — an unpadded string silently +// mis-decodes its tail rather than throwing (found the hard way during the +// Step 0 spike: a truncated cert string decoded to valid-looking base64 but +// corrupted JSON, with no error until JSON.parse on the *decoded* result). + +export function base64UrlDecode(s: string): Uint8Array { + const unpadded = s.replace(/-/g, '+').replace(/_/g, '/'); + const padded = unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export function base64UrlEncode(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} diff --git a/extension/src/constants.ts b/extension/src/constants.ts new file mode 100644 index 0000000..a30aa80 --- /dev/null +++ b/extension/src/constants.ts @@ -0,0 +1,15 @@ +// Compile-time pinned constants (extension/claude.md). Any key material +// received at runtime that does not match these is rejected before +// verification runs. Real dev values from dev/keys/ — hand-set for this +// slice; scripts/setup-dev.js's constant-patching automation is a +// documented fast-follow once this pattern is proven (see dev.md). +// +// Regenerating dev keys requires updating both of these and rebuilding. + +/** ML-DSA-44 — verifies domain registration certificates (crypto.md). */ +export const BACKEND_PUBLIC_KEY_B64 = + 'VkwMOKMIP1wRds9rvkfothm_h_YVMND-nZW2hhGlhTrkapzOcmV2WkhELuGdDnzb7dn40I89r1nh53s3NOXRHK9Cg0SoWmh3KvDCOCrZcy67lizJ2YfNbc6-8mwRzmSWzBUt2nw3eqjoxYHWsDj4RT_OYBO7iExgzdumQkXxox8-QxHdg6lrFzDjtmyf3xNlI85x2WXHqISh9QZ8Hh7RzHuL9kJ0_n8jkP83SJYU0priPyPmxYjG7sjzikXNRsoW4nYmRhkY4LuD1ygzuHS3ZV90pjN_Id8ttLEUT3RAdMvlk_mUiG4le3oxwrhoypfeOECq80olooMcrnZJSD_m3PyDbuxIPstVrrQraLp-IgHNPameWATyr22c_-AI2qtdK6aVWVb-W9D7x8hooEQV2TQLXzKSxzpwpcey3-T1PWb0cwICCjq3MbBA9yCcwqeCjCG-FY2Vu4HQCIWPyJDB-5RAjVmyKntNX4v1ska7aXlNYj5mUYY5vSQRYfW4oY7eSARoeLpueEkoEPeKgRAFK0_MqafkDLgXTVx9ZGYN0QTQwfwTjoPi3BRHcJDMsILvD65E9jrKx6Bc5aaPlux9naMGSwRtKRIteEjfqKXcZ8Dtm-MGbNNoAiDHqkqsROa-DST9WNOY57SHZF2uXc7ZbXixXbcuj6lN_vrB0pl1BaiNQzoxciePbgvocUNQHa9Jqhs-D5NIdrK4BGaBItYoeD2WdynfMnE9wttXDin5LJyU5zqQksWhxYIM4LPd4I2_Fiqpw8EdYuIgipfwWZiTDjDu9SsLt9kwkyjADdVTWYiB_AvNPqmniJyTTYJNhPWxbZ2fLDRsRNNq55pZf2PhRQiaBX_-6KKVUgir3DfJf7WFS9FnDYLtBRwc7cobagmtwH6QMzN2uEuqpTmyNBP6N7_2SD6LBvBlQ1f9qdQMb63q_waoPJ76OQOOQa1qQ3wfFCaGbZm1N_61uGpxPENLv2ZczBIRiViPcpPDHxDLGy4nYlDQ7Uk3yhD-urFGO63LnHrG69dDDrMc38-xT6Aaq5J6tx7qp20Ggao5O-gJckHKkfCsFNllelu_BmZIqRjrBR6tJYeZBKFuFO34BaUlxoG8AYk9eza5lsJzgjobL89Ngn9r-GqeRSMEBZw-YIwIx8Bn-yy3PCZcROkXqCSXlJ-9cc7z2dcbMg1Sg0nWGW_i73CGoGSl2eLjVJBpTWfnWvDPVLL6EjlPlfSlguw1xlQkxpW2f6MdAdpA9kwew389x058NLrgig2gIRL0Xu565zaC6KVUUd3cf-isnpByffZbKRn1kTspJaXTUXIV_0g_qJ12qaiK5LKqbQ77TJ16cheC5z_h6Ig5hoBXCgEplmYKE5rbBkvqjeSpWemciQx6kMySUygNjOmYAzTk5atOgYUgw5aLx2d_9AYDq4zu0MczLOiVzfLIe2Qon3Ng2BtzPx0PJMqnI9elxdAmDVJNkew3RuQE_vuJbBL2Dw0jYDzkYBuCcLLSUmbshfe6P8IJwjT115DDxzRXK634EHkh1fNWLcAa4GtyGywHXlM4IrwrsENHSGbpG5ONp4vJBCgackRjuQhBRZXLmOqmTj27n_OiUqH2VKIm4ZyFWeBBaXXeXEyhjtUsN16CkaaNFciuCwmcYO-v95g2TK9-lw9wHOF9TQfq3US4iSO48RNMX8H-yLgq_6iSvgD2hU_V-oKeui0UoXEZiI-42Dv3fm5XCvgknubie78x3JdDm8CXQw'; + +/** Ed25519 — verifies key exchange tokens (crypto.md). Not used by this slice's + * verification-only logic; compiled in now since claude.md pins both together. */ +export const ED25519_PUBLIC_KEY_B64 = 'Z9ppr33AB5gznf-mQU2F9Y3E-MEoNcGHtmlh7rzaTh4'; diff --git a/extension/src/popup/popup.css b/extension/src/popup/popup.css new file mode 100644 index 0000000..b4fae0b --- /dev/null +++ b/extension/src/popup/popup.css @@ -0,0 +1,196 @@ +/* Design tokens from gui.md — same values used throughout the mockups + built and approved earlier (QR/PIN, error/security, status, ambient). */ +:root { + --card-bg: #ffffff; + --card-border: rgba(15, 23, 42, 0.08); + --text-primary: #0f172a; + --text-secondary: #667085; + --text-tertiary: #94a3b8; + --accent: #2f6fed; + --accent-hover: #2559c9; + --accent-tint: #e9f0fe; + --accent-track: #e2e8f0; + --box-border: #d7dce4; + --box-bg: #f8fafc; + --divider: rgba(15, 23, 42, 0.07); + --ghost-hover: rgba(15, 23, 42, 0.04); + --btn-border: #d7dce4; + --neutral-badge: #94a3b8; + --neutral-tint: #eef1f4; + --inactive-badge: #cbd5e1; + --inactive-tint: #f4f6f8; + --amber: #f59e0b; + --amber-tint: #fef3e2; + --red: #dc2626; + --red-tint: #fdecec; + --green: #16a34a; + --green-tint: #e8f6ec; +} +@media (prefers-color-scheme: dark) { + :root { + --card-bg: #12161f; + --card-border: rgba(255, 255, 255, 0.08); + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + --accent: #5b8bff; + --accent-hover: #7fa2ff; + --accent-tint: rgba(91, 139, 255, 0.14); + --accent-track: #232a38; + --box-border: #2a3140; + --box-bg: #171c26; + --divider: rgba(255, 255, 255, 0.08); + --ghost-hover: rgba(255, 255, 255, 0.06); + --btn-border: #2a3140; + --neutral-badge: #64748b; + --neutral-tint: rgba(100, 116, 139, 0.16); + --inactive-badge: #475569; + --inactive-tint: rgba(71, 85, 105, 0.16); + --amber: #fbbf24; + --amber-tint: rgba(251, 191, 36, 0.12); + --red: #ef4444; + --red-tint: rgba(239, 68, 68, 0.12); + --green: #22c55e; + --green-tint: rgba(34, 197, 94, 0.14); + } +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + width: 300px; +} +body { + background: var(--card-bg); + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--divider); +} +.brand { display: flex; align-items: center; gap: 8px; } +.brand .mark { width: 20px; height: 20px; border-radius: 6px; background: var(--accent); } +.brand .word { font-weight: 700; font-size: 14.5px; letter-spacing: -0.01em; color: var(--text-primary); } +.origin { font-size: 12.5px; color: var(--text-tertiary); max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.card-body { + padding: 28px 20px 22px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + /* Chrome sizes a popup's window once, from its content height at first + paint, and does not reliably resize it as innerHTML later changes + (confirmed the hard way: a popup opened during a short state like + idle stayed that size through phase_2/3, leaving the real buttons + below the window's actual clickable bounds — clicking them landed + "outside" the popup and closed it). Sized for phase_2's QR screen + (the tallest: 260px canvas + countdown + caption + action ≈ 416px), + so every state gets the same window size regardless of which one is + showing when the popup first opens. */ + min-height: 420px; + justify-content: center; +} + +.status-badge { + width: 48px; + height: 48px; + border-radius: 13px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + flex-shrink: 0; +} +.status-badge svg { width: 22px; height: 22px; } + +.pulse { animation: locqr-pulse 1.6s ease-in-out infinite; } +@keyframes locqr-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } +.spinner { animation: locqr-spin 1.1s linear infinite; } +@keyframes locqr-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .pulse, .spinner { animation: none; } +} + +.status-heading { font-size: 14.5px; font-weight: 650; color: var(--text-primary); margin-bottom: 6px; } +.status-heading.security { color: var(--red); } +.status-body { font-size: 12.5px; line-height: 1.55; color: var(--text-secondary); max-width: 230px; } + +.actions { width: 100%; margin-top: 20px; display: flex; flex-direction: column; gap: 8px; } +.btn-primary { + width: 100%; + padding: 10px 0; + border: none; + border-radius: 10px; + background: var(--accent); + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} +.btn-primary:hover { background: var(--accent-hover); } +.btn-primary:disabled { opacity: 0.6; cursor: default; } + +.btn-ghost { + width: 100%; + padding: 8px 0; + border: none; + background: none; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + border-radius: 8px; +} +.btn-ghost:hover { background: var(--ghost-hover); } + +.status-note { font-size: 12.5px; color: var(--text-tertiary); margin-top: 14px; } + +.ring-spinner { + width: 22px; + height: 22px; + border: 2.4px solid var(--accent-tint); + border-top-color: var(--accent); + border-radius: 50%; +} + +.countdown-bar { + width: 100%; + height: 4px; + border-radius: 999px; + background: var(--accent-track); + overflow: hidden; + margin: 16px 0 14px; +} +.countdown-fill { + height: 100%; + width: 100%; + background: var(--accent); + border-radius: 999px; + transition: width 0.2s linear; +} + +.qr-canvas { width: 260px; height: 260px; display: block; } + +.pin-groups { display: flex; gap: 18px; } +.pin-group { display: flex; gap: 6px; } +.pin-digit { + width: 42px; + height: 50px; + border: 1.5px solid var(--box-border); + background: var(--box-bg); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-family: "SF Mono", "Menlo", "Consolas", monospace; + font-size: 21px; + font-weight: 650; + color: var(--text-primary); +} diff --git a/extension/src/popup/popup.html b/extension/src/popup/popup.html new file mode 100644 index 0000000..4531314 --- /dev/null +++ b/extension/src/popup/popup.html @@ -0,0 +1,19 @@ + + + + + locqr + + + +
+
+
+
locqr
+
+
+
+
+ + + diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts new file mode 100644 index 0000000..60a7a6b --- /dev/null +++ b/extension/src/popup/popup.ts @@ -0,0 +1,356 @@ +// Popup UI — renders the active tab's verification and run state. Markup/CSS +// ported from the gui.md mockups built and approved earlier this session +// (ambient, error/security, status, QR/PIN screens). + +import { create as createQrCode } from 'qrcode'; +import { getTabState } from '../background/state.js'; +import type { CertInvalidReason, PopupActionKind, PopupActionMessage, RunErrorClass, RunErrorReason, TabState } from '../types.js'; + +interface ActionSpec { + label: string; + kind: PopupActionKind; + style: 'primary' | 'ghost'; +} + +interface Content { + badgeClass: string; // tint background var + iconColor: string; // stroke/fill color var + glyph: 'dot' | 'dash' | 'check' | 'exclaim' | 'shield' | 'spinner'; + pulse?: boolean; + heading: string; + headingSecurity?: boolean; + body: string; + note?: string; + actions?: ActionSpec[]; +} + +function originText(url: string | undefined): string { + if (!url) return ''; + try { + return new URL(url).hostname; + } catch { + return ''; + } +} + +const RUN_ERROR_COPY: Record = { + ttl_exhausted: { heading: 'Code expired', body: 'This sign-in code expired before it was used.' }, + pin_timeout: { heading: 'Timed out', body: "The code wasn't confirmed in time, so this sign-in attempt was cancelled." }, + user_abort: { heading: 'Sign-in cancelled', body: 'You cancelled this sign-in attempt.' }, + backend_unreachable: { + heading: "Can't reach LOCQR", + body: "We couldn't reach the LOCQR server. Check your connection and try again.", + }, + relay_timeout: { heading: 'No response', body: "Your phone didn't respond in time." }, + relay_error: { heading: 'Something went wrong', body: "We couldn't finish signing you in. Please try again." }, +}; + +function runErrorContent(reason: RunErrorReason, errorClass: RunErrorClass): Content { + const { heading, body } = RUN_ERROR_COPY[reason]; + + if (errorClass === 'security') { + return { + badgeClass: 'red-tint', + iconColor: 'red', + glyph: 'shield', + heading, + headingSecurity: true, + body, + actions: [{ label: 'Dismiss', kind: 'dismiss', style: 'ghost' }], + }; + } + + return { + badgeClass: 'amber-tint', + iconColor: 'amber', + glyph: 'exclaim', + heading, + body, + actions: [ + { label: 'Retry', kind: 'retry', style: 'primary' }, + { label: 'Dismiss', kind: 'dismiss', style: 'ghost' }, + ], + }; +} + +function contentFor(state: TabState, origin: string): Content { + switch (state.kind) { + case 'unverified': + return { + badgeClass: 'neutral-tint', + iconColor: 'neutral-badge', + glyph: 'dot', + pulse: true, + heading: 'Checking…', + body: 'Looking for LOCQR sign-in support on this page.', + }; + case 'not_registered': + return { + badgeClass: 'inactive-tint', + iconColor: 'inactive-badge', + glyph: 'dash', + heading: 'Not available here', + body: origin ? `${origin} hasn't set up sign-in with LOCQR.` : "This site hasn't set up sign-in with LOCQR.", + }; + case 'idle': + return { + badgeClass: 'accent-tint', + iconColor: 'accent', + glyph: 'check', + heading: 'Ready to sign in', + body: `${state.origin} supports secure sign-in with LOCQR.`, + actions: [{ label: 'Sign in', kind: 'start_run', style: 'primary' }], + }; + case 'account_error': + return state.reason === 'unreachable' + ? { + badgeClass: 'amber-tint', + iconColor: 'amber', + glyph: 'exclaim', + heading: "Can't reach LOCQR", + body: "We couldn't reach the LOCQR server. Check your connection and try again.", + } + : { + badgeClass: 'amber-tint', + iconColor: 'amber', + glyph: 'exclaim', + heading: 'Account inactive', + body: 'Sign-in is currently unavailable for this site.', + }; + case 'cert_invalid': + return certInvalidContent(state.reason, state.security, origin); + case 'phase_1': + return { + badgeClass: 'accent-tint', + iconColor: 'accent', + glyph: 'spinner', + heading: 'Connecting…', + body: 'Setting up a secure connection to your phone.', + actions: [{ label: 'Cancel', kind: 'abort', style: 'ghost' }], + }; + case 'phase_4': + return { + badgeClass: 'accent-tint', + iconColor: 'accent', + glyph: 'spinner', + heading: 'Almost done…', + body: 'Waiting for your phone to finish sending your sign-in details.', + actions: [{ label: 'Cancel', kind: 'abort', style: 'ghost' }], + }; + case 'delivered': + return { + badgeClass: 'green-tint', + iconColor: 'green', + glyph: 'check', + heading: 'Signed in', + body: `You're securely signed in to ${state.origin}.`, + note: 'Closing automatically…', + }; + case 'run_error': + return runErrorContent(state.reason, state.errorClass); + case 'phase_2': + case 'phase_3': + // Rendered by their own dedicated templates in render() — never + // reaches the generic status content builder. + throw new Error(`contentFor called for ${state.kind}, which has its own template`); + } +} + +function certInvalidContent(reason: CertInvalidReason, security: boolean, origin: string): Content { + if (security) { + const body: Record = { + domain_mismatch: "This page's certificate doesn't match its domain. This site may be impersonating a registered LOCQR site.", + signature_invalid: "This site's LOCQR certificate failed a signature check.", + malformed: "This site's LOCQR certificate could not be read.", + }; + return { + badgeClass: 'red-tint', + iconColor: 'red', + glyph: 'shield', + heading: 'Security warning', + headingSecurity: true, + body: body[reason] ?? "This site's LOCQR certificate failed a security check.", + }; + } + + const copy: Record = { + insecure_origin: { heading: "Connection isn't secure", body: 'LOCQR requires a secure (https) connection.' }, + expired: { heading: 'Certificate expired', body: "This site's LOCQR certificate needs to be renewed by its owner." }, + not_yet_valid: { heading: 'Certificate not active yet', body: "This site's LOCQR certificate isn't valid yet." }, + }; + const { heading, body } = copy[reason] ?? { + heading: 'Certificate error', + body: origin ? `${origin}'s LOCQR certificate has a problem.` : 'This site’s LOCQR certificate has a problem.', + }; + return { badgeClass: 'amber-tint', iconColor: 'amber', glyph: 'exclaim', heading, body }; +} + +const GLYPHS: Record, (color: string) => string> = { + dot: (c) => ``, + dash: (c) => ``, + check: (c) => ``, + exclaim: (c) => ``, + shield: (c) => ``, +}; + +function badgeHtml(c: Content): string { + const inner = c.glyph === 'spinner' ? `
` : GLYPHS[c.glyph](c.iconColor); + return `
${inner}
`; +} + +function actionsHtml(actions: ActionSpec[] | undefined): string { + if (!actions?.length) return ''; + const buttons = actions + .map((a) => ``) + .join(''); + return `
${buttons}
`; +} + +function qrScreenHtml(): string { + return ` + +
+
Scan with the companion app.
+
+ `; +} + +function pinScreenHtml(pin: string): string { + const digit = (d: string) => `
${d}
`; + const digits = pin.split(''); + return ` +
+
${digits.slice(0, 3).map(digit).join('')}
+
${digits.slice(3, 6).map(digit).join('')}
+
+
+
Confirm this code matches your companion app.
+
+ + +
+ `; +} + +/** + * Draws the raw QR module matrix onto a canvas by hand (gui.md: not the + * qrcode package's own renderer) so the light/dark colorways can differ in + * quiet-zone treatment — the dark colorway uses the card's own padding as + * its margin instead of baking one into the raster (gui.md, "Phase 2 — QR"). + */ +function renderQr(canvas: HTMLCanvasElement, content: string): void { + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const qr = createQrCode(content, { errorCorrectionLevel: 'M' }); + const size = qr.modules.size; + const data = qr.modules.data; + + // Fills the card body's actual content width (300px popup − 20px + // padding × 2) so the QR's outer edge lines up with the countdown bar's + // edge below it — no extra margin beyond the shared 20px card padding. + const displayPx = 260; + const marginModules = isDark ? 0 : 4; + const modulePx = displayPx / (size + marginModules * 2); + + canvas.width = displayPx; + canvas.height = displayPx; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.fillStyle = isDark ? getComputedStyle(document.documentElement).getPropertyValue('--card-bg').trim() : '#ffffff'; + ctx.fillRect(0, 0, displayPx, displayPx); + + ctx.fillStyle = isDark ? '#ffffff' : '#1a1a1a'; + for (let row = 0; row < size; row++) { + for (let col = 0; col < size; col++) { + if (!data[row * size + col]) continue; + const x = (col + marginModules) * modulePx; + const y = (row + marginModules) * modulePx; + ctx.fillRect(x, y, Math.ceil(modulePx), Math.ceil(modulePx)); + } + } +} + +let countdownTimer: ReturnType | undefined; + +function stopCountdown(): void { + if (countdownTimer !== undefined) { + clearInterval(countdownTimer); + countdownTimer = undefined; + } +} + +/** Countdown bar fill — driven by the server-issued TTL, never a local default (claude.md). */ +function startCountdown(phaseStartedAt: number, ttlSeconds: number): void { + const fill = document.getElementById('countdown-fill'); + if (!fill) return; + const update = () => { + const elapsedSeconds = (Date.now() - phaseStartedAt) / 1000; + const remaining = Math.max(0, Math.min(1, 1 - elapsedSeconds / ttlSeconds)); + fill.style.width = `${remaining * 100}%`; + if (remaining <= 0) stopCountdown(); + }; + update(); + countdownTimer = setInterval(update, 200); +} + +function sendPopupAction(tabId: number | undefined, action: PopupActionKind): void { + if (tabId === undefined) return; + const message: PopupActionMessage = { type: 'popup_action', tabId, action }; + void chrome.runtime.sendMessage(message); +} + +function wireActions(tabId: number | undefined): void { + document.querySelectorAll('#body button[data-action]').forEach((btn) => { + const action = btn.dataset.action as PopupActionKind; + btn.addEventListener('click', () => sendPopupAction(tabId, action)); + }); +} + +function render(state: TabState, origin: string, tabId: number | undefined): void { + document.getElementById('origin')!.textContent = origin; + stopCountdown(); + const body = document.getElementById('body')!; + + if (state.kind === 'phase_2') { + body.innerHTML = qrScreenHtml(); + renderQr(document.getElementById('qr-canvas') as HTMLCanvasElement, state.qrContent); + startCountdown(state.phaseStartedAt, state.qrTtlSeconds); + wireActions(tabId); + return; + } + + if (state.kind === 'phase_3') { + body.innerHTML = pinScreenHtml(state.pin); + startCountdown(state.phaseStartedAt, state.pinTtlSeconds); + wireActions(tabId); + return; + } + + const c = contentFor(state, origin); + body.innerHTML = ` + ${badgeHtml(c)} +
${c.heading}
+
${c.body}
+ ${actionsHtml(c.actions)} + ${c.note ? `
${c.note}
` : ''} + `; + wireActions(tabId); +} + +async function activeTab(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab; +} + +async function refresh(): Promise { + const tab = await activeTab(); + const origin = originText(tab?.url); + const state = tab?.id === undefined ? ({ kind: 'unverified' } as const) : await getTabState(tab.id); + render(state, origin, tab?.id); +} + +chrome.storage.onChanged.addListener((_changes, areaName) => { + if (areaName === 'session') void refresh(); +}); + +void refresh(); diff --git a/extension/src/types.ts b/extension/src/types.ts new file mode 100644 index 0000000..2aaabff --- /dev/null +++ b/extension/src/types.ts @@ -0,0 +1,158 @@ +// Shared types. Port message shapes mirror interfaces.md exactly — including +// its naming note: Port `type` values are snake_case; the SDK translates +// these into the colon-separated `locqr.on()` event names. This file is the +// extension side only, so it stays in the Port's own snake_case vocabulary. + +export type LocqrErrorCode = 'NOT_INSTALLED' | 'NOT_REGISTERED' | 'CERT_INVALID' | 'ACCOUNT_ERROR' | 'RUN_FAILED'; + +export interface LocqrError { + code: LocqrErrorCode; + reason?: string; + security?: boolean; +} + +// --- SDK -> Extension --- + +export interface InitMessage { + type: 'init'; + cert?: string; +} + +export interface RequestCredentialMessage { + type: 'request_credential'; +} + +export interface AbortMessage { + type: 'abort'; +} + +export type SdkMessage = InitMessage | RequestCredentialMessage | AbortMessage; + +// --- Extension -> SDK --- + +export interface VerificationValidMessage { + type: 'verification'; + status: 'valid'; + features: string[]; +} + +export interface VerificationErrorMessage { + type: 'verification'; + status: 'error'; + error: LocqrError; +} + +export type VerificationMessage = VerificationValidMessage | VerificationErrorMessage; + +// --- Run lifecycle events, extension -> SDK (unsolicited) --- + +export interface Credential { + username: string; + password: string; +} + +export interface RunStartedMessage { + type: 'run_started'; +} + +export interface RunDeliveredMessage { + type: 'run_delivered'; + credential: Credential; +} + +export interface RunErrorEventMessage { + type: 'run_error'; + error: LocqrError; +} + +export type ExtensionToSdkMessage = VerificationMessage | RunStartedMessage | RunDeliveredMessage | RunErrorEventMessage; + +// --- Popup -> Background (internal chrome.runtime.sendMessage, distinct +// from the external SDK Port — these never cross to the page). --- + +export type PopupActionKind = 'start_run' | 'confirm_pin' | 'abort' | 'retry' | 'dismiss'; + +export interface PopupActionMessage { + type: 'popup_action'; + tabId: number; + action: PopupActionKind; +} + +// --- Relay messages (interfaces.md, "Relay messages") --- + +export interface RelayKemCiphertextMessage { + type: 'kem_ciphertext'; + payload_b64: string; +} + +export interface RelayCredentialMessage { + type: 'credential'; + payload_b64: string; +} + +export type RelayMessage = RelayKemCiphertextMessage | RelayCredentialMessage; + +// --- Run bundle upload (interfaces.md, "Run bundle upload") --- + +export interface RunBundleUploadRequest { + runId: string; + origin: string; + x25519_pubkey: string; + kem_pubkey: string; +} + +export interface RunBundleUploadResponse { + signed_token: string; + qr_ttl: number; + max_auto_refresh: number; + pin_ttl: number; +} + +// --- Per-tab state (extension/claude.md's state model) --- + +export type CertInvalidReason = + | 'insecure_origin' + | 'malformed' + | 'signature_invalid' + | 'domain_mismatch' + | 'expired' + | 'not_yet_valid'; + +export type AccountErrorReason = 'rejected' | 'unreachable'; + +/** + * Per extension/claude.md's `run_error` classes — `alpha_hash_mismatch` and + * `pin_mismatch` deliberately excluded (companion-only detections, see that + * file's `run_error` note). Only `user_abort` is wired up by any code path + * in this slice; the rest are real and specified but deferred to a + * follow-up hardening pass (see gui.md's "Open / not yet specified" — same + * pattern). + */ +export type RunErrorReason = 'ttl_exhausted' | 'pin_timeout' | 'user_abort' | 'backend_unreachable' | 'relay_timeout' | 'relay_error'; + +export type RunErrorClass = 'security' | 'timeout_user' | 'network'; + +/** + * This union (run-phase variants included) is stored in chrome.storage.session + * for popup reactivity, same mechanism as the verification states — but it + * only ever carries display-safe data: QR content and PIN digits are + * already what's shown on screen / scanned by a camera, not secret. The + * actual keypairs, shared secrets, and run key are a *different* thing — + * they live only in background/run-secrets.ts's in-memory map and are never + * serialized anywhere, matching the security invariant that private key + * material never leaves the service worker (extension/claude.md's Data + * storage table lists these separately from "Per-tab state machine state" + * for exactly this reason). + */ +export type TabState = + | { kind: 'unverified' } + | { kind: 'not_registered' } + | { kind: 'cert_invalid'; reason: CertInvalidReason; security: boolean } + | { kind: 'account_error'; reason: AccountErrorReason } + | { kind: 'idle'; origin: string; features: string[] } + | { kind: 'phase_1'; origin: string; features: string[] } + | { kind: 'phase_2'; origin: string; features: string[]; qrContent: string; qrTtlSeconds: number; phaseStartedAt: number } + | { kind: 'phase_3'; origin: string; features: string[]; pin: string; pinTtlSeconds: number; phaseStartedAt: number } + | { kind: 'phase_4'; origin: string; features: string[] } + | { kind: 'delivered'; origin: string; features: string[] } + | { kind: 'run_error'; origin: string; features: string[]; reason: RunErrorReason; errorClass: RunErrorClass }; diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..96d7e85 --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "WebWorker", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "types": ["chrome"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/interfaces.md b/interfaces.md index e56ab42..df78538 100644 --- a/interfaces.md +++ b/interfaces.md @@ -231,6 +231,53 @@ is sufficient. --- +## PIN confirmation + +Extension → Backend (POST) and Companion → Backend (GET, polled), addressed +to `runId` — a real two-way handshake, not the companion proceeding on its +own schedule. The companion is only allowed to advance to credential +selection once *both* its own local user has tapped Continue (`mobile/claude.md`'s +`pin_display → awaiting_confirmation`) *and* this confirmation has arrived; +neither side's local action alone is sufficient. This mirrors Bluetooth +Numeric Comparison pairing, which communicates each side's confirmation to +the other as part of completing the handshake, rather than trusting the two +devices to act on the same timeline independently. + +**`POST /run/relay/:runId/confirm`** + +Sent by the extension the moment its user clicks Confirm on the PIN screen +(`phase_3 → phase_4`). No body. `204 No Content` on success. Fire-and-forget +from the extension's side — its own `phase_4` transition is the real local +gate on its side and does not depend on this call succeeding; a failed POST +here only means the companion never gets unlocked and the run eventually +times out. + +**`GET /run/relay/:runId/confirm`** + +Polled by the companion after its own local Continue tap, until it returns +confirmed or a bounded wait elapses (`mobile/claude.md`'s +`error/confirmation_timeout`). + +```json +{ "confirmed": true } +``` + +Always `200` with `confirmed: false` for a `runId` that has not been +confirmed — including a `runId` the server has never heard of. There's no +separate 404 case: the confirmation marker is a plain fire-and-forget flag +(see below), not backed by a durable record of which run IDs are real, so +there's nothing to distinguish "not yet confirmed" from "unknown" against. +A companion polling a wrong/mistyped `runId` degrades to the same +`confirmation_timeout` as one that's just slow — acceptable for this +PoC scope. + +Server-side state here is a plain in-memory marker (`RelaySessionRegistry`, +server/claude.md), not a durable/authenticated record — it exists only to +carry a one-time signal for the duration of one run and is pruned well +after any realistic `pin_ttl`. + +--- + ## Encrypted credential payload **Plaintext** (UTF-8 encoded): diff --git a/mobile/claude.md b/mobile/claude.md index 4a45071..15b17b4 100644 --- a/mobile/claude.md +++ b/mobile/claude.md @@ -90,10 +90,21 @@ deferred UX decision. bundle fetch, alpha_hash recomputation. - `key_exchange` — verification passed; performing X25519 key agreement and ML-KEM-768 encapsulation; sending `kem_ciphertext` to backend relay. -- `pin_display` — run key derived; PIN displayed to user. User reads - the PIN, confirms the match on the extension, then taps Continue on the - companion to advance. The companion does not receive a signal from the - extension; the two devices proceed independently from this point. +- `pin_display` — run key derived; PIN displayed to user. User reads the + PIN and taps Continue on the companion once they've compared it. +- `awaiting_confirmation` — user has tapped Continue; polling + `GET /run/relay/:runId/confirm` (interfaces.md, "PIN confirmation") for + the extension's own confirmation, which only exists once its user has + separately clicked Confirm there. Advancing requires both: this device's + local tap and the other device's remote signal, neither one alone. + Superseded design note: an earlier version of this document had the + companion proceed the instant its own user tapped Continue, with "the + companion does not receive a signal from the extension." That turned out + to be weaker than necessary for no real benefit — Bluetooth-style + Numeric Comparison pairing has always communicated the confirmation + between devices as part of completing the handshake, and there was no + good reason for this protocol to do less. See interfaces.md's "PIN + confirmation" section for the wire-level detail. - `credential_select` — credentials for the current site presented; vault already unlocked from biometric at session initiation. If exactly one credential is stored for the site it may be pre-selected. If multiple are @@ -132,7 +143,13 @@ key_exchange → error/relay_error (network: cannot send kem_ciphertext) pin_display - → credential_select (user taps Continue) + → awaiting_confirmation (user taps Continue) + → idle (user cancels; run key discarded) + +awaiting_confirmation + → credential_select (extension's confirmation received) + → error/confirmation_timeout (bounded wait elapses with no confirmation — + network class, retry offered) → idle (user cancels; run key discarded) credential_select @@ -170,6 +187,11 @@ warning; no retry offered): *Network class* (transient; retry offered): - `backend_unreachable` — key bundle fetch failed. - `relay_error` — could not send `kem_ciphertext` or credential ciphertext. +- `confirmation_timeout` — polled for the extension's PIN confirmation + past a bounded wait with no response. Not security-class: the absence of + a signal isn't evidence of tampering, just that the other side hasn't + (yet, or ever) clicked Confirm — could be an inattentive user, a closed + tab, or a genuine network issue. --- @@ -221,6 +243,8 @@ All algorithm parameters and normative ordering rules are in `../crypto.md`. - Fetch key bundle by run ID (at-most-once; bundle destroyed on fetch). - Send `kem_ciphertext` to relay addressed to run ID (Phase 2). +- Poll for the extension's PIN confirmation, addressed to run ID + (`awaiting_confirmation`, above). - Send encrypted credential ciphertext to relay addressed to run ID (Phase 4). - Report security-class errors to `/security/report`. diff --git a/scripts/companion-harness.mjs b/scripts/companion-harness.mjs new file mode 100644 index 0000000..cdd212e --- /dev/null +++ b/scripts/companion-harness.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +// Protocol-level stand-in for the companion app (mobile/claude.md's real +// target — this is not a preview of it, per the run-flow plan's explicit +// scope note). Drives the companion's real cryptographic and network +// responsibilities against the real server and a real running extension, +// so the run flow can be verified end-to-end without the companion app +// existing yet — same "curl/Puppeteer instead of mocks" approach used +// throughout this project. +// +// Subcommands, in increasing order of manual effort: +// +// node companion-harness.mjs run [username] [password] +// The easy path for manual testing. Does the key exchange, prints the +// PIN, then polls interfaces.md's PIN confirmation endpoint — the real +// signal, not a human keypress standing in for it — before delivering +// a credential (defaults: alice@example.com / hunter2). One paste, no +// keypress needed. +// +// node companion-harness.mjs key-exchange +// node companion-harness.mjs deliver-credential +// The two steps split apart, for scripted/automated use. +// +// This is a real two-way handshake now, not the companion proceeding on +// its own schedule: the extension posts its confirmation when the user +// clicks Confirm, and the companion (real or, here, this harness) is +// expected to wait for it before it ever selects/sends a credential — +// `run` above does exactly that via waitForPinConfirmation(). Calling +// `deliver-credential` directly, without confirmation ever having been +// posted, is still possible (nothing here stops you) but the extension +// will just discard it — the actual gate lives there, not in this script. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { webcrypto } from 'node:crypto'; +import { Agent, setGlobalDispatcher } from 'undici'; +import { ed25519, x25519 } from '@noble/curves/ed25519.js'; +import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; +import canonicalize from 'canonicalize'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const API_BASE = process.env.LOCQR_API_BASE ?? 'https://api.locqr.dev:3000'; +// dev/keys/ed25519-public.b64 — same pinned dev constant compiled into the +// extension (crypto.md's ED25519_PUBLIC_KEY_B64); must match or every +// signature check below fails. +const ED25519_PUBLIC_KEY_B64 = 'Z9ppr33AB5gznf-mQU2F9Y3E-MEoNcGHtmlh7rzaTh4'; +// interfaces.md: 30s clock-skew leeway past expires_at is the only clock +// tolerance in the protocol. +const EXPIRY_LEEWAY_SECONDS = 30; + +// Trust the local mkcert CA (dev.md) for the harness's own TLS connections — +// Node doesn't consult the system/NSS trust stores the browsers use. +setGlobalDispatcher(new Agent({ connect: { ca: readFileSync(join(__dirname, '..', 'dev', 'certs', 'rootCA.pem')) } })); + +function b64urlDecode(s) { + return new Uint8Array(Buffer.from(s, 'base64url')); +} + +function b64urlEncode(bytes) { + return Buffer.from(bytes).toString('base64url'); +} + +function concat(...parts) { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +} + +async function hkdfSha256(ikm, salt, info, lengthBytes) { + const key = await webcrypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']); + const bits = await webcrypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt, info: new TextEncoder().encode(info) }, + key, + lengthBytes * 8, + ); + return new Uint8Array(bits); +} + +// crypto.md: run_key = HKDF-SHA256(x25519_shared || kem_shared_secret, salt=runId_utf8, info="locqr-run-key-v1", 32). +async function deriveRunKey(x25519Shared, kemSharedSecret, runId) { + const ikm = concat(x25519Shared, kemSharedSecret); + const salt = new TextEncoder().encode(runId); + return hkdfSha256(ikm, salt, 'locqr-run-key-v1', 32); +} + +// mobile/claude.md: PIN = HKDF-SHA256(run_key, salt=[], info="locqr-pin-v1", 4) -> big-endian uint32 -> mod 1e6 -> zero-padded. +// Must match extension/src/background/crypto.ts's derivePin() exactly, or the two sides' PINs never agree. +async function derivePin(runKey) { + const bits = await hkdfSha256(runKey, new Uint8Array(0), 'locqr-pin-v1', 4); + const value = new DataView(bits.buffer, bits.byteOffset, bits.byteLength).getUint32(0, false); + return (value % 1_000_000).toString().padStart(6, '0'); +} + +async function sha256(...parts) { + const digest = await webcrypto.subtle.digest('SHA-256', concat(...parts)); + return new Uint8Array(digest); +} + +function bytesEqual(a, b) { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +async function postRelay(runId, type, payloadB64) { + const response = await fetch(`${API_BASE}/run/relay/${runId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, payload_b64: payloadB64 }), + }); + if (!response.ok) { + throw new Error(`POST /run/relay/${runId} (${type}) failed: ${response.status}`); + } +} + +async function keyExchange(signedToken) { + const envelope = JSON.parse(Buffer.from(signedToken, 'base64url').toString('utf8')); + const { v, runId, url, expires_at: expiresAt, alpha_hash: alphaHashB64, sig } = envelope; + + if (v !== 1) throw new Error(`unsupported token version: ${v}`); + + // interfaces.md: signed payload is the envelope minus `sig`, JCS-canonicalised. + const payload = { v, runId, url, expires_at: expiresAt, alpha_hash: alphaHashB64 }; + const signatureValid = ed25519.verify(b64urlDecode(sig), Buffer.from(canonicalize(payload), 'utf8'), b64urlDecode(ED25519_PUBLIC_KEY_B64)); + if (!signatureValid) throw new Error('token_signature_invalid'); + + const nowSeconds = Date.now() / 1000; + if (nowSeconds > expiresAt + EXPIRY_LEEWAY_SECONDS) throw new Error('ttl_expired'); + + const bundleResponse = await fetch(`${API_BASE}/run/bundle/${runId}`); + if (!bundleResponse.ok) throw new Error(`bundle_consumed_or_expired (${bundleResponse.status})`); + const { x25519_pubkey: x25519PubB64, kem_pubkey: kemPubB64 } = await bundleResponse.json(); + + const extensionX25519Pub = b64urlDecode(x25519PubB64); + const extensionKemPub = b64urlDecode(kemPubB64); + + const recomputedAlphaHash = await sha256(extensionX25519Pub, extensionKemPub); + if (!bytesEqual(recomputedAlphaHash, b64urlDecode(alphaHashB64))) throw new Error('alpha_hash_mismatch'); + + const companionX25519 = x25519.keygen(); + const x25519Shared = x25519.getSharedSecret(companionX25519.secretKey, extensionX25519Pub); + + const { cipherText: kemCiphertext, sharedSecret: kemSharedSecret } = ml_kem768.encapsulate(extensionKemPub); + + const runKey = await deriveRunKey(x25519Shared, kemSharedSecret, runId); + const pin = await derivePin(runKey); + + await postRelay(runId, 'kem_ciphertext', b64urlEncode(concat(companionX25519.publicKey, kemCiphertext))); + + return { runId, pin, runKey: b64urlEncode(runKey) }; +} + +async function deliverCredential(runId, runKeyB64, username, password) { + const runKey = b64urlDecode(runKeyB64); + const nonce = webcrypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(JSON.stringify({ username, password })); + + const key = await webcrypto.subtle.importKey('raw', runKey, 'AES-GCM', false, ['encrypt']); + const ciphertextAndTag = new Uint8Array(await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, plaintext)); + + await postRelay(runId, 'credential', b64urlEncode(concat(nonce, ciphertextAndTag))); +} + +const CONFIRMATION_POLL_INTERVAL_MS = 1000; +const CONFIRMATION_POLL_TIMEOUT_MS = 180_000; // generous safety net — the extension's own pin_ttl alarm is the real, authoritative deadline + +/** + * Polls interfaces.md's PIN confirmation endpoint rather than trusting a + * human keypress to mean "I've confirmed and the browser has processed + * it" — those aren't the same thing (a click can be sent and still not + * have landed yet, e.g. a slow service worker wake). This waits for the + * real signal instead of a proxy for it. + */ +async function waitForPinConfirmation(runId) { + const deadline = Date.now() + CONFIRMATION_POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + const response = await fetch(`${API_BASE}/run/relay/${runId}/confirm`); + if (response.ok) { + const { confirmed } = await response.json(); + if (confirmed) return; + } + await new Promise((resolve) => setTimeout(resolve, CONFIRMATION_POLL_INTERVAL_MS)); + } + throw new Error('timed out waiting for PIN confirmation from the browser'); +} + +async function runInteractive(signedToken, username, password) { + const { runId, pin, runKey } = await keyExchange(signedToken); + + console.log(`\nPIN: ${pin}`); + console.log('Compare that against the PIN shown in the extension popup, then click Confirm there.'); + console.log('Waiting for the browser to confirm...'); + + await waitForPinConfirmation(runId); + console.log('Confirmed by the browser.'); + + await deliverCredential(runId, runKey, username, password); + console.log(`Delivered credential (${username} / ${password}). The popup should now show "Signed in".`); +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + + if (command === 'run') { + const [signedToken, username = 'alice@example.com', password = 'hunter2'] = args; + if (!signedToken) throw new Error('usage: companion-harness.mjs run [username] [password]'); + await runInteractive(signedToken, username, password); + return; + } + + if (command === 'key-exchange') { + const [signedToken] = args; + if (!signedToken) throw new Error('usage: companion-harness.mjs key-exchange '); + console.log(JSON.stringify(await keyExchange(signedToken))); + return; + } + + if (command === 'deliver-credential') { + const [runId, runKeyB64, username, password] = args; + if (!runId || !runKeyB64 || !username || !password) { + throw new Error('usage: companion-harness.mjs deliver-credential '); + } + await deliverCredential(runId, runKeyB64, username, password); + console.log(JSON.stringify({ ok: true })); + return; + } + + throw new Error(`unknown command: ${command ?? '(none)'} — expected "run", "key-exchange", or "deliver-credential"`); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; +}); diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 0000000..7eb432a --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,135 @@ +{ + "name": "locqr-dev-setup", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "locqr-dev-setup", + "version": "0.1.0", + "dependencies": { + "@noble/curves": "^2.3.0", + "@noble/post-quantum": "0.6.1", + "@oqs/liboqs-js": "0.15.1", + "canonicalize": "3.0.0", + "undici": "^8.10.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", + "integrity": "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.3.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oqs/liboqs-js": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@oqs/liboqs-js/-/liboqs-js-0.15.1.tgz", + "integrity": "sha512-nt1M2CuI4JC1FGoLeD0771C/BcisCuJqh8o/54dcUY0nU88Bf7Qv8vF05SA+wLAVf8UTuZD/l6FlS2LI8NjGbg==", + "license": "MIT", + "bin": { + "liboqs": "bin/cli.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/canonicalize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz", + "integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..447df78 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,18 @@ +{ + "name": "locqr-dev-setup", + "private": true, + "version": "0.1.0", + "description": "Dev environment bootstrapping for LOCQR (dev.md). Node.js per crypto.md's pinned Node.js-setup-script row for ML-KEM-768/ML-DSA-44.", + "type": "module", + "scripts": { + "setup": "node setup-dev.js", + "companion": "node companion-harness.mjs" + }, + "dependencies": { + "@noble/curves": "^2.3.0", + "@noble/post-quantum": "0.6.1", + "@oqs/liboqs-js": "0.15.1", + "canonicalize": "3.0.0", + "undici": "^8.10.0" + } +} diff --git a/scripts/serve-website.js b/scripts/serve-website.js new file mode 100644 index 0000000..450dc3f --- /dev/null +++ b/scripts/serve-website.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// Serves website/ over HTTPS using the mkcert-issued test.locqr.dev cert +// (dev.md — HTTPS is required, externally_connectable needs a secure +// context). Also mounts sdk/dist under /sdk/, since the test page imports +// the SDK directly and there's no build step tying the two together +// (sdk/claude.md: packaging/distribution is deferred). +// +// Plain Node https + fs — no extra dependency for something this small. + +import { createServer } from 'node:https'; +import { readFile, stat } from 'node:fs/promises'; +import { extname, join, normalize } from 'node:path'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const WEBSITE_DIR = join(ROOT, 'website'); +const SDK_DIST_DIR = join(ROOT, 'sdk', 'dist'); +const PORT = 5173; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', +}; + +async function resolveFile(urlPath) { + const [mountPath, baseDir] = urlPath.startsWith('/sdk/') + ? [urlPath.slice('/sdk'.length), SDK_DIST_DIR] + : [urlPath, WEBSITE_DIR]; + + const safePath = normalize(mountPath === '/' ? '/index.html' : mountPath).replace(/^(\.\.[/\\])+/, ''); + const filePath = join(baseDir, safePath); + if (!filePath.startsWith(baseDir)) return null; // path traversal guard + + try { + const stats = await stat(filePath); + return stats.isFile() ? filePath : null; + } catch { + return null; + } +} + +const options = { + cert: await readFile(join(ROOT, 'dev', 'certs', 'test.locqr.dev.pem')), + key: await readFile(join(ROOT, 'dev', 'certs', 'test.locqr.dev-key.pem')), +}; + +createServer(options, async (req, res) => { + const url = new URL(req.url ?? '/', 'https://test.locqr.dev'); + const filePath = await resolveFile(url.pathname); + + if (!filePath) { + res.writeHead(404).end('Not found'); + return; + } + + const body = await readFile(filePath); + res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] ?? 'application/octet-stream' }); + res.end(body); +}).listen(PORT, () => { + console.log(`Serving website/ (and sdk/dist/ at /sdk/) -> https://test.locqr.dev:${PORT}`); +}); diff --git a/scripts/setup-dev.js b/scripts/setup-dev.js new file mode 100644 index 0000000..a2b47d9 --- /dev/null +++ b/scripts/setup-dev.js @@ -0,0 +1,302 @@ +#!/usr/bin/env node +// LOCQR dev environment bootstrapping — see ../dev.md. +// +// Scope: dev signing keypairs (Ed25519, ML-DSA-44), the signed test +// registration certificate, mkcert local-CA TLS certs, and the extension's +// stable dev identity (key.pem). Pinned-constant patching +// (extension/src/constants.ts, companion's equivalent) is still deferred — +// added once there's a real constants file to patch rather than a +// hand-set one. +// +// Node.js, not shell or Java: crypto.md pins `@oqs/liboqs-js` specifically +// for "Node.js setup script" ML-DSA-44 work — this script runs in plain +// Node, not a service worker, so the WASM-dynamic-import restriction that +// ruled that library out for the extension (crypto.md, "Resolved +// 2026-08-16") doesn't apply here. +// +// Idempotent: every artifact here is checked for existence before being +// generated. Re-running this script on an existing environment changes +// nothing. To rotate keys, delete the relevant dev/keys or dev/certs file(s) +// first — see dev.md, "Rotating dev keys". + +import { createMLDSA44 } from '@oqs/liboqs-js'; +import canonicalize from 'canonicalize'; +import { execFileSync } from 'node:child_process'; +import { createHash, createPublicKey, generateKeyPairSync } from 'node:crypto'; +import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); // repo root, one up from scripts/ +const KEYS_DIR = join(ROOT, 'dev', 'keys'); +const CERTS_DIR = join(ROOT, 'dev', 'certs'); +const EXTENSION_DIR = join(ROOT, 'extension'); + +const TEST_DOMAIN = 'test.locqr.dev'; +const API_DOMAIN = 'api.locqr.dev'; +const CERT_LIFETIME_SECONDS = 10 * 365 * 24 * 60 * 60; // 10 years — a dev cert that outlives the dev cycle + +function readIfPresent(path) { + return existsSync(path) ? readFileSync(path, 'utf8').trim() : null; +} + +function writeAndAnnounce(path, contents, label) { + writeFileSync(path, contents + '\n'); + console.log(` generated ${label} -> ${path}`); +} + +/** + * @oqs/liboqs-js validates key/message arguments by strict duck-typing + * (`value.constructor.name === 'Uint8Array'`, not `instanceof`) — a Node + * `Buffer` fails that check even though it *is* a Uint8Array by + * inheritance, since its constructor is named `Buffer`. Every byte array + * handed to sign()/verify() has to go through this first. + */ +function toUint8Array(buffer) { + return Uint8Array.from(buffer); +} + +/** + * Ed25519 dev keypair for the backend's key-exchange-token signing (crypto.md, + * server/claude.md). JWK export gives the raw 32-byte scalar directly as + * base64url (RFC 4648 §5, matching interfaces.md's shared conventions) — no + * PKCS8 wrapping, no manual point encoding. + */ +function ensureEd25519Keys() { + const secretPath = join(KEYS_DIR, 'ed25519-secret.b64'); + const publicPath = join(KEYS_DIR, 'ed25519-public.b64'); + + const existingSecret = readIfPresent(secretPath); + const existingPublic = readIfPresent(publicPath); + if (existingSecret && existingPublic) { + console.log(' Ed25519 dev keypair already present, skipping.'); + return { secretB64: existingSecret, publicB64: existingPublic }; + } + + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const secretB64 = privateKey.export({ format: 'jwk' }).d; + const publicB64 = publicKey.export({ format: 'jwk' }).x; + + writeAndAnnounce(secretPath, secretB64, 'Ed25519 secret key'); + writeAndAnnounce(publicPath, publicB64, 'Ed25519 public key'); + console.log(' NEW Ed25519 keypair — remember to commit both files (dev.md).'); + + return { secretB64, publicB64 }; +} + +/** + * ML-DSA-44 dev keypair for signing the domain registration certificate + * (crypto.md). Only ever used offline, by this script — the running server + * never signs with it live (server/claude.md). + */ +async function ensureMlDsaKeys() { + const secretPath = join(KEYS_DIR, 'ml-dsa-44-secret.b64'); + const publicPath = join(KEYS_DIR, 'ml-dsa-44-public.b64'); + + const existingSecret = readIfPresent(secretPath); + const existingPublic = readIfPresent(publicPath); + if (existingSecret && existingPublic) { + console.log(' ML-DSA-44 dev keypair already present, skipping.'); + return { secretB64: existingSecret, publicB64: existingPublic }; + } + + const signer = await createMLDSA44(); + let secretB64; + let publicB64; + try { + const { publicKey, secretKey } = signer.generateKeyPair(); + secretB64 = Buffer.from(secretKey).toString('base64url'); + publicB64 = Buffer.from(publicKey).toString('base64url'); + } finally { + signer.destroy(); // WASM memory isn't GC'd — see @oqs/liboqs-js README + } + + writeAndAnnounce(secretPath, secretB64, 'ML-DSA-44 secret key'); + writeAndAnnounce(publicPath, publicB64, 'ML-DSA-44 public key'); + console.log(' NEW ML-DSA-44 keypair — remember to commit both files (dev.md).'); + + return { secretB64, publicB64 }; +} + +/** + * Issues and signs the test registration certificate for TEST_DOMAIN + * (interfaces.md, "Registration certificate"). Stable/committed like the + * keys — re-running this script does not reissue it, so its issued_at/ + * expires_at don't churn on every developer's machine. + */ +async function ensureTestRegistrationCert(mlDsaKeys) { + const certPath = join(CERTS_DIR, 'test-registration.b64'); + if (existsSync(certPath)) { + console.log(' Test registration certificate already present, skipping.'); + return; + } + + const issuedAt = Math.floor(Date.now() / 1000); + const payload = { + v: 1, + domain: TEST_DOMAIN, + issued_at: issuedAt, + expires_at: issuedAt + CERT_LIFETIME_SECONDS, + features: ['login'], + }; + + const canonical = canonicalize(payload); + const message = new TextEncoder().encode(canonical); + const secretKey = toUint8Array(Buffer.from(mlDsaKeys.secretB64, 'base64url')); + const publicKey = toUint8Array(Buffer.from(mlDsaKeys.publicB64, 'base64url')); + + const signer = await createMLDSA44(); + let signature; + try { + signature = signer.sign(message, secretKey); + // Self-check before writing anything to disk: sign() and verify() are + // independent code paths in the library: this catches a broken pairing + // (e.g. mismatched key files) at generation time, not at first + // extension-side verification. + const verifies = signer.verify(message, signature, publicKey); + if (!verifies) { + throw new Error('ML-DSA-44 self-check failed: signature does not verify against its own public key.'); + } + } finally { + signer.destroy(); + } + + const envelope = { ...payload, sig: Buffer.from(signature).toString('base64url') }; + const encoded = Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64url'); + + writeAndAnnounce(certPath, encoded, 'test registration certificate'); + console.log(` NEW registration cert for ${TEST_DOMAIN}, valid ${CERT_LIFETIME_SECONDS / (365 * 24 * 60 * 60)} years — remember to commit (dev.md).`); +} + +/** + * mkcert local CA — installs it into the system/browser trust stores + * (idempotent by mkcert's own design; safe to call every run) and exports + * rootCA.pem for other developers to import (dev.md, "Committed artifacts"). + */ +function ensureMkcertRootCa() { + const dest = join(CERTS_DIR, 'rootCA.pem'); + execFileSync('mkcert', ['-install'], { stdio: 'inherit' }); + if (existsSync(dest)) { + console.log(' rootCA.pem already exported, skipping.'); + return; + } + const caRoot = execFileSync('mkcert', ['-CAROOT'], { encoding: 'utf8' }).trim(); + writeFileSync(dest, readFileSync(join(caRoot, 'rootCA.pem'))); + console.log(` exported mkcert root CA -> ${dest}`); +} + +/** TLS cert+key for one local dev domain, via mkcert. */ +function ensureDomainCert(domain) { + const certPath = join(CERTS_DIR, `${domain}.pem`); + const keyPath = join(CERTS_DIR, `${domain}-key.pem`); + if (existsSync(certPath) && existsSync(keyPath)) { + console.log(` ${domain} cert already present, skipping.`); + return; + } + execFileSync('mkcert', ['-cert-file', certPath, '-key-file', keyPath, domain], { stdio: 'inherit' }); + console.log(` generated ${domain} cert -> ${certPath}`); +} + +/** + * dev.md is explicit that this script checks for the required /etc/hosts + * entries and prints instructions if missing, but never edits the file + * itself — hosts file changes need root and are exactly the kind of system + * change a script shouldn't make unannounced. + */ +function checkHostsFile() { + let hosts; + try { + hosts = readFileSync('/etc/hosts', 'utf8'); + } catch { + console.log(' Could not read /etc/hosts — add these entries manually:'); + console.log(` 127.0.0.1 ${TEST_DOMAIN}`); + console.log(` 127.0.0.1 ${API_DOMAIN}`); + return; + } + const missing = [TEST_DOMAIN, API_DOMAIN].filter((d) => !hosts.includes(d)); + if (missing.length === 0) { + console.log(' /etc/hosts already has the required entries.'); + return; + } + console.log(' /etc/hosts is missing entries for: ' + missing.join(', ')); + console.log(' Add these lines yourself (this script does not modify /etc/hosts):'); + for (const d of missing) console.log(` 127.0.0.1 ${d}`); +} + +/** Chrome's extension-ID derivation: SHA-256(DER SPKI public key), first 16 + * bytes, each nibble mapped to a letter a-p. Unrelated to our Ed25519/ + * ML-DSA-44 protocol keys — this is Chrome's own extension identity + * mechanism (RSA), used only to pin a stable ID for the unpacked dev build. */ +function computeExtensionId(publicKeyDer) { + const hash = createHash('sha256').update(publicKeyDer).digest(); + let id = ''; + for (const byte of hash.subarray(0, 16)) { + id += String.fromCharCode(97 + (byte >> 4)); + id += String.fromCharCode(97 + (byte & 0x0f)); + } + return id; +} + +/** + * extension/key.pem pins the unpacked extension's ID across machines and + * rebuilds (dev.md). The "key" field it produces goes in manifest.json. + */ +function ensureExtensionKeyPem() { + const keyPath = join(EXTENSION_DIR, 'key.pem'); + + let publicKeyDer; + if (existsSync(keyPath)) { + console.log(' extension/key.pem already present, skipping.'); + publicKeyDer = createPublicKey(readFileSync(keyPath, 'utf8')).export({ type: 'spki', format: 'der' }); + } else { + mkdirSync(EXTENSION_DIR, { recursive: true }); + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'der' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + writeFileSync(keyPath, privateKey); + console.log(` generated extension/key.pem -> ${keyPath}`); + console.log(' NEW extension identity — remember to commit (dev.md).'); + publicKeyDer = publicKey; + } + + console.log(` dev extension ID: ${computeExtensionId(publicKeyDer)}`); + console.log(` manifest.json "key": ${publicKeyDer.toString('base64')}`); +} + +async function main() { + mkdirSync(KEYS_DIR, { recursive: true }); + mkdirSync(CERTS_DIR, { recursive: true }); + + console.log('Ed25519 signing key (key exchange tokens):'); + ensureEd25519Keys(); + + console.log('ML-DSA-44 signing key (domain registration certs):'); + const mlDsaKeys = await ensureMlDsaKeys(); + + console.log(`Test registration certificate (${TEST_DOMAIN}):`); + await ensureTestRegistrationCert(mlDsaKeys); + + console.log('mkcert local CA:'); + ensureMkcertRootCa(); + + console.log(`TLS certs (${TEST_DOMAIN}, ${API_DOMAIN}):`); + ensureDomainCert(TEST_DOMAIN); + ensureDomainCert(API_DOMAIN); + + console.log('/etc/hosts:'); + checkHostsFile(); + + console.log('Extension dev identity:'); + ensureExtensionKeyPem(); + + console.log('\nDone. Not yet covered by this script (see dev.md): pinned-constant'); + console.log('patching (extension/src/constants.ts, companion equivalent) —'); + console.log('added once those files exist to patch.'); +} + +main().catch((err) => { + console.error('setup-dev.js failed:', err); + process.exitCode = 1; +}); diff --git a/sdk/package-lock.json b/sdk/package-lock.json new file mode 100644 index 0000000..5a732ec --- /dev/null +++ b/sdk/package-lock.json @@ -0,0 +1,65 @@ +{ + "name": "locqr-sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "locqr-sdk", + "version": "0.1.0", + "devDependencies": { + "@types/chrome": "^0.0.280", + "typescript": "^5.6.0" + } + }, + "node_modules/@types/chrome": { + "version": "0.0.280", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.280.tgz", + "integrity": "sha512-AotSmZrL9bcZDDmSI1D9dE7PGbhOur5L0cKxXd7IqbVizQWCY4gcvupPUVsQ4FfDj3V2tt/iOpomT9EY0s+w1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..5e7e91e --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,15 @@ +{ + "name": "locqr-sdk", + "private": true, + "version": "0.1.0", + "description": "LOCQR JS SDK — thin bridge between a website and the extension's Port. See sdk/claude.md.", + "type": "module", + "scripts": { + "build": "tsc" + }, + "dependencies": {}, + "devDependencies": { + "@types/chrome": "^0.0.280", + "typescript": "^5.6.0" + } +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 0000000..394b7ce --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,180 @@ +// LOCQR JS SDK — a thin bridge, per sdk/claude.md: "no logic of its own," +// no cryptography, no state beyond the Port connection itself. Wraps +// chrome.runtime.connect(extensionId) — the Port protocol is normative in +// interfaces.md; this file only translates it into locqr.init()/ready/ +// requestCredential()/on()/off(). + +export type LocqrErrorCode = 'NOT_INSTALLED' | 'NOT_REGISTERED' | 'CERT_INVALID' | 'ACCOUNT_ERROR' | 'RUN_FAILED'; + +export interface LocqrError { + code: LocqrErrorCode; + reason?: string; + security?: boolean; +} + +export interface InitOptions { + cert: string; + /** Required for real external-page connections in practice — the dev + * build's extension ID is not the same as the Store-published one + * (sdk/claude.md, "Extension ID in development"). */ + extensionId?: string; +} + +interface ReadyPayload { + features: string[]; +} + +export interface Credential { + username: string; + password: string; +} + +interface EventPayloads { + ready: ReadyPayload; + 'run:started': Record; + 'run:delivered': { credential: Credential }; + 'run:error': { error: LocqrError }; +} + +type LocqrEvent = keyof EventPayloads; +type EventHandler = (payload: EventPayloads[E]) => void; + +interface VerificationValidMessage { + type: 'verification'; + status: 'valid'; + features: string[]; +} +interface VerificationErrorMessage { + type: 'verification'; + status: 'error'; + error: LocqrError; +} +interface RunStartedMessage { + type: 'run_started'; +} +interface RunDeliveredMessage { + type: 'run_delivered'; + credential: Credential; +} +interface RunErrorMessage { + type: 'run_error'; + error: LocqrError; +} +type ExtensionMessage = VerificationValidMessage | VerificationErrorMessage | RunStartedMessage | RunDeliveredMessage | RunErrorMessage; + +function notInstalled(): Promise { + return Promise.reject({ code: 'NOT_INSTALLED' } satisfies LocqrError); +} + +class Locqr { + private port: chrome.runtime.Port | null = null; + private readyPromise: Promise = notInstalled(); + private readonly handlers = new Map>(); + private settled = false; + // requestCredential()'s pending settlers — one run at a time, per sdk/claude.md + // (mixing the Promise and event models on the same run is not supported; + // this SDK stays a thin bridge and doesn't police that, it just tracks + // the single in-flight requestCredential() call if there is one). + private runResolvers: { resolve: (credential: Credential) => void; reject: (error: LocqrError) => void } | null = null; + + init(options: InitOptions): void { + this.settled = false; + + if (typeof chrome === 'undefined' || !chrome.runtime?.connect) { + // NOT_INSTALLED is produced locally — it never arrives over the wire + // (interfaces.md, Port message protocol). + this.readyPromise = notInstalled(); + this.readyPromise.catch(() => {}); + return; + } + + this.readyPromise = new Promise((resolve, reject) => { + const settle = (fn: () => void) => { + if (this.settled) return; + this.settled = true; + fn(); + }; + + try { + this.port = options.extensionId ? chrome.runtime.connect(options.extensionId) : chrome.runtime.connect(); + } catch { + settle(() => reject({ code: 'NOT_INSTALLED' } satisfies LocqrError)); + return; + } + + this.port.onDisconnect.addListener(() => { + // Disconnected before a verification response ever arrived — + // the extension isn't there to talk to. + settle(() => reject({ code: 'NOT_INSTALLED' } satisfies LocqrError)); + // A disconnect mid-run (e.g. the extension's service worker + // was torn down) also ends any in-flight requestCredential(). + this.runResolvers?.reject({ code: 'RUN_FAILED', reason: 'backend_unreachable' }); + this.runResolvers = null; + }); + + this.port.onMessage.addListener((message: ExtensionMessage) => { + switch (message.type) { + case 'verification': + if (message.status === 'valid') { + const payload: ReadyPayload = { features: message.features }; + this.emit('ready', payload); + settle(() => resolve(payload)); + } else { + settle(() => reject(message.error)); + } + return; + case 'run_started': + this.emit('run:started', {}); + return; + case 'run_delivered': + this.emit('run:delivered', { credential: message.credential }); + this.runResolvers?.resolve(message.credential); + this.runResolvers = null; + return; + case 'run_error': + this.emit('run:error', { error: message.error }); + this.runResolvers?.reject(message.error); + this.runResolvers = null; + return; + } + }); + + this.port.postMessage({ type: 'init', cert: options.cert }); + }); + this.readyPromise.catch(() => {}); // consumer awaits `ready` on their own schedule + } + + get ready(): Promise { + return this.readyPromise; + } + + /** Triggers Phase 1. Resolves with the credential at the end of Phase 4, rejects on run_error. */ + requestCredential(): Promise { + if (!this.port) { + return Promise.reject({ code: 'NOT_INSTALLED' } satisfies LocqrError); + } + const port = this.port; + return new Promise((resolve, reject) => { + this.runResolvers = { resolve, reject }; + port.postMessage({ type: 'request_credential' }); + }); + } + + on(event: E, handler: EventHandler): void { + if (!this.handlers.has(event)) this.handlers.set(event, new Set()); + this.handlers.get(event)!.add(handler as EventHandler); + } + + off(event: E, handler: EventHandler): void { + this.handlers.get(event)?.delete(handler as EventHandler); + } + + private emit(event: E, payload: EventPayloads[E]): void { + for (const handler of this.handlers.get(event) ?? []) { + handler(payload); + } + } +} + +const locqr = new Locqr(); +export default locqr; diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 0000000..8262327 --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "types": ["chrome"], + "outDir": "dist", + "declaration": true + }, + "include": ["src"] +} diff --git a/server/claude.md b/server/claude.md index 085a78c..ca02249 100644 --- a/server/claude.md +++ b/server/claude.md @@ -129,6 +129,18 @@ suspended), the message is buffered briefly. Buffer lifetime is short (seconds); if the extension does not reconnect within that window the message is discarded and the run must be restarted. +**`POST /run/relay/:runId/confirm` (extension only)** and +**`GET /run/relay/:runId/confirm` (polled — companion only)** +PIN confirmation handshake (`../interfaces.md`, "PIN confirmation"). The +extension POSTs when its user clicks Confirm; the companion polls the GET +until it sees `{ confirmed: true }` before proceeding to select/send a +credential. Current scope: a plain in-memory marker in +`RelaySessionRegistry` (same component as the relay message buffer above, +not a separate store), pruned on its own longer TTL — see that class's +Javadoc. Both requests are unauthenticated, same as the rest of this +surface at current scope; `runId` itself is the only addressing key, same +trust model as the relay endpoints above. + ### Admin / website endpoints Domain registration and account management. Deferred beyond the one endpoint @@ -160,8 +172,10 @@ completing Phase 1. The server maintains a mapping of run ID → active connecti **Message flow:** 1. Extension opens WS for run ID (after Phase 1). 2. Companion POSTs kem_ciphertext → server pushes to extension WS (Phase 2). -3. Companion POSTs encrypted credential → server pushes to extension WS (Phase 4). -4. WS closes on run end (delivered, error, or TTL expiry). +3. Extension user clicks Confirm → extension POSTs `/run/relay/:runId/confirm`; + companion polls the same path (GET) and unblocks once it sees confirmed. +4. Companion POSTs encrypted credential → server pushes to extension WS (Phase 4). +5. WS closes on run end (delivered, error, or TTL expiry). The extension is responsible for keeping its WS connection alive across service worker suspensions using `chrome.alarms` or equivalent. The server-side buffer diff --git a/server/src/main/java/dev/locqr/server/config/CorsConfig.java b/server/src/main/java/dev/locqr/server/config/CorsConfig.java new file mode 100644 index 0000000..5fc4508 --- /dev/null +++ b/server/src/main/java/dev/locqr/server/config/CorsConfig.java @@ -0,0 +1,39 @@ +package dev.locqr.server.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * {@code host_permissions} in the extension's manifest only changes + * browser-side behavior — it lets the service worker's fetch() read a + * response that's missing CORS headers. It does not stop the browser from + * sending {@code Origin: chrome-extension://} on that fetch, and it has + * no effect at all on whether *this server* enforces its own CORS policy + * against that origin. Found this the hard way: scoping this mapping to + * {@code /**} initially blocked the extension's own {@code /security/report} + * calls with a 403 — Spring's CORS check doesn't know or care about + * host_permissions, it just saw an Origin not in the allowlist. The + * extension needed *no* CORS config at all (that's what host_permissions is + * for); only a real webpage does. So this stays scoped to exactly the one + * endpoint a webpage calls directly in this slice — not the whole API — + * specifically so it can never again regress an extension-only endpoint by + * accident the way the blanket {@code /**} mapping just did. + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + private final LocqrProperties properties; + + public CorsConfig(LocqrProperties properties) { + this.properties = properties; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/domain/registration") + .allowedOrigins(properties.allowedOrigins().toArray(new String[0])) + .allowedMethods("GET") + .allowedHeaders("*"); + } +} diff --git a/server/src/main/java/dev/locqr/server/config/LocqrProperties.java b/server/src/main/java/dev/locqr/server/config/LocqrProperties.java index b90b76f..e59f6c4 100644 --- a/server/src/main/java/dev/locqr/server/config/LocqrProperties.java +++ b/server/src/main/java/dev/locqr/server/config/LocqrProperties.java @@ -2,6 +2,8 @@ package dev.locqr.server.config; import org.springframework.boot.context.properties.ConfigurationProperties; +import java.util.List; + /** * Binds the {@code locqr.*} tree in application.yml. Current (PoC) scope * config only — see server/claude.md for the current-vs-production split. @@ -11,6 +13,7 @@ public record LocqrProperties( String testDomain, String ed25519SecretKeyPath, String registrationCertPath, + List allowedOrigins, Run run, Relay relay ) { diff --git a/server/src/main/java/dev/locqr/server/controller/PinConfirmationController.java b/server/src/main/java/dev/locqr/server/controller/PinConfirmationController.java new file mode 100644 index 0000000..a34c53b --- /dev/null +++ b/server/src/main/java/dev/locqr/server/controller/PinConfirmationController.java @@ -0,0 +1,39 @@ +package dev.locqr.server.controller; + +import dev.locqr.server.dto.PinConfirmationResponse; +import dev.locqr.server.relay.RelaySessionRegistry; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * PIN-confirmation handshake (interfaces.md, "PIN confirmation") — the + * extension posts here when its user clicks Confirm; the companion polls + * here after its own local pin_display gate before it's allowed to proceed + * to credential_select. Neither side trusts the other's timing without + * this: the companion no longer just proceeds on its own schedule + * (mobile/claude.md, updated). + */ +@RestController +public class PinConfirmationController { + + private final RelaySessionRegistry registry; + + public PinConfirmationController(RelaySessionRegistry registry) { + this.registry = registry; + } + + @PostMapping("/run/relay/{runId}/confirm") + public ResponseEntity confirm(@PathVariable String runId) { + registry.confirmPin(runId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @GetMapping("/run/relay/{runId}/confirm") + public PinConfirmationResponse status(@PathVariable String runId) { + return new PinConfirmationResponse(registry.isPinConfirmed(runId)); + } +} diff --git a/server/src/main/java/dev/locqr/server/dto/PinConfirmationResponse.java b/server/src/main/java/dev/locqr/server/dto/PinConfirmationResponse.java new file mode 100644 index 0000000..3c56ccf --- /dev/null +++ b/server/src/main/java/dev/locqr/server/dto/PinConfirmationResponse.java @@ -0,0 +1,8 @@ +package dev.locqr.server.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** GET /run/relay/:runId/confirm response — interfaces.md "PIN confirmation". */ +public record PinConfirmationResponse( + @JsonProperty("confirmed") boolean confirmed +) {} diff --git a/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java b/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java index e7e7993..af74155 100644 --- a/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java +++ b/server/src/main/java/dev/locqr/server/relay/RelaySessionRegistry.java @@ -26,6 +26,13 @@ import java.util.concurrent.TimeUnit; * bounds how long, matching the doc's "buffer lifetime is short (seconds); * if the extension does not reconnect within that window the message is * discarded and the run must be restarted." + * + *

Also tracks the PIN-confirmation handshake (interfaces.md, "PIN + * confirmation"): the extension posts a confirmation here when its user + * clicks Confirm, and the companion polls for it before it's allowed to + * proceed past its own local pin_display gate. Same ephemeral-state + * lifecycle and TTL as the message buffer above, so it's tracked alongside + * it rather than in a separate component. */ @Component public class RelaySessionRegistry { @@ -34,8 +41,17 @@ public class RelaySessionRegistry { private record Buffered(String json, Instant receivedAt) {} + // Not a functional deadline — the extension's own pin_ttl alarm is what + // actually bounds how long a user has to confirm. This is just memory + // hygiene for the server, so it's set generously longer than any + // realistic pin_ttl rather than reusing the much shorter relay message + // buffer TTL (8s — nowhere near enough time for a human to compare a + // PIN and click Confirm). + private static final Duration CONFIRMATION_TTL = Duration.ofMinutes(10); + private final Map sessions = new ConcurrentHashMap<>(); private final Map> buffers = new ConcurrentHashMap<>(); + private final Map confirmedAt = new ConcurrentHashMap<>(); private final Duration bufferTtl; private final ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "relay-buffer-cleanup"); @@ -45,7 +61,7 @@ public class RelaySessionRegistry { public RelaySessionRegistry(LocqrProperties properties) { this.bufferTtl = Duration.ofSeconds(properties.relay().bufferTtlSeconds()); - this.cleanup.scheduleAtFixedRate(this::pruneExpiredBuffers, 1, 1, TimeUnit.SECONDS); + this.cleanup.scheduleAtFixedRate(this::pruneExpired, 1, 1, TimeUnit.SECONDS); } public void register(String runId, WebSocketSession session) { @@ -57,6 +73,16 @@ public class RelaySessionRegistry { sessions.remove(runId, session); } + /** Extension side: user clicked Confirm. */ + public void confirmPin(String runId) { + confirmedAt.put(runId, Instant.now()); + } + + /** Companion side: has the extension confirmed yet? */ + public boolean isPinConfirmed(String runId) { + return confirmedAt.containsKey(runId); + } + /** * Forwards a relay message to the extension's socket if open; otherwise * buffers it for {@link #bufferTtl}. @@ -86,14 +112,16 @@ public class RelaySessionRegistry { } } - private void pruneExpiredBuffers() { - Instant cutoff = Instant.now().minus(bufferTtl); + private void pruneExpired() { + Instant bufferCutoff = Instant.now().minus(bufferTtl); buffers.forEach((runId, queue) -> { - queue.removeIf(m -> m.receivedAt().isBefore(cutoff)); + queue.removeIf(m -> m.receivedAt().isBefore(bufferCutoff)); if (queue.isEmpty()) { buffers.remove(runId, queue); } }); + Instant confirmationCutoff = Instant.now().minus(CONFIRMATION_TTL); + confirmedAt.entrySet().removeIf(e -> e.getValue().isBefore(confirmationCutoff)); } private void send(WebSocketSession session, String json) { diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 38c0fb6..a2be97e 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -1,10 +1,25 @@ server: port: ${API_PORT:3000} + ssl: + bundle: api-locqr-dev + +spring: + ssl: + bundle: + pem: + api-locqr-dev: + keystore: + certificate: ${API_TLS_CERT_PATH:file:../dev/certs/api.locqr.dev.pem} + private-key: ${API_TLS_KEY_PATH:file:../dev/certs/api.locqr.dev-key.pem} locqr: test-domain: ${TEST_DOMAIN:test.locqr.dev} ed25519-secret-key-path: ${ED25519_SECRET_KEY_PATH:../dev/keys/ed25519-secret.b64} registration-cert-path: ${REGISTRATION_CERT_PATH:../dev/certs/test-registration.b64} + # Webpage-context clients only (test website, later the companion web + # stub) — the extension's service worker bypasses CORS via manifest + # host_permissions instead. Ports match dev.md's documented defaults. + allowed-origins: ${ALLOWED_ORIGINS:https://test.locqr.dev:5173,https://test.locqr.dev:5174} run: qr-ttl-seconds: 90 max-auto-refresh: 3 diff --git a/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java b/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java index 7b34b8b..f13c1d1 100644 --- a/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java +++ b/server/src/test/java/dev/locqr/server/RunFlowIntegrationTest.java @@ -55,6 +55,11 @@ class RunFlowIntegrationTest { @DynamicPropertySource static void dynamicKeyAndCert(DynamicPropertyRegistry registry) throws Exception { + // TLS termination (server.ssl.*, application.yml) is verified separately against + // the real mkcert-issued cert and trust chain — see dev.md / server/claude.md. + // These tests are about the API contract, so they talk plain HTTP to a random port. + registry.add("server.ssl.enabled", () -> false); + Path tempDir = Files.createTempDirectory("locqr-server-it"); TestEd25519Keys.Generated keys = TestEd25519Keys.generate(); @@ -228,6 +233,45 @@ class RunFlowIntegrationTest { .expectStatus().isBadRequest(); } + // --- PIN confirmation handshake (interfaces.md) --- + + @Test + void pinConfirmation_isFalseUntilTheExtensionConfirms_thenTrue() { + String runId = "confirm-it-" + UUID.randomUUID(); + + client.get().uri("/run/relay/{runId}/confirm", runId) + .exchange() + .expectStatus().isOk() + .expectBody(PinConfirmationResponse.class) + .isEqualTo(new PinConfirmationResponse(false)); + + client.post().uri("/run/relay/{runId}/confirm", runId) + .exchange() + .expectStatus().isNoContent(); + + client.get().uri("/run/relay/{runId}/confirm", runId) + .exchange() + .expectStatus().isOk() + .expectBody(PinConfirmationResponse.class) + .isEqualTo(new PinConfirmationResponse(true)); + } + + @Test + void pinConfirmation_doesNotConfirmADifferentRunId() { + String confirmedRunId = "confirm-it-" + UUID.randomUUID(); + String otherRunId = "confirm-it-" + UUID.randomUUID(); + + client.post().uri("/run/relay/{runId}/confirm", confirmedRunId) + .exchange() + .expectStatus().isNoContent(); + + client.get().uri("/run/relay/{runId}/confirm", otherRunId) + .exchange() + .expectStatus().isOk() + .expectBody(PinConfirmationResponse.class) + .isEqualTo(new PinConfirmationResponse(false)); + } + private static String randomBase64(int numBytes) { byte[] bytes = new byte[numBytes]; new java.security.SecureRandom().nextBytes(bytes); diff --git a/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java b/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java index 6ca4856..1ca639c 100644 --- a/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java +++ b/server/src/test/java/dev/locqr/server/crypto/SigningServiceTest.java @@ -90,6 +90,7 @@ class SigningServiceTest { "test.locqr.dev", keyFile.toString(), "/nonexistent", + java.util.List.of(), new LocqrProperties.Run(90, 3, 120), new LocqrProperties.Relay(8)); } diff --git a/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java b/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java index 1f72018..6f23191 100644 --- a/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java +++ b/server/src/test/java/dev/locqr/server/relay/RelaySessionRegistryTest.java @@ -9,6 +9,8 @@ import org.springframework.web.socket.WebSocketSession; import java.util.ArrayList; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -100,9 +102,34 @@ class RelaySessionRegistryTest { verify(session, never()).sendMessage(any()); } + @Test + void isPinConfirmed_isFalse_forARunThatHasNotBeenConfirmed() { + RelaySessionRegistry registry = registry(30); + + assertFalse(registry.isPinConfirmed("run-1")); + } + + @Test + void isPinConfirmed_isTrue_afterConfirmPin() { + RelaySessionRegistry registry = registry(30); + + registry.confirmPin("run-1"); + + assertTrue(registry.isPinConfirmed("run-1")); + } + + @Test + void confirmPin_doesNotConfirmADifferentRunId() { + RelaySessionRegistry registry = registry(30); + + registry.confirmPin("run-1"); + + assertFalse(registry.isPinConfirmed("run-other")); + } + private RelaySessionRegistry registry(int bufferTtlSeconds) { RelaySessionRegistry registry = new RelaySessionRegistry(new LocqrProperties( - "test.locqr.dev", "unused", "unused", + "test.locqr.dev", "unused", "unused", java.util.List.of(), new LocqrProperties.Run(90, 3, 120), new LocqrProperties.Relay(bufferTtlSeconds))); created.add(registry); diff --git a/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java b/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java index ed6bbe0..1dc1ef3 100644 --- a/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java +++ b/server/src/test/java/dev/locqr/server/store/RegistrationCertificateStoreTest.java @@ -46,6 +46,7 @@ class RegistrationCertificateStoreTest { TEST_DOMAIN, "unused", certPath, + java.util.List.of(), new LocqrProperties.Run(90, 3, 120), new LocqrProperties.Relay(8)); } diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..c154d1d --- /dev/null +++ b/website/index.html @@ -0,0 +1,118 @@ + + + + +LOCQR test site + + + +

LOCQR test site

+

Loading registration certificate…

+ +
+ + + +
+ +

Event log

+
+ + + +