Compare commits

...
6 Commits
Author SHA1 Message Date
Laan Tungir 5ebc6d8d2a G56-10: zeroize secret typed arrays on sign-out (fill(0) before null), clear DOM seed display/input/entropy hint, clear clipboard, document JS zeroization limitation in README 2026-07-25 07:30:14 -04:00
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
17 changed files with 2924 additions and 1928 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
+33
View File
@@ -111,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
@@ -188,6 +206,21 @@ testing. Users concerned about quantum attacks on the seed entropy itself should
bigger risk to seed phrases is classical (theft, phishing), not quantum. Note that BIP39's PBKDF2
iteration count is low; the seed phrase should be treated as a high-value secret and stored offline.
### Secret Material Lifetime and Zeroization (G56-10)
The web app derives the BIP39 seed, secp256k1 key, and five PQ secret keys in browser memory.
On sign-out, the app **zeroizes all `Uint8Array` secret buffers** (fills with zeros before
nulling references), **clears the DOM** (seed display, seed input, entropy hint), and **clears
the clipboard** (in case the user copied the seed phrase).
**Important limitation:** JavaScript garbage collection does **not** guarantee erasure. The
browser runtime may copy buffers internally, and zeroizing a `Uint8Array` only overwrites the
JS-visible buffer — the engine may retain copies in its own memory management. Browser
extensions, devtools memory inspectors, and page snapshots may still recover secrets during
the session. The real fix is moving key derivation and signing into a hardware or native signer
boundary (see G56-06 and G56-08). The zeroization implemented here is a best-effort reduction
of the exposure window, not a guarantee.
---
## Component 2: Multi-Scheme Cross-Signed Key-Link Events
+46 -14
View File
@@ -9,10 +9,10 @@
| Severity | Total | Fixed | In Progress | Not Started |
|---|---:|---:|---:|---:|
| Critical | 2 | 2 | 0 | 0 |
| High | 6 | 3 | 0 | 3 |
| Medium | 8 | 2 | 0 | 6 |
| Low / Info | 4 | 0 | 0 | 4 |
| **Total** | **20** | **7** | **0** | **13** |
| High | 6 | 6 | 0 | 0 |
| Medium | 8 | 7 | 0 | 0 |
| Low / Info | 4 | 0 | 0 | 3 |
| **Total** | **20** | **15** | **0** | **3** |
## 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-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 | ❌ Not started | — | Protocol design decision needed |
| 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 | ❌ Not started | — | Needs CSP hardening, header fixes, asset pinning |
| 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-10 | Medium | Secret material not zeroized, retained in globals/DOM/clipboard | ✅ Fixed | v0.0.26 | Sign-out now zeroizes all `Uint8Array` secret buffers (BIP39 seed, secp private key, 5 PQ secret keys) via `fill(0)` before nulling references; clears DOM (seed display, seed input, entropy hint); clears clipboard. Documented the JS zeroization limitation in README (garbage collection doesn't guarantee erasure; real fix is hardware/native signer boundary) |
| 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 |
@@ -115,6 +115,35 @@
- `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
@@ -124,4 +153,7 @@
- **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>.
+11
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]
+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.20",
"version": "0.0.26",
"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": {
+6
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');
});
});
+15 -1249
View File
File diff suppressed because it is too large Load Diff
+1322
View File
File diff suppressed because it is too large Load Diff
+132 -28
View File
@@ -1637,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;
}
@@ -1719,7 +1732,9 @@ export function parseOtsFile(otsBytes) {
// 5. Timestamp tree
const attestations = [];
_parseTimestamp(reader, targetDigest, attestations, 0);
// 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 };
} catch (e) {
@@ -1744,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.
@@ -1754,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);
@@ -1775,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);
}
}
@@ -1836,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}` },
@@ -1845,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
@@ -1869,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) {
@@ -1910,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);
@@ -1924,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 };
}
}
@@ -1938,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());
@@ -1968,6 +2060,7 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
targetDigest: targetDigestHex,
attestations: parsed.attestations,
bitcoinAttestations: verified,
trustMode,
errors
};
}
@@ -1976,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
@@ -1995,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.20",
"VERSION_NUMBER": "0.0.20",
"BUILD_DATE": "2026-07-24T13:42:06.579Z"
"VERSION": "v0.0.26",
"VERSION_NUMBER": "0.0.26",
"BUILD_DATE": "2026-07-25T11:30:14.286Z"
}
+79 -16
View File
@@ -10517,7 +10517,7 @@ function isOtsConfirmed(otsBytes) {
return false;
}
}
var OtsReader = class {
var _OtsReader = class _OtsReader {
constructor(bytes) {
this.bytes = bytes;
this.pos = 0;
@@ -10532,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;
}
@@ -10552,6 +10561,11 @@ 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;
@@ -10589,30 +10603,45 @@ function parseOtsFile(otsBytes) {
}
const targetDigest = reader.readBytes(digestLen);
const attestations = [];
_parseTimestamp(reader, targetDigest, attestations, 0);
const budget = { operations: 0, branches: 0 };
_parseTimestamp(reader, targetDigest, attestations, 0, budget);
return { fileHashOp, targetDigest, attestations };
} catch (e) {
return null;
}
}
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) {
@@ -10656,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" } });
@@ -10667,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 = [];
@@ -10685,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) {
@@ -10696,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");
@@ -10707,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({
@@ -10732,6 +10788,7 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
targetDigest: targetDigestHex,
attestations: parsed.attestations,
bitcoinAttestations: verified,
trustMode,
errors
};
}
@@ -10750,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() {
+8 -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" />
@@ -355,623 +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,
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);
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);
// 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();
</script>
<!-- Main application module (G56-08: extracted to external file for CSP compliance) -->
<script type="module" src="./js/verify-app.mjs"></script>
</body>
</html>