work in progress
This commit is contained in:
@@ -3,5 +3,6 @@ test/tls/
|
||||
node_modules/
|
||||
companion/dist/
|
||||
extension/dist/
|
||||
sdk/dist/
|
||||
server/target/
|
||||
.idea/
|
||||
|
||||
@@ -233,30 +233,65 @@ requires re-verifying interoperability end-to-end.
|
||||
|
||||
| Concern | Runtime | Library | Version |
|
||||
|---|---|---|---|
|
||||
| ML-KEM-768 + ML-DSA-44 | Browser / extension / web stub | `@oqs/liboqs-js` (npm) | 0.15.1 |
|
||||
| ML-KEM-768 + ML-DSA-44 | Browser extension (MV3 service worker) | `@noble/post-quantum` (npm) | 0.6.1 |
|
||||
| ML-KEM-768 + ML-DSA-44 | Web companion stub (normal page context) | `@oqs/liboqs-js` (npm) — unconfirmed, see below | 0.15.1 |
|
||||
| ML-KEM-768 + ML-DSA-44 | Node.js setup script | `@oqs/liboqs-js` (npm) | 0.15.1 |
|
||||
| ML-KEM-768 + ML-DSA-44 | Android / Kotlin | BouncyCastle (liboqs-java for production) | — |
|
||||
| ML-KEM-768 + ML-DSA-44 | Java/Spring (production) | liboqs-java | — |
|
||||
| X25519 | Browser extension (MV3 service worker) | `@noble/curves` (npm) | 2.3.0 |
|
||||
| X25519 | Node.js (companion test harness, setup script) | `@noble/curves` (npm) | 2.3.0 |
|
||||
| JCS (RFC 8785) | Node.js | `canonicalize` (npm) | 3.0.0 |
|
||||
| JCS (RFC 8785) | Browser | `canonicalize` (same package, ESM build) | 3.0.0 |
|
||||
| JCS (RFC 8785) | Android / Kotlin | `io.github.erdtman:java-json-canonicalization` (Maven Central) | — |
|
||||
| JCS (RFC 8785) | Java/Spring | `io.github.erdtman:java-json-canonicalization` (Maven Central) | — |
|
||||
|
||||
**X25519 had no pinned library at all until the run-flow slice.** Native
|
||||
WebCrypto X25519 support is inconsistent enough across Chrome versions to be
|
||||
a real risk, and untested here — rather than add a second unverified
|
||||
platform dependency alongside ML-KEM/ML-DSA, `@noble/curves` was chosen
|
||||
specifically because it's the same audited "noble" family as
|
||||
`@noble/post-quantum`, already proven working in this exact service worker
|
||||
context (pure JS, static imports, no WASM). Used on the Node side
|
||||
(companion test harness, and available to the setup script) too, so the
|
||||
X25519 implementation isn't duplicated across two different libraries for
|
||||
one shared protocol.
|
||||
|
||||
**Resolved 2026-08-16 — `@oqs/liboqs-js` does not work in the extension's
|
||||
service worker, empirically, not just theoretically.** This was flagged
|
||||
below as something to verify before writing any ML-KEM/ML-DSA code; it's now
|
||||
verified. Built a minimal MV3 extension, loaded it in real Chrome via
|
||||
Puppeteer/CDP (not a simulated environment), and read the actual service
|
||||
worker console: `@oqs/liboqs-js` lazy-loads each algorithm's WASM via
|
||||
`await import(...)` computed inside its own module, and Chrome threw
|
||||
`TypeError: import() is disallowed on ServiceWorkerGlobalScope by the HTML
|
||||
specification` — a hard platform restriction (referenced in the error:
|
||||
[w3c/ServiceWorker#1356](https://github.com/w3c/ServiceWorker/issues/1356)),
|
||||
not a bug or a config problem to work around.
|
||||
|
||||
**The extension now uses `@noble/post-quantum@0.6.1`** (npm, pure
|
||||
TypeScript, audited by Trail of Bits, static imports only, no WASM) — this
|
||||
was already named below as the fallback for exactly this scenario. Verified
|
||||
working the same way: real ML-DSA-44 signature from `dev/certs/test-registration.b64`
|
||||
verified correctly inside the actual service worker, plus a tampered-payload
|
||||
negative control confirmed to fail. One API difference from `@oqs/liboqs-js`
|
||||
worth flagging for implementers: argument order is
|
||||
`verify(signature, message, publicKey)`, not `verify(message, signature, publicKey)`.
|
||||
|
||||
The web companion stub row above is **not yet tested** — it runs in a normal
|
||||
page context (`mobile/claude.md`: "single-page web application," no service
|
||||
worker), where dynamic `import()` is allowed, so `@oqs/liboqs-js` should
|
||||
still work there in principle. Whether to keep two different PQ libraries
|
||||
across the browser-side components, or standardize on `@noble/post-quantum`
|
||||
everywhere for consistency (simpler, no WASM asset handling anywhere), is an
|
||||
open call for whoever builds the companion — not yet decided.
|
||||
|
||||
**`@oqs/liboqs-js`:** Published by PQCA (Post-Quantum Cryptography Alliance),
|
||||
WASM bindings to the reference liboqs implementation, zero npm dependencies,
|
||||
MIT licence. Covers both ML-KEM-768 and ML-DSA-44 in a single package.
|
||||
Tracks the NIST reference implementation directly, reducing the risk of subtle
|
||||
algorithm deviations. Unpacked size is ~17 MB; the WASM binary will be large in
|
||||
the extension bundle. Before writing any ML-KEM or ML-DSA code, verify that the
|
||||
async `init()` call is compatible with the Manifest V3 service worker
|
||||
environment: no DOM dependency, compatible with `import()` or `importScripts`.
|
||||
|
||||
**Fallback:** If `@oqs/liboqs-js` proves incompatible with the service worker
|
||||
context, `@noble/post-quantum@0.6.1` (npm, pure TypeScript, audited by Trail
|
||||
of Bits) covers both algorithms and is unconditionally service-worker safe. It
|
||||
is not WASM; use it only if the WASM init path cannot be made to work. Any
|
||||
switch from `@oqs/liboqs-js` to `@noble/post-quantum` requires a full
|
||||
cross-component interoperability test before adoption.
|
||||
algorithm deviations. Unpacked size is ~17 MB. Still the right choice for the
|
||||
Node.js setup script (confirmed working — see `scripts/setup-dev.js`) and
|
||||
possibly the web companion stub (unconfirmed); ruled out for the extension.
|
||||
|
||||
**JCS consistency:** All four runtimes use libraries from the same author
|
||||
(Samuel Erdtman, editor of RFC 8785). This maximises the likelihood of
|
||||
|
||||
@@ -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
|
||||
`pin_ttl`. Awaiting user confirmation on the popup. On TTL expiry: transition
|
||||
to `run_error / pin_timeout`.
|
||||
- `phase_4` — user confirmed PIN; awaiting encrypted credential from relay.
|
||||
- `phase_4` — user confirmed PIN (`POST /run/relay/:runId/confirm` sent to
|
||||
unlock the companion, interfaces.md's "PIN confirmation" — fire-and-forget;
|
||||
this transition itself is the extension's own real local gate and doesn't
|
||||
wait on that call); awaiting encrypted credential from relay.
|
||||
- `delivered` — credential decrypted and handed to the JS SDK; transitions
|
||||
automatically to `idle` after brief display.
|
||||
- `run_error` — run failed or aborted at any phase; reason and class
|
||||
held for display. Three classes:
|
||||
|
||||
*Security-class* (extension notifies backend; user sees explicit security
|
||||
warning; no retry offered):
|
||||
- `alpha_hash_mismatch` — key bundle received from backend does not match the
|
||||
commitment in the QR; indicates tampering between extension and backend.
|
||||
- `pin_mismatch` — run keys diverged; should not occur if all prior
|
||||
verifications passed.
|
||||
held for display. Two classes currently apply to the extension:
|
||||
|
||||
*Timeout / user class* (expected; user offered retry):
|
||||
- `ttl_exhausted` — QR auto-refreshes used up; user must manually retry.
|
||||
@@ -130,6 +126,21 @@ machine. The popup reflects the state of the currently active tab.
|
||||
- `relay_timeout` — no message received from relay within expected window.
|
||||
- `relay_error` — relay returned an error or malformed response.
|
||||
|
||||
A *Security-class* (extension notifies backend; user sees an explicit
|
||||
security warning; no retry offered) also exists in the shared
|
||||
`RunErrorReason` vocabulary — `gui.md`'s "Security warning — mid-run"
|
||||
template is built for it — but the extension has no member of it today.
|
||||
`alpha_hash_mismatch` and `pin_mismatch` were listed here in an earlier
|
||||
draft; both turned out to be companion-only detections copied across by
|
||||
sharing one `RunErrorReason` enum with `mobile/claude.md` (see that
|
||||
file's near-identical wording) — the extension never receives a second,
|
||||
independent value to compare against its own alpha_hash or PIN, so
|
||||
neither is actually detectable on this side. A human comparing the two
|
||||
devices' screens and declining to confirm is exactly what `user_abort`
|
||||
already covers. `bundle_consumed` (see the design note below) is the one
|
||||
concrete candidate for a real extension-side security-class reason, if
|
||||
the server is ever extended to push it over the relay.
|
||||
|
||||
### Transitions
|
||||
|
||||
```
|
||||
@@ -163,8 +174,9 @@ phase_2
|
||||
phase_3
|
||||
→ phase_4 (user confirms PIN)
|
||||
→ run_error/pin_timeout (pin_ttl expired)
|
||||
→ run_error/pin_mismatch (security: run keys diverged)
|
||||
→ run_error/user_abort (user rejects or cancels)
|
||||
→ run_error/user_abort (user rejects or cancels — the mechanism
|
||||
for a PIN the user sees not matching;
|
||||
see the run_error note above)
|
||||
|
||||
phase_4
|
||||
→ delivered (credential received; GCM auth passes)
|
||||
@@ -341,6 +353,8 @@ The extension communicates with the backend over HTTPS. Endpoints used:
|
||||
- Open and maintain a WebSocket connection to `/run/relay/:runId` for
|
||||
incoming relay messages (kem_ciphertext in Phase 2, encrypted credential in
|
||||
Phase 4).
|
||||
- Post PIN confirmation to `/run/relay/:runId/confirm` (phase_3 → phase_4,
|
||||
unlocks the companion — interfaces.md's "PIN confirmation").
|
||||
- Query account status for a domain (site verification, step 3).
|
||||
- Report security-class errors to `/security/report`.
|
||||
|
||||
|
||||
+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
|
||||
likely to reject reversed polarity.
|
||||
|
||||
- Display size 190×190px in the card body.
|
||||
- Display size 260×260px — fills the card body's content width edge to
|
||||
edge (300px popup − 20px padding × 2), so the QR's outer edge lines up
|
||||
with the countdown bar below it. No margin beyond that shared 20px card
|
||||
padding; a smaller fixed size (190px, tried initially) left extra
|
||||
whitespace around the code and made it read as unnecessarily dense for
|
||||
its size.
|
||||
- Caption: "Scan with the companion app."
|
||||
- Countdown bound to `qr_ttl`.
|
||||
|
||||
@@ -132,11 +137,18 @@ which are ambient page state rather than part of an active run — nothing to
|
||||
retry or dismiss; it persists until the cert or page changes.
|
||||
|
||||
**Security warning — mid-run** — same red badge and "Security warning"
|
||||
heading, but with a single **Dismiss** button. Used for `run_error`
|
||||
security-class reasons (`alpha_hash_mismatch`, `pin_mismatch`), which
|
||||
interrupt something the user started. Dismiss uses the outlined/ghost button
|
||||
style, never the filled accent button — nothing about a blocked state should
|
||||
visually read as "the expected path," the way Confirm does in phase 3.
|
||||
heading, but with a single **Dismiss** button. Built for a `run_error`
|
||||
security-class reason that interrupts something the user started, the same
|
||||
way `cert_invalid`'s security variant interrupts before one starts. No
|
||||
concrete extension-side reason uses this template today — `claude.md`'s
|
||||
`run_error` note explains why `alpha_hash_mismatch`/`pin_mismatch` (this
|
||||
template's original motivating examples) turned out to be companion-only
|
||||
detections; `bundle_consumed` is the one real candidate, if the server is
|
||||
ever extended to signal it. The template stays ready either way: the popup
|
||||
code renders any `errorClass: 'security'` reason through it generically.
|
||||
Dismiss uses the outlined/ghost button style, never the filled accent
|
||||
button — nothing about a blocked state should visually read as "the
|
||||
expected path," the way Confirm does in phase 3.
|
||||
|
||||
**Placeholder copy — needs a plain-language pass.** The reason strings used
|
||||
while designing this ("relay timed out," "run has been stopped and
|
||||
|
||||
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
|
||||
|
||||
**Plaintext** (UTF-8 encoded):
|
||||
|
||||
+29
-5
@@ -90,10 +90,21 @@ deferred UX decision.
|
||||
bundle fetch, alpha_hash recomputation.
|
||||
- `key_exchange` — verification passed; performing X25519 key agreement and
|
||||
ML-KEM-768 encapsulation; sending `kem_ciphertext` to backend relay.
|
||||
- `pin_display` — run key derived; PIN displayed to user. User reads
|
||||
the PIN, confirms the match on the extension, then taps Continue on the
|
||||
companion to advance. The companion does not receive a signal from the
|
||||
extension; the two devices proceed independently from this point.
|
||||
- `pin_display` — run key derived; PIN displayed to user. User reads the
|
||||
PIN and taps Continue on the companion once they've compared it.
|
||||
- `awaiting_confirmation` — user has tapped Continue; polling
|
||||
`GET /run/relay/:runId/confirm` (interfaces.md, "PIN confirmation") for
|
||||
the extension's own confirmation, which only exists once its user has
|
||||
separately clicked Confirm there. Advancing requires both: this device's
|
||||
local tap and the other device's remote signal, neither one alone.
|
||||
Superseded design note: an earlier version of this document had the
|
||||
companion proceed the instant its own user tapped Continue, with "the
|
||||
companion does not receive a signal from the extension." That turned out
|
||||
to be weaker than necessary for no real benefit — Bluetooth-style
|
||||
Numeric Comparison pairing has always communicated the confirmation
|
||||
between devices as part of completing the handshake, and there was no
|
||||
good reason for this protocol to do less. See interfaces.md's "PIN
|
||||
confirmation" section for the wire-level detail.
|
||||
- `credential_select` — credentials for the current site presented; vault
|
||||
already unlocked from biometric at session initiation. If exactly one
|
||||
credential is stored for the site it may be pre-selected. If multiple are
|
||||
@@ -132,7 +143,13 @@ key_exchange
|
||||
→ error/relay_error (network: cannot send kem_ciphertext)
|
||||
|
||||
pin_display
|
||||
→ credential_select (user taps Continue)
|
||||
→ awaiting_confirmation (user taps Continue)
|
||||
→ idle (user cancels; run key discarded)
|
||||
|
||||
awaiting_confirmation
|
||||
→ credential_select (extension's confirmation received)
|
||||
→ error/confirmation_timeout (bounded wait elapses with no confirmation —
|
||||
network class, retry offered)
|
||||
→ idle (user cancels; run key discarded)
|
||||
|
||||
credential_select
|
||||
@@ -170,6 +187,11 @@ warning; no retry offered):
|
||||
*Network class* (transient; retry offered):
|
||||
- `backend_unreachable` — key bundle fetch failed.
|
||||
- `relay_error` — could not send `kem_ciphertext` or credential ciphertext.
|
||||
- `confirmation_timeout` — polled for the extension's PIN confirmation
|
||||
past a bounded wait with no response. Not security-class: the absence of
|
||||
a signal isn't evidence of tampering, just that the other side hasn't
|
||||
(yet, or ever) clicked Confirm — could be an inattentive user, a closed
|
||||
tab, or a genuine network issue.
|
||||
|
||||
---
|
||||
|
||||
@@ -221,6 +243,8 @@ All algorithm parameters and normative ordering rules are in `../crypto.md`.
|
||||
|
||||
- Fetch key bundle by run ID (at-most-once; bundle destroyed on fetch).
|
||||
- Send `kem_ciphertext` to relay addressed to run ID (Phase 2).
|
||||
- Poll for the extension's PIN confirmation, addressed to run ID
|
||||
(`awaiting_confirmation`, above).
|
||||
- Send encrypted credential ciphertext to relay addressed to run ID (Phase 4).
|
||||
- Report security-class errors to `/security/report`.
|
||||
|
||||
|
||||
@@ -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
|
||||
and the run must be restarted.
|
||||
|
||||
**`POST /run/relay/:runId/confirm` (extension only)** and
|
||||
**`GET /run/relay/:runId/confirm` (polled — companion only)**
|
||||
PIN confirmation handshake (`../interfaces.md`, "PIN confirmation"). The
|
||||
extension POSTs when its user clicks Confirm; the companion polls the GET
|
||||
until it sees `{ confirmed: true }` before proceeding to select/send a
|
||||
credential. Current scope: a plain in-memory marker in
|
||||
`RelaySessionRegistry` (same component as the relay message buffer above,
|
||||
not a separate store), pruned on its own longer TTL — see that class's
|
||||
Javadoc. Both requests are unauthenticated, same as the rest of this
|
||||
surface at current scope; `runId` itself is the only addressing key, same
|
||||
trust model as the relay endpoints above.
|
||||
|
||||
### Admin / website endpoints
|
||||
|
||||
Domain registration and account management. Deferred beyond the one endpoint
|
||||
@@ -160,8 +172,10 @@ completing Phase 1. The server maintains a mapping of run ID → active connecti
|
||||
**Message flow:**
|
||||
1. Extension opens WS for run ID (after Phase 1).
|
||||
2. Companion POSTs kem_ciphertext → server pushes to extension WS (Phase 2).
|
||||
3. Companion POSTs encrypted credential → server pushes to extension WS (Phase 4).
|
||||
4. WS closes on run end (delivered, error, or TTL expiry).
|
||||
3. Extension user clicks Confirm → extension POSTs `/run/relay/:runId/confirm`;
|
||||
companion polls the same path (GET) and unblocks once it sees confirmed.
|
||||
4. Companion POSTs encrypted credential → server pushes to extension WS (Phase 4).
|
||||
5. WS closes on run end (delivered, error, or TTL expiry).
|
||||
|
||||
The extension is responsible for keeping its WS connection alive across service
|
||||
worker suspensions using `chrome.alarms` or equivalent. The server-side buffer
|
||||
|
||||
@@ -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 java.util.List;
|
||||
|
||||
/**
|
||||
* Binds the {@code locqr.*} tree in application.yml. Current (PoC) scope
|
||||
* config only — see server/claude.md for the current-vs-production split.
|
||||
@@ -11,6 +13,7 @@ public record LocqrProperties(
|
||||
String testDomain,
|
||||
String ed25519SecretKeyPath,
|
||||
String registrationCertPath,
|
||||
List<String> allowedOrigins,
|
||||
Run run,
|
||||
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);
|
||||
* if the extension does not reconnect within that window the message is
|
||||
* 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
|
||||
public class RelaySessionRegistry {
|
||||
@@ -34,8 +41,17 @@ public class RelaySessionRegistry {
|
||||
|
||||
private record Buffered(String json, Instant receivedAt) {}
|
||||
|
||||
// Not a functional deadline — the extension's own pin_ttl alarm is what
|
||||
// actually bounds how long a user has to confirm. This is just memory
|
||||
// hygiene for the server, so it's set generously longer than any
|
||||
// realistic pin_ttl rather than reusing the much shorter relay message
|
||||
// buffer TTL (8s — nowhere near enough time for a human to compare a
|
||||
// PIN and click Confirm).
|
||||
private static final Duration CONFIRMATION_TTL = Duration.ofMinutes(10);
|
||||
|
||||
private final Map<String, WebSocketSession> sessions = 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 ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "relay-buffer-cleanup");
|
||||
@@ -45,7 +61,7 @@ public class RelaySessionRegistry {
|
||||
|
||||
public RelaySessionRegistry(LocqrProperties properties) {
|
||||
this.bufferTtl = Duration.ofSeconds(properties.relay().bufferTtlSeconds());
|
||||
this.cleanup.scheduleAtFixedRate(this::pruneExpiredBuffers, 1, 1, TimeUnit.SECONDS);
|
||||
this.cleanup.scheduleAtFixedRate(this::pruneExpired, 1, 1, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public void register(String runId, WebSocketSession session) {
|
||||
@@ -57,6 +73,16 @@ public class RelaySessionRegistry {
|
||||
sessions.remove(runId, session);
|
||||
}
|
||||
|
||||
/** Extension side: user clicked Confirm. */
|
||||
public void confirmPin(String runId) {
|
||||
confirmedAt.put(runId, Instant.now());
|
||||
}
|
||||
|
||||
/** Companion side: has the extension confirmed yet? */
|
||||
public boolean isPinConfirmed(String runId) {
|
||||
return confirmedAt.containsKey(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards a relay message to the extension's socket if open; otherwise
|
||||
* buffers it for {@link #bufferTtl}.
|
||||
@@ -86,14 +112,16 @@ public class RelaySessionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private void pruneExpiredBuffers() {
|
||||
Instant cutoff = Instant.now().minus(bufferTtl);
|
||||
private void pruneExpired() {
|
||||
Instant bufferCutoff = Instant.now().minus(bufferTtl);
|
||||
buffers.forEach((runId, queue) -> {
|
||||
queue.removeIf(m -> m.receivedAt().isBefore(cutoff));
|
||||
queue.removeIf(m -> m.receivedAt().isBefore(bufferCutoff));
|
||||
if (queue.isEmpty()) {
|
||||
buffers.remove(runId, queue);
|
||||
}
|
||||
});
|
||||
Instant confirmationCutoff = Instant.now().minus(CONFIRMATION_TTL);
|
||||
confirmedAt.entrySet().removeIf(e -> e.getValue().isBefore(confirmationCutoff));
|
||||
}
|
||||
|
||||
private void send(WebSocketSession session, String json) {
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
server:
|
||||
port: ${API_PORT:3000}
|
||||
ssl:
|
||||
bundle: api-locqr-dev
|
||||
|
||||
spring:
|
||||
ssl:
|
||||
bundle:
|
||||
pem:
|
||||
api-locqr-dev:
|
||||
keystore:
|
||||
certificate: ${API_TLS_CERT_PATH:file:../dev/certs/api.locqr.dev.pem}
|
||||
private-key: ${API_TLS_KEY_PATH:file:../dev/certs/api.locqr.dev-key.pem}
|
||||
|
||||
locqr:
|
||||
test-domain: ${TEST_DOMAIN:test.locqr.dev}
|
||||
ed25519-secret-key-path: ${ED25519_SECRET_KEY_PATH:../dev/keys/ed25519-secret.b64}
|
||||
registration-cert-path: ${REGISTRATION_CERT_PATH:../dev/certs/test-registration.b64}
|
||||
# Webpage-context clients only (test website, later the companion web
|
||||
# stub) — the extension's service worker bypasses CORS via manifest
|
||||
# host_permissions instead. Ports match dev.md's documented defaults.
|
||||
allowed-origins: ${ALLOWED_ORIGINS:https://test.locqr.dev:5173,https://test.locqr.dev:5174}
|
||||
run:
|
||||
qr-ttl-seconds: 90
|
||||
max-auto-refresh: 3
|
||||
|
||||
@@ -55,6 +55,11 @@ class RunFlowIntegrationTest {
|
||||
|
||||
@DynamicPropertySource
|
||||
static void dynamicKeyAndCert(DynamicPropertyRegistry registry) throws Exception {
|
||||
// TLS termination (server.ssl.*, application.yml) is verified separately against
|
||||
// the real mkcert-issued cert and trust chain — see dev.md / server/claude.md.
|
||||
// These tests are about the API contract, so they talk plain HTTP to a random port.
|
||||
registry.add("server.ssl.enabled", () -> false);
|
||||
|
||||
Path tempDir = Files.createTempDirectory("locqr-server-it");
|
||||
|
||||
TestEd25519Keys.Generated keys = TestEd25519Keys.generate();
|
||||
@@ -228,6 +233,45 @@ class RunFlowIntegrationTest {
|
||||
.expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
// --- PIN confirmation handshake (interfaces.md) ---
|
||||
|
||||
@Test
|
||||
void pinConfirmation_isFalseUntilTheExtensionConfirms_thenTrue() {
|
||||
String runId = "confirm-it-" + UUID.randomUUID();
|
||||
|
||||
client.get().uri("/run/relay/{runId}/confirm", runId)
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(PinConfirmationResponse.class)
|
||||
.isEqualTo(new PinConfirmationResponse(false));
|
||||
|
||||
client.post().uri("/run/relay/{runId}/confirm", runId)
|
||||
.exchange()
|
||||
.expectStatus().isNoContent();
|
||||
|
||||
client.get().uri("/run/relay/{runId}/confirm", runId)
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(PinConfirmationResponse.class)
|
||||
.isEqualTo(new PinConfirmationResponse(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pinConfirmation_doesNotConfirmADifferentRunId() {
|
||||
String confirmedRunId = "confirm-it-" + UUID.randomUUID();
|
||||
String otherRunId = "confirm-it-" + UUID.randomUUID();
|
||||
|
||||
client.post().uri("/run/relay/{runId}/confirm", confirmedRunId)
|
||||
.exchange()
|
||||
.expectStatus().isNoContent();
|
||||
|
||||
client.get().uri("/run/relay/{runId}/confirm", otherRunId)
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(PinConfirmationResponse.class)
|
||||
.isEqualTo(new PinConfirmationResponse(false));
|
||||
}
|
||||
|
||||
private static String randomBase64(int numBytes) {
|
||||
byte[] bytes = new byte[numBytes];
|
||||
new java.security.SecureRandom().nextBytes(bytes);
|
||||
|
||||
@@ -90,6 +90,7 @@ class SigningServiceTest {
|
||||
"test.locqr.dev",
|
||||
keyFile.toString(),
|
||||
"/nonexistent",
|
||||
java.util.List.of(),
|
||||
new LocqrProperties.Run(90, 3, 120),
|
||||
new LocqrProperties.Relay(8));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.springframework.web.socket.WebSocketSession;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -100,9 +102,34 @@ class RelaySessionRegistryTest {
|
||||
verify(session, never()).sendMessage(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPinConfirmed_isFalse_forARunThatHasNotBeenConfirmed() {
|
||||
RelaySessionRegistry registry = registry(30);
|
||||
|
||||
assertFalse(registry.isPinConfirmed("run-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPinConfirmed_isTrue_afterConfirmPin() {
|
||||
RelaySessionRegistry registry = registry(30);
|
||||
|
||||
registry.confirmPin("run-1");
|
||||
|
||||
assertTrue(registry.isPinConfirmed("run-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmPin_doesNotConfirmADifferentRunId() {
|
||||
RelaySessionRegistry registry = registry(30);
|
||||
|
||||
registry.confirmPin("run-1");
|
||||
|
||||
assertFalse(registry.isPinConfirmed("run-other"));
|
||||
}
|
||||
|
||||
private RelaySessionRegistry registry(int bufferTtlSeconds) {
|
||||
RelaySessionRegistry registry = new RelaySessionRegistry(new LocqrProperties(
|
||||
"test.locqr.dev", "unused", "unused",
|
||||
"test.locqr.dev", "unused", "unused", java.util.List.of(),
|
||||
new LocqrProperties.Run(90, 3, 120),
|
||||
new LocqrProperties.Relay(bufferTtlSeconds)));
|
||||
created.add(registry);
|
||||
|
||||
@@ -46,6 +46,7 @@ class RegistrationCertificateStoreTest {
|
||||
TEST_DOMAIN,
|
||||
"unused",
|
||||
certPath,
|
||||
java.util.List.of(),
|
||||
new LocqrProperties.Run(90, 3, 120),
|
||||
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