work in progress

This commit is contained in:
2026-08-16 22:00:08 +02:00
parent a914ef1ec4
commit 3c11a88a33
60 changed files with 4487 additions and 41 deletions
+1
View File
@@ -3,5 +3,6 @@ test/tls/
node_modules/ node_modules/
companion/dist/ companion/dist/
extension/dist/ extension/dist/
sdk/dist/
server/target/ server/target/
.idea/ .idea/
+47 -12
View File
@@ -233,30 +233,65 @@ requires re-verifying interoperability end-to-end.
| Concern | Runtime | Library | Version | | 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 | 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 | Android / Kotlin | BouncyCastle (liboqs-java for production) | — |
| ML-KEM-768 + ML-DSA-44 | Java/Spring (production) | liboqs-java | — | | 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) | Node.js | `canonicalize` (npm) | 3.0.0 |
| JCS (RFC 8785) | Browser | `canonicalize` (same package, ESM build) | 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) | Android / Kotlin | `io.github.erdtman:java-json-canonicalization` (Maven Central) | — |
| JCS (RFC 8785) | Java/Spring | `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), **`@oqs/liboqs-js`:** Published by PQCA (Post-Quantum Cryptography Alliance),
WASM bindings to the reference liboqs implementation, zero npm dependencies, 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. 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 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 algorithm deviations. Unpacked size is ~17 MB. Still the right choice for the
the extension bundle. Before writing any ML-KEM or ML-DSA code, verify that the Node.js setup script (confirmed working — see `scripts/setup-dev.js`) and
async `init()` call is compatible with the Manifest V3 service worker possibly the web companion stub (unconfirmed); ruled out for the extension.
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.
**JCS consistency:** All four runtimes use libraries from the same author **JCS consistency:** All four runtimes use libraries from the same author
(Samuel Erdtman, editor of RFC 8785). This maximises the likelihood of (Samuel Erdtman, editor of RFC 8785). This maximises the likelihood of
+28
View File
@@ -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-----
+27
View File
@@ -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-----
+30
View File
@@ -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-----
+1
View File
@@ -0,0 +1 @@
eyJ2IjoxLCJkb21haW4iOiJ0ZXN0LmxvY3FyLmRldiIsImlzc3VlZF9hdCI6MTc4Njg4NDY2MiwiZXhwaXJlc19hdCI6MjEwMjI0NDY2MiwiZmVhdHVyZXMiOlsibG9naW4iXSwic2lnIjoiMks1RXNBemZWWHVJR3l3a2pBZVBDMXRNdXJiMVNQNVRLcDNQX0laYXloaHhWZExjWFM1TUllLTFURVB3VS1hUUt1MDdVa3JOM3E1bjhGZmVacGFYM0JRbG55cExGTjlTbVRxTGkwcTE0TWZCSTRzX0o1QzRUckxVaXdZQnBPRVpMV2ZYQVE1SUtrc0hQTjBfWnFyaEloY3FPdlFwS2tNcUNtdUpkVmZNQXJpVTZnelplOHZyTmp4YUFacFpiNEd1YWF3UVdUUkpWOExrektzejJxQlYwMnV0YlNmejBqWHVaOG5qQ08xcDZhMGFEQkZHeEY4cVRnR1dheVl3MUloNGQ2NC00QTQ5RVpzT3hwZU8zT1luQTktWmd2cV9hc3J2aThUX3dTd3dtWDRCSWJFMUVyc0I1c25tcEZYRGo3SWJQOHZUM21JdXBHMG1uMkNlcDhTMkRVaGdEM2lSMnZYTjNyNE5ZSnVUVDZiWmRrVlNRd1BuUjdZVGFvUW0wbzh3TnVxeUlhX2tPcTQ2NGRHQ2VyMzNJazdZOE85YXZFV3ExUG9XSy03SF9JT0lOUzc4TGIwVzJwM0xUb2xvZ0p0YjVtRzgxZFNpNG9IUF9BVE9hSVNrN1g1S0FsZjNYUUl5X1ItR0tYZlBWODJYQlBCd2pUb0s5VWliVjhDV1FXUHB5enhZb1pjWU9NcUFUcFYwWE1FUjhROU1fTXVpS0pZTEg0cmVDMmI3aXpJV3N6c1hvOWxNTnBtbnVyT19veUx0NW5DendiZTM5VkI0RTlwQzJFTEdQdXRYM0dyVFJiRC1HbVVobF8tQlpfS1g2bkh1WHR2Ukc1eDhLSkdxVm5oZ0c0cVA5QWxRNTd5U3VQMzVLajktUHRUR3UwRnFRQnE1c1JYMFJfbUVCamJwOHZUVGJCelJrX2tCYy1scmtmZTBxQlFXUzEwQWVkYnRnZWhTcXljSUtKZnFzTFZ4TXJ3Y3BmZVFSeHdsTUJVYjE5TDRBRm5YZGRXZWtUbzdLM2dwam9EMm9QSW1Oa2NDTHdJejRJd0hiOUhXRUdWOGc1b09MNmhBdXBBR3gwLV9SaEthcEdqdUs2RDJpdmNGd19YMm9OVnNmaTlEdVhVampZM3BVVENzaWFyb3FCeDFMQmIwN29PWmdiRUFJdkJKVXBXYjI2Ml82OW5BdHozaTVvQTVBNHcyN1N1YjF1SXhBcnRfVUItaEtCYy1GVEJCeExROFVpX014WjVoNlA2dW9lREY2X3VTQXRBYXh3elhEenpEVXowR1dsbkNVZkVrd1A5My1CMm85Mm9mRjVqc010d0hwalFwR0lhdTFMQ0tOX21IU3NqMUlGN3d0dDFFRVJ2bWhHcmRmRHhhd2VFMkg2RjVsZFgtN1lFem1US1VPWW9QYWhFaHNULUM3LXVSay1ybnBxWXZYRTN1aG5FQnlhVS1qZ3NQS1IxTDJsQ01paW00UnhrdzduZUNaNHYycXcyTC03WllGektFVU1ubzA4MHhlcm9wMGtRMnhtVmMxUWJoUHFDalRyYVpGSFRQZ0pEVzJKQk5kbzJRaWpIaTRYeVcyRGRkMUdlX19Xcy1aUXN1ZmQzbU1haVlqTHBzUVd2STZHa3BLZ0xSZmIxMTJraUJfcGlDWU5nNDRhOUhrTkZ1NjRMbjdYUVhvOGlydWRFNk1vV1VVci1zR3dTeU81UFZPOFhmM3hveUJ6d01XdGVKNjJvSkM3cWV3QkgxcHZyUzR2WXRMVkZrS2lLMWZfMERTTlpob3k1NjBYSlJuZjd5U1RMbU5kMkFXVkJ0emFXN0lJbUdHbDhHU1lmVlpQYVRjZWtZZDNTUTBOY3FzMVJzcWlCRlBqLXhWOW52OXd1eEJMNjg3Z2QxRGFqZ1pVN2VfUGN1dkltdnR6aFZISkFsYjNxcmJTTk9CaFlabnFXSC1ZZF9BSmZ0dzhObGJucjVUZlc3RDEtS19ZYkRzZVpscmpzbFF5cU5BcVhIMmlfa2NZV0dqd0VvcU1MVE14bzdIeGozd2t3M2VNNmFmc3BqVHhrN01CZk9zQk84SERfSVNiSnA5SHdWQ3BPWHNwY0kyQTVXc3k0REVoemtSU05UQ0ZfSWhfZmRlNTRZRmRnNS05bHFKZUpwUTEwUjEtUXBCazR2OVNxc0kyYVJNVEtDMXFCQ3QwN2ppTnlCOVZFSDlsOWNHMmtBdmR0OGZIVC1TUlcteFdPcHlSb2k1dnRHbjQ0RzdtUnhGejJIMU5UVWhabmJqVjhUcVpZV2hoV1E2UDg0SHZHN0J2bUp0eC1jczBibnpBbTllWk13SXBpWDBQM04wSzlSeUh2QjZvNkNOTVhLemNNTHFOUG9iLUs3TW8wX1VaQ1BIT3FZNTNlTmthS0taSnlhaUxqeHpFRWlOT2hmSTc1RUtRSGJMY0t1WlM5dG1XTXdiVVItb2p0djBNeDVqTFVsaHNaTndXc1NKY1M3ZnVxZUI4ak5MMXVLcG1PTUd2TU4wOGVPb0VhVkhyZlN3cWZ4UVctel85bEpPRWo4VVRqMVpJVkRhNWhTTnZSMnh6c3FUcVIxaGM1emtxbi1TTkFreWhtaFhKVlB3aERJdzFfbDdXdkV3WVJTOUYxWFFiMTBudkRhaUwzVHFLcXhzYkNuV0ZvaEQ5WkRJQjlZbVRra2VMV1BsOEVUbXZ3Rm0wUjNpUngzV3cxLTdCNVFwdDhEWjhUcm94eE1aZzF2TXFZU2w1bnQyUDVESHV3SUNIZ0lWR0EzOFRpajhSTmJxWnZaWGZHWmNmeHg5WHdZSGtycXpodjIxMzFpa0lvSVloOGtBc25UcDBOTzJhbGl0TDlRN1J1eDR6eHhFWWV1VDBDZFFMU29zRXNpQTNEdnlSSWJBdkdfMDNNMXZXWVBfbWs0dDZ4bl80N3lydlk5azd1T0VEMkdFcnVoT0ZHaEkyek45N24ybGh4eGdXanhVVW9XT094LVVJY01pakw0R0VGcVY2ZHcxRXN4RWd2Z1V6SU51a2VKT1MzcHNOaThhbW1RX01TSlVsUWJtc0czQU1wSS1DS185anpyTFlnTGNXQnVKVWtyQlg1dGs4T3NnYXFQcTZXT013eGNaekp1eFBoWFZ2T2FKb2FQZjNvNGcxU25aLTNoLWxQcUZQTFNHVkRpbmRBVzZvOGJDMVhvTGZmR0F1ckhxQTlTdDNEMlhrVUlGVjVuV29zdjJkcDM1MnFOZUxPbnNWYUZvemN5QjRRV2htODBTWV9QYmtxTTl5bUNtN2Y2VjRDRnFaYWp6TWpFU2FpRzBmSkNDZGdteWF3Z1lTdnFCWGtwYUhqN0dHeXdQSFNGbkpldnRIWWtGVVVVdGNNT196YnFvSjk0aDlrUEdrdUtYY0pnTGFuRGFjTkRMc3VYaXdfajBfTld2MkR0Z2VFQUk0Q3pKNHFKVk5DYi1ENnc4SC1DRDZPX1RObm9sejJCZXh1WWxkUTdLdDZQYVZzOXZZNEVCS3Vydmdvdm1VSGdpOGNzRDh2NUdMWWNVb2JpUVlRQWszTzZEaEJ0MWVBbDhJeXpOMU56MXoxWTcwaDN2MHlpUnlzV09oXzBIYVBfR0JOc1JXNmZsYjE4dldNRlRtQU1LVnRaTlg3MHY2MVF1MGphem9WY1hoS1N0WUY5WjF1ZDRveWdSODluUy1zTWJnVjk3X2hzWVV4cDhZVkhWLV92OV94Wi0wcEtCZExuajlra2xsVU5ZckhPSjBfekViZXBnYWJrWGY5MkFuMDBrRmtmOHRIWDNvelNURmtMNEM0cWcwY0xSdUF1enIycGJxR0ZvTnlWZXM2N3k5TDk4Z3VxRnl2NGpWQ2ROaUZpOV9NQ1ZscG8zRF90OW5nUXg1NnV5YV83ZGQ1dEdod01kVExhMEFEa2RLTl9YSWstYWU2eGNDdFJMalNzSUR2cGdZM1ZVckJ5TEZVUGg1MEZsN3NRbmFFUTgydGRZbmFSV2tFc0FSSHUyRWVlOFM5RXdvaGg3TFRhM05IVkpqMUZoS2RmWVk4Tzc0U1VXVHRSTVFKVHhBckItd0Utd1gwNlh2N1pFQWhKWVo3ZEZpdENWWTBjRjZ3RjJ2WnhtYmVibW9XaFgwTGlCaV9vSmlFOHcxZHE2NEZ0RlJjaHlLY0ZFU1VyTnprN1AwVktYWFotcEsyeXM3dS14QXNORVRnOFVYUE8wOVRjOF9uOEVoZ2pMQzB1T0ZoaWpyTEcyZHZlNERSTlVscDE1X2dBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFCUWlNamsifQ
+28
View File
@@ -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-----
+27
View File
@@ -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-----
+1
View File
@@ -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
+1
View File
@@ -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
+26
View File
@@ -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}/`);
+25 -11
View File
@@ -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 - `phase_3` — run key derived; PIN displayed with a countdown derived from
`pin_ttl`. Awaiting user confirmation on the popup. On TTL expiry: transition `pin_ttl`. Awaiting user confirmation on the popup. On TTL expiry: transition
to `run_error / pin_timeout`. 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 - `delivered` — credential decrypted and handed to the JS SDK; transitions
automatically to `idle` after brief display. automatically to `idle` after brief display.
- `run_error` — run failed or aborted at any phase; reason and class - `run_error` — run failed or aborted at any phase; reason and class
held for display. Three classes: held for display. Two classes currently apply to the extension:
*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.
*Timeout / user class* (expected; user offered retry): *Timeout / user class* (expected; user offered retry):
- `ttl_exhausted` — QR auto-refreshes used up; user must manually 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_timeout` — no message received from relay within expected window.
- `relay_error` — relay returned an error or malformed response. - `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 ### Transitions
``` ```
@@ -163,8 +174,9 @@ phase_2
phase_3 phase_3
→ phase_4 (user confirms PIN) → phase_4 (user confirms PIN)
→ run_error/pin_timeout (pin_ttl expired) → run_error/pin_timeout (pin_ttl expired)
→ run_error/pin_mismatch (security: run keys diverged) → run_error/user_abort (user rejects or cancels — the mechanism
→ run_error/user_abort (user rejects or cancels) for a PIN the user sees not matching;
see the run_error note above)
phase_4 phase_4
→ delivered (credential received; GCM auth passes) → 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 - Open and maintain a WebSocket connection to `/run/relay/:runId` for
incoming relay messages (kem_ciphertext in Phase 2, encrypted credential in incoming relay messages (kem_ciphertext in Phase 2, encrypted credential in
Phase 4). 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). - Query account status for a domain (site verification, step 3).
- Report security-class errors to `/security/report`. - Report security-class errors to `/security/report`.
+18 -6
View File
@@ -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 requirement, not a nice-to-have, since older scanners are the ones most
likely to reject reversed polarity. 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." - Caption: "Scan with the companion app."
- Countdown bound to `qr_ttl`. - 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. retry or dismiss; it persists until the cert or page changes.
**Security warning — mid-run** — same red badge and "Security warning" **Security warning — mid-run** — same red badge and "Security warning"
heading, but with a single **Dismiss** button. Used for `run_error` heading, but with a single **Dismiss** button. Built for a `run_error`
security-class reasons (`alpha_hash_mismatch`, `pin_mismatch`), which security-class reason that interrupts something the user started, the same
interrupt something the user started. Dismiss uses the outlined/ghost button way `cert_invalid`'s security variant interrupts before one starts. No
style, never the filled accent button — nothing about a blocked state should concrete extension-side reason uses this template today — `claude.md`'s
visually read as "the expected path," the way Confirm does in phase 3. `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 **Placeholder copy — needs a plain-language pass.** The reason strings used
while designing this ("relay timed out," "run has been stopped and while designing this ("relay timed out," "run has been stopped and
Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

+28
View File
@@ -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-----
+31
View File
@@ -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"]
}
+984
View File
@@ -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"
}
}
}
}
+22
View File
@@ -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"
}
}
+100
View File
@@ -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<BundleUploaded | BundleUploadFailed> {
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<void> {
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<DomainStatusResult | DomainUnreachable> {
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<void> {
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);
}
}
+110
View File
@@ -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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<string> {
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<Credential> {
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;
}
+198
View File
@@ -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<Exclude<CertInvalidReason, 'domain_mismatch' | 'signature_invalid' | 'malformed'>, 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<void> {
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<void> {
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<void> {
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<void> {
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);
});
+44
View File
@@ -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<number, WebSocket>();
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);
}
}
+34
View File
@@ -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<number, RunSecrets>();
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);
}
+269
View File
@@ -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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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' } });
}
+37
View File
@@ -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<number, chrome.runtime.Port>();
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);
}
}
+25
View File
@@ -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<TabState> {
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<void> {
await chrome.storage.session.set({ [storageKey(tabId)]: state });
}
export async function clearTabState(tabId: number): Promise<void> {
await chrome.storage.session.remove(storageKey(tabId));
}
+111
View File
@@ -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<CertInvalidReason> = 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<string, unknown>;
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[] };
}
+26
View File
@@ -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(/=+$/, '');
}
+15
View File
@@ -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';
+196
View File
@@ -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);
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>locqr</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div class="card-header">
<div class="brand">
<div class="mark"></div>
<div class="word">locqr</div>
</div>
<div class="origin" id="origin"></div>
</div>
<div class="card-body" id="body"></div>
<script type="module" src="popup.js"></script>
</body>
</html>
+356
View File
@@ -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<RunErrorReason, { heading: string; body: string }> = {
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<string, string> = {
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<string, { heading: string; body: string }> = {
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 sites LOCQR certificate has a problem.',
};
return { badgeClass: 'amber-tint', iconColor: 'amber', glyph: 'exclaim', heading, body };
}
const GLYPHS: Record<Exclude<Content['glyph'], 'spinner'>, (color: string) => string> = {
dot: (c) => `<svg viewBox="0 0 24 24" fill="none" stroke="var(--${c})" stroke-width="2.4" stroke-linecap="round"><circle cx="12" cy="12" r="2.6" fill="var(--${c})" stroke="none"/></svg>`,
dash: (c) => `<svg viewBox="0 0 24 24" fill="none" stroke="var(--${c})" stroke-width="2.4" stroke-linecap="round"><line x1="7" y1="12" x2="17" y2="12"/></svg>`,
check: (c) => `<svg viewBox="0 0 24 24" fill="none" stroke="var(--${c})" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 12 9 17 20 6"/></svg>`,
exclaim: (c) => `<svg viewBox="0 0 24 24" fill="none" stroke="var(--${c})" stroke-width="2.2" stroke-linecap="round"><line x1="12" y1="7" x2="12" y2="13.5"/><circle cx="12" cy="17" r="0.6" fill="var(--${c})" stroke="none"/></svg>`,
shield: (c) => `<svg viewBox="0 0 24 24" fill="none" stroke="var(--${c})" stroke-width="1.8" stroke-linejoin="round"><path d="M12 3.5 19 6.3v5.2c0 4.4-3 7.6-7 8.9-4-1.3-7-4.5-7-8.9V6.3z"/><line x1="12" y1="9" x2="12" y2="13" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="15.6" r="0.6" fill="var(--${c})" stroke="none"/></svg>`,
};
function badgeHtml(c: Content): string {
const inner = c.glyph === 'spinner' ? `<div class="ring-spinner spinner"></div>` : GLYPHS[c.glyph](c.iconColor);
return `<div class="status-badge ${c.pulse ? 'pulse' : ''}" style="background: var(--${c.badgeClass})">${inner}</div>`;
}
function actionsHtml(actions: ActionSpec[] | undefined): string {
if (!actions?.length) return '';
const buttons = actions
.map((a) => `<button class="${a.style === 'primary' ? 'btn-primary' : 'btn-ghost'}" data-action="${a.kind}">${a.label}</button>`)
.join('');
return `<div class="actions">${buttons}</div>`;
}
function qrScreenHtml(): string {
return `
<canvas id="qr-canvas" class="qr-canvas"></canvas>
<div class="countdown-bar"><div class="countdown-fill" id="countdown-fill"></div></div>
<div class="status-body">Scan with the companion app.</div>
<div class="actions"><button class="btn-ghost" data-action="abort">Abort</button></div>
`;
}
function pinScreenHtml(pin: string): string {
const digit = (d: string) => `<div class="pin-digit">${d}</div>`;
const digits = pin.split('');
return `
<div class="pin-groups">
<div class="pin-group">${digits.slice(0, 3).map(digit).join('')}</div>
<div class="pin-group">${digits.slice(3, 6).map(digit).join('')}</div>
</div>
<div class="countdown-bar"><div class="countdown-fill" id="countdown-fill"></div></div>
<div class="status-body">Confirm this code matches your companion app.</div>
<div class="actions">
<button class="btn-primary" data-action="confirm_pin">Confirm</button>
<button class="btn-ghost" data-action="abort">Abort</button>
</div>
`;
}
/**
* 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<typeof setInterval> | 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<HTMLButtonElement>('#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)}
<div class="status-heading${c.headingSecurity ? ' security' : ''}">${c.heading}</div>
<div class="status-body">${c.body}</div>
${actionsHtml(c.actions)}
${c.note ? `<div class="status-note">${c.note}</div>` : ''}
`;
wireActions(tabId);
}
async function activeTab(): Promise<chrome.tabs.Tab | undefined> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab;
}
async function refresh(): Promise<void> {
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();
+158
View File
@@ -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 };
+14
View File
@@ -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"]
}
+47
View File
@@ -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 ## Encrypted credential payload
**Plaintext** (UTF-8 encoded): **Plaintext** (UTF-8 encoded):
+29 -5
View File
@@ -90,10 +90,21 @@ deferred UX decision.
bundle fetch, alpha_hash recomputation. bundle fetch, alpha_hash recomputation.
- `key_exchange` — verification passed; performing X25519 key agreement and - `key_exchange` — verification passed; performing X25519 key agreement and
ML-KEM-768 encapsulation; sending `kem_ciphertext` to backend relay. ML-KEM-768 encapsulation; sending `kem_ciphertext` to backend relay.
- `pin_display` — run key derived; PIN displayed to user. User reads - `pin_display` — run key derived; PIN displayed to user. User reads the
the PIN, confirms the match on the extension, then taps Continue on the PIN and taps Continue on the companion once they've compared it.
companion to advance. The companion does not receive a signal from the - `awaiting_confirmation` — user has tapped Continue; polling
extension; the two devices proceed independently from this point. `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 - `credential_select` — credentials for the current site presented; vault
already unlocked from biometric at session initiation. If exactly one already unlocked from biometric at session initiation. If exactly one
credential is stored for the site it may be pre-selected. If multiple are 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) → error/relay_error (network: cannot send kem_ciphertext)
pin_display 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) → idle (user cancels; run key discarded)
credential_select credential_select
@@ -170,6 +187,11 @@ warning; no retry offered):
*Network class* (transient; retry offered): *Network class* (transient; retry offered):
- `backend_unreachable` — key bundle fetch failed. - `backend_unreachable` — key bundle fetch failed.
- `relay_error` — could not send `kem_ciphertext` or credential ciphertext. - `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). - Fetch key bundle by run ID (at-most-once; bundle destroyed on fetch).
- Send `kem_ciphertext` to relay addressed to run ID (Phase 2). - 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). - Send encrypted credential ciphertext to relay addressed to run ID (Phase 4).
- Report security-class errors to `/security/report`. - Report security-class errors to `/security/report`.
+238
View File
@@ -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 <signed_token> [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 <signed_token>
// node companion-harness.mjs deliver-credential <runId> <runKeyB64> <username> <password>
// 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 <signed_token> [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 <signed_token>');
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 <runId> <runKeyB64> <username> <password>');
}
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;
});
+135
View File
@@ -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"
}
}
}
}
+18
View File
@@ -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"
}
}
+64
View File
@@ -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}`);
});
+302
View File
@@ -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;
});
+65
View File
@@ -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"
}
}
}
}
+15
View File
@@ -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"
}
}
+180
View File
@@ -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<string, never>;
'run:delivered': { credential: Credential };
'run:error': { error: LocqrError };
}
type LocqrEvent = keyof EventPayloads;
type EventHandler<E extends LocqrEvent = LocqrEvent> = (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<never> {
return Promise.reject<never>({ code: 'NOT_INSTALLED' } satisfies LocqrError);
}
class Locqr {
private port: chrome.runtime.Port | null = null;
private readyPromise: Promise<ReadyPayload> = notInstalled();
private readonly handlers = new Map<LocqrEvent, Set<EventHandler>>();
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<ReadyPayload>((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<ReadyPayload> {
return this.readyPromise;
}
/** Triggers Phase 1. Resolves with the credential at the end of Phase 4, rejects on run_error. */
requestCredential(): Promise<Credential> {
if (!this.port) {
return Promise.reject<Credential>({ code: 'NOT_INSTALLED' } satisfies LocqrError);
}
const port = this.port;
return new Promise<Credential>((resolve, reject) => {
this.runResolvers = { resolve, reject };
port.postMessage({ type: 'request_credential' });
});
}
on<E extends LocqrEvent>(event: E, handler: EventHandler<E>): void {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler as EventHandler);
}
off<E extends LocqrEvent>(event: E, handler: EventHandler<E>): void {
this.handlers.get(event)?.delete(handler as EventHandler);
}
private emit<E extends LocqrEvent>(event: E, payload: EventPayloads[E]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(payload);
}
}
}
const locqr = new Locqr();
export default locqr;
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"types": ["chrome"],
"outDir": "dist",
"declaration": true
},
"include": ["src"]
}
+16 -2
View File
@@ -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 if the extension does not reconnect within that window the message is discarded
and the run must be restarted. 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 ### Admin / website endpoints
Domain registration and account management. Deferred beyond the one endpoint 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:** **Message flow:**
1. Extension opens WS for run ID (after Phase 1). 1. Extension opens WS for run ID (after Phase 1).
2. Companion POSTs kem_ciphertext → server pushes to extension WS (Phase 2). 2. Companion POSTs kem_ciphertext → server pushes to extension WS (Phase 2).
3. Companion POSTs encrypted credential → server pushes to extension WS (Phase 4). 3. Extension user clicks Confirm → extension POSTs `/run/relay/:runId/confirm`;
4. WS closes on run end (delivered, error, or TTL expiry). 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 The extension is responsible for keeping its WS connection alive across service
worker suspensions using `chrome.alarms` or equivalent. The server-side buffer worker suspensions using `chrome.alarms` or equivalent. The server-side buffer
@@ -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://<id>} 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("*");
}
}
@@ -2,6 +2,8 @@ package dev.locqr.server.config;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
/** /**
* Binds the {@code locqr.*} tree in application.yml. Current (PoC) scope * Binds the {@code locqr.*} tree in application.yml. Current (PoC) scope
* config only see server/claude.md for the current-vs-production split. * config only see server/claude.md for the current-vs-production split.
@@ -11,6 +13,7 @@ public record LocqrProperties(
String testDomain, String testDomain,
String ed25519SecretKeyPath, String ed25519SecretKeyPath,
String registrationCertPath, String registrationCertPath,
List<String> allowedOrigins,
Run run, Run run,
Relay relay Relay relay
) { ) {
@@ -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<Void> 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));
}
}
@@ -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
) {}
@@ -26,6 +26,13 @@ import java.util.concurrent.TimeUnit;
* bounds how long, matching the doc's "buffer lifetime is short (seconds); * bounds how long, matching the doc's "buffer lifetime is short (seconds);
* if the extension does not reconnect within that window the message is * if the extension does not reconnect within that window the message is
* discarded and the run must be restarted." * discarded and the run must be restarted."
*
* <p>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 @Component
public class RelaySessionRegistry { public class RelaySessionRegistry {
@@ -34,8 +41,17 @@ public class RelaySessionRegistry {
private record Buffered(String json, Instant receivedAt) {} 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<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final Map<String, Queue<Buffered>> buffers = new ConcurrentHashMap<>(); private final Map<String, Queue<Buffered>> buffers = new ConcurrentHashMap<>();
private final Map<String, Instant> confirmedAt = new ConcurrentHashMap<>();
private final Duration bufferTtl; private final Duration bufferTtl;
private final ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor(r -> { private final ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "relay-buffer-cleanup"); Thread t = new Thread(r, "relay-buffer-cleanup");
@@ -45,7 +61,7 @@ public class RelaySessionRegistry {
public RelaySessionRegistry(LocqrProperties properties) { public RelaySessionRegistry(LocqrProperties properties) {
this.bufferTtl = Duration.ofSeconds(properties.relay().bufferTtlSeconds()); 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) { public void register(String runId, WebSocketSession session) {
@@ -57,6 +73,16 @@ public class RelaySessionRegistry {
sessions.remove(runId, session); 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 * Forwards a relay message to the extension's socket if open; otherwise
* buffers it for {@link #bufferTtl}. * buffers it for {@link #bufferTtl}.
@@ -86,14 +112,16 @@ public class RelaySessionRegistry {
} }
} }
private void pruneExpiredBuffers() { private void pruneExpired() {
Instant cutoff = Instant.now().minus(bufferTtl); Instant bufferCutoff = Instant.now().minus(bufferTtl);
buffers.forEach((runId, queue) -> { buffers.forEach((runId, queue) -> {
queue.removeIf(m -> m.receivedAt().isBefore(cutoff)); queue.removeIf(m -> m.receivedAt().isBefore(bufferCutoff));
if (queue.isEmpty()) { if (queue.isEmpty()) {
buffers.remove(runId, queue); 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) { private void send(WebSocketSession session, String json) {
+15
View File
@@ -1,10 +1,25 @@
server: server:
port: ${API_PORT:3000} 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: locqr:
test-domain: ${TEST_DOMAIN:test.locqr.dev} test-domain: ${TEST_DOMAIN:test.locqr.dev}
ed25519-secret-key-path: ${ED25519_SECRET_KEY_PATH:../dev/keys/ed25519-secret.b64} ed25519-secret-key-path: ${ED25519_SECRET_KEY_PATH:../dev/keys/ed25519-secret.b64}
registration-cert-path: ${REGISTRATION_CERT_PATH:../dev/certs/test-registration.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: run:
qr-ttl-seconds: 90 qr-ttl-seconds: 90
max-auto-refresh: 3 max-auto-refresh: 3
@@ -55,6 +55,11 @@ class RunFlowIntegrationTest {
@DynamicPropertySource @DynamicPropertySource
static void dynamicKeyAndCert(DynamicPropertyRegistry registry) throws Exception { 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"); Path tempDir = Files.createTempDirectory("locqr-server-it");
TestEd25519Keys.Generated keys = TestEd25519Keys.generate(); TestEd25519Keys.Generated keys = TestEd25519Keys.generate();
@@ -228,6 +233,45 @@ class RunFlowIntegrationTest {
.expectStatus().isBadRequest(); .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) { private static String randomBase64(int numBytes) {
byte[] bytes = new byte[numBytes]; byte[] bytes = new byte[numBytes];
new java.security.SecureRandom().nextBytes(bytes); new java.security.SecureRandom().nextBytes(bytes);
@@ -90,6 +90,7 @@ class SigningServiceTest {
"test.locqr.dev", "test.locqr.dev",
keyFile.toString(), keyFile.toString(),
"/nonexistent", "/nonexistent",
java.util.List.of(),
new LocqrProperties.Run(90, 3, 120), new LocqrProperties.Run(90, 3, 120),
new LocqrProperties.Relay(8)); new LocqrProperties.Relay(8));
} }
@@ -9,6 +9,8 @@ import org.springframework.web.socket.WebSocketSession;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; 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.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -100,9 +102,34 @@ class RelaySessionRegistryTest {
verify(session, never()).sendMessage(any()); 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) { private RelaySessionRegistry registry(int bufferTtlSeconds) {
RelaySessionRegistry registry = new RelaySessionRegistry(new LocqrProperties( 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.Run(90, 3, 120),
new LocqrProperties.Relay(bufferTtlSeconds))); new LocqrProperties.Relay(bufferTtlSeconds)));
created.add(registry); created.add(registry);
@@ -46,6 +46,7 @@ class RegistrationCertificateStoreTest {
TEST_DOMAIN, TEST_DOMAIN,
"unused", "unused",
certPath, certPath,
java.util.List.of(),
new LocqrProperties.Run(90, 3, 120), new LocqrProperties.Run(90, 3, 120),
new LocqrProperties.Relay(8)); new LocqrProperties.Relay(8));
} }
+118
View File
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LOCQR test site</title>
<style>
/* Intentionally bare (website/claude.md) — readability only, no application logic. */
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 40px auto; padding: 0 16px; color: #111; }
h1 { font-size: 20px; }
form { display: flex; flex-direction: column; gap: 8px; max-width: 280px; margin-bottom: 32px; }
input, button { padding: 8px; font-size: 14px; }
button { cursor: pointer; }
#log { font-family: ui-monospace, monospace; font-size: 12px; background: #f5f5f5; border: 1px solid #ddd;
padding: 12px; height: 320px; overflow-y: auto; white-space: pre-wrap; }
#log .entry { border-bottom: 1px solid #e5e5e5; padding: 4px 0; }
#log .time { color: #888; }
</style>
</head>
<body>
<h1>LOCQR test site</h1>
<p id="status">Loading registration certificate…</p>
<form id="login-form">
<input id="username" type="text" placeholder="Username" readonly />
<input id="password" type="password" placeholder="Password" readonly />
<button id="sign-in-btn" type="button" disabled>Sign in with LOCQR</button>
</form>
<h2 style="font-size: 14px;">Event log</h2>
<div id="log"></div>
<script type="module">
import locqr from '/sdk/index.js';
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
// "Every SDK event, Promise resolution, rejection, and error — including
// the full payload — is appended to the log in real time with a
// timestamp." (website/claude.md — this is the primary debugging tool.)
function log(label, payload) {
const time = new Date().toISOString().split('T')[1].replace('Z', '');
const entry = document.createElement('div');
entry.className = 'entry';
entry.innerHTML = `<span class="time">${time}</span> <strong>${label}</strong> ${payload !== undefined ? JSON.stringify(payload) : ''}`;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
}
// Dev extension ID, derived from extension/key.pem (dev.md, "Extension ID
// in development"). Stable as long as key.pem doesn't change.
const DEV_EXTENSION_ID = 'daijigbjegngkjckedcbhhdjkampjgia';
async function main() {
log('page:loaded');
let cert;
try {
// Registration cert served by the backend rather than hand-copied
// into this page (website/claude.md leaves the mechanism open;
// server/claude.md's GET /domain/registration implements this option).
const response = await fetch('https://api.locqr.dev:3000/domain/registration?domain=test.locqr.dev');
if (!response.ok) throw new Error(`registration fetch failed: ${response.status}`);
({ cert } = await response.json());
log('cert:fetched', { length: cert.length });
} catch (err) {
statusEl.textContent = 'Could not fetch registration certificate — is the server running?';
log('cert:fetch_error', { message: String(err) });
return;
}
statusEl.textContent = 'Initializing LOCQR…';
log('locqr:init', { extensionId: DEV_EXTENSION_ID });
locqr.on('ready', (payload) => log('event:ready', payload));
locqr.on('run:started', (payload) => log('event:run:started', payload));
locqr.on('run:delivered', (payload) => log('event:run:delivered', payload));
locqr.on('run:error', (payload) => log('event:run:error', payload));
locqr.init({ cert, extensionId: DEV_EXTENSION_ID });
try {
const result = await locqr.ready;
statusEl.textContent = `Ready — features: ${result.features.join(', ')}`;
log('ready:resolved', result);
signInBtn.disabled = false;
} catch (error) {
statusEl.textContent = `Not ready — ${error.code}${error.reason ? ' / ' + error.reason : ''}`;
log('ready:rejected', error);
}
}
const signInBtn = document.getElementById('sign-in-btn');
const usernameEl = document.getElementById('username');
const passwordEl = document.getElementById('password');
signInBtn.addEventListener('click', async () => {
signInBtn.disabled = true;
usernameEl.value = '';
passwordEl.value = '';
log('requestCredential:called');
try {
const credential = await locqr.requestCredential();
usernameEl.value = credential.username;
passwordEl.value = credential.password;
log('requestCredential:resolved', { username: credential.username });
} catch (error) {
statusEl.textContent = `Sign-in didn't complete — ${error.code}${error.reason ? ' / ' + error.reason : ''}`;
log('requestCredential:rejected', error);
} finally {
signInBtn.disabled = false;
}
});
main();
</script>
</body>
</html>