Compare commits

...
8 Commits
Author SHA1 Message Date
Laan Tungir 5d6dbc9ecd G56-11/12/13/14/15/18: relay acceptance required for both events, resume validates signatures + persists complete events, OTS parser safe varuint + operation/branch budgets, commit lockfile + fix .gitmodules, G56-18 won't-fix (public events) 2026-07-24 14:43:29 -04:00
Laan Tungir 66babc5a7a G56-08: apply nginx security headers on server (HSTS, nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP, CORP) scoped to /post-quantum/ only — all High-severity findings now addressed 2026-07-24 13:14:32 -04:00
Laan Tungir 87a8893785 G56-08: browser hardening — extract inline JS to external files (index-app.mjs, verify-app.mjs, version-display.js), remove unsafe-inline from script-src CSP, add frame-ancestors none + referrer-policy, SRI hashes on signer scripts, nginx security headers config 2026-07-24 11:09:50 -04:00
Laan Tungir 5bc36c37e7 G56-06: document honest continuity position (asserted not proven — no in-browser crypto can prove seed-to-key binding when browser is the adversary); reject Mode 1 (no signers accept seeds) and Mode 2 (fakeable by same browser); add out-of-band verification recommendation to UI 2026-07-24 10:30:29 -04:00
Laan Tungir f29f63950b G56-04: multi-explorer cross-check (fail on disagreement) + trustMode field in verifyOtsProof result (multi-explorer-checked/single-explorer-checked/structural-only) + UI trust-mode labels + remove misleading lite-client comments 2026-07-24 10:15:01 -04:00
Laan Tungir a1e1759704 G56-05: validateSignerOutput() helper — checks all fields, verifies no template field changed, pubkey matches identity, recomputes ID, verifies Schnorr sig, rejects extra fields; replaced reconstruction at all 4 signEvent() call sites; 15 new tests 2026-07-24 09:42:06 -04:00
Laan Tungir 113885eb96 G56-07: fail-closed verifiers (verifyNostrEvent, parseOtsFile never throw on malformed input), add MAX_OTS_PROOF_SIZE/MAX_EVENT_JSON_SIZE size guards, 23 fuzz/property tests (random JSON, random bytes, truncation, oversized inputs) 2026-07-24 09:25:52 -04:00
Laan Tungir cfa0ecc220 G56-16: reconcile stale claims — README OTS trust model (API-assisted vs light-client), remove stale not-implemented items, soften nip_proposal signer/OTS/pending-proof wording, add research-prototype banners to verify.html + all docs and research files 2026-07-24 09:09:51 -04:00
25 changed files with 3617 additions and 2024 deletions
-1
View File
@@ -7,7 +7,6 @@ build/
# Node.js
node_modules/
package-lock.json
# Source maps
*.bundle.js.map
+3
View File
@@ -0,0 +1,3 @@
[submodule "resources/javascript-opentimestamps"]
path = resources/javascript-opentimestamps
url = https://github.com/opentimestamps/javascript-opentimestamps.git
+52 -6
View File
@@ -1,5 +1,12 @@
# Post-Quantum Nostr
> **⚠️ Research prototype.** This project is an experimental pre-quantum key-commitment and
> identity-link system. It does **not**, by itself, make a Nostr identity post-quantum secure:
> it links PQ keys to an existing identity and anchors that link in Bitcoin. Complete migration
> requires companion protocols (PQ event authentication, rotation, encryption, client adoption)
> that are not yet specified or implemented. Do not enter a valuable existing mnemonic into the
> web app. See the [implementation status](#implementation-status) section for what exists today.
A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.
## Table of Contents
@@ -104,6 +111,24 @@ The strategy uses existing Nostr infrastructure (NIP-06, NIP-03) plus one new ev
The BIP39 seed is a 64-byte entropy string derived from a mnemonic via PBKDF2-HMAC-SHA512 (2048 iterations). This is a symmetric KDF — no public-key cryptography involved. A quantum computer provides no advantage beyond Grover's quadratic speedup.
> **⚠️ Continuity is asserted, not proven (G56-06).** The old Nostr identity *authorizes* the PQ
> keys by signing an event that contains them. This is an authorization, not a cryptographic proof
> that the PQ keys were derived from the seed phrase shown to the user. A compromised browser could
> display one mnemonic while publishing attacker-controlled PQ keys — and any in-browser signature
> (secp or PQ) could be faked by the same compromised browser. No in-browser cryptographic trick
> can prove the seed and the published keys are bound, because the browser is the potential adversary.
>
> **The only real proof is out-of-band verification:** after the migration, the user should take
> their seed phrase to a *different* device or browser, derive the keys independently, and confirm
> the public keys match the published event. This is the one step a compromised browser cannot fake.
>
> **Why not verify the mnemonic against the current identity?** Deriving the NIP-06 secp key from
> an entered mnemonic and requiring it to match the signed-in pubkey would provide a strong
> continuity proof. However, no current Nostr signers (NIP-07 extensions) accept seed phrases, so
> this would require the user to type their valuable existing mnemonic into the web page — an
> unacceptable security risk. This mode is deferred until hardware PQ signers or seed-accepting
> signers exist.
**The seed is algorithm-agnostic.** It's just entropy. You can derive any key type from it:
```mermaid
@@ -374,7 +399,28 @@ The kind 9999 proof carrier *carries* the OTS proof; the kind 1 event *is* what'
### Current OTS Verification Status
The current implementation detects Bitcoin confirmation by searching the `.ots` proof bytes for the Bitcoin block-header attestation magic bytes, and delegates proof upgrading to a server-side helper. **Full client-side OTS verification** (parsing the proof, validating the Merkle path, checking the block header against the Bitcoin chain) is planned but not yet implemented. The vendored `javascript-opentimestamps` library in `resources/` provides the necessary primitives and will be integrated in a future release.
> **Research prototype — trust model.** The current OTS verification is **API-assisted**, not a
> full Bitcoin light-client verification. Treat results accordingly.
The current implementation:
- Parses the `.ots` proof and validates the Merkle path against a block header obtained from a
public Bitcoin explorer API (mempool.space).
- Detects Bitcoin confirmation by searching the proof bytes for the block-header attestation magic
bytes and confirming the attested block height via the same API.
- Delegates proof *upgrading* (asking calendars for a confirmed proof) to a server-side helper.
**What is NOT done:**
- **Full light-client verification** — validating the block header's proof-of-work, difficulty, and
chain linkage independently, without trusting an explorer API. The vendored
`javascript-opentimestamps` library in `resources/` provides primitives for this and will be
integrated in a future release.
- **Multi-explorer cross-checking** — comparing independent APIs and rejecting disagreement.
Because the block header is trusted from a single API, a compromised or malicious explorer could
falsify a confirmation. The UI labels this as "API-checked," not "cryptographically verified on
Bitcoin."
### What Should Be OpenTimestamped
@@ -762,12 +808,12 @@ The current implementation is a static web app (`www/`) that performs the full m
| Component | Description |
|---|---|
| Full client-side OTS verification | Parse the .ots proof, validate the Merkle path, check the block header against the Bitcoin chain (the vendored `javascript-opentimestamps` library in `resources/` provides the primitives) |
| OTS target-digest binding | Verify the OTS proof's target digest equals the event's `sha256` tag |
| Full light-client Bitcoin verification | Validate block headers, proof-of-work, difficulty, and chain linkage independently of an explorer API (the vendored `javascript-opentimestamps` library in `resources/` provides primitives). Current path trusts a single explorer API for the header. |
| Multi-explorer cross-checking | Compare independent Bitcoin APIs and reject disagreement |
| Raw-nsec migration (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding |
| Quantum-safe self-storage (Component 5) | OTP or symmetric-key encryption for kind 30078 data |
| Test suite | Known-answer tests for derivation, PQ sign/verify round-trips, event verification |
| 24-word mnemonic default | Currently defaults to 12 words; 24 recommended for production |
| PQ event authentication / rotation / revocation | Companion protocols for signing future events with PQ keys, rotating/revoking keys, PQ encryption |
| Independent implementation / test vectors | A second implementation reproducing canonical encoding, derivation, and selection |
### Dependencies
@@ -778,7 +824,7 @@ The current implementation is a static web app (`www/`) that performs the full m
- [`@scure/bip32`](https://github.com/paulmillr/scure-bip32) — BIP32 HD wallet derivation
- [`@scure/base`](https://github.com/paulmillr/scure-base) — bech32 (npub) encoding
- [OpenTimestamps](https://opentimestamps.org/) — calendar servers for timestamping
- [`javascript-opentimestamps`](resources/javascript-opentimestamps/) — vendored OTS library (present, not yet integrated for client-side verification)
- [`javascript-opentimestamps`](resources/javascript-opentimestamps/) — vendored OTS library (used as a fixture source; full light-client integration pending)
- [nostr-login-lite](https://github.com/nostrband/nostr-login-lite) — NIP-07 / NIP-46 authentication
---
+82 -16
View File
@@ -9,10 +9,10 @@
| Severity | Total | Fixed | In Progress | Not Started |
|---|---:|---:|---:|---:|
| Critical | 2 | 2 | 0 | 0 |
| High | 6 | 1 | 0 | 5 |
| Medium | 8 | 1 | 0 | 7 |
| Low / Info | 4 | 0 | 0 | 4 |
| **Total** | **20** | **4** | **0** | **16** |
| High | 6 | 6 | 0 | 0 |
| Medium | 8 | 6 | 0 | 1 |
| Low / Info | 4 | 0 | 0 | 3 |
| **Total** | **20** | **14** | **0** | **4** |
## Findings status
@@ -21,21 +21,21 @@
| G56-01 | Critical | Verifier accepts no PQ signatures and missing signatures as successful | ✅ Fixed | v0.0.12 | Strict mandatory algorithm policy, fail-closed on malformed input, structured result fields, 11 adversarial tests |
| G56-02 | Critical | Earliest-valid-anchor rule not implemented; relay discovery selects latest replaceable event | ✅ Fixed | v0.0.14v0.0.16 | Switched to non-replaceable kind 9999, append-only upgrades with `upgrade_of` tag, `selectCanonicalProofCarrier()` function, removed `limit: 1` from relay queries, updated all docs |
| G56-03 | High | Outer wrapper identity not bound to embedded identity | ✅ Fixed | v0.0.14 | Added `outerPubkey` and `expectedAuthor` options to `verifyNIPQRContent()`, `identityBound` field in result, `validForMigration` requires identity binding, 4 identity binding tests |
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ❌ Not started | — | Needs trust-mode labeling or real header validation |
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ❌ Not started | — | Needs signer-validation helper |
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ❌ Not started | — | Protocol design decision needed |
| G56-07 | High | Verifier throws on malformed relay/paste input | ⚠️ Partially fixed | v0.0.12 | G56-01 fix wrapped base64/hex decoding in try/catch and made verifyNIPQRContent fail closed. Broader call-site wrapping in verify.html also done. Remaining: fuzz testing, all export functions |
| G56-08 | High | Browser origin security insufficient for seed handling | ❌ Not started | — | Needs CSP hardening, header fixes, asset pinning |
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ✅ Fixed | v0.0.21 | Tier 1+2: rewrote `fetchBitcoinBlockHeader()` to query ALL providers and cross-check merkle roots (fail on disagreement); added `trustMode` field to `verifyOtsProof()` result (`multi-explorer-checked` / `single-explorer-checked` / `structural-only`); updated verify.html + index.html UI to display trust mode label; removed misleading "lite-client verification" / "independently verifiable" comments. Full light-client PoW verification (Tier 3) remains as future work. 2 test assertions added (trustMode present on verified proof, structural-only on pending) |
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ✅ Fixed | v0.0.20 | Added `validateSignerOutput()` helper: checks all required fields present + typed, verifies no template field (content/tags/created_at/kind) changed, verifies pubkey equals expected identity, recomputes + verifies event ID, verifies Schnorr signature, rejects extra fields. Replaced reconstruction at all 4 `signEvent()` call sites (2 in index.html, 2 in verify.html) with validation + retain-exact-signed-object. 15 new tests (mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed) |
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ✅ Fixed (design decision: assert-only) | v0.0.22 | Design decision: continuity is *asserted* by the old identity's signature, not *proven* by cryptography. No in-browser cryptographic trick can prove the seed and published PQ keys are bound, because a compromised browser controls both the seed display and all signatures. Mode 1 (mnemonic-match) rejected: no current Nostr signers accept seed phrases, so it would require typing a valuable mnemonic into a web page — unacceptable risk. Mode 2 (successor secp signature) rejected: a malicious browser can fake the secp signature just as easily as PQ signatures — adds no real binding. Fix: documented the honest position in README.md + nip_proposal.md (continuity is asserted not proven; only out-of-band verification on a different device can prove the seed matches the published keys); added UI recommendation in index.html seed step to verify out-of-band after migration |
| G56-07 | High | Verifier throws on malformed relay/paste input | ✅ Fixed | v0.0.12, v0.0.19 | v0.0.12 wrapped base64/hex in try/catch + verifyNIPQRContent fail closed + verify.html call-site wrapping. v0.0.19 added structural validation + try/catch to `verifyNostrEvent()` (returns false on malformed input), wrapped `parseOtsFile()` body in try/catch (returns null), added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards, 23 fuzz/property tests (random JSON, random bytes, truncation, oversized inputs, wrong-typed objects) |
| G56-08 | High | Browser origin security insufficient for seed handling | ✅ Fixed | v0.0.23 | Code fixes: extracted all inline JS to external files (`index-app.mjs`, `verify-app.mjs`, `version-display.js`), removed `'unsafe-inline'` from `script-src` in CSP, added `frame-ancestors 'none'` + `<meta name="referrer" content="no-referrer">`, added SRI hashes (`integrity`/`crossorigin`) to nostr-login-lite scripts. Server headers applied to nginx `conf.d/default.conf` (scoped to `/post-quantum/` location only): HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Permissions-Policy`, COOP, CORP. Verified headers present on `/post-quantum/` and absent on other paths. Remaining (future): vendor signer scripts into repo for full source auditability |
| G56-09 | Medium | 12-word seed default, warning removed | ✅ Fixed | v0.0.17 | Defaulted `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` to 24 words (256-bit); added 12/24-word selector UI; restored entropy-strength warning; removed "quantum-safe root of trust" / "Make your Nostr identity quantum-resistant" claims from index.html, README.md, nip_proposal.md; 4 new tests |
| G56-10 | Medium | Secret material not zeroized, retained in globals/DOM/clipboard | ❌ Not started | — | Minimize secret lifetime, overwrite typed arrays |
| G56-11 | Medium | Publication advances despite zero relay acceptance | ❌ Not started | — | Require relay OK for both events |
| G56-12 | Medium | Resume workflow insufficiently bound to original event | ⚠️ Partially fixed | v0.0.14 | Resume now fetches all candidates and matches by event ID. Remaining: persist complete immutable artifacts, validate all bindings before signing |
| G56-13 | Medium | OTS parser lacks total input/operation budgets, unsafe 32-bit varuint | ❌ Not started | — | Add size/operation/branch limits, safe varuint parsing |
| G56-14 | Medium | Dependency resolution not reproducible; lockfile ignored and inconsistent | ❌ Not started | — | Commit lockfile, use npm ci, add CI |
| G56-15 | Medium | OpenTimestamps submodule not clonable; historical dependency risk | ❌ Not started | — | Fix .gitmodules or vendor immutable snapshot |
| G56-16 | Medium | Documentation and UI overclaim implementation status | ⚠️ Partially fixed | v0.0.14v0.0.16 | Updated nip_proposal.md, README.md, explanation.md, why_what_how.md, laans_explanation.md for kind 9999. Remaining: broader status reconciliation |
| G56-11 | Medium | Publication advances despite zero relay acceptance | ✅ Fixed | v0.0.25 | Require at least one relay OK for EACH event (kind 1 + kind 9999) before marking step done; show error + keep button enabled if zero acceptance; show partial-coverage warning if some relays failed |
| G56-12 | Medium | Resume workflow insufficiently bound to original event | ✅ Fixed | v0.0.14, v0.0.25 | v0.0.14: fetch all candidates + match by event ID. v0.0.25: persist complete proof carrier + kind 1 event JSON in `savePendingOts()`; restore complete events on resume (not just ID); validate fetched event signature via `verifyNostrEvent()` before adopting; don't blindly fall back to `allCandidates[0]` — require valid signature + correct pubkey |
| G56-13 | Medium | OTS parser lacks total input/operation budgets, unsafe 32-bit varuint | ✅ Fixed | v0.0.25 | Replaced 32-bit bitwise `readVaruint()` with safe arithmetic (`Math.pow(2, shift)` + max value/length checks); added `OTS_MAX_OPERATIONS` (10000) and `OTS_MAX_BRANCHES` (1000) budgets tracked via a budget object passed through `_parseTimestamp`/`_processTimestampEntry`; `MAX_OTS_PROOF_SIZE` (1 MiB) already added in G56-07 |
| G56-14 | Medium | Dependency resolution not reproducible; lockfile ignored and inconsistent | ✅ Fixed | v0.0.25 | Removed `package-lock.json` from `.gitignore`; generated and committed `package-lock.json`; use `npm ci` for reproducible installs |
| G56-15 | Medium | OpenTimestamps submodule not clonable; historical dependency risk | ✅ Fixed | v0.0.25 | Created `.gitmodules` with correct URL (`https://github.com/opentimestamps/javascript-opentimestamps.git`); `git clone --recursive` now works |
| G56-16 | Medium | Documentation and UI overclaim implementation status | ✅ Fixed | v0.0.14v0.0.18 | Kind 9999 docs (v0.0.14v0.0.16); v0.0.17 fixed index.html claims + 24-word default; v0.0.18 reconciled README OTS trust model (API-assisted vs light-client), removed stale "not implemented" items (target-digest binding, test suite, 24-word default), softened nip_proposal.md ("No private key ever touches a server" → NIP-07/remote-signer caveat; pending-proof "establishes submission time" → "evidence of submission"; fixed "republished" → "new proof carrier published"), added research-prototype banners to verify.html + README + nip_proposal + explanation.md + laans_explanation.md + why_what_how.md + all 5 research/ docs |
| G56-17 | Low | Block-height statement not validated | ❌ Not started | — | Validate against OTS height or remove from signed content |
| G56-18 | Low | Relay URL policy permits insecure ws:// | ❌ Not started | — | Require wss:// in production |
| G56-18 | Low | Relay URL policy permits insecure ws:// | ✅ Won't fix | — | Nostr events are public — an attacker reading them over ws:// is not a security concern. The events contain no secrets. wss:// is recommended but not required. |
| G56-19 | Low | Missing event IDs accepted by library verifier | ❌ Not started | — | Require exact NIP-01 shape with 64-hex ID |
| G56-20 | Info | Large events may be rejected by relays; interoperability untested | ❌ Not started | — | Test against target relay policies |
@@ -84,10 +84,76 @@
- Updated README.md entropy table and nip_proposal.md mermaid diagram
- Added 4 new tests (24-word default, 12-word option, invalid entropyBits rejection for both functions)
### v0.0.18 — G56-16 fix (status reconciliation)
- README.md: rewrote OTS verification status section to distinguish API-assisted verification (current, trusts explorer) from full light-client verification (not implemented); added research-prototype banner
- README.md: removed stale "not implemented" items (OTS target-digest binding, test suite, 24-word default); added accurate not-implemented items (light-client verification, multi-explorer cross-check, PQ event auth/rotation, independent implementation)
- nip_proposal.md: softened "No private key ever touches a server" → NIP-07/remote-signer caveat + API-assisted OTS trust model; pending-proof "establishes submission time" → "evidence of submission, not an independent timestamp"; fixed "republished" → "new proof carrier published with upgrade_of tag"
- verify.html: added research-prototype note; rewrote card description to list each verification check as a separate result line (PQ signatures individually, policy sufficiency, identity binding)
- Added research-prototype / design-only banners to explanation.md, laans_explanation.md, why_what_how.md, and all 5 research/ docs
- No new tests (documentation-only change)
### v0.0.19 — G56-07 fix (fuzz testing + fail-closed verifiers)
- `verifyNostrEvent()`: added structural validation (type-checks all required fields) + try/catch; returns `false` on any malformed input instead of throwing
- `parseOtsFile()`: wrapped parser body in try/catch; returns `null` on any malformed/truncated input
- Added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards; oversized inputs rejected before any parsing/decoding work
- `verifyNIPQRContent()`: added content-size guard at entry
- verify.html relay path already had try/catch (G56-01); now defense-in-depth with internal fail-closed
- 23 new fuzz/property tests: random JSON shapes to `verifyNIPQRContent` (1000 iterations), random bytes to `parseOtsFile` (2000 iterations), random objects to `verifyNostrEvent` (500 iterations), truncated valid-prefix bytes, oversized inputs, wrong-typed objects, `isOtsConfirmed` fuzz
### v0.0.20 — G56-05 fix (signer output validation)
- Added `validateSignerOutput(signedEvent, template, expectedPubkey)` helper in `pq-crypto.mjs`:
- Checks all 7 required NIP-01 fields present + correctly typed
- Verifies id (64 hex), pubkey (64 hex), sig (128 hex) format
- Verifies no template field changed (content, tags deep-compare, created_at, kind)
- Verifies returned pubkey equals expected identity
- Recomputes event ID and verifies match
- Verifies Schnorr signature
- Rejects extra unexpected fields
- Returns the validated signed object (not a reconstruction)
- Replaced reconstruction at all 4 `signEvent()` call sites:
- `index.html`: kind 1 announcement (was mixing template + signer fields), proof carrier (same), upgrade (now validated)
- `verify.html`: upgrade (now validated)
- 15 new tests: valid output, mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed, no-expectedPubkey backward compat
### v0.0.21 — G56-04 fix (Bitcoin trust-mode labeling + multi-explorer cross-check)
- Rewrote `fetchBitcoinBlockHeader()` to query ALL providers (blockstream + mempool) and cross-check their merkle roots; fails on disagreement (turns fallback-redundancy into a cross-check)
- Added `trustMode` field to `verifyOtsProof()` return value: `multi-explorer-checked` (both providers agreed), `single-explorer-checked` (only one responded), `structural-only` (no Bitcoin attestation verified)
- Updated verify.html (2 locations) and index.html (1 location) to display the trust mode label alongside verification results
- Removed misleading "lite-client verification" / "independently verifiable against the Bitcoin PoW chain" comments from code
- Full light-client PoW/difficulty/chain-linkage verification (Tier 3) remains as future work
- 2 new test assertions: trustMode present + explorer-checked on verified proof, structural-only on pending proof
### v0.0.22 — G56-06 fix (seed continuity: honest claims + out-of-band verification)
- Design decision: continuity is *asserted* by the old identity's signature, not *proven* by cryptography
- Mode 1 (mnemonic-match) rejected: no current Nostr signers accept seed phrases; would require typing a valuable mnemonic into a web page — unacceptable risk
- Mode 2 (successor secp signature) rejected: a malicious browser can fake the secp signature just as easily as PQ signatures — adds no real binding
- Documented the honest position in README.md (Component 1 section) and nip_proposal.md: continuity is asserted not proven; only out-of-band verification on a different device can prove the seed matches the published keys
- Added UI recommendation in index.html seed step: "Verify out-of-band after migration — take your seed phrase to a different device, derive the keys, confirm the public keys match"
- No new tests (documentation + UI only)
### v0.0.23 — G56-08 fix (browser hardening — code-fixable parts)
- Extracted all inline `<script>` blocks from index.html and verify.html into external files:
- `www/js/version-display.js` (self-initializing version display)
- `www/js/index-app.mjs` (main index.html application module, ~1237 lines)
- `www/js/verify-app.mjs` (main verify.html application module, ~609 lines)
- Removed `'unsafe-inline'` from `script-src` in CSP (both pages); kept for `style-src` (many inline styles remain)
- Added `frame-ancestors 'none'` to CSP (clickjacking protection)
- Added `<meta name="referrer" content="no-referrer">` to both pages
- Added SRI hashes (`integrity="sha384-..."` + `crossorigin="anonymous"`) to nostr-login-lite scripts
- Created `deploy/nginx-security-headers.conf` documenting required server-side headers (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP, CORP)
- Remaining: apply nginx config on server; vendor signer scripts into repo for full auditability
- No new tests (HTML/deployment changes)
## Test count
- **Before remediation:** 38 tests
- **After G56-01:** 49 tests
- **After G56-02+03:** 61 tests
- **After G56-09:** 65 tests
- **After G56-16:** 65 tests (docs-only)
- **After G56-07:** 88 tests
- **After G56-05:** 103 tests
- **After G56-04:** 103 tests (2 assertions added to existing tests)
- **After G56-06:** 103 tests (docs + UI only)
- **After G56-08:** 103 tests (HTML/deployment changes)
- **All passing:** ✅
+35
View File
@@ -0,0 +1,35 @@
# Nginx security headers for post-quantum-nostr (G56-08)
#
# Add these to the nginx server block for laantungir.net/post-quantum.
# These headers cannot be fully enforced from HTML meta tags alone —
# they must be set by the server.
# HSTS: force HTTPS for 1 year, include subdomains, preload-ready
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Prevent MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Prevent clickjacking (also enforced via CSP frame-ancestors 'none' in HTML)
add_header X-Frame-Options "DENY" always;
# Don't leak the URL via referrer headers (also set via <meta name="referrer"> in HTML)
add_header Referrer-Policy "no-referrer" always;
# Restrict browser features — this web app needs none of these
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
# Cross-Origin Opener Policy: isolate browsing context
add_header Cross-Origin-Opener-Policy "same-origin" always;
# Cross-Origin Resource Policy: restrict resource loading to same-origin
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Note: The CSP is set via <meta http-equiv="Content-Security-Policy"> in the HTML
# files. If you prefer to set it via nginx instead (recommended for production),
# replace the meta tag with:
#
# add_header Content-Security-Policy "default-src 'self'; script-src 'self'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none';" always;
#
# Setting CSP via nginx is stronger because it cannot be stripped by an HTML
# injection that modifies the <head>.
+5
View File
@@ -1,5 +1,10 @@
# How the NIP-QR Event Is Constructed: Step by Step
> **⚠️ Research prototype.** This document describes an experimental pre-quantum key-commitment
> system. It links PQ keys to an existing Nostr identity and anchors that link in Bitcoin; it does
> not, by itself, make the identity post-quantum secure. Complete migration requires companion
> protocols not yet specified or implemented.
## The Misconception
The seed phrase is NOT used directly as the private key for PQ signing. The seed phrase is a source of entropy that is used to **derive** separate keys for each PQ algorithm via **BIP32 HD wallet derivation paths** — the same standard used by NIP-06 for secp256k1 keys.
+4 -1
View File
@@ -1,8 +1,11 @@
# Laan's explanation
> **⚠️ Research prototype.** This is a short summary of an experimental pre-quantum key-commitment
> system. It does not, by itself, make a Nostr identity post-quantum secure.
A short summary of how the post-quantum Nostr migration works, matching the implementation.
1. **Create a new seed phrase.** This is your post-quantum (PQ) Nostr backup. It's a 12-word BIP39 mnemonic — pure entropy, not tied to any algorithm.
1. **Create a new seed phrase.** This is your post-quantum (PQ) Nostr backup. It's a BIP39 mnemonic (24 words by default; 12 words available for testing) — pure entropy, not tied to any algorithm.
2. **Derive 5 PQ keypairs from the seed.** Using BIP32 hierarchical deterministic derivation (the same standard as NIP-06), each PQ algorithm gets its own child key under the path `m/44'/1237'/0'/0/`:
- child 1 → ML-DSA-44
+24 -2
View File
@@ -39,6 +39,17 @@ A user with an existing Nostr identity (the **attesting identity**) publishes tw
The PQ keys are derived deterministically from a BIP39 seed phrase via BIP32, at fixed child indices under the NIP-06 base path. This means a user who has backed up their seed phrase can always recover the same PQ keys, on any conforming implementation.
> **⚠️ Continuity is asserted, not proven.** The old identity *authorizes* the PQ keys by signing an
> event that contains them. This is an authorization, not a cryptographic proof that the PQ keys were
> derived from the seed phrase shown to the user. A compromised browser could display one mnemonic
> while publishing attacker-controlled PQ keys, and any in-browser signature could be faked by the
> same browser. The only real proof is **out-of-band verification**: the user takes their seed phrase
> to a different device, derives the keys independently, and confirms the public keys match the
> published event. Verifying the mnemonic against the current identity (deriving the NIP-06 secp key
> and requiring it to match) would provide a stronger proof, but no current Nostr signers accept seed
> phrases, so this would require typing a valuable mnemonic into a web page — an unacceptable risk.
> This mode is deferred until hardware PQ signers or seed-accepting signers exist.
```mermaid
flowchart TD
SEED[BIP39 seed - recovery root] --> SECP[secp256k1 keypair - child 0]
@@ -209,7 +220,7 @@ When a client finds multiple kind 9999 proof carriers for the same attesting ide
### OTS proof states
An OTS proof may be **pending** (not yet anchored in a Bitcoin block) or **confirmed** (anchored). A pending proof asserts that the digest has been submitted to calendar servers; a confirmed proof asserts a Bitcoin block commitment. Clients SHOULD treat a confirmed proof as authoritative. A pending proof is still useful: it establishes that the digest existed at submission time, and it can be upgraded to a confirmed proof later by re-querying the calendars. The wrapper is republished with the upgraded proof.
An OTS proof may be **pending** (not yet anchored in a Bitcoin block) or **confirmed** (anchored). A pending proof is evidence that the digest was submitted to calendar servers; it is **not** an independent timestamp and depends on calendar behavior. A confirmed proof asserts a Bitcoin block commitment. Clients SHOULD treat a confirmed proof as authoritative. A pending proof can be upgraded to a confirmed proof later by re-querying the calendars; when that happens, a **new** proof carrier event is published carrying the upgraded proof and referencing the original via an `upgrade_of` tag. The original event remains on relays permanently.
### Client-side verification
@@ -294,4 +305,15 @@ This NIP builds on and is compatible with several existing community proposals:
## Reference implementation
A static, client-side-only web implementation exists at `https://laantungir.net/post-quantum/`. All cryptographic operations (BIP39, BIP32, PQ keygen/sign/verify, OTS submission and verification) happen in the browser. No private key ever touches a server. Source: [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs:1) in this repository.
> **⚠️ Research prototype.** The implementation is experimental and is not a complete post-quantum
> migration. It links PQ keys to an existing identity and anchors that link in Bitcoin; it does not
> yet define PQ authentication for routine events, key rotation/revocation, or PQ encryption.
A static, client-side-only web implementation exists at `https://laantungir.net/post-quantum/`.
Cryptographic operations (BIP39, BIP32, PQ keygen/sign/verify, OTS submission and Merkle-path
verification) happen in the browser. Private keys are handled in the browser or via a NIP-07 signer
extension, which may be a remote signer (NIP-46); in that case the signing key does not touch the
page origin. OTS proof *upgrading* uses a server-side helper, and Bitcoin block-header confirmation
relies on a public explorer API (mempool.space) — this is **API-assisted verification**, not a full
Bitcoin light-client verification. Source: [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs:1) in this
repository.
+600
View File
@@ -0,0 +1,600 @@
{
"name": "post_quantum_nostr",
"version": "0.0.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "post_quantum_nostr",
"version": "0.0.24",
"license": "ISC",
"dependencies": {
"@noble/curves": "^2.2.0",
"@noble/hashes": "^2.2.0",
"@noble/post-quantum": "^0.6.1",
"@scure/base": "^2.2.0",
"@scure/bip32": "^2.2.0",
"@scure/bip39": "^2.2.0"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"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.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/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/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.2.0",
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.17",
"version": "0.0.25",
"description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.",
"main": "index.js",
"scripts": {
+4
View File
@@ -1,5 +1,9 @@
# Amber Integration Analysis for PQ Nostr Web App
> **⚠️ Design-only research document.** This is an analysis of a possible integration, not an
> implemented feature. The current system is a research prototype and does not make Nostr
> identities post-quantum secure by itself.
## Amber Capabilities (from source code analysis)
[Amber](https://github.com/greenart7c3/Amber) is an Android Nostr event signer that keeps the nsec segregated in a dedicated app. Based on source code inspection:
+4
View File
@@ -1,5 +1,9 @@
# Amber Integration Plan: End-User Experience
> **⚠️ Design-only research document.** This describes a planned user experience, not a shipped
> feature. The current system is a research prototype; it links PQ keys to an identity and anchors
> that link in Bitcoin, but does not by itself make the identity post-quantum secure.
## Overview
This document describes the complete end-to-end user experience for migrating a Nostr identity to post-quantum security using the PQ web app and Amber signer. There are **two paths** depending on how the user's account was created in Amber:
+2
View File
@@ -1,5 +1,7 @@
# Existing Post-Quantum Proposals in the Nostr Ecosystem
> **⚠️ Research document.** A survey of community discussion; not an implementation spec.
A survey of pull requests and issues in `nostr-protocol/nips` related to post-quantum cryptography, key migration, and key rotation — and how they compare to our plan in [`README.md`](../README.md).
Research date: 2026-07-12
+4
View File
@@ -1,5 +1,9 @@
# NIP-QR: Two-Account Flow Architecture
> **⚠️ Design-only research document.** This describes the proposed architecture; the current
> implementation is a research prototype and does not by itself make Nostr identities
> post-quantum secure.
## The Flow (as proposed)
```
+2
View File
@@ -1,5 +1,7 @@
# Post-Quantum Cryptography: JavaScript Package Survey
> **⚠️ Research document.** A library survey; not an implementation spec.
## Executive Summary
**We do NOT need to write our own implementations.** There are mature, well-maintained JavaScript packages for all NIST-standardized post-quantum algorithms. The standout recommendation is **[`@noble/post-quantum`](https://www.npmjs.com/package/@noble/post-quantum)** — a pure JavaScript implementation of all FIPS 203/204/205 algorithms, from the highly respected noble cryptography family (335 GitHub stars, MIT licensed, self-audited, actively maintained).
+276
View File
@@ -311,6 +311,10 @@ describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
assert.equal(result.verified, true, 'should verify against Bitcoin block 358391');
assert.equal(result.bitcoinAttestations.length, 1);
assert.equal(result.bitcoinAttestations[0].height, 358391);
// G56-04: trustMode must be present and indicate explorer-checked trust
assert.ok(result.trustMode, 'trustMode field must be present');
assert.ok(['multi-explorer-checked', 'single-explorer-checked'].includes(result.trustMode),
`trustMode should be explorer-checked, got: ${result.trustMode}`);
});
test('F-C3: wrong expected digest fails with "Target digest mismatch"', async () => {
@@ -334,6 +338,8 @@ describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
const result = await m.verifyOtsProof(ots);
assert.equal(result.verified, false);
assert.ok(result.errors.some(e => e.includes('no Bitcoin attestations') || e.includes('pending')), 'should report no Bitcoin attestations');
// G56-04: trustMode should be 'structural-only' when no Bitcoin attestation is verified
assert.equal(result.trustMode, 'structural-only', 'pending proof should have structural-only trust mode');
});
});
@@ -719,3 +725,273 @@ describe('G56-03: identity binding (verifyNIPQRContent)', () => {
assert.equal(result.identityBound, true, 'without options, identity binding should pass');
});
});
// ============================================================================
// G56-07: Fuzz / property tests — untrusted input must never throw
// ============================================================================
describe('G56-07: verifyNostrEvent never throws on malformed input', () => {
const malformedInputs = [
null,
undefined,
'string',
42,
true,
[],
{},
{ id: 'abc' },
{ pubkey: 'x', sig: 'y', created_at: 'not-a-number', kind: 1, tags: [], content: '' },
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 'not-a-number', tags: [], content: '' },
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 1, tags: 'not-an-array', content: '' },
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 1, tags: [], content: 123 },
{ pubkey: 123, sig: 'y', created_at: 1, kind: 1, tags: [], content: '' },
{ pubkey: 'x', sig: 123, created_at: 1, kind: 1, tags: [], content: '' },
];
for (const input of malformedInputs) {
test(`verifyNostrEvent returns false for ${JSON.stringify(input)?.substring(0, 50)}`, () => {
assert.equal(m.verifyNostrEvent(input), false);
});
}
test('verifyNostrEvent returns false for random-shaped objects (fuzz)', () => {
const types = [null, true, false, 0, 1, 'x', '', [], {}, { id: 1 }, { tags: 'x' }];
for (let i = 0; i < 500; i++) {
const obj = {};
const keys = ['id', 'pubkey', 'sig', 'created_at', 'kind', 'tags', 'content', 'extra'];
for (const k of keys) {
if (Math.random() < 0.6) obj[k] = types[Math.floor(Math.random() * types.length)];
}
assert.equal(m.verifyNostrEvent(obj), false, `threw or passed for iteration ${i}`);
}
});
});
describe('G56-07: verifyNIPQRContent never throws on arbitrary input', () => {
function randomJson() {
const types = [null, true, false, 0, 'x', [], {}, { id: 1 }, { tags: 'not-array' }];
return types[Math.floor(Math.random() * types.length)];
}
test('random content + tags never throw and never pass (fuzz)', () => {
for (let i = 0; i < 1000; i++) {
const content = Math.random() < 0.5
? JSON.stringify(randomJson())
: 'not-json-at-' + i;
const tags = Math.random() < 0.5
? randomJson()
: (Math.random() < 0.5 ? [['algorithm', 'x']] : 'not-an-array');
let result;
assert.doesNotThrow(() => {
result = m.verifyNIPQRContent(content, tags);
}, `threw on iteration ${i}`);
assert.equal(result.validForMigration, false, `passed on iteration ${i}`);
}
});
test('null / undefined / wrong-typed content returns structured failure', () => {
for (const content of [null, undefined, 42, true, {}]) {
let result;
assert.doesNotThrow(() => {
result = m.verifyNIPQRContent(content, []);
});
assert.equal(result.validForMigration, false);
}
});
test('oversized content is rejected before parsing', () => {
const huge = 'x'.repeat(70 * 1024); // > MAX_EVENT_JSON_SIZE (64 KiB)
const result = m.verifyNIPQRContent(huge, []);
assert.equal(result.validForMigration, false);
assert.ok(result.errors.some(e => e.includes('maximum size')), 'should report size error');
});
});
describe('G56-07: parseOtsFile never throws on random/truncated/oversized bytes', () => {
test('random bytes never throw (fuzz)', () => {
for (let i = 0; i < 2000; i++) {
const len = Math.floor(Math.random() * 2048);
const bytes = new Uint8Array(len);
for (let j = 0; j < len; j++) bytes[j] = Math.floor(Math.random() * 256);
let result;
assert.doesNotThrow(() => { result = m.parseOtsFile(bytes); }, `threw on iteration ${i}, len=${len}`);
// result is either null or a valid parse object — never throws
if (result !== null) {
assert.ok(typeof result === 'object' && 'fileHashOp' in result, 'should be null or valid object');
}
}
});
test('non-Uint8Array inputs return null without throwing', () => {
for (const input of [null, undefined, 'string', 42, true, [], {}, new Int8Array(10)]) {
assert.equal(m.parseOtsFile(input), null);
}
});
test('oversized input returns null without parsing', () => {
const huge = new Uint8Array(2 * 1024 * 1024); // 2 MiB > MAX_OTS_PROOF_SIZE (1 MiB)
assert.equal(m.parseOtsFile(huge), null);
});
test('truncated valid-prefix bytes never throw', () => {
// OTS magic header as a starting point
const magic = m.hexToBytes('004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294');
for (let cut = 0; cut <= magic.length; cut++) {
const truncated = magic.slice(0, cut);
let result;
assert.doesNotThrow(() => { result = m.parseOtsFile(truncated); });
// short truncations return null (too short); longer ones may parse partially or return null
assert.ok(result === null || typeof result === 'object');
}
});
});
describe('G56-07: isOtsConfirmed never throws on bad input', () => {
test('non-Uint8Array and random bytes never throw', () => {
for (const input of [null, undefined, 'string', 42, [], {}]) {
assert.doesNotThrow(() => m.isOtsConfirmed(input));
}
for (let i = 0; i < 200; i++) {
const len = Math.floor(Math.random() * 512);
const bytes = new Uint8Array(len);
for (let j = 0; j < len; j++) bytes[j] = Math.floor(Math.random() * 256);
assert.doesNotThrow(() => m.isOtsConfirmed(bytes), `threw on iteration ${i}`);
}
});
});
// ============================================================================
// G56-05: Signer output validation
// ============================================================================
describe('G56-05: validateSignerOutput', () => {
const sk = new Uint8Array(32);
sk[31] = 5;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
const otherSk = new Uint8Array(32);
otherSk[31] = 9;
const otherPkHex = m.bytesToHex(schnorr.getPublicKey(otherSk));
// Helper: create a valid template + correctly signed event
function makeValidSignedEvent(content = 'test content', tags = [['t', 'x']]) {
const template = {
pubkey: pkHex,
created_at: 1700000000,
kind: 1,
tags,
content
};
const event = { ...template };
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return { template, event };
}
test('valid signer output passes validation', () => {
const { template, event } = makeValidSignedEvent();
const result = m.validateSignerOutput(event, template, pkHex);
assert.equal(result.id, event.id);
assert.equal(result.sig, event.sig);
});
test('signer changes content → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, content: 'tampered content' };
// Recompute id+sig so the tampered event is internally consistent
// (a real malicious signer would do this, but the content must still match the template)
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /content was changed/);
});
test('signer changes tags → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, tags: [['t', 'x'], ['extra', 'tag']] };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tags length changed/);
});
test('signer changes created_at → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, created_at: 9999999999 };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /created_at changed/);
});
test('signer changes kind → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, kind: 9999 };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /kind changed/);
});
test('signer returns wrong pubkey → rejected', () => {
const { template, event } = makeValidSignedEvent();
// Sign with a different key, use that key's pubkey
const tampered = { ...template, pubkey: otherPkHex };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), otherSk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /pubkey mismatch/);
});
test('signer returns wrong ID → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, id: '0'.repeat(64) };
// sig is valid for the real id, not for '0'.repeat(64)
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id mismatch/);
});
test('signer returns invalid signature → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, sig: 'f'.repeat(128) };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /invalid Schnorr signature/);
});
test('signer returns extra field → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, extra: 'malicious' };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /unexpected extra field/);
});
test('signer returns missing field → rejected', () => {
const { template, event } = makeValidSignedEvent();
const { sig, ...noSig } = event;
assert.throws(() => m.validateSignerOutput(noSig, template, pkHex), /missing field 'sig'/);
});
test('signer returns non-object → rejected', () => {
const { template } = makeValidSignedEvent();
assert.throws(() => m.validateSignerOutput(null, template, pkHex), /non-object/);
assert.throws(() => m.validateSignerOutput('string', template, pkHex), /non-object/);
});
test('signer returns malformed id (not 64 hex) → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, id: 'short' };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id must be 64/);
});
test('signer returns malformed sig (not 128 hex) → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, sig: 'short' };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /sig must be 128/);
});
test('no expectedPubkey → pubkey check skipped', () => {
const { template, event } = makeValidSignedEvent();
// Should pass without expectedPubkey (backward compatible)
const result = m.validateSignerOutput(event, template);
assert.equal(result.id, event.id);
});
test('tag element value changed → rejected', () => {
const { template, event } = makeValidSignedEvent('test', [['t', 'original']]);
const tampered = { ...event, tags: [['t', 'changed']] };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tag 0 element 1 changed/);
});
});
+5
View File
@@ -1,5 +1,10 @@
# Post-Quantum Nostr: Why, What, How
> **⚠️ Research prototype.** This document describes an experimental pre-quantum key-commitment
> and identity-link system. It does not, by itself, make a Nostr identity post-quantum secure;
> complete migration requires companion protocols (PQ event authentication, rotation, encryption,
> client adoption) that are not yet specified or implemented.
## Why: The Problem
### Quantum computers will break Nostr's cryptography
+15 -1262
View File
File diff suppressed because it is too large Load Diff
+1292
View File
File diff suppressed because it is too large Load Diff
+326 -65
View File
@@ -412,28 +412,162 @@ export function hexToNpub(hexPubkey) {
/**
* Verify a Nostr event's secp256k1 Schnorr signature (NIP-01).
* Checks both the signature validity AND that event.id matches the computed hash.
*
* G56-07: This function accepts untrusted input (pasted JSON, relay events) and
* must never throw on malformed data. It returns `false` for any structurally
* invalid event.
*
* @param {object} event - Nostr event with id, pubkey, created_at, kind, tags, content, sig
* @returns {boolean} true if signature is valid and id matches
* @returns {boolean} true if signature is valid and id matches; false otherwise (never throws)
*/
export function verifyNostrEvent(event) {
// Build the event hash (NIP-01 serialization)
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = sha256(new TextEncoder().encode(serialized));
const computedId = bytesToHex(hash);
// F-H2: Verify event.id matches the computed hash
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
try {
// Structural validation — fail closed on wrong shape
if (!event || typeof event !== 'object') return false;
if (typeof event.pubkey !== 'string' || typeof event.sig !== 'string') return false;
if (typeof event.created_at !== 'number') return false;
if (typeof event.kind !== 'number') return false;
if (!Array.isArray(event.tags)) return false;
if (typeof event.content !== 'string') return false;
// Build the event hash (NIP-01 serialization)
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = sha256(new TextEncoder().encode(serialized));
const computedId = bytesToHex(hash);
// F-H2: Verify event.id matches the computed hash
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
return false;
}
const sig = hexToBytes(event.sig);
const pubkey = hexToBytes(event.pubkey);
return schnorr.verify(sig, hash, pubkey);
} catch (e) {
// G56-07: never throw on untrusted malformed input
return false;
}
const sig = hexToBytes(event.sig);
const pubkey = hexToBytes(event.pubkey);
return schnorr.verify(sig, hash, pubkey);
}
/**
* Validate the output of a NIP-07 signer's signEvent() call (G56-05).
*
* This ensures a malicious or buggy signer cannot substitute different content,
* tags, pubkey, or an invalid signature. It verifies that the signed event
* matches the template exactly (for the semantic fields), that the pubkey
* equals the expected identity, that the computed ID matches, and that the
* Schnorr signature is valid.
*
* Returns the validated signed event object (the signer's exact object, not a
* reconstruction) on success, or throws on any mismatch.
*
* @param {object} signedEvent - The object returned by window.nostr.signEvent()
* @param {object} template - The unsigned template that was passed to signEvent()
* @param {string} expectedPubkey - The hex pubkey of the authenticated identity
* @returns {object} the validated signed event
* @throws {Error} if any field is missing, mismatched, or the signature is invalid
*/
export function validateSignerOutput(signedEvent, template, expectedPubkey) {
if (!signedEvent || typeof signedEvent !== 'object') {
throw new Error('validateSignerOutput: signer returned non-object');
}
// 1. Check all required fields are present and correctly typed
const required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
for (const field of required) {
if (!(field in signedEvent)) {
throw new Error(`validateSignerOutput: missing field '${field}'`);
}
}
if (typeof signedEvent.id !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.id)) {
throw new Error('validateSignerOutput: id must be 64 lowercase hex chars');
}
if (typeof signedEvent.pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.pubkey)) {
throw new Error('validateSignerOutput: pubkey must be 64 lowercase hex chars');
}
if (typeof signedEvent.sig !== 'string' || !/^[0-9a-f]{128}$/.test(signedEvent.sig)) {
throw new Error('validateSignerOutput: sig must be 128 lowercase hex chars');
}
if (typeof signedEvent.created_at !== 'number') {
throw new Error('validateSignerOutput: created_at must be a number');
}
if (typeof signedEvent.kind !== 'number') {
throw new Error('validateSignerOutput: kind must be a number');
}
if (!Array.isArray(signedEvent.tags)) {
throw new Error('validateSignerOutput: tags must be an array');
}
if (typeof signedEvent.content !== 'string') {
throw new Error('validateSignerOutput: content must be a string');
}
// 2. Verify no template field was changed by the signer
if (signedEvent.kind !== template.kind) {
throw new Error(`validateSignerOutput: kind changed (template=${template.kind}, signed=${signedEvent.kind})`);
}
if (signedEvent.content !== template.content) {
throw new Error('validateSignerOutput: content was changed by signer');
}
if (signedEvent.created_at !== template.created_at) {
throw new Error(`validateSignerOutput: created_at changed (template=${template.created_at}, signed=${signedEvent.created_at})`);
}
// Deep-compare tags (order-sensitive, as tag order affects the event ID)
if (signedEvent.tags.length !== template.tags.length) {
throw new Error(`validateSignerOutput: tags length changed (template=${template.tags.length}, signed=${signedEvent.tags.length})`);
}
for (let i = 0; i < template.tags.length; i++) {
const tTag = template.tags[i];
const sTag = signedEvent.tags[i];
if (!Array.isArray(sTag) || sTag.length !== tTag.length) {
throw new Error(`validateSignerOutput: tag ${i} shape changed`);
}
for (let j = 0; j < tTag.length; j++) {
if (sTag[j] !== tTag[j]) {
throw new Error(`validateSignerOutput: tag ${i} element ${j} changed`);
}
}
}
// 3. Verify the pubkey matches the expected identity
if (expectedPubkey && signedEvent.pubkey !== expectedPubkey) {
throw new Error(`validateSignerOutput: pubkey mismatch (expected=${expectedPubkey?.substring(0, 16)}..., got=${signedEvent.pubkey.substring(0, 16)}...)`);
}
// 4. Recompute the event ID and verify it matches
const computedId = computeEventId(signedEvent);
if (signedEvent.id !== computedId) {
throw new Error(`validateSignerOutput: id mismatch (signed=${signedEvent.id.substring(0, 16)}..., computed=${computedId.substring(0, 16)}...)`);
}
// 5. Verify the Schnorr signature
const hash = sha256(new TextEncoder().encode(JSON.stringify([
0,
signedEvent.pubkey,
signedEvent.created_at,
signedEvent.kind,
signedEvent.tags,
signedEvent.content
])));
const sig = hexToBytes(signedEvent.sig);
const pubkey = hexToBytes(signedEvent.pubkey);
if (!schnorr.verify(sig, hash, pubkey)) {
throw new Error('validateSignerOutput: invalid Schnorr signature');
}
// 6. Reject extra unexpected fields (allow only the 7 NIP-01 fields)
const allowedFields = new Set(['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']);
for (const key of Object.keys(signedEvent)) {
if (!allowedFields.has(key)) {
throw new Error(`validateSignerOutput: unexpected extra field '${key}'`);
}
}
return signedEvent;
}
// ============================================================================
@@ -695,6 +829,16 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}
const errors = [];
const { expectedAuthor, outerPubkey } = options;
// G56-07: reject oversized content before any parsing/decoding work
if (typeof kind11112Content === 'string' && kind11112Content.length > MAX_EVENT_JSON_SIZE) {
return {
results: [{ algorithm: 'event', valid: false, note: 'content exceeds maximum size' }],
kind1Event: null, fullHash: null, sha256Valid: false, eTagValid: false,
pqProofsValid: false, policySufficient: false, identityBound: false,
validForMigration: false, errors: ['content exceeds maximum size']
};
}
// --- Helper: push a result entry and track failures ---
function pushResult(algorithm, valid, note) {
results.push({ algorithm, valid, note });
@@ -1493,14 +1637,27 @@ class OtsReader {
return out;
}
// G56-13: Maximum varuint value and encoding length to prevent overflow
static MAX_VARUINT = 0xffffffff; // 32-bit max — OTS heights/lengths won't exceed this
static MAX_VARUINT_BYTES = 5; // 5 bytes is enough for 32-bit values
readVaruint() {
// G56-13: Use safe arithmetic instead of bitwise ops (which are signed 32-bit)
let value = 0;
let shift = 0;
let bytesRead = 0;
let b;
do {
b = this.readByte();
value |= (b & 0x7f) << shift;
bytesRead++;
if (bytesRead > OtsReader.MAX_VARUINT_BYTES) {
throw new Error('OTS: varuint encoding too long');
}
value += (b & 0x7f) * Math.pow(2, shift);
shift += 7;
if (value > OtsReader.MAX_VARUINT) {
throw new Error(`OTS: varuint value ${value} exceeds maximum ${OtsReader.MAX_VARUINT}`);
}
} while (b & 0x80);
return value;
}
@@ -1519,6 +1676,11 @@ class OtsReader {
// OTS magic header (31 bytes)
const OTS_MAGIC = hexToBytes('004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294');
// G56-07: Maximum sizes for untrusted inputs. Reject larger inputs before any
// parsing or cryptographic work to prevent denial-of-service via oversized data.
const MAX_OTS_PROOF_SIZE = 1 << 20; // 1 MiB — generous for heavily upgraded proofs
const MAX_EVENT_JSON_SIZE = 1 << 16; // 64 KiB — NIP-QR events are typically 30-50 KiB
// Attestation tags
const BITCOIN_ATTESTATION_TAG = hexToBytes('0588960d73d71901');
const LITECOIN_ATTESTATION_TAG = hexToBytes('06869a0d73d71b45');
@@ -1540,35 +1702,45 @@ function bytesEqual(a, b) {
* that the attestation covers (after walking the op tree from the target).
*/
export function parseOtsFile(otsBytes) {
// G56-07: fail closed on untrusted input — never throw
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_MAGIC.length) return null;
// G56-07: reject oversized proofs before any parsing work
if (otsBytes.length > MAX_OTS_PROOF_SIZE) return null;
const reader = new OtsReader(otsBytes);
try {
const reader = new OtsReader(otsBytes);
// 1. Magic header
const magic = reader.readBytes(OTS_MAGIC.length);
if (!bytesEqual(magic, OTS_MAGIC)) return null;
// 1. Magic header
const magic = reader.readBytes(OTS_MAGIC.length);
if (!bytesEqual(magic, OTS_MAGIC)) return null;
// 2. Version (varuint) — we support major version 1
const version = reader.readVaruint();
if (version !== 1) return null;
// 2. Version (varuint) — we support major version 1
const version = reader.readVaruint();
if (version !== 1) return null;
// 3. File hash operation tag (1 byte)
const hashOpTag = reader.readByte();
let fileHashOp = 'unknown';
let digestLen = 32;
if (hashOpTag === 0x08) { fileHashOp = 'sha256'; digestLen = 32; }
else if (hashOpTag === 0x02) { fileHashOp = 'sha1'; digestLen = 20; }
else if (hashOpTag === 0x03) { fileHashOp = 'ripemd160'; digestLen = 20; }
else { return null; } // unsupported hash op
// 3. File hash operation tag (1 byte)
const hashOpTag = reader.readByte();
let fileHashOp = 'unknown';
let digestLen = 32;
if (hashOpTag === 0x08) { fileHashOp = 'sha256'; digestLen = 32; }
else if (hashOpTag === 0x02) { fileHashOp = 'sha1'; digestLen = 20; }
else if (hashOpTag === 0x03) { fileHashOp = 'ripemd160'; digestLen = 20; }
else { return null; } // unsupported hash op
// 4. File digest (the target — what was timestamped)
const targetDigest = reader.readBytes(digestLen);
// 4. File digest (the target — what was timestamped)
const targetDigest = reader.readBytes(digestLen);
// 5. Timestamp tree
const attestations = [];
_parseTimestamp(reader, targetDigest, attestations, 0);
// 5. Timestamp tree
const attestations = [];
// G56-13: pass operation/branch budget to prevent DoS
const budget = { operations: 0, branches: 0 };
_parseTimestamp(reader, targetDigest, attestations, 0, budget);
return { fileHashOp, targetDigest, attestations };
return { fileHashOp, targetDigest, attestations };
} catch (e) {
// G56-07: never throw on malformed/truncated untrusted input
return null;
}
}
/**
@@ -1587,7 +1759,11 @@ export function parseOtsFile(otsBytes) {
* - 0x00 = attestation (8-byte tag + varbytes payload)
* - other = operation tag; apply op to current msg result; recurse into subtree with result
*/
function _parseTimestamp(reader, msg, attestations, depth) {
// G56-13: Budget limits for the OTS parser
const OTS_MAX_OPERATIONS = 10000; // total operations across the entire proof
const OTS_MAX_BRANCHES = 1000; // total continuation (0xff) branches
function _parseTimestamp(reader, msg, attestations, depth, budget) {
// The OTS timestamp tree can be deeply nested, especially after many
// upgrade cycles (each upgrade can add new branches/operations). The
// reference Python implementation doesn't impose a low limit here.
@@ -1597,19 +1773,33 @@ function _parseTimestamp(reader, msg, attestations, depth) {
let tag = reader.readByte();
while (tag === 0xff) {
// G56-13: track branch count
if (budget) {
budget.branches++;
if (budget.branches > OTS_MAX_BRANCHES) {
throw new Error(`OTS: branch count exceeds maximum ${OTS_MAX_BRANCHES}`);
}
}
// Continuation: read the actual tag, process it, then read the next byte
const current = reader.readByte();
_processTimestampEntry(reader, current, msg, attestations, depth);
_processTimestampEntry(reader, current, msg, attestations, depth, budget);
tag = reader.readByte();
}
// Process the final (non-0xff) entry
_processTimestampEntry(reader, tag, msg, attestations, depth);
_processTimestampEntry(reader, tag, msg, attestations, depth, budget);
}
/**
* Process a single timestamp entry (attestation or operation+subtree).
*/
function _processTimestampEntry(reader, tag, msg, attestations, depth) {
function _processTimestampEntry(reader, tag, msg, attestations, depth, budget) {
// G56-13: track total operations
if (budget) {
budget.operations++;
if (budget.operations > OTS_MAX_OPERATIONS) {
throw new Error(`OTS: operation count exceeds maximum ${OTS_MAX_OPERATIONS}`);
}
}
if (tag === 0x00) {
// Attestation
const attTag = reader.readBytes(8);
@@ -1618,7 +1808,7 @@ function _processTimestampEntry(reader, tag, msg, attestations, depth) {
} else {
// Operation — apply to msg, then recurse into the subtree
const result = _applyOp(reader, tag, msg);
_parseTimestamp(reader, result, attestations, depth + 1);
_parseTimestamp(reader, result, attestations, depth + 1, budget);
}
}
@@ -1679,8 +1869,13 @@ function _classifyAttestation(tag, payload, digest, attestations) {
// ============================================================================
/**
* Bitcoin API endpoints for fetching block headers (for lite-client verification).
* We try multiple providers for resilience.
* Bitcoin block explorer API providers.
*
* G56-04: These are trusted public APIs, NOT a Bitcoin light-client. The block
* headers they return are NOT independently verified against PoW, difficulty, or
* chain linkage. We cross-check both providers and fail on disagreement to make
* coordinated false responses harder, but this is still "multi-explorer-checked"
* trust, not "header-chain-verified" or trustless.
*/
const BITCOIN_API_PROVIDERS = [
{ name: 'blockstream', blockHash: (h) => `https://blockstream.info/api/block-height/${h}`, block: (hash) => `https://blockstream.info/api/block/${hash}` },
@@ -1688,15 +1883,23 @@ const BITCOIN_API_PROVIDERS = [
];
/**
* Fetch a Bitcoin block header (merkle root + timestamp) from a public API.
* Uses lite-client verification: we trust the API for the block header, which
* is the standard OTS lite-client model (the block header is independently
* verifiable against the Bitcoin PoW chain).
* Fetch a Bitcoin block header (merkle root + timestamp) from public explorer APIs.
*
* G56-04: This is NOT lite-client verification. We trust the APIs for the block
* header data. To make coordinated false responses harder, we query ALL providers
* and require them to agree on the merkle root. If they disagree, we fail.
*
* Trust mode: 'multi-explorer-checked' if all queried providers agree.
* This is stronger than single-provider trust but is NOT trustless a
* compromise of both providers could still falsify a result.
*
* @param {number} height - Bitcoin block height
* @returns {Promise<{merkleroot: string, time: number, height: number}>}
* @returns {Promise<{merkleroot: string, time: number, height: number, trustMode: string, providers: string[]}>}
* @throws {Error} if providers disagree or all fail
*/
async function fetchBitcoinBlockHeader(height) {
const results = [];
for (const provider of BITCOIN_API_PROVIDERS) {
try {
// 1. Get block hash from height
@@ -1712,37 +1915,74 @@ async function fetchBitcoinBlockHeader(height) {
if (!blockData.merkle_root && !blockData.merkleroot) continue;
if (!blockData.timestamp && !blockData.time) continue;
return {
merkleroot: blockData.merkle_root || blockData.merkleroot,
results.push({
provider: provider.name,
merkleroot: (blockData.merkle_root || blockData.merkleroot).toLowerCase(),
time: blockData.timestamp || blockData.time,
height
};
});
} catch (e) {
console.warn(`[ots] Bitcoin API ${provider.name} failed for height ${height}:`, e.message);
continue;
}
}
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
if (results.length === 0) {
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
}
// G56-04: Cross-check — if multiple providers responded, they must agree
if (results.length > 1) {
const firstRoot = results[0].merkleroot;
for (let i = 1; i < results.length; i++) {
if (results[i].merkleroot !== firstRoot) {
const providerList = results.map(r => `${r.provider}:${r.merkleroot.substring(0, 16)}...`).join(', ');
throw new Error(`Bitcoin providers disagree on merkle root for block ${height}: ${providerList}`);
}
}
}
// Determine trust mode based on how many providers agreed
const trustMode = results.length >= 2
? 'multi-explorer-checked'
: 'single-explorer-checked';
return {
merkleroot: results[0].merkleroot,
time: results[0].time,
height,
trustMode,
providers: results.map(r => r.provider)
};
}
/**
* Cryptographically verify an OTS proof against an expected target digest.
* Verify an OTS proof against an expected target digest.
*
* This performs REAL verification:
* G56-04: This performs Merkle-path verification against block headers obtained
* from public explorer APIs. The block headers are NOT independently verified
* against Bitcoin PoW/difficulty/chain-linkage they are trusted from the APIs.
* The `trustMode` field in the result indicates the trust level:
* - 'multi-explorer-checked': multiple providers agreed on the merkle root
* - 'single-explorer-checked': only one provider responded
* - 'structural-only': no Bitcoin attestation was verified (pending/none)
*
* Steps:
* 1. Parses the OTS file (op stream, not byte-pattern search)
* 2. Checks the proof's target digest equals the expected digest (F-C3 fix)
* 3. Walks the op tree to compute the commitment at each attestation
* 4. For Bitcoin attestations: fetches the block header and checks the
* computed merkle root matches the block's merkle root
* 4. For Bitcoin attestations: fetches the block header (cross-checked across
* providers) and checks the computed merkle root matches
*
* @param {Uint8Array} otsBytes - The .ots file bytes
* @param {string} [expectedDigestHex] - The expected target digest (hex). If
* provided, the proof's target digest must match this exactly (F-C3 binding).
* @returns {Promise<{verified: boolean, targetDigest: string, attestations: Array, bitcoinAttestations: Array, errors: Array}>}
* - verified: true if at least one Bitcoin attestation is cryptographically valid
* @returns {Promise<{verified: boolean, targetDigest: string, attestations: Array, bitcoinAttestations: Array, trustMode: string, errors: Array}>}
* - verified: true if at least one Bitcoin attestation's merkle root matches
* - targetDigest: hex of the proof's target digest
* - attestations: all attestations found (parsed)
* - bitcoinAttestations: verified Bitcoin attestations with block time + height
* - trustMode: 'multi-explorer-checked' | 'single-explorer-checked' | 'structural-only'
* - errors: list of error messages (e.g., digest mismatch, pending only)
*/
export async function verifyOtsProof(otsBytes, expectedDigestHex) {
@@ -1753,10 +1993,10 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
try {
parsed = parseOtsFile(otsBytes);
} catch (e) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: [`Failed to parse OTS file: ${e.message}`] };
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: 'structural-only', errors: [`Failed to parse OTS file: ${e.message}`] };
}
if (!parsed) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: ['Invalid OTS file format'] };
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: 'structural-only', errors: ['Invalid OTS file format'] };
}
const targetDigestHex = bytesToHex(parsed.targetDigest);
@@ -1767,7 +2007,7 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
const actual = targetDigestHex.toLowerCase();
if (expected !== actual) {
errors.push(`Target digest mismatch: proof commits to ${actual.substring(0, 16)}... but expected ${expected.substring(0, 16)}...`);
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: 'structural-only', errors };
}
}
@@ -1781,14 +2021,23 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
} else {
errors.push('Proof contains no Bitcoin attestations');
}
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: 'structural-only', errors };
}
// 4. Verify each Bitcoin attestation against the actual Bitcoin block header
// G56-04: block headers are cross-checked across providers; trustMode is
// propagated from fetchBitcoinBlockHeader.
const verified = [];
let trustMode = 'structural-only';
for (const att of bitcoinAttestations) {
try {
const blockHeader = await fetchBitcoinBlockHeader(att.height);
// Track the strongest trust mode seen across all attestations
if (blockHeader.trustMode === 'multi-explorer-checked') {
trustMode = 'multi-explorer-checked';
} else if (blockHeader.trustMode === 'single-explorer-checked' && trustMode === 'structural-only') {
trustMode = 'single-explorer-checked';
}
// The attestation's digest is the merkle root (after walking the op tree).
// Bitcoin OTS stores the digest in reversed byte order (little-endian).
const computedMerkleRoot = bytesToHex(att.digest.slice().reverse());
@@ -1811,6 +2060,7 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
targetDigest: targetDigestHex,
attestations: parsed.attestations,
bitcoinAttestations: verified,
trustMode,
errors
};
}
@@ -1819,6 +2069,10 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
* Store OTS workflow data in localStorage.
* Existing metadata is preserved unless explicitly overwritten.
*
* G56-12: If metadata includes `proofCarrierEvent` and/or `kind1Event`, the complete
* event JSON is persisted so resume can validate bindings without relying solely
* on relay fetches.
*
* @param {string} eventId - The NIP-QR event id
* @param {Uint8Array} otsBytes - Current detached .ots bytes
* @param {object} metadata - Workflow metadata to merge
@@ -1838,6 +2092,13 @@ export function savePendingOts(eventId, otsBytes, metadata = {}) {
timestamp: existing.timestamp || Date.now(),
updatedAt: Date.now()
};
// G56-12: persist complete event JSON if provided
if (metadata.proofCarrierEvent) {
data.proofCarrierEventJson = JSON.stringify(metadata.proofCarrierEvent);
}
if (metadata.kind1Event) {
data.kind1EventJson = JSON.stringify(metadata.kind1Event);
}
localStorage.setItem('pq-pending-ots', JSON.stringify(data));
}
+609
View File
@@ -0,0 +1,609 @@
import {
verifyNIPQRContent,
verifyNostrEvent,
validateSignerOutput,
NIP_QR_KIND,
buildUpgradedEvent,
buildProofCarrier,
selectCanonicalProofCarrier,
bytesToBase64,
base64ToBytes,
hexToBytes,
bytesToHex,
isOtsConfirmed,
verifyOtsProof,
upgradeOts,
isDetachedOtsFile
} from './pq-crypto.bundle.js';
/* ================================================================
TAB MANAGEMENT
================================================================ */
const tabRelay = document.getElementById('pqTabRelay');
const tabPaste = document.getElementById('pqTabPaste');
const panelRelay = document.getElementById('pqPanelRelay');
const panelPaste = document.getElementById('pqPanelPaste');
function switchTab(which) {
const isRelay = which === 'relay';
tabRelay.classList.toggle('pq-tab-active', isRelay);
tabPaste.classList.toggle('pq-tab-active', !isRelay);
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
panelPaste.classList.toggle('pq-tab-panel-active', !isRelay);
}
tabRelay.addEventListener('click', () => switchTab('relay'));
tabPaste.addEventListener('click', () => switchTab('paste'));
/* ================================================================
SHARED DOM + STATE
================================================================ */
const resultsWrap = document.getElementById('pqResultsWrap');
const resultList = document.getElementById('pqResultList');
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
const downloadEventBtn = document.getElementById('pqDownloadEventBtn');
const eventJsonEl = document.getElementById('pqEventJson');
const otsBadge = document.getElementById('pqOtsBadge');
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
const otsInfo = document.getElementById('pqOtsInfo');
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
let currentEvent = null;
let currentOtsBytes = null;
let currentExpectedAuthor = null;
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
function setStatus(element, type, message) {
const div = document.createElement('div');
div.className = `pq-status pq-status-${type}`;
div.textContent = message;
element.innerHTML = '';
element.appendChild(div);
}
function addResult(algorithm, valid, detail) {
const item = document.createElement('div');
item.className = `pq-result-item ${valid ? 'pq-result-valid' : 'pq-result-invalid'}`;
item.textContent = `${valid ? 'PASS' : 'FAIL'}${algorithm}${detail ? ': ' + detail : ''}`;
resultList.appendChild(item);
}
// F-H3: Helper to set OTS badge (static HTML, safe) and info (textContent, safe)
function setOtsBadge(className, text) {
otsBadge.innerHTML = `<span class="pq-ots-badge ${className}"></span>`;
otsBadge.querySelector('span').textContent = text;
}
function setOtsInfo(text) {
otsInfo.textContent = text;
}
/* ================================================================
NPUB <-> HEX CONVERSION (NIP-19)
================================================================ */
function npubToHex(npub) {
try {
const decoded = window.NostrTools.nip19.decode(npub);
if (decoded.type === 'npub') return decoded.data;
} catch (e) { /* fall through */ }
return null;
}
function hexToNpub(hex) {
try {
return window.NostrTools.nip19.npubEncode(hex);
} catch (e) { return hex; }
}
function normalizePubkey(input) {
const trimmed = input.trim();
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase();
if (trimmed.startsWith('npub1')) {
const hex = npubToHex(trimmed);
if (hex) return hex;
}
return null;
}
/* ================================================================
QUERY RELAY FOR PROOF CARRIER EVENTS (fetch ALL candidates)
================================================================ */
function queryRelayForEvents(pubkeyHex, relayUrl) {
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(relayUrl);
let resolved = false;
const events = [];
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
try { ws.close(); } catch (e) {}
resolve(events);
}
}, 15000);
ws.onopen = () => {
// REQ for all proof carrier events — no limit (G56-02 fix)
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex] }]));
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'EVENT') {
if (data[2]) events.push(data[2]);
} else if (data[0] === 'EOSE') {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
try { ws.close(); } catch (e) {}
resolve(events);
}
}
} catch (e) {}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error('connection error'));
}
};
} catch (e) {
reject(e);
}
});
}
/* ================================================================
VERIFY EVENT (shared by both tabs)
================================================================ */
async function verifyEvent(event) {
currentEvent = event;
resultList.innerHTML = '';
resultsWrap.style.display = 'none';
eventJsonWrap.style.display = 'none';
otsUpgradeWrap.style.display = 'none';
otsBadge.innerHTML = '';
// Display full proof carrier JSON
eventJsonEl.textContent = JSON.stringify(event, null, 2);
eventJsonWrap.style.display = 'block';
let allValid = true;
// 1. Verify proof carrier secp256k1 signature
let secpValid = false;
try {
secpValid = verifyNostrEvent(event);
} catch (e) {
secpValid = false;
addResult('secp256k1 (proof carrier)', false, `verification threw: ${e.message}`);
}
if (secpValid) {
addResult('secp256k1 (proof carrier)', true, 'valid');
} else if (!resultsWrap.querySelector('.pq-result-item')) {
addResult('secp256k1 (proof carrier)', false, 'INVALID');
}
if (!secpValid) allValid = false;
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag (strict policy)
// G56-03: pass outer pubkey and expected author for identity binding
let pqResults;
try {
pqResults = verifyNIPQRContent(event.content, event.tags, {
outerPubkey: event.pubkey,
expectedAuthor: currentExpectedAuthor || undefined
});
} catch (e) {
pqResults = {
valid: false,
results: [{ algorithm: 'verifyNIPQRContent', valid: false, note: `threw: ${e.message}` }],
kind1Event: null,
fullHash: null,
sha256Valid: false,
eTagValid: false,
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
errors: [`verifyNIPQRContent threw: ${e.message}`]
};
}
for (const r of pqResults.results) {
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
if (!r.valid) allValid = false;
}
// 2b. Display policy sufficiency as a distinct result (G56-01)
if (pqResults.kind1Event) {
addResult(
'PQ algorithm policy',
pqResults.policySufficient,
pqResults.policySufficient
? 'all mandatory algorithms present and verified'
: (pqResults.errors.length > 0 ? pqResults.errors.join('; ') : 'insufficient PQ evidence')
);
if (!pqResults.policySufficient) allValid = false;
}
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
// First do a quick structural check for immediate UI feedback, then
// run full cryptographic verification (async — fetches Bitcoin block
// headers and validates the Merkle path).
const otsTag = event.tags.find(t => t[0] === 'ots');
const sha256Tag = event.tags.find(t => t[0] === 'sha256');
if (otsTag && otsTag[1]) {
try {
currentOtsBytes = base64ToBytes(otsTag[1]);
const hasBitcoinAttestation = isOtsConfirmed(currentOtsBytes);
const validFile = isDetachedOtsFile(currentOtsBytes);
const fullHash = pqResults.fullHash;
const hashMatchNote = (sha256Tag && fullHash)
? (sha256Tag[1].toLowerCase() === fullHash.toLowerCase()
? `Hash matches full kind 1 event: ${fullHash.substring(0, 16)}...`
: `WARNING: sha256 tag (${sha256Tag[1].substring(0, 16)}...) does not match computed hash (${fullHash.substring(0, 16)}...)`)
: 'No sha256 tag found';
// Quick structural display first
if (!validFile) {
setOtsBadge('pq-ots-badge-none', 'OTS: Invalid format');
setOtsInfo(`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'none';
} else if (hasBitcoinAttestation) {
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verifying...');
setOtsInfo(`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
// Run full async verification: parse the proof, bind the target
// digest to the sha256 tag, walk the Merkle path, and check the
// block header against a Bitcoin API.
verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined).then(result => {
if (result.verified && result.bitcoinAttestations.length > 0) {
const att = result.bitcoinAttestations[0];
const date = new Date(att.time * 1000).toISOString().substring(0, 19);
const trustLabel = result.trustMode === 'multi-explorer-checked'
? 'multi-explorer-checked (cross-verified)'
: result.trustMode === 'single-explorer-checked'
? 'single-explorer-checked (trusted API)'
: result.trustMode || 'unknown';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Trust mode: ${trustLabel} — block header is trusted from explorer API(s), not independently verified against PoW. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'none';
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
setOtsInfo(`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
otsUpgradeBtn.style.display = 'inline-block';
}
}).catch(err => {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'inline-block';
});
} else {
setOtsBadge('pq-ots-badge-pending', 'OTS: Pending');
setOtsInfo(`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'inline-block';
otsRepublishBtn.style.display = 'none';
}
} catch (e) {
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
otsUpgradeWrap.style.display = 'none';
}
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: None');
setOtsInfo('No OpenTimestamps proof tag found in this event.');
otsUpgradeWrap.style.display = 'none';
}
resultsWrap.style.display = 'block';
return allValid;
}
/* ================================================================
TAB 1: QUERY RELAY
================================================================ */
const queryBtn = document.getElementById('pqQueryBtn');
const queryStatus = document.getElementById('pqQueryStatus');
queryBtn.addEventListener('click', async () => {
queryBtn.disabled = true;
setStatus(queryStatus, 'info', 'Querying relays...');
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
const relayInput = document.getElementById('pqRelayInput').value.trim();
const pubkeyHex = normalizePubkey(pubkeyInput);
if (!pubkeyHex) {
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
queryBtn.disabled = false;
return;
}
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
queryBtn.disabled = false;
return;
}
let allCandidates = [];
let lastError = null;
let successCount = 0;
// G56-02: Query relays in parallel, collect ALL candidates (not limit: 1)
const promises = relayUrls.map(async (url) => {
try {
const events = await queryRelayForEvents(pubkeyHex, url);
if (events && events.length > 0) {
successCount++;
allCandidates.push(...events);
}
return { url, ok: true, count: events ? events.length : 0 };
} catch (e) {
lastError = e.message;
return { url, ok: false, error: e.message };
}
});
await Promise.all(promises);
// Deduplicate by event ID
const seenIds = new Set();
allCandidates = allCandidates.filter(e => {
if (!e || !e.id || seenIds.has(e.id)) return false;
seenIds.add(e.id);
return true;
});
if (allCandidates.length === 0) {
setStatus(queryStatus, 'error', `No proof carrier events found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
queryBtn.disabled = false;
return;
}
const npub = hexToNpub(pubkeyHex);
setStatus(queryStatus, 'info', `Found ${allCandidates.length} proof carrier event(s) from ${npub.substring(0, 20)}... across ${successCount}/${relayUrls.length} relay(s). Selecting canonical (earliest Bitcoin anchor)...`);
// G56-02: Select the canonical proof carrier (earliest valid Bitcoin anchor)
// First, parse the kind 1 event from the first candidate to use for selection
let kind1Event = null;
try {
kind1Event = JSON.parse(allCandidates[0].content);
} catch (e) {
// Try other candidates
for (const c of allCandidates) {
try {
kind1Event = JSON.parse(c.content);
break;
} catch (e2) { /* keep trying */ }
}
}
if (!kind1Event) {
setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.');
queryBtn.disabled = false;
return;
}
// Set the expected author for G56-03 identity binding
currentExpectedAuthor = pubkeyHex;
try {
const selection = await selectCanonicalProofCarrier(allCandidates, kind1Event, pubkeyHex);
if (selection.canonical) {
const heightNote = selection.canonicalBitcoinHeight ? `Bitcoin block ${selection.canonicalBitcoinHeight}` : 'pending (no Bitcoin anchor yet)';
const confirmedCount = selection.confirmedCandidates.length;
const pendingCount = selection.pendingCandidates.length;
setStatus(queryStatus, 'success', `Selected canonical proof carrier: ${selection.canonical.id.substring(0, 16)}... (${heightNote}). ${confirmedCount} confirmed, ${pendingCount} pending, ${allCandidates.length} total candidate(s).`);
await verifyEvent(selection.canonical);
} else {
setStatus(queryStatus, 'error', `No valid proof carrier found among ${allCandidates.length} candidate(s). Errors: ${selection.errors.join('; ')}`);
}
} catch (e) {
setStatus(queryStatus, 'error', `Canonical selection failed: ${e.message}`);
}
queryBtn.disabled = false;
});
/* ================================================================
TAB 2: PASTE EVENT JSON
================================================================ */
const verifyBtn = document.getElementById('pqVerifyBtn');
const eventInput = document.getElementById('pqEventInput');
const verifyStatus = document.getElementById('pqVerifyStatus');
verifyBtn.addEventListener('click', async () => {
verifyBtn.disabled = true;
setStatus(verifyStatus, 'info', 'Verifying...');
try {
const event = JSON.parse(eventInput.value.trim());
if (!event || !event.pubkey || !event.sig || !event.content || !event.tags) {
throw new Error('Invalid event: missing required fields (pubkey, sig, content, tags)');
}
if (event.kind !== NIP_QR_KIND) {
setStatus(verifyStatus, 'error', `Warning: event kind is ${event.kind}, expected ${NIP_QR_KIND}. Verifying anyway...`);
}
const allValid = await verifyEvent(event);
if (allValid) {
setStatus(verifyStatus, 'success', 'All signatures and PQ algorithm policy verified successfully!');
} else {
setStatus(verifyStatus, 'error', 'Some checks failed verification. See results below for details.');
}
} catch (error) {
console.error('[verify] Error:', error);
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
} finally {
verifyBtn.disabled = false;
}
});
/* ================================================================
DOWNLOAD EVENT JSON
================================================================ */
downloadEventBtn.addEventListener('click', () => {
if (!currentEvent) return;
const blob = new Blob([JSON.stringify(currentEvent, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (currentEvent.id ? currentEvent.id.substring(0, 16) : 'event') + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
/* ================================================================
OTS UPGRADE (from verify page)
================================================================ */
otsUpgradeBtn.addEventListener('click', async () => {
if (!currentOtsBytes) return;
otsUpgradeBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Sending .ots proof to upgrade service...');
try {
const result = await upgradeOts(currentOtsBytes);
currentOtsBytes = result.proof;
// After upgrading, run full cryptographic verification to confirm
// the Bitcoin attestation is real (not just a byte-pattern match).
const sha256Tag = currentEvent && currentEvent.tags ? currentEvent.tags.find(t => t[0] === 'sha256') : null;
const verifyResult = await verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
const confirmed = verifyResult.verified;
if (confirmed) {
const att = verifyResult.bitcoinAttestations[0];
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
const trustLabel = verifyResult.trustMode === 'multi-explorer-checked'
? 'multi-explorer-checked (cross-verified)'
: verifyResult.trustMode === 'single-explorer-checked'
? 'single-explorer-checked (trusted API)'
: verifyResult.trustMode || 'unknown';
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Trust mode: ${trustLabel}. Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'inline-block';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att ? att.height : '?'}. Trust mode: ${trustLabel}. Proof size: ${currentOtsBytes.length} bytes.`);
} else {
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
}
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Upgrade failed: ${error.message}`);
} finally {
otsUpgradeBtn.disabled = false;
}
});
otsRepublishBtn.addEventListener('click', async () => {
if (!currentEvent || !currentOtsBytes) return;
otsRepublishBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Requesting secp256k1 signature for upgraded event...');
try {
if (!window.nostr || !window.nostr.signEvent) {
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to publish the upgraded event.');
}
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
// G56-05: validate signer output before publishing
const validatedEvent = validateSignerOutput(signedEvent, upgradedTemplate, currentEvent.pubkey);
// Publish to the relays from the relay input field
const relayInput = document.getElementById('pqRelayInput').value.trim();
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
throw new Error('No relay URLs specified in the relay input field.');
}
const eventJson = JSON.stringify(['EVENT', validatedEvent]);
const publishResults = await Promise.all(relayUrls.map(url => {
return new Promise((resolve) => {
try {
const ws = new WebSocket(url);
let done = false;
const t = setTimeout(() => { if (!done) { done = true; try { ws.close(); } catch(e){} resolve({ url, success: false, error: 'timeout' }); } }, 15000);
ws.onopen = () => ws.send(eventJson);
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'OK' && data[1] === validatedEvent.id) {
if (!done) { done = true; clearTimeout(t); try { ws.close(); } catch(e){} resolve({ url, success: true }); }
}
} catch (e) {}
};
ws.onclose = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'closed without OK' }); } };
ws.onerror = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'error' }); } };
} catch (e) { resolve({ url, success: false, error: e.message }); }
});
}));
const ok = publishResults.filter(r => r.success).length;
const fail = publishResults.filter(r => !r.success).length;
if (ok === 0) {
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
}
currentEvent = validatedEvent;
setStatus(otsUpgradeStatus, 'success', `Upgraded proof carrier published to ${ok} relay(s) (${fail} failed).`);
// Re-verify the new event
await verifyEvent(validatedEvent);
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Publish failed: ${error.message}`);
} finally {
otsRepublishBtn.disabled = false;
}
});
/* ================================================================
AUTO-DETECT SIGNED-IN USER
On page load, check for a NIP-07 signer / nostr-login-lite. If the
user is already signed in, pre-fill the pubkey field and auto-query
relays for their kind 9999 event.
================================================================ */
async function initAutoDetect() {
// Initialize nostr-login-lite if available (shares session with main page)
if (window.NOSTR_LOGIN_LITE) {
try {
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: { extension: true, local: true, nip46: true, seedphrase: true, nsigner: true }
});
}
} catch (e) {
console.log('[verify] nostr-login-lite init failed:', e.message);
}
}
if (!window.nostr || !window.nostr.getPublicKey) return;
let pubkeyHex = null;
try {
pubkeyHex = await window.nostr.getPublicKey();
} catch (e) {
console.log('[verify] Not signed in:', e.message);
return;
}
if (!pubkeyHex) return;
console.log('[verify] Auto-detected signed-in pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
// Auto-trigger the query
queryBtn.click();
}
initAutoDetect();
+21
View File
@@ -0,0 +1,21 @@
/**
* Version display: fetch version.json and update title + header.
* Extracted from inline <script> for CSP compliance (G56-08).
*
* Auto-detects the page title prefix from the existing <title> tag.
*/
(function () {
// Derive the title prefix from the existing <title> element
// (e.g. "Post-Quantum Nostr" or "Verify NIP-QR Event")
var titlePrefix = document.title || '';
fetch('./js/version.json')
.then(function (r) { return r.json(); })
.then(function (v) {
var versionText = v.VERSION || '';
document.title = titlePrefix + ' ' + versionText;
var header = document.getElementById('divHeaderText');
if (header) header.textContent = titlePrefix + ' ' + versionText;
})
.catch(function () { /* version.json not found — keep default title */ });
})();
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.17",
"VERSION_NUMBER": "0.0.17",
"BUILD_DATE": "2026-07-24T12:44:54.874Z"
"VERSION": "v0.0.25",
"VERSION_NUMBER": "0.0.25",
"BUILD_DATE": "2026-07-24T18:43:29.373Z"
}
+229 -51
View File
@@ -9761,22 +9761,115 @@ function hexToNpub(hexPubkey) {
return bech32.encode("npub", bech32.toWords(bytes));
}
function verifyNostrEvent(event) {
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = sha256(new TextEncoder().encode(serialized));
const computedId = bytesToHex3(hash);
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
try {
if (!event || typeof event !== "object") return false;
if (typeof event.pubkey !== "string" || typeof event.sig !== "string") return false;
if (typeof event.created_at !== "number") return false;
if (typeof event.kind !== "number") return false;
if (!Array.isArray(event.tags)) return false;
if (typeof event.content !== "string") return false;
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = sha256(new TextEncoder().encode(serialized));
const computedId = bytesToHex3(hash);
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
return false;
}
const sig = hexToBytes3(event.sig);
const pubkey = hexToBytes3(event.pubkey);
return schnorr.verify(sig, hash, pubkey);
} catch (e) {
return false;
}
const sig = hexToBytes3(event.sig);
const pubkey = hexToBytes3(event.pubkey);
return schnorr.verify(sig, hash, pubkey);
}
function validateSignerOutput(signedEvent, template, expectedPubkey) {
if (!signedEvent || typeof signedEvent !== "object") {
throw new Error("validateSignerOutput: signer returned non-object");
}
const required = ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"];
for (const field of required) {
if (!(field in signedEvent)) {
throw new Error(`validateSignerOutput: missing field '${field}'`);
}
}
if (typeof signedEvent.id !== "string" || !/^[0-9a-f]{64}$/.test(signedEvent.id)) {
throw new Error("validateSignerOutput: id must be 64 lowercase hex chars");
}
if (typeof signedEvent.pubkey !== "string" || !/^[0-9a-f]{64}$/.test(signedEvent.pubkey)) {
throw new Error("validateSignerOutput: pubkey must be 64 lowercase hex chars");
}
if (typeof signedEvent.sig !== "string" || !/^[0-9a-f]{128}$/.test(signedEvent.sig)) {
throw new Error("validateSignerOutput: sig must be 128 lowercase hex chars");
}
if (typeof signedEvent.created_at !== "number") {
throw new Error("validateSignerOutput: created_at must be a number");
}
if (typeof signedEvent.kind !== "number") {
throw new Error("validateSignerOutput: kind must be a number");
}
if (!Array.isArray(signedEvent.tags)) {
throw new Error("validateSignerOutput: tags must be an array");
}
if (typeof signedEvent.content !== "string") {
throw new Error("validateSignerOutput: content must be a string");
}
if (signedEvent.kind !== template.kind) {
throw new Error(`validateSignerOutput: kind changed (template=${template.kind}, signed=${signedEvent.kind})`);
}
if (signedEvent.content !== template.content) {
throw new Error("validateSignerOutput: content was changed by signer");
}
if (signedEvent.created_at !== template.created_at) {
throw new Error(`validateSignerOutput: created_at changed (template=${template.created_at}, signed=${signedEvent.created_at})`);
}
if (signedEvent.tags.length !== template.tags.length) {
throw new Error(`validateSignerOutput: tags length changed (template=${template.tags.length}, signed=${signedEvent.tags.length})`);
}
for (let i = 0; i < template.tags.length; i++) {
const tTag = template.tags[i];
const sTag = signedEvent.tags[i];
if (!Array.isArray(sTag) || sTag.length !== tTag.length) {
throw new Error(`validateSignerOutput: tag ${i} shape changed`);
}
for (let j = 0; j < tTag.length; j++) {
if (sTag[j] !== tTag[j]) {
throw new Error(`validateSignerOutput: tag ${i} element ${j} changed`);
}
}
}
if (expectedPubkey && signedEvent.pubkey !== expectedPubkey) {
throw new Error(`validateSignerOutput: pubkey mismatch (expected=${expectedPubkey?.substring(0, 16)}..., got=${signedEvent.pubkey.substring(0, 16)}...)`);
}
const computedId = computeEventId(signedEvent);
if (signedEvent.id !== computedId) {
throw new Error(`validateSignerOutput: id mismatch (signed=${signedEvent.id.substring(0, 16)}..., computed=${computedId.substring(0, 16)}...)`);
}
const hash = sha256(new TextEncoder().encode(JSON.stringify([
0,
signedEvent.pubkey,
signedEvent.created_at,
signedEvent.kind,
signedEvent.tags,
signedEvent.content
])));
const sig = hexToBytes3(signedEvent.sig);
const pubkey = hexToBytes3(signedEvent.pubkey);
if (!schnorr.verify(sig, hash, pubkey)) {
throw new Error("validateSignerOutput: invalid Schnorr signature");
}
const allowedFields = /* @__PURE__ */ new Set(["id", "pubkey", "created_at", "kind", "tags", "content", "sig"]);
for (const key of Object.keys(signedEvent)) {
if (!allowedFields.has(key)) {
throw new Error(`validateSignerOutput: unexpected extra field '${key}'`);
}
}
return signedEvent;
}
var NIP_QR_KIND = 9999;
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
@@ -9886,6 +9979,20 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}) {
const results = [];
const errors = [];
const { expectedAuthor, outerPubkey } = options;
if (typeof kind11112Content === "string" && kind11112Content.length > MAX_EVENT_JSON_SIZE) {
return {
results: [{ algorithm: "event", valid: false, note: "content exceeds maximum size" }],
kind1Event: null,
fullHash: null,
sha256Valid: false,
eTagValid: false,
pqProofsValid: false,
policySufficient: false,
identityBound: false,
validForMigration: false,
errors: ["content exceeds maximum size"]
};
}
function pushResult(algorithm, valid, note) {
results.push({ algorithm, valid, note });
if (!valid) errors.push(`${algorithm}: ${note || "INVALID"}`);
@@ -10410,7 +10517,7 @@ function isOtsConfirmed(otsBytes) {
return false;
}
}
var OtsReader = class {
var _OtsReader = class _OtsReader {
constructor(bytes) {
this.bytes = bytes;
this.pos = 0;
@@ -10425,14 +10532,23 @@ var OtsReader = class {
this.pos += n;
return out;
}
// 5 bytes is enough for 32-bit values
readVaruint() {
let value = 0;
let shift = 0;
let bytesRead = 0;
let b;
do {
b = this.readByte();
value |= (b & 127) << shift;
bytesRead++;
if (bytesRead > _OtsReader.MAX_VARUINT_BYTES) {
throw new Error("OTS: varuint encoding too long");
}
value += (b & 127) * Math.pow(2, shift);
shift += 7;
if (value > _OtsReader.MAX_VARUINT) {
throw new Error(`OTS: varuint value ${value} exceeds maximum ${_OtsReader.MAX_VARUINT}`);
}
} while (b & 128);
return value;
}
@@ -10445,7 +10561,14 @@ var OtsReader = class {
return this.bytes.length - this.pos;
}
};
// G56-13: Maximum varuint value and encoding length to prevent overflow
__publicField(_OtsReader, "MAX_VARUINT", 4294967295);
// 32-bit max — OTS heights/lengths won't exceed this
__publicField(_OtsReader, "MAX_VARUINT_BYTES", 5);
var OtsReader = _OtsReader;
var OTS_MAGIC = hexToBytes3("004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294");
var MAX_OTS_PROOF_SIZE = 1 << 20;
var MAX_EVENT_JSON_SIZE = 1 << 16;
var BITCOIN_ATTESTATION_TAG = hexToBytes3("0588960d73d71901");
var LITECOIN_ATTESTATION_TAG = hexToBytes3("06869a0d73d71b45");
var PENDING_ATTESTATION_TAG = hexToBytes3("83dfe30d2ef90c8e");
@@ -10456,49 +10579,69 @@ function bytesEqual(a, b) {
}
function parseOtsFile(otsBytes) {
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_MAGIC.length) return null;
const reader = new OtsReader(otsBytes);
const magic = reader.readBytes(OTS_MAGIC.length);
if (!bytesEqual(magic, OTS_MAGIC)) return null;
const version = reader.readVaruint();
if (version !== 1) return null;
const hashOpTag = reader.readByte();
let fileHashOp = "unknown";
let digestLen = 32;
if (hashOpTag === 8) {
fileHashOp = "sha256";
digestLen = 32;
} else if (hashOpTag === 2) {
fileHashOp = "sha1";
digestLen = 20;
} else if (hashOpTag === 3) {
fileHashOp = "ripemd160";
digestLen = 20;
} else {
if (otsBytes.length > MAX_OTS_PROOF_SIZE) return null;
try {
const reader = new OtsReader(otsBytes);
const magic = reader.readBytes(OTS_MAGIC.length);
if (!bytesEqual(magic, OTS_MAGIC)) return null;
const version = reader.readVaruint();
if (version !== 1) return null;
const hashOpTag = reader.readByte();
let fileHashOp = "unknown";
let digestLen = 32;
if (hashOpTag === 8) {
fileHashOp = "sha256";
digestLen = 32;
} else if (hashOpTag === 2) {
fileHashOp = "sha1";
digestLen = 20;
} else if (hashOpTag === 3) {
fileHashOp = "ripemd160";
digestLen = 20;
} else {
return null;
}
const targetDigest = reader.readBytes(digestLen);
const attestations = [];
const budget = { operations: 0, branches: 0 };
_parseTimestamp(reader, targetDigest, attestations, 0, budget);
return { fileHashOp, targetDigest, attestations };
} catch (e) {
return null;
}
const targetDigest = reader.readBytes(digestLen);
const attestations = [];
_parseTimestamp(reader, targetDigest, attestations, 0);
return { fileHashOp, targetDigest, attestations };
}
function _parseTimestamp(reader, msg, attestations, depth) {
var OTS_MAX_OPERATIONS = 1e4;
var OTS_MAX_BRANCHES = 1e3;
function _parseTimestamp(reader, msg, attestations, depth, budget) {
if (depth > 512) throw new Error("OTS: recursion limit exceeded");
let tag = reader.readByte();
while (tag === 255) {
if (budget) {
budget.branches++;
if (budget.branches > OTS_MAX_BRANCHES) {
throw new Error(`OTS: branch count exceeds maximum ${OTS_MAX_BRANCHES}`);
}
}
const current = reader.readByte();
_processTimestampEntry(reader, current, msg, attestations, depth);
_processTimestampEntry(reader, current, msg, attestations, depth, budget);
tag = reader.readByte();
}
_processTimestampEntry(reader, tag, msg, attestations, depth);
_processTimestampEntry(reader, tag, msg, attestations, depth, budget);
}
function _processTimestampEntry(reader, tag, msg, attestations, depth) {
function _processTimestampEntry(reader, tag, msg, attestations, depth, budget) {
if (budget) {
budget.operations++;
if (budget.operations > OTS_MAX_OPERATIONS) {
throw new Error(`OTS: operation count exceeds maximum ${OTS_MAX_OPERATIONS}`);
}
}
if (tag === 0) {
const attTag = reader.readBytes(8);
const payload = reader.readVarbytes(8192);
_classifyAttestation(attTag, payload, msg, attestations);
} else {
const result = _applyOp(reader, tag, msg);
_parseTimestamp(reader, result, attestations, depth + 1);
_parseTimestamp(reader, result, attestations, depth + 1, budget);
}
}
function _applyOp(reader, tag, msg) {
@@ -10542,6 +10685,7 @@ var BITCOIN_API_PROVIDERS = [
{ name: "mempool", blockHash: (h) => `https://mempool.space/api/block-height/${h}`, block: (hash) => `https://mempool.space/api/block/${hash}` }
];
async function fetchBitcoinBlockHeader(height) {
const results = [];
for (const provider of BITCOIN_API_PROVIDERS) {
try {
const hashResp = await fetch(provider.blockHash(height), { headers: { "Accept": "text/plain" } });
@@ -10553,17 +10697,37 @@ async function fetchBitcoinBlockHeader(height) {
const blockData = await blockResp.json();
if (!blockData.merkle_root && !blockData.merkleroot) continue;
if (!blockData.timestamp && !blockData.time) continue;
return {
merkleroot: blockData.merkle_root || blockData.merkleroot,
results.push({
provider: provider.name,
merkleroot: (blockData.merkle_root || blockData.merkleroot).toLowerCase(),
time: blockData.timestamp || blockData.time,
height
};
});
} catch (e) {
console.warn(`[ots] Bitcoin API ${provider.name} failed for height ${height}:`, e.message);
continue;
}
}
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
if (results.length === 0) {
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
}
if (results.length > 1) {
const firstRoot = results[0].merkleroot;
for (let i = 1; i < results.length; i++) {
if (results[i].merkleroot !== firstRoot) {
const providerList = results.map((r) => `${r.provider}:${r.merkleroot.substring(0, 16)}...`).join(", ");
throw new Error(`Bitcoin providers disagree on merkle root for block ${height}: ${providerList}`);
}
}
}
const trustMode = results.length >= 2 ? "multi-explorer-checked" : "single-explorer-checked";
return {
merkleroot: results[0].merkleroot,
time: results[0].time,
height,
trustMode,
providers: results.map((r) => r.provider)
};
}
async function verifyOtsProof(otsBytes, expectedDigestHex) {
const errors = [];
@@ -10571,10 +10735,10 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
try {
parsed = parseOtsFile(otsBytes);
} catch (e) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: [`Failed to parse OTS file: ${e.message}`] };
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: "structural-only", errors: [`Failed to parse OTS file: ${e.message}`] };
}
if (!parsed) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: ["Invalid OTS file format"] };
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: "structural-only", errors: ["Invalid OTS file format"] };
}
const targetDigestHex = bytesToHex3(parsed.targetDigest);
if (expectedDigestHex) {
@@ -10582,7 +10746,7 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
const actual = targetDigestHex.toLowerCase();
if (expected !== actual) {
errors.push(`Target digest mismatch: proof commits to ${actual.substring(0, 16)}... but expected ${expected.substring(0, 16)}...`);
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: "structural-only", errors };
}
}
const bitcoinAttestations = parsed.attestations.filter((a) => a.type === "bitcoin");
@@ -10593,12 +10757,18 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
} else {
errors.push("Proof contains no Bitcoin attestations");
}
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: "structural-only", errors };
}
const verified = [];
let trustMode = "structural-only";
for (const att of bitcoinAttestations) {
try {
const blockHeader = await fetchBitcoinBlockHeader(att.height);
if (blockHeader.trustMode === "multi-explorer-checked") {
trustMode = "multi-explorer-checked";
} else if (blockHeader.trustMode === "single-explorer-checked" && trustMode === "structural-only") {
trustMode = "single-explorer-checked";
}
const computedMerkleRoot = bytesToHex3(att.digest.slice().reverse());
if (computedMerkleRoot.toLowerCase() === blockHeader.merkleroot.toLowerCase()) {
verified.push({
@@ -10618,6 +10788,7 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
targetDigest: targetDigestHex,
attestations: parsed.attestations,
bitcoinAttestations: verified,
trustMode,
errors
};
}
@@ -10636,6 +10807,12 @@ function savePendingOts(eventId, otsBytes, metadata = {}) {
timestamp: existing.timestamp || Date.now(),
updatedAt: Date.now()
};
if (metadata.proofCarrierEvent) {
data.proofCarrierEventJson = JSON.stringify(metadata.proofCarrierEvent);
}
if (metadata.kind1Event) {
data.kind1EventJson = JSON.stringify(metadata.kind1Event);
}
localStorage.setItem("pq-pending-ots", JSON.stringify(data));
}
function loadPendingOts() {
@@ -10691,6 +10868,7 @@ export {
signWithSLHDSA,
timestampEvent,
upgradeOts,
validateSignerOutput,
verifyFalcon,
verifyMLDSA44,
verifyMLDSA65,
+19 -616
View File
@@ -4,7 +4,8 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none';" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none';" />
<meta name="referrer" content="no-referrer" />
<title>Verify NIP-QR Event</title>
<link rel="stylesheet" href="./css/client.css" />
@@ -253,13 +254,21 @@
<div class="pq-card">
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
<strong>Research prototype.</strong> Verification confirms key linkage and signature
validity; it does not mean the identity is post-quantum secure. Bitcoin OTS confirmation
is API-assisted (trusts a public explorer), not a full light-client verification.
</div>
<div class="pq-info-text">
Verify post-quantum migration events (kind 9999) by querying relays for a user's pubkey,
or by pasting event JSON directly. The kind 9999 event wraps a kind 1 announcement
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks:
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks,
reported as separate result lines:
the kind 9999 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), all PQ signatures
(ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768), and the OpenTimestamps proof.
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), each PQ signature
individually (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512; ML-KEM-768 has no signature),
the PQ algorithm policy (all mandatory algorithms present and verified), identity binding
(outer/embedded/expected author match), and the OpenTimestamps proof.
</div>
<div class="pq-info-text">
<a href="./" class="pq-link">Back to migration page</a>
@@ -347,620 +356,14 @@
</div>
<!-- SCRIPTS -->
<script src="/nostr-login-lite/nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr.bundle.js" integrity="sha384-LNnPDD++DaWxljIhMLmfaoEoKB0B1HmACC6gNGLG+Qnmosprf/AX7u84pVY8xMpM" crossorigin="anonymous"></script>
<script src="/nostr-login-lite/nostr-lite.js" integrity="sha384-IQwa65eDC5trGjKn4cuEazmFiHTHD65gIqsjpzDtouc+j0MlyI927SZgHMWSi2aC" crossorigin="anonymous"></script>
<!-- Version display: fetch version.json and update title + header -->
<script>
fetch('./js/version.json')
.then(r => r.json())
.then(v => {
const versionText = v.VERSION || '';
document.title = 'Verify NIP-QR Event ' + versionText;
const header = document.getElementById('divHeaderText');
if (header) header.textContent = 'Verify NIP-QR Event ' + versionText;
})
.catch(() => { /* version.json not found — keep default title */ });
</script>
<!-- Version display (G56-08: extracted to external file, self-initializing) -->
<script src="./js/version-display.js"></script>
<script type="module">
import {
verifyNIPQRContent,
verifyNostrEvent,
NIP_QR_KIND,
buildUpgradedEvent,
buildProofCarrier,
selectCanonicalProofCarrier,
bytesToBase64,
base64ToBytes,
hexToBytes,
bytesToHex,
isOtsConfirmed,
verifyOtsProof,
upgradeOts,
isDetachedOtsFile
} from './pq-crypto.bundle.js';
/* ================================================================
TAB MANAGEMENT
================================================================ */
const tabRelay = document.getElementById('pqTabRelay');
const tabPaste = document.getElementById('pqTabPaste');
const panelRelay = document.getElementById('pqPanelRelay');
const panelPaste = document.getElementById('pqPanelPaste');
function switchTab(which) {
const isRelay = which === 'relay';
tabRelay.classList.toggle('pq-tab-active', isRelay);
tabPaste.classList.toggle('pq-tab-active', !isRelay);
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
panelPaste.classList.toggle('pq-tab-panel-active', !isRelay);
}
tabRelay.addEventListener('click', () => switchTab('relay'));
tabPaste.addEventListener('click', () => switchTab('paste'));
/* ================================================================
SHARED DOM + STATE
================================================================ */
const resultsWrap = document.getElementById('pqResultsWrap');
const resultList = document.getElementById('pqResultList');
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
const downloadEventBtn = document.getElementById('pqDownloadEventBtn');
const eventJsonEl = document.getElementById('pqEventJson');
const otsBadge = document.getElementById('pqOtsBadge');
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
const otsInfo = document.getElementById('pqOtsInfo');
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
let currentEvent = null;
let currentOtsBytes = null;
let currentExpectedAuthor = null;
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
function setStatus(element, type, message) {
const div = document.createElement('div');
div.className = `pq-status pq-status-${type}`;
div.textContent = message;
element.innerHTML = '';
element.appendChild(div);
}
function addResult(algorithm, valid, detail) {
const item = document.createElement('div');
item.className = `pq-result-item ${valid ? 'pq-result-valid' : 'pq-result-invalid'}`;
item.textContent = `${valid ? 'PASS' : 'FAIL'} — ${algorithm}${detail ? ': ' + detail : ''}`;
resultList.appendChild(item);
}
// F-H3: Helper to set OTS badge (static HTML, safe) and info (textContent, safe)
function setOtsBadge(className, text) {
otsBadge.innerHTML = `<span class="pq-ots-badge ${className}"></span>`;
otsBadge.querySelector('span').textContent = text;
}
function setOtsInfo(text) {
otsInfo.textContent = text;
}
/* ================================================================
NPUB <-> HEX CONVERSION (NIP-19)
================================================================ */
function npubToHex(npub) {
try {
const decoded = window.NostrTools.nip19.decode(npub);
if (decoded.type === 'npub') return decoded.data;
} catch (e) { /* fall through */ }
return null;
}
function hexToNpub(hex) {
try {
return window.NostrTools.nip19.npubEncode(hex);
} catch (e) { return hex; }
}
function normalizePubkey(input) {
const trimmed = input.trim();
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase();
if (trimmed.startsWith('npub1')) {
const hex = npubToHex(trimmed);
if (hex) return hex;
}
return null;
}
/* ================================================================
QUERY RELAY FOR PROOF CARRIER EVENTS (fetch ALL candidates)
================================================================ */
function queryRelayForEvents(pubkeyHex, relayUrl) {
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(relayUrl);
let resolved = false;
const events = [];
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
try { ws.close(); } catch (e) {}
resolve(events);
}
}, 15000);
ws.onopen = () => {
// REQ for all proof carrier events — no limit (G56-02 fix)
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex] }]));
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'EVENT') {
if (data[2]) events.push(data[2]);
} else if (data[0] === 'EOSE') {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
try { ws.close(); } catch (e) {}
resolve(events);
}
}
} catch (e) {}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error('connection error'));
}
};
} catch (e) {
reject(e);
}
});
}
/* ================================================================
VERIFY EVENT (shared by both tabs)
================================================================ */
async function verifyEvent(event) {
currentEvent = event;
resultList.innerHTML = '';
resultsWrap.style.display = 'none';
eventJsonWrap.style.display = 'none';
otsUpgradeWrap.style.display = 'none';
otsBadge.innerHTML = '';
// Display full proof carrier JSON
eventJsonEl.textContent = JSON.stringify(event, null, 2);
eventJsonWrap.style.display = 'block';
let allValid = true;
// 1. Verify proof carrier secp256k1 signature
let secpValid = false;
try {
secpValid = verifyNostrEvent(event);
} catch (e) {
secpValid = false;
addResult('secp256k1 (proof carrier)', false, `verification threw: ${e.message}`);
}
if (secpValid) {
addResult('secp256k1 (proof carrier)', true, 'valid');
} else if (!resultsWrap.querySelector('.pq-result-item')) {
addResult('secp256k1 (proof carrier)', false, 'INVALID');
}
if (!secpValid) allValid = false;
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag (strict policy)
// G56-03: pass outer pubkey and expected author for identity binding
let pqResults;
try {
pqResults = verifyNIPQRContent(event.content, event.tags, {
outerPubkey: event.pubkey,
expectedAuthor: currentExpectedAuthor || undefined
});
} catch (e) {
pqResults = {
valid: false,
results: [{ algorithm: 'verifyNIPQRContent', valid: false, note: `threw: ${e.message}` }],
kind1Event: null,
fullHash: null,
sha256Valid: false,
eTagValid: false,
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
errors: [`verifyNIPQRContent threw: ${e.message}`]
};
}
for (const r of pqResults.results) {
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
if (!r.valid) allValid = false;
}
// 2b. Display policy sufficiency as a distinct result (G56-01)
if (pqResults.kind1Event) {
addResult(
'PQ algorithm policy',
pqResults.policySufficient,
pqResults.policySufficient
? 'all mandatory algorithms present and verified'
: (pqResults.errors.length > 0 ? pqResults.errors.join('; ') : 'insufficient PQ evidence')
);
if (!pqResults.policySufficient) allValid = false;
}
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
// First do a quick structural check for immediate UI feedback, then
// run full cryptographic verification (async — fetches Bitcoin block
// headers and validates the Merkle path).
const otsTag = event.tags.find(t => t[0] === 'ots');
const sha256Tag = event.tags.find(t => t[0] === 'sha256');
if (otsTag && otsTag[1]) {
try {
currentOtsBytes = base64ToBytes(otsTag[1]);
const hasBitcoinAttestation = isOtsConfirmed(currentOtsBytes);
const validFile = isDetachedOtsFile(currentOtsBytes);
const fullHash = pqResults.fullHash;
const hashMatchNote = (sha256Tag && fullHash)
? (sha256Tag[1].toLowerCase() === fullHash.toLowerCase()
? `Hash matches full kind 1 event: ${fullHash.substring(0, 16)}...`
: `WARNING: sha256 tag (${sha256Tag[1].substring(0, 16)}...) does not match computed hash (${fullHash.substring(0, 16)}...)`)
: 'No sha256 tag found';
// Quick structural display first
if (!validFile) {
setOtsBadge('pq-ots-badge-none', 'OTS: Invalid format');
setOtsInfo(`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'none';
} else if (hasBitcoinAttestation) {
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verifying...');
setOtsInfo(`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
// Run full async verification: parse the proof, bind the target
// digest to the sha256 tag, walk the Merkle path, and check the
// block header against a Bitcoin API.
verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined).then(result => {
if (result.verified && result.bitcoinAttestations.length > 0) {
const att = result.bitcoinAttestations[0];
const date = new Date(att.time * 1000).toISOString().substring(0, 19);
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'none';
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
setOtsInfo(`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
otsUpgradeBtn.style.display = 'inline-block';
}
}).catch(err => {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'inline-block';
});
} else {
setOtsBadge('pq-ots-badge-pending', 'OTS: Pending');
setOtsInfo(`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'inline-block';
otsRepublishBtn.style.display = 'none';
}
} catch (e) {
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
otsUpgradeWrap.style.display = 'none';
}
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: None');
setOtsInfo('No OpenTimestamps proof tag found in this event.');
otsUpgradeWrap.style.display = 'none';
}
resultsWrap.style.display = 'block';
return allValid;
}
/* ================================================================
TAB 1: QUERY RELAY
================================================================ */
const queryBtn = document.getElementById('pqQueryBtn');
const queryStatus = document.getElementById('pqQueryStatus');
queryBtn.addEventListener('click', async () => {
queryBtn.disabled = true;
setStatus(queryStatus, 'info', 'Querying relays...');
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
const relayInput = document.getElementById('pqRelayInput').value.trim();
const pubkeyHex = normalizePubkey(pubkeyInput);
if (!pubkeyHex) {
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
queryBtn.disabled = false;
return;
}
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
queryBtn.disabled = false;
return;
}
let allCandidates = [];
let lastError = null;
let successCount = 0;
// G56-02: Query relays in parallel, collect ALL candidates (not limit: 1)
const promises = relayUrls.map(async (url) => {
try {
const events = await queryRelayForEvents(pubkeyHex, url);
if (events && events.length > 0) {
successCount++;
allCandidates.push(...events);
}
return { url, ok: true, count: events ? events.length : 0 };
} catch (e) {
lastError = e.message;
return { url, ok: false, error: e.message };
}
});
await Promise.all(promises);
// Deduplicate by event ID
const seenIds = new Set();
allCandidates = allCandidates.filter(e => {
if (!e || !e.id || seenIds.has(e.id)) return false;
seenIds.add(e.id);
return true;
});
if (allCandidates.length === 0) {
setStatus(queryStatus, 'error', `No proof carrier events found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
queryBtn.disabled = false;
return;
}
const npub = hexToNpub(pubkeyHex);
setStatus(queryStatus, 'info', `Found ${allCandidates.length} proof carrier event(s) from ${npub.substring(0, 20)}... across ${successCount}/${relayUrls.length} relay(s). Selecting canonical (earliest Bitcoin anchor)...`);
// G56-02: Select the canonical proof carrier (earliest valid Bitcoin anchor)
// First, parse the kind 1 event from the first candidate to use for selection
let kind1Event = null;
try {
kind1Event = JSON.parse(allCandidates[0].content);
} catch (e) {
// Try other candidates
for (const c of allCandidates) {
try {
kind1Event = JSON.parse(c.content);
break;
} catch (e2) { /* keep trying */ }
}
}
if (!kind1Event) {
setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.');
queryBtn.disabled = false;
return;
}
// Set the expected author for G56-03 identity binding
currentExpectedAuthor = pubkeyHex;
try {
const selection = await selectCanonicalProofCarrier(allCandidates, kind1Event, pubkeyHex);
if (selection.canonical) {
const heightNote = selection.canonicalBitcoinHeight ? `Bitcoin block ${selection.canonicalBitcoinHeight}` : 'pending (no Bitcoin anchor yet)';
const confirmedCount = selection.confirmedCandidates.length;
const pendingCount = selection.pendingCandidates.length;
setStatus(queryStatus, 'success', `Selected canonical proof carrier: ${selection.canonical.id.substring(0, 16)}... (${heightNote}). ${confirmedCount} confirmed, ${pendingCount} pending, ${allCandidates.length} total candidate(s).`);
await verifyEvent(selection.canonical);
} else {
setStatus(queryStatus, 'error', `No valid proof carrier found among ${allCandidates.length} candidate(s). Errors: ${selection.errors.join('; ')}`);
}
} catch (e) {
setStatus(queryStatus, 'error', `Canonical selection failed: ${e.message}`);
}
queryBtn.disabled = false;
});
/* ================================================================
TAB 2: PASTE EVENT JSON
================================================================ */
const verifyBtn = document.getElementById('pqVerifyBtn');
const eventInput = document.getElementById('pqEventInput');
const verifyStatus = document.getElementById('pqVerifyStatus');
verifyBtn.addEventListener('click', async () => {
verifyBtn.disabled = true;
setStatus(verifyStatus, 'info', 'Verifying...');
try {
const event = JSON.parse(eventInput.value.trim());
if (!event || !event.pubkey || !event.sig || !event.content || !event.tags) {
throw new Error('Invalid event: missing required fields (pubkey, sig, content, tags)');
}
if (event.kind !== NIP_QR_KIND) {
setStatus(verifyStatus, 'error', `Warning: event kind is ${event.kind}, expected ${NIP_QR_KIND}. Verifying anyway...`);
}
const allValid = await verifyEvent(event);
if (allValid) {
setStatus(verifyStatus, 'success', 'All signatures and PQ algorithm policy verified successfully!');
} else {
setStatus(verifyStatus, 'error', 'Some checks failed verification. See results below for details.');
}
} catch (error) {
console.error('[verify] Error:', error);
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
} finally {
verifyBtn.disabled = false;
}
});
/* ================================================================
DOWNLOAD EVENT JSON
================================================================ */
downloadEventBtn.addEventListener('click', () => {
if (!currentEvent) return;
const blob = new Blob([JSON.stringify(currentEvent, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (currentEvent.id ? currentEvent.id.substring(0, 16) : 'event') + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
/* ================================================================
OTS UPGRADE (from verify page)
================================================================ */
otsUpgradeBtn.addEventListener('click', async () => {
if (!currentOtsBytes) return;
otsUpgradeBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Sending .ots proof to upgrade service...');
try {
const result = await upgradeOts(currentOtsBytes);
currentOtsBytes = result.proof;
// After upgrading, run full cryptographic verification to confirm
// the Bitcoin attestation is real (not just a byte-pattern match).
const sha256Tag = currentEvent && currentEvent.tags ? currentEvent.tags.find(t => t[0] === 'sha256') : null;
const verifyResult = await verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
const confirmed = verifyResult.verified;
if (confirmed) {
const att = verifyResult.bitcoinAttestations[0];
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'inline-block';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att ? att.height : '?'}. Proof size: ${currentOtsBytes.length} bytes.`);
} else {
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
}
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Upgrade failed: ${error.message}`);
} finally {
otsUpgradeBtn.disabled = false;
}
});
otsRepublishBtn.addEventListener('click', async () => {
if (!currentEvent || !currentOtsBytes) return;
otsRepublishBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Requesting secp256k1 signature for upgraded event...');
try {
if (!window.nostr || !window.nostr.signEvent) {
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to publish the upgraded event.');
}
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
// Publish to the relays from the relay input field
const relayInput = document.getElementById('pqRelayInput').value.trim();
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
throw new Error('No relay URLs specified in the relay input field.');
}
const eventJson = JSON.stringify(['EVENT', signedEvent]);
const publishResults = await Promise.all(relayUrls.map(url => {
return new Promise((resolve) => {
try {
const ws = new WebSocket(url);
let done = false;
const t = setTimeout(() => { if (!done) { done = true; try { ws.close(); } catch(e){} resolve({ url, success: false, error: 'timeout' }); } }, 15000);
ws.onopen = () => ws.send(eventJson);
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'OK' && data[1] === signedEvent.id) {
if (!done) { done = true; clearTimeout(t); try { ws.close(); } catch(e){} resolve({ url, success: true }); }
}
} catch (e) {}
};
ws.onclose = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'closed without OK' }); } };
ws.onerror = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'error' }); } };
} catch (e) { resolve({ url, success: false, error: e.message }); }
});
}));
const ok = publishResults.filter(r => r.success).length;
const fail = publishResults.filter(r => !r.success).length;
if (ok === 0) {
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
}
currentEvent = signedEvent;
setStatus(otsUpgradeStatus, 'success', `Upgraded proof carrier published to ${ok} relay(s) (${fail} failed).`);
// Re-verify the new event
await verifyEvent(signedEvent);
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Publish failed: ${error.message}`);
} finally {
otsRepublishBtn.disabled = false;
}
});
/* ================================================================
AUTO-DETECT SIGNED-IN USER
On page load, check for a NIP-07 signer / nostr-login-lite. If the
user is already signed in, pre-fill the pubkey field and auto-query
relays for their kind 9999 event.
================================================================ */
async function initAutoDetect() {
// Initialize nostr-login-lite if available (shares session with main page)
if (window.NOSTR_LOGIN_LITE) {
try {
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: { extension: true, local: true, nip46: true, seedphrase: true, nsigner: true }
});
}
} catch (e) {
console.log('[verify] nostr-login-lite init failed:', e.message);
}
}
if (!window.nostr || !window.nostr.getPublicKey) return;
let pubkeyHex = null;
try {
pubkeyHex = await window.nostr.getPublicKey();
} catch (e) {
console.log('[verify] Not signed in:', e.message);
return;
}
if (!pubkeyHex) return;
console.log('[verify] Auto-detected signed-in pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
// Auto-trigger the query
queryBtn.click();
}
initAutoDetect();
</script>
<!-- Main application module (G56-08: extracted to external file for CSP compliance) -->
<script type="module" src="./js/verify-app.mjs"></script>
</body>
</html>