work in progress
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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-----
|
||||||
@@ -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-----
|
||||||
@@ -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-----
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
eyJ2IjoxLCJkb21haW4iOiJ0ZXN0LmxvY3FyLmRldiIsImlzc3VlZF9hdCI6MTc4Njg4NDY2MiwiZXhwaXJlc19hdCI6MjEwMjI0NDY2MiwiZmVhdHVyZXMiOlsibG9naW4iXSwic2lnIjoiMks1RXNBemZWWHVJR3l3a2pBZVBDMXRNdXJiMVNQNVRLcDNQX0laYXloaHhWZExjWFM1TUllLTFURVB3VS1hUUt1MDdVa3JOM3E1bjhGZmVacGFYM0JRbG55cExGTjlTbVRxTGkwcTE0TWZCSTRzX0o1QzRUckxVaXdZQnBPRVpMV2ZYQVE1SUtrc0hQTjBfWnFyaEloY3FPdlFwS2tNcUNtdUpkVmZNQXJpVTZnelplOHZyTmp4YUFacFpiNEd1YWF3UVdUUkpWOExrektzejJxQlYwMnV0YlNmejBqWHVaOG5qQ08xcDZhMGFEQkZHeEY4cVRnR1dheVl3MUloNGQ2NC00QTQ5RVpzT3hwZU8zT1luQTktWmd2cV9hc3J2aThUX3dTd3dtWDRCSWJFMUVyc0I1c25tcEZYRGo3SWJQOHZUM21JdXBHMG1uMkNlcDhTMkRVaGdEM2lSMnZYTjNyNE5ZSnVUVDZiWmRrVlNRd1BuUjdZVGFvUW0wbzh3TnVxeUlhX2tPcTQ2NGRHQ2VyMzNJazdZOE85YXZFV3ExUG9XSy03SF9JT0lOUzc4TGIwVzJwM0xUb2xvZ0p0YjVtRzgxZFNpNG9IUF9BVE9hSVNrN1g1S0FsZjNYUUl5X1ItR0tYZlBWODJYQlBCd2pUb0s5VWliVjhDV1FXUHB5enhZb1pjWU9NcUFUcFYwWE1FUjhROU1fTXVpS0pZTEg0cmVDMmI3aXpJV3N6c1hvOWxNTnBtbnVyT19veUx0NW5DendiZTM5VkI0RTlwQzJFTEdQdXRYM0dyVFJiRC1HbVVobF8tQlpfS1g2bkh1WHR2Ukc1eDhLSkdxVm5oZ0c0cVA5QWxRNTd5U3VQMzVLajktUHRUR3UwRnFRQnE1c1JYMFJfbUVCamJwOHZUVGJCelJrX2tCYy1scmtmZTBxQlFXUzEwQWVkYnRnZWhTcXljSUtKZnFzTFZ4TXJ3Y3BmZVFSeHdsTUJVYjE5TDRBRm5YZGRXZWtUbzdLM2dwam9EMm9QSW1Oa2NDTHdJejRJd0hiOUhXRUdWOGc1b09MNmhBdXBBR3gwLV9SaEthcEdqdUs2RDJpdmNGd19YMm9OVnNmaTlEdVhVampZM3BVVENzaWFyb3FCeDFMQmIwN29PWmdiRUFJdkJKVXBXYjI2Ml82OW5BdHozaTVvQTVBNHcyN1N1YjF1SXhBcnRfVUItaEtCYy1GVEJCeExROFVpX014WjVoNlA2dW9lREY2X3VTQXRBYXh3elhEenpEVXowR1dsbkNVZkVrd1A5My1CMm85Mm9mRjVqc010d0hwalFwR0lhdTFMQ0tOX21IU3NqMUlGN3d0dDFFRVJ2bWhHcmRmRHhhd2VFMkg2RjVsZFgtN1lFem1US1VPWW9QYWhFaHNULUM3LXVSay1ybnBxWXZYRTN1aG5FQnlhVS1qZ3NQS1IxTDJsQ01paW00UnhrdzduZUNaNHYycXcyTC03WllGektFVU1ubzA4MHhlcm9wMGtRMnhtVmMxUWJoUHFDalRyYVpGSFRQZ0pEVzJKQk5kbzJRaWpIaTRYeVcyRGRkMUdlX19Xcy1aUXN1ZmQzbU1haVlqTHBzUVd2STZHa3BLZ0xSZmIxMTJraUJfcGlDWU5nNDRhOUhrTkZ1NjRMbjdYUVhvOGlydWRFNk1vV1VVci1zR3dTeU81UFZPOFhmM3hveUJ6d01XdGVKNjJvSkM3cWV3QkgxcHZyUzR2WXRMVkZrS2lLMWZfMERTTlpob3k1NjBYSlJuZjd5U1RMbU5kMkFXVkJ0emFXN0lJbUdHbDhHU1lmVlpQYVRjZWtZZDNTUTBOY3FzMVJzcWlCRlBqLXhWOW52OXd1eEJMNjg3Z2QxRGFqZ1pVN2VfUGN1dkltdnR6aFZISkFsYjNxcmJTTk9CaFlabnFXSC1ZZF9BSmZ0dzhObGJucjVUZlc3RDEtS19ZYkRzZVpscmpzbFF5cU5BcVhIMmlfa2NZV0dqd0VvcU1MVE14bzdIeGozd2t3M2VNNmFmc3BqVHhrN01CZk9zQk84SERfSVNiSnA5SHdWQ3BPWHNwY0kyQTVXc3k0REVoemtSU05UQ0ZfSWhfZmRlNTRZRmRnNS05bHFKZUpwUTEwUjEtUXBCazR2OVNxc0kyYVJNVEtDMXFCQ3QwN2ppTnlCOVZFSDlsOWNHMmtBdmR0OGZIVC1TUlcteFdPcHlSb2k1dnRHbjQ0RzdtUnhGejJIMU5UVWhabmJqVjhUcVpZV2hoV1E2UDg0SHZHN0J2bUp0eC1jczBibnpBbTllWk13SXBpWDBQM04wSzlSeUh2QjZvNkNOTVhLemNNTHFOUG9iLUs3TW8wX1VaQ1BIT3FZNTNlTmthS0taSnlhaUxqeHpFRWlOT2hmSTc1RUtRSGJMY0t1WlM5dG1XTXdiVVItb2p0djBNeDVqTFVsaHNaTndXc1NKY1M3ZnVxZUI4ak5MMXVLcG1PTUd2TU4wOGVPb0VhVkhyZlN3cWZ4UVctel85bEpPRWo4VVRqMVpJVkRhNWhTTnZSMnh6c3FUcVIxaGM1emtxbi1TTkFreWhtaFhKVlB3aERJdzFfbDdXdkV3WVJTOUYxWFFiMTBudkRhaUwzVHFLcXhzYkNuV0ZvaEQ5WkRJQjlZbVRra2VMV1BsOEVUbXZ3Rm0wUjNpUngzV3cxLTdCNVFwdDhEWjhUcm94eE1aZzF2TXFZU2w1bnQyUDVESHV3SUNIZ0lWR0EzOFRpajhSTmJxWnZaWGZHWmNmeHg5WHdZSGtycXpodjIxMzFpa0lvSVloOGtBc25UcDBOTzJhbGl0TDlRN1J1eDR6eHhFWWV1VDBDZFFMU29zRXNpQTNEdnlSSWJBdkdfMDNNMXZXWVBfbWs0dDZ4bl80N3lydlk5azd1T0VEMkdFcnVoT0ZHaEkyek45N24ybGh4eGdXanhVVW9XT094LVVJY01pakw0R0VGcVY2ZHcxRXN4RWd2Z1V6SU51a2VKT1MzcHNOaThhbW1RX01TSlVsUWJtc0czQU1wSS1DS185anpyTFlnTGNXQnVKVWtyQlg1dGs4T3NnYXFQcTZXT013eGNaekp1eFBoWFZ2T2FKb2FQZjNvNGcxU25aLTNoLWxQcUZQTFNHVkRpbmRBVzZvOGJDMVhvTGZmR0F1ckhxQTlTdDNEMlhrVUlGVjVuV29zdjJkcDM1MnFOZUxPbnNWYUZvemN5QjRRV2htODBTWV9QYmtxTTl5bUNtN2Y2VjRDRnFaYWp6TWpFU2FpRzBmSkNDZGdteWF3Z1lTdnFCWGtwYUhqN0dHeXdQSFNGbkpldnRIWWtGVVVVdGNNT196YnFvSjk0aDlrUEdrdUtYY0pnTGFuRGFjTkRMc3VYaXdfajBfTld2MkR0Z2VFQUk0Q3pKNHFKVk5DYi1ENnc4SC1DRDZPX1RObm9sejJCZXh1WWxkUTdLdDZQYVZzOXZZNEVCS3Vydmdvdm1VSGdpOGNzRDh2NUdMWWNVb2JpUVlRQWszTzZEaEJ0MWVBbDhJeXpOMU56MXoxWTcwaDN2MHlpUnlzV09oXzBIYVBfR0JOc1JXNmZsYjE4dldNRlRtQU1LVnRaTlg3MHY2MVF1MGphem9WY1hoS1N0WUY5WjF1ZDRveWdSODluUy1zTWJnVjk3X2hzWVV4cDhZVkhWLV92OV94Wi0wcEtCZExuajlra2xsVU5ZckhPSjBfekViZXBnYWJrWGY5MkFuMDBrRmtmOHRIWDNvelNURmtMNEM0cWcwY0xSdUF1enIycGJxR0ZvTnlWZXM2N3k5TDk4Z3VxRnl2NGpWQ2ROaUZpOV9NQ1ZscG8zRF90OW5nUXg1NnV5YV83ZGQ1dEdod01kVExhMEFEa2RLTl9YSWstYWU2eGNDdFJMalNzSUR2cGdZM1ZVckJ5TEZVUGg1MEZsN3NRbmFFUTgydGRZbmFSV2tFc0FSSHUyRWVlOFM5RXdvaGg3TFRhM05IVkpqMUZoS2RmWVk4Tzc0U1VXVHRSTVFKVHhBckItd0Utd1gwNlh2N1pFQWhKWVo3ZEZpdENWWTBjRjZ3RjJ2WnhtYmVibW9XaFgwTGlCaV9vSmlFOHcxZHE2NEZ0RlJjaHlLY0ZFU1VyTnprN1AwVktYWFotcEsyeXM3dS14QXNORVRnOFVYUE8wOVRjOF9uOEVoZ2pMQzB1T0ZoaWpyTEcyZHZlNERSTlVscDE1X2dBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFCUWlNamsifQ
|
||||||
@@ -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-----
|
||||||
@@ -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-----
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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 |
@@ -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-----
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Generated
+984
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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' } });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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[] };
|
||||||
|
}
|
||||||
@@ -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(/=+$/, '');
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 site’s 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();
|
||||||
@@ -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 };
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
Generated
+135
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
Generated
+65
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user