Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d6dbc9ecd | ||
|
|
66babc5a7a | ||
|
|
87a8893785 | ||
|
|
5bc36c37e7 | ||
|
|
f29f63950b | ||
|
|
a1e1759704 | ||
|
|
113885eb96 | ||
|
|
cfa0ecc220 | ||
|
|
fee9d6c344 | ||
|
|
7a5701ff51 | ||
|
|
2eb465ed26 | ||
|
|
be5afe3c91 |
@@ -7,7 +7,6 @@ build/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Source maps
|
||||
*.bundle.js.map
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "resources/javascript-opentimestamps"]
|
||||
path = resources/javascript-opentimestamps
|
||||
url = https://github.com/opentimestamps/javascript-opentimestamps.git
|
||||
@@ -1,5 +1,12 @@
|
||||
# Post-Quantum Nostr
|
||||
|
||||
> **⚠️ Research prototype.** This project is an experimental pre-quantum key-commitment and
|
||||
> identity-link system. It does **not**, by itself, make a Nostr identity post-quantum secure:
|
||||
> it links PQ keys to an existing identity and anchors that link in Bitcoin. Complete migration
|
||||
> requires companion protocols (PQ event authentication, rotation, encryption, client adoption)
|
||||
> that are not yet specified or implemented. Do not enter a valuable existing mnemonic into the
|
||||
> web app. See the [implementation status](#implementation-status) section for what exists today.
|
||||
|
||||
A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.
|
||||
|
||||
## Table of Contents
|
||||
@@ -104,11 +111,29 @@ 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
|
||||
flowchart TD
|
||||
SEED[BIP39 Seed - 64 bytes, quantum-safe]
|
||||
SEED[BIP39 Seed - 64 bytes, recovery root]
|
||||
SEED --> BIP32[BIP32 derivation - secp256k1 keypair]
|
||||
SEED --> PQKEM[BIP32 derivation - ML-KEM keypair]
|
||||
SEED --> PQDSA[BIP32 derivation - ML-DSA keypair]
|
||||
@@ -173,10 +198,13 @@ This is not a separate or weaker construction — it IS BIP32. Using BIP32 paths
|
||||
|
||||
| Mnemonic length | Entropy | Post-quantum security (Grover's) | Assessment |
|
||||
|---|---|---|---|
|
||||
| 12 words | 128 bits | ~64 bits | Borderline |
|
||||
| 24 words | 256 bits | ~128 bits | Adequate |
|
||||
| 12 words | 128 bits | ~64 bits | Testing only — weaker under a quantum brute-force model |
|
||||
| 24 words | 256 bits | ~128 bits | Recommended for a PQ recovery root |
|
||||
|
||||
For users concerned about quantum attacks on the seed entropy itself, 24-word mnemonics are recommended. The bigger risk to seed phrases is classical (theft, phishing), not quantum. (Note: the current implementation defaults to 12-word mnemonics; 24-word support is recommended for production use.)
|
||||
The implementation defaults to 24-word (256-bit) mnemonics, with a 12-word option available for
|
||||
testing. Users concerned about quantum attacks on the seed entropy itself should use 24 words. The
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -221,18 +249,18 @@ flowchart TD
|
||||
SIGN4 --> KIND1
|
||||
MLKEM --> KIND1
|
||||
|
||||
SECP --> ACCT1[Account 1 - existing identity signs kind 1 and kind 11112]
|
||||
SECP --> ACCT1[Account 1 - existing identity signs kind 1 and kind 9999]
|
||||
|
||||
KIND1 --> OTS[OpenTimestamp the kind 1 event hash]
|
||||
OTS --> KIND11112[Kind 11112 Wrapper Event - carries OTS proof]
|
||||
KIND11112 --> RELAY[Published to relays NOW]
|
||||
OTS --> KIND9999[Kind 9999 Proof Carrier - carries OTS proof]
|
||||
KIND9999 --> RELAY[Published to relays NOW]
|
||||
|
||||
RELAY --> CLIENT1[PQ-aware client - picks ML-DSA]
|
||||
RELAY --> CLIENT2[PQ-aware client - picks SLH-DSA]
|
||||
RELAY --> CLIENT3[Legacy client - ignores all PQ keys]
|
||||
|
||||
style KIND1 fill:#ffa
|
||||
style KIND11112 fill:#ffa
|
||||
style KIND9999 fill:#ffa
|
||||
style OTS fill:#cfc
|
||||
```
|
||||
|
||||
@@ -275,9 +303,9 @@ pending timestamp on the Bitcoin blockchain via OpenTimestamps.
|
||||
|
||||
Each PQ signature scheme signs `TextEncoder.encode(content)` — the human-readable attestation text. The event is signed with the user's **existing** Nostr identity (Account #1) via `window.nostr.signEvent` (NIP-07).
|
||||
|
||||
#### 2. Kind 11112 Wrapper (machine-verifiable, carries the OTS proof)
|
||||
#### 2. Kind 9999 Proof Carrier (machine-verifiable, carries the OTS proof)
|
||||
|
||||
A replaceable event (kind 11112, in the 10000–19999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. The content is the full kind 1 event as JSON (so verifiers don't need to fetch it from relays).
|
||||
A non-replaceable event (kind 9999, in the 0–9999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. The content is the full kind 1 event as JSON (so verifiers don't need to fetch it from relays). Each publication is permanent — upgrades publish a new event with an `upgrade_of` tag referencing the previous one.
|
||||
|
||||
**Content:** `JSON.stringify(kind1Event)` — the full signed kind 1 event embedded as a JSON string.
|
||||
|
||||
@@ -292,7 +320,7 @@ The wrapper is signed with the user's existing identity (Account #1) via `window
|
||||
|
||||
**What the event proves:**
|
||||
- Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signed the attestation text — verified by checking each PQ signature against the pubkey in its tag.
|
||||
- The user's existing Nostr identity (Account #1) authorized the migration — verified by the secp256k1 Schnorr signature on the kind 1 and kind 11112 events.
|
||||
- The user's existing Nostr identity (Account #1) authorized the migration — verified by the secp256k1 Schnorr signature on the kind 1 and kind 9999 events.
|
||||
- The event is anchored to a pre-quantum Bitcoin block via OpenTimestamps — the event with the earliest valid OTS proof wins (see Component 3).
|
||||
|
||||
**What the event does NOT prove:**
|
||||
@@ -334,7 +362,7 @@ If a specific PQ scheme is later found to be weak (like SIKE was):
|
||||
|
||||
## Component 3: OpenTimestamps for Pre-Quantum Anchoring
|
||||
|
||||
NIP-03 (OpenTimestamps Attestations for Events) anchors Nostr event hashes to the Bitcoin blockchain via OpenTimestamps. The implementation submits the **SHA-256 of the full signed kind 1 event JSON** (including its `id` and `sig`) to the OpenTimestamps calendar servers, and embeds the resulting `.ots` proof in the kind 11112 wrapper's `ots` tag.
|
||||
NIP-03 (OpenTimestamps Attestations for Events) anchors Nostr event hashes to the Bitcoin blockchain via OpenTimestamps. The implementation submits the **SHA-256 of the full signed kind 1 event JSON** (including its `id` and `sig`) to the OpenTimestamps calendar servers, and embeds the resulting `.ots` proof in the kind 9999 proof carrier's `ots` tag.
|
||||
|
||||
### Why Bitcoin Timestamping Is Quantum-Resistant
|
||||
|
||||
@@ -364,14 +392,35 @@ The kind 1 announcement event MUST be timestamped via OpenTimestamps. This preve
|
||||
|
||||
The implementation timestamps the **SHA-256 of the full signed kind 1 event JSON** (computed by hashing `JSON.stringify(kind1Event)`, which includes the `id` and `sig` fields). This hash is:
|
||||
1. Submitted to OpenTimestamps calendar servers to obtain a pending `.ots` proof.
|
||||
2. Recorded in the kind 11112 wrapper's `sha256` tag.
|
||||
3. The `.ots` proof is embedded in the kind 11112 wrapper's `ots` tag.
|
||||
2. Recorded in the kind 9999 proof carrier's `sha256` tag.
|
||||
3. The `.ots` proof is embedded in the kind 9999 proof carrier's `ots` tag.
|
||||
|
||||
The kind 11112 wrapper *carries* the OTS proof; the kind 1 event *is* what's timestamped. Verifiers re-compute the hash from the embedded kind 1 event and check it matches the `sha256` tag.
|
||||
The kind 9999 proof carrier *carries* the OTS proof; the kind 1 event *is* what's timestamped. Verifiers re-compute the hash from the embedded kind 1 event and check it matches the `sha256` tag.
|
||||
|
||||
### Current OTS Verification Status
|
||||
|
||||
The current implementation detects Bitcoin confirmation by searching the `.ots` proof bytes for the Bitcoin block-header attestation magic bytes, and delegates proof upgrading to a server-side helper. **Full client-side OTS verification** (parsing the proof, validating the Merkle path, checking the block header against the Bitcoin chain) is planned but not yet implemented. The vendored `javascript-opentimestamps` library in `resources/` provides the necessary primitives and will be integrated in a future release.
|
||||
> **Research prototype — trust model.** The current OTS verification is **API-assisted**, not a
|
||||
> full Bitcoin light-client verification. Treat results accordingly.
|
||||
|
||||
The current implementation:
|
||||
|
||||
- Parses the `.ots` proof and validates the Merkle path against a block header obtained from a
|
||||
public Bitcoin explorer API (mempool.space).
|
||||
- Detects Bitcoin confirmation by searching the proof bytes for the block-header attestation magic
|
||||
bytes and confirming the attested block height via the same API.
|
||||
- Delegates proof *upgrading* (asking calendars for a confirmed proof) to a server-side helper.
|
||||
|
||||
**What is NOT done:**
|
||||
|
||||
- **Full light-client verification** — validating the block header's proof-of-work, difficulty, and
|
||||
chain linkage independently, without trusting an explorer API. The vendored
|
||||
`javascript-opentimestamps` library in `resources/` provides primitives for this and will be
|
||||
integrated in a future release.
|
||||
- **Multi-explorer cross-checking** — comparing independent APIs and rejecting disagreement.
|
||||
|
||||
Because the block header is trusted from a single API, a compromised or malicious explorer could
|
||||
falsify a confirmation. The UI labels this as "API-checked," not "cryptographically verified on
|
||||
Bitcoin."
|
||||
|
||||
### What Should Be OpenTimestamped
|
||||
|
||||
@@ -646,8 +695,8 @@ flowchart TD
|
||||
KIND1 --> SECP1[Account 1 signs kind 1 via signer]
|
||||
SECP1 --> HASH[SHA-256 of full signed kind 1 event JSON]
|
||||
HASH --> OTS[Submit hash to OpenTimestamps]
|
||||
OTS --> KIND11112[Build kind 11112 wrapper with OTS proof]
|
||||
KIND11112 --> SECP2[Account 1 signs kind 11112 via signer]
|
||||
OTS --> KIND9999[Build kind 9999 proof carrier with OTS proof]
|
||||
KIND9999 --> SECP2[Account 1 signs kind 9999 via signer]
|
||||
SECP2 --> RELAY[Publish both events to relays]
|
||||
SEED --> BACKUP[User writes down 12-word phrase]
|
||||
end
|
||||
@@ -655,7 +704,7 @@ flowchart TD
|
||||
subgraph CONFIRM["Bitcoin confirmation"]
|
||||
POLL[Poll OTS upgrade service]
|
||||
POLL --> UPGRADE[Upgrade .ots proof with Bitcoin attestation]
|
||||
UPGRADE --> REPUBLISH[Republish kind 11112 with confirmed proof]
|
||||
UPGRADE --> REPUBLISH[Publish new kind 9999 with confirmed proof]
|
||||
end
|
||||
|
||||
subgraph FUTURE["Post-quantum - when quantum computers arrive"]
|
||||
@@ -682,10 +731,10 @@ flowchart TD
|
||||
2. Client generates a 12-word phrase, shows it to the user to write down
|
||||
3. Client derives 5 PQ keypairs from the seed via BIP32 paths
|
||||
4. Each PQ signature scheme signs the human-readable attestation text
|
||||
5. The user's signer signs the kind 1 announcement and the kind 11112 wrapper
|
||||
5. The user's signer signs the kind 1 announcement and the kind 9999 proof carrier
|
||||
6. The SHA-256 of the full signed kind 1 event is submitted to OpenTimestamps
|
||||
7. Both events are published to relays
|
||||
8. The client polls for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof
|
||||
8. The client polls for Bitcoin confirmation and publishes a new kind 9999 proof carrier with the confirmed OTS proof (append-only — the original remains on relays)
|
||||
9. After quantum migration: clients use PQ keys, old key retired
|
||||
|
||||
---
|
||||
@@ -747,7 +796,7 @@ The current implementation is a static web app (`www/`) that performs the full m
|
||||
| PQ signing (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| ML-KEM-768 keygen (KEM, no signing) | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| Kind 1 announcement event construction | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| Kind 11112 wrapper event construction | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| Kind 9999 proof carrier event construction | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| NIP-01 event ID computation and Schnorr verification | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| OpenTimestamps submission (pending proof) | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| OTS upgrade polling (via server helper) | Implemented | `www/js/pq-crypto.mjs` |
|
||||
@@ -759,12 +808,12 @@ The current implementation is a static web app (`www/`) that performs the full m
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| Full client-side OTS verification | Parse the .ots proof, validate the Merkle path, check the block header against the Bitcoin chain (the vendored `javascript-opentimestamps` library in `resources/` provides the primitives) |
|
||||
| OTS target-digest binding | Verify the OTS proof's target digest equals the event's `sha256` tag |
|
||||
| Full light-client Bitcoin verification | Validate block headers, proof-of-work, difficulty, and chain linkage independently of an explorer API (the vendored `javascript-opentimestamps` library in `resources/` provides primitives). Current path trusts a single explorer API for the header. |
|
||||
| Multi-explorer cross-checking | Compare independent Bitcoin APIs and reject disagreement |
|
||||
| Raw-nsec migration (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding |
|
||||
| Quantum-safe self-storage (Component 5) | OTP or symmetric-key encryption for kind 30078 data |
|
||||
| Test suite | Known-answer tests for derivation, PQ sign/verify round-trips, event verification |
|
||||
| 24-word mnemonic default | Currently defaults to 12 words; 24 recommended for production |
|
||||
| PQ event authentication / rotation / revocation | Companion protocols for signing future events with PQ keys, rotating/revoking keys, PQ encryption |
|
||||
| Independent implementation / test vectors | A second implementation reproducing canonical encoding, derivation, and selection |
|
||||
|
||||
### Dependencies
|
||||
|
||||
@@ -775,7 +824,7 @@ The current implementation is a static web app (`www/`) that performs the full m
|
||||
- [`@scure/bip32`](https://github.com/paulmillr/scure-bip32) — BIP32 HD wallet derivation
|
||||
- [`@scure/base`](https://github.com/paulmillr/scure-base) — bech32 (npub) encoding
|
||||
- [OpenTimestamps](https://opentimestamps.org/) — calendar servers for timestamping
|
||||
- [`javascript-opentimestamps`](resources/javascript-opentimestamps/) — vendored OTS library (present, not yet integrated for client-side verification)
|
||||
- [`javascript-opentimestamps`](resources/javascript-opentimestamps/) — vendored OTS library (used as a fixture source; full light-client integration pending)
|
||||
- [nostr-login-lite](https://github.com/nostrband/nostr-login-lite) — NIP-07 / NIP-46 authentication
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# GPT-5.6 Audit Remediation Progress
|
||||
|
||||
**Audit:** [`audits/GPT5.6/audit.md`](audit.md:1)
|
||||
**Mitigation plan:** [`audits/GPT5.6/audit_mitigation.md`](audit_mitigation.md:1)
|
||||
**Last updated:** 2026-07-24
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Total | Fixed | In Progress | Not Started |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Critical | 2 | 2 | 0 | 0 |
|
||||
| High | 6 | 6 | 0 | 0 |
|
||||
| Medium | 8 | 6 | 0 | 1 |
|
||||
| Low / Info | 4 | 0 | 0 | 3 |
|
||||
| **Total** | **20** | **14** | **0** | **4** |
|
||||
|
||||
## Findings status
|
||||
|
||||
| ID | Severity | Finding | Status | Version | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| 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.14–v0.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 | ✅ Fixed | v0.0.21 | Tier 1+2: rewrote `fetchBitcoinBlockHeader()` to query ALL providers and cross-check merkle roots (fail on disagreement); added `trustMode` field to `verifyOtsProof()` result (`multi-explorer-checked` / `single-explorer-checked` / `structural-only`); updated verify.html + index.html UI to display trust mode label; removed misleading "lite-client verification" / "independently verifiable" comments. Full light-client PoW verification (Tier 3) remains as future work. 2 test assertions added (trustMode present on verified proof, structural-only on pending) |
|
||||
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ✅ Fixed | v0.0.20 | Added `validateSignerOutput()` helper: checks all required fields present + typed, verifies no template field (content/tags/created_at/kind) changed, verifies pubkey equals expected identity, recomputes + verifies event ID, verifies Schnorr signature, rejects extra fields. Replaced reconstruction at all 4 `signEvent()` call sites (2 in index.html, 2 in verify.html) with validation + retain-exact-signed-object. 15 new tests (mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed) |
|
||||
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ✅ Fixed (design decision: assert-only) | v0.0.22 | Design decision: continuity is *asserted* by the old identity's signature, not *proven* by cryptography. No in-browser cryptographic trick can prove the seed and published PQ keys are bound, because a compromised browser controls both the seed display and all signatures. Mode 1 (mnemonic-match) rejected: no current Nostr signers accept seed phrases, so it would require typing a valuable mnemonic into a web page — unacceptable risk. Mode 2 (successor secp signature) rejected: a malicious browser can fake the secp signature just as easily as PQ signatures — adds no real binding. Fix: documented the honest position in README.md + nip_proposal.md (continuity is asserted not proven; only out-of-band verification on a different device can prove the seed matches the published keys); added UI recommendation in index.html seed step to verify out-of-band after migration |
|
||||
| G56-07 | High | Verifier throws on malformed relay/paste input | ✅ Fixed | v0.0.12, v0.0.19 | v0.0.12 wrapped base64/hex in try/catch + verifyNIPQRContent fail closed + verify.html call-site wrapping. v0.0.19 added structural validation + try/catch to `verifyNostrEvent()` (returns false on malformed input), wrapped `parseOtsFile()` body in try/catch (returns null), added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards, 23 fuzz/property tests (random JSON, random bytes, truncation, oversized inputs, wrong-typed objects) |
|
||||
| G56-08 | High | Browser origin security insufficient for seed handling | ✅ Fixed | v0.0.23 | Code fixes: extracted all inline JS to external files (`index-app.mjs`, `verify-app.mjs`, `version-display.js`), removed `'unsafe-inline'` from `script-src` in CSP, added `frame-ancestors 'none'` + `<meta name="referrer" content="no-referrer">`, added SRI hashes (`integrity`/`crossorigin`) to nostr-login-lite scripts. Server headers applied to nginx `conf.d/default.conf` (scoped to `/post-quantum/` location only): HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Permissions-Policy`, COOP, CORP. Verified headers present on `/post-quantum/` and absent on other paths. Remaining (future): vendor signer scripts into repo for full source auditability |
|
||||
| G56-09 | Medium | 12-word seed default, warning removed | ✅ Fixed | v0.0.17 | Defaulted `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` to 24 words (256-bit); added 12/24-word selector UI; restored entropy-strength warning; removed "quantum-safe root of trust" / "Make your Nostr identity quantum-resistant" claims from index.html, README.md, nip_proposal.md; 4 new tests |
|
||||
| G56-10 | Medium | Secret material not zeroized, retained in globals/DOM/clipboard | ❌ Not started | — | Minimize secret lifetime, overwrite typed arrays |
|
||||
| G56-11 | Medium | Publication advances despite zero relay acceptance | ✅ 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.14–v0.0.18 | Kind 9999 docs (v0.0.14–v0.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:// | ✅ 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 |
|
||||
|
||||
## Changes by version
|
||||
|
||||
### v0.0.12 — G56-01 fix
|
||||
- Rewrote `verifyNIPQRContent()` with strict mandatory algorithm policy
|
||||
- Missing signatures on signature algorithms now fail (not treated as KEM successes)
|
||||
- Unknown algorithms reported as ignored, not valid evidence
|
||||
- Duplicate algorithm detection
|
||||
- Public key length validation
|
||||
- All base64/verification wrapped in try/catch (fail closed)
|
||||
- Added `pqProofsValid`, `policySufficient`, `validForMigration`, `errors` fields to result
|
||||
- Updated `verify.html` to display "PQ algorithm policy" result line
|
||||
- Added 11 adversarial tests
|
||||
|
||||
### v0.0.13 — Checkbox UI fix
|
||||
- Fixed checkbox fill (background red when checked, not just border)
|
||||
|
||||
### v0.0.14 — G56-02 + G56-03 fix
|
||||
- Changed `NIP_QR_KIND` from 11112 (replaceable) to 9999 (non-replaceable)
|
||||
- Added `buildProofCarrier()` with `ots_status` and `upgrade_of` tags
|
||||
- `buildUpgradedEvent()` now creates append-only events (not replacements)
|
||||
- Added `selectCanonicalProofCarrier()` for earliest-valid-anchor selection
|
||||
- Removed `limit: 1` from relay queries in verify.html and index.html
|
||||
- Added identity binding (`outerPubkey`, `expectedAuthor`) to `verifyNIPQRContent()`
|
||||
- Added `identityBound` field to verification result
|
||||
- Updated resume workflow to fetch all candidates
|
||||
- Added 12 new tests (8 for G56-02, 4 for G56-03)
|
||||
|
||||
### v0.0.15 — Documentation update
|
||||
- Updated nip_proposal.md and README.md for kind 9999
|
||||
|
||||
### v0.0.16 — Eliminate old references
|
||||
- Eliminated all remaining "kind 11112" references from user-facing files and docs
|
||||
- Updated explanation.md, laans_explanation.md, why_what_how.md
|
||||
- Cleaned up button IDs and import statements in index.html
|
||||
|
||||
### v0.0.17 — G56-09 fix
|
||||
- `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` now default to 24 words (256-bit entropy)
|
||||
- Both accept an `entropyBits` argument (128 or 256) and validate it
|
||||
- Added 12/24-word radio selector to the seed-generation panel in `index.html`; toggling regenerates the seed
|
||||
- Restored entropy-strength warning (12 words = ~64-bit under Grover, testing only; 24 words recommended)
|
||||
- Removed inaccurate claims: "Make your Nostr identity quantum-resistant" → "Create an experimental pre-quantum PQ key commitment"; "quantum-safe root of trust" → "recovery root"; "quantum-safe seed phrase" → "seed phrase"
|
||||
- Added research-prototype warning to the unauthenticated landing card
|
||||
- Updated README.md entropy table and nip_proposal.md mermaid diagram
|
||||
- Added 4 new tests (24-word default, 12-word option, invalid entropyBits rejection for both functions)
|
||||
|
||||
### v0.0.18 — G56-16 fix (status reconciliation)
|
||||
- README.md: rewrote OTS verification status section to distinguish API-assisted verification (current, trusts explorer) from full light-client verification (not implemented); added research-prototype banner
|
||||
- README.md: removed stale "not implemented" items (OTS target-digest binding, test suite, 24-word default); added accurate not-implemented items (light-client verification, multi-explorer cross-check, PQ event auth/rotation, independent implementation)
|
||||
- nip_proposal.md: softened "No private key ever touches a server" → NIP-07/remote-signer caveat + API-assisted OTS trust model; pending-proof "establishes submission time" → "evidence of submission, not an independent timestamp"; fixed "republished" → "new proof carrier published with upgrade_of tag"
|
||||
- verify.html: added research-prototype note; rewrote card description to list each verification check as a separate result line (PQ signatures individually, policy sufficiency, identity binding)
|
||||
- Added research-prototype / design-only banners to explanation.md, laans_explanation.md, why_what_how.md, and all 5 research/ docs
|
||||
- No new tests (documentation-only change)
|
||||
|
||||
### v0.0.19 — G56-07 fix (fuzz testing + fail-closed verifiers)
|
||||
- `verifyNostrEvent()`: added structural validation (type-checks all required fields) + try/catch; returns `false` on any malformed input instead of throwing
|
||||
- `parseOtsFile()`: wrapped parser body in try/catch; returns `null` on any malformed/truncated input
|
||||
- Added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards; oversized inputs rejected before any parsing/decoding work
|
||||
- `verifyNIPQRContent()`: added content-size guard at entry
|
||||
- verify.html relay path already had try/catch (G56-01); now defense-in-depth with internal fail-closed
|
||||
- 23 new fuzz/property tests: random JSON shapes to `verifyNIPQRContent` (1000 iterations), random bytes to `parseOtsFile` (2000 iterations), random objects to `verifyNostrEvent` (500 iterations), truncated valid-prefix bytes, oversized inputs, wrong-typed objects, `isOtsConfirmed` fuzz
|
||||
|
||||
### v0.0.20 — G56-05 fix (signer output validation)
|
||||
- Added `validateSignerOutput(signedEvent, template, expectedPubkey)` helper in `pq-crypto.mjs`:
|
||||
- Checks all 7 required NIP-01 fields present + correctly typed
|
||||
- Verifies id (64 hex), pubkey (64 hex), sig (128 hex) format
|
||||
- Verifies no template field changed (content, tags deep-compare, created_at, kind)
|
||||
- Verifies returned pubkey equals expected identity
|
||||
- Recomputes event ID and verifies match
|
||||
- Verifies Schnorr signature
|
||||
- Rejects extra unexpected fields
|
||||
- Returns the validated signed object (not a reconstruction)
|
||||
- Replaced reconstruction at all 4 `signEvent()` call sites:
|
||||
- `index.html`: kind 1 announcement (was mixing template + signer fields), proof carrier (same), upgrade (now validated)
|
||||
- `verify.html`: upgrade (now validated)
|
||||
- 15 new tests: valid output, mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed, no-expectedPubkey backward compat
|
||||
|
||||
### v0.0.21 — G56-04 fix (Bitcoin trust-mode labeling + multi-explorer cross-check)
|
||||
- Rewrote `fetchBitcoinBlockHeader()` to query ALL providers (blockstream + mempool) and cross-check their merkle roots; fails on disagreement (turns fallback-redundancy into a cross-check)
|
||||
- Added `trustMode` field to `verifyOtsProof()` return value: `multi-explorer-checked` (both providers agreed), `single-explorer-checked` (only one responded), `structural-only` (no Bitcoin attestation verified)
|
||||
- Updated verify.html (2 locations) and index.html (1 location) to display the trust mode label alongside verification results
|
||||
- Removed misleading "lite-client verification" / "independently verifiable against the Bitcoin PoW chain" comments from code
|
||||
- Full light-client PoW/difficulty/chain-linkage verification (Tier 3) remains as future work
|
||||
- 2 new test assertions: trustMode present + explorer-checked on verified proof, structural-only on pending proof
|
||||
|
||||
### v0.0.22 — G56-06 fix (seed continuity: honest claims + out-of-band verification)
|
||||
- Design decision: continuity is *asserted* by the old identity's signature, not *proven* by cryptography
|
||||
- Mode 1 (mnemonic-match) rejected: no current Nostr signers accept seed phrases; would require typing a valuable mnemonic into a web page — unacceptable risk
|
||||
- Mode 2 (successor secp signature) rejected: a malicious browser can fake the secp signature just as easily as PQ signatures — adds no real binding
|
||||
- Documented the honest position in README.md (Component 1 section) and nip_proposal.md: continuity is asserted not proven; only out-of-band verification on a different device can prove the seed matches the published keys
|
||||
- Added UI recommendation in index.html seed step: "Verify out-of-band after migration — take your seed phrase to a different device, derive the keys, confirm the public keys match"
|
||||
- No new tests (documentation + UI only)
|
||||
|
||||
### v0.0.23 — G56-08 fix (browser hardening — code-fixable parts)
|
||||
- Extracted all inline `<script>` blocks from index.html and verify.html into external files:
|
||||
- `www/js/version-display.js` (self-initializing version display)
|
||||
- `www/js/index-app.mjs` (main index.html application module, ~1237 lines)
|
||||
- `www/js/verify-app.mjs` (main verify.html application module, ~609 lines)
|
||||
- Removed `'unsafe-inline'` from `script-src` in CSP (both pages); kept for `style-src` (many inline styles remain)
|
||||
- Added `frame-ancestors 'none'` to CSP (clickjacking protection)
|
||||
- Added `<meta name="referrer" content="no-referrer">` to both pages
|
||||
- Added SRI hashes (`integrity="sha384-..."` + `crossorigin="anonymous"`) to nostr-login-lite scripts
|
||||
- Created `deploy/nginx-security-headers.conf` documenting required server-side headers (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP, CORP)
|
||||
- Remaining: apply nginx config on server; vendor signer scripts into repo for full auditability
|
||||
- No new tests (HTML/deployment changes)
|
||||
|
||||
## Test count
|
||||
|
||||
- **Before remediation:** 38 tests
|
||||
- **After G56-01:** 49 tests
|
||||
- **After G56-02+03:** 61 tests
|
||||
- **After G56-09:** 65 tests
|
||||
- **After G56-16:** 65 tests (docs-only)
|
||||
- **After G56-07:** 88 tests
|
||||
- **After G56-05:** 103 tests
|
||||
- **After G56-04:** 103 tests (2 assertions added to existing tests)
|
||||
- **After G56-06:** 103 tests (docs + UI only)
|
||||
- **After G56-08:** 103 tests (HTML/deployment changes)
|
||||
- **All passing:** ✅
|
||||
@@ -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>.
|
||||
+10
-5
@@ -1,5 +1,10 @@
|
||||
# How the NIP-QR Event Is Constructed: Step by Step
|
||||
|
||||
> **⚠️ Research prototype.** This document describes an experimental pre-quantum key-commitment
|
||||
> system. It links PQ keys to an existing Nostr identity and anchors that link in Bitcoin; it does
|
||||
> not, by itself, make the identity post-quantum secure. Complete migration requires companion
|
||||
> protocols not yet specified or implemented.
|
||||
|
||||
## The Misconception
|
||||
|
||||
The seed phrase is NOT used directly as the private key for PQ signing. The seed phrase is a source of entropy that is used to **derive** separate keys for each PQ algorithm via **BIP32 HD wallet derivation paths** — the same standard used by NIP-06 for secp256k1 keys.
|
||||
@@ -175,13 +180,13 @@ full_hash → OpenTimestamps calendar → pending .ots proof
|
||||
|
||||
This hash is what gets anchored to the Bitcoin blockchain.
|
||||
|
||||
## Step 11: Build and sign the kind 11112 wrapper event
|
||||
## Step 11: Build and sign the kind 9999 proof carrier event
|
||||
|
||||
A kind 11112 wrapper event is built that embeds the full kind 1 event as JSON content and carries the OTS proof:
|
||||
A kind 9999 proof carrier event is built that embeds the full kind 1 event as JSON content and carries the OTS proof:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 11112,
|
||||
"kind": 9999,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey (hex)>",
|
||||
"created_at": 1720780000,
|
||||
"content": "<JSON string of the full signed kind 1 event>",
|
||||
@@ -197,7 +202,7 @@ This wrapper is sent to the user's signer (`window.nostr.signEvent()`), which si
|
||||
|
||||
## Step 12: Publish both events
|
||||
|
||||
Both the kind 1 announcement and the kind 11112 wrapper are published to Nostr relays. The client then polls the OpenTimestamps upgrade service for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof once the Bitcoin attestation is received.
|
||||
Both the kind 1 announcement and the kind 9999 proof carrier are published to Nostr relays. The client then polls the OpenTimestamps upgrade service for Bitcoin confirmation and republishes the kind 9999 proof carrier with the confirmed OTS proof once the Bitcoin attestation is received.
|
||||
|
||||
---
|
||||
|
||||
@@ -210,7 +215,7 @@ Both the kind 1 announcement and the kind 11112 wrapper are published to Nostr r
|
||||
| SLH-DSA-128s private key | The attestation text (content) | PQ signature (hash-based, Cat 1) | Conservative fallback if lattice schemes break |
|
||||
| Falcon-512 private key | The attestation text (content) | PQ signature (lattice, Cat 1) | Compact signatures, compatible with trbouma's PR |
|
||||
| Account #1 secp256k1 key | The kind 1 announcement event | Schnorr signature | Existing identity authorizes the migration |
|
||||
| Account #1 secp256k1 key | The kind 11112 wrapper event | Schnorr signature | Existing identity authorizes the wrapper + OTS proof |
|
||||
| Account #1 secp256k1 key | The kind 9999 proof carrier event | Schnorr signature | Existing identity authorizes the wrapper + OTS proof |
|
||||
| ML-KEM-768 | (nothing — KEM, not signature) | N/A | Ownership asserted by attestation text; authorized by Account #1's signature covering the tags |
|
||||
|
||||
## Summary: BIP32 Key Derivation Tree
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Laan's explanation
|
||||
|
||||
> **⚠️ Research prototype.** This is a short summary of an experimental pre-quantum key-commitment
|
||||
> system. It does not, by itself, make a Nostr identity post-quantum secure.
|
||||
|
||||
A short summary of how the post-quantum Nostr migration works, matching the implementation.
|
||||
|
||||
1. **Create a new seed phrase.** This is your post-quantum (PQ) Nostr backup. It's a 12-word BIP39 mnemonic — pure entropy, not tied to any algorithm.
|
||||
1. **Create a new seed phrase.** This is your post-quantum (PQ) Nostr backup. It's a BIP39 mnemonic (24 words by default; 12 words available for testing) — pure entropy, not tied to any algorithm.
|
||||
|
||||
2. **Derive 5 PQ keypairs from the seed.** Using BIP32 hierarchical deterministic derivation (the same standard as NIP-06), each PQ algorithm gets its own child key under the path `m/44'/1237'/0'/0/`:
|
||||
- child 1 → ML-DSA-44
|
||||
@@ -17,10 +20,10 @@ A short summary of how the post-quantum Nostr migration works, matching the impl
|
||||
|
||||
4. **Each PQ key signs the attestation.** Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signs the attestation text. ML-KEM-768 does NOT sign — it's a KEM (Key Encapsulation Mechanism), not a signature scheme. Its public key is included in a tag without a signature.
|
||||
|
||||
5. **Your existing identity signs the events.** The kind 1 announcement (with PQ signatures in its tags) and a kind 11112 wrapper (which embeds the kind 1 event as JSON and carries the OpenTimestamps proof) are both signed by your **existing** Nostr identity via `window.nostr.signEvent()`. This authorizes the migration.
|
||||
5. **Your existing identity signs the events.** The kind 1 announcement (with PQ signatures in its tags) and a kind 9999 proof carrier (which embeds the kind 1 event as JSON and carries the OpenTimestamps proof) are both signed by your **existing** Nostr identity via `window.nostr.signEvent()`. This authorizes the migration.
|
||||
|
||||
6. **Timestamp the kind 1 event.** The SHA-256 of the full signed kind 1 event JSON is submitted to OpenTimestamps, anchoring it to the Bitcoin blockchain. This proves the event existed at this point in time, pre-quantum.
|
||||
|
||||
7. **Publish both events to relays.** The kind 1 announcement and the kind 11112 wrapper are published. The client then polls for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof.
|
||||
7. **Publish both events to relays.** The kind 1 announcement and the kind 9999 proof carrier are published. The client then polls for Bitcoin confirmation and republishes the kind 9999 proof carrier with the confirmed OTS proof.
|
||||
|
||||
**Note:** The seed-derived secp256k1 key (at `m/44'/1237'/0'/0/0`) is derived but not used to sign. There is no "successor signature." The security model relies on OTS precedence — the real event is anchored pre-quantum, and a forged event cannot be backdated to before that anchor.
|
||||
|
||||
+40
-15
@@ -35,19 +35,30 @@ The PQ standardization landscape is still young. SIKE was a NIST finalist broken
|
||||
A user with an existing Nostr identity (the **attesting identity**) publishes two events:
|
||||
|
||||
1. A **kind 1 announcement** — a human-readable attestation whose `content` is signed by each PQ signature scheme, with the PQ public keys and signatures carried in `algorithm` tags.
|
||||
2. A **kind 11112 wrapper** — a replaceable event that embeds the full signed kind 1 event as JSON content and carries an OpenTimestamps proof anchoring the kind 1 event to a Bitcoin block.
|
||||
2. A **kind 9999 proof carrier** — a non-replaceable event that embeds the full signed kind 1 event as JSON content and carries an OpenTimestamps proof anchoring the kind 1 event to a Bitcoin block. Upgrades (pending → confirmed) publish a new kind 9999 event with an `upgrade_of` tag referencing the previous one; both remain on relays permanently.
|
||||
|
||||
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 - quantum-safe root of trust] --> SECP[secp256k1 keypair - child 0]
|
||||
SEED[BIP39 seed - recovery root] --> SECP[secp256k1 keypair - child 0]
|
||||
SEED --> PQ[PQ keypairs - children 1..8]
|
||||
SECP --> K1[Kind 1 announcement - attesting identity signs]
|
||||
PQ --> K1
|
||||
K1 --> OTS[OpenTimestamps anchor to Bitcoin block]
|
||||
OTS --> K11112[Kind 11112 wrapper - carries OTS proof]
|
||||
K11112 --> RELAY[Published to relays]
|
||||
OTS --> K9999[Kind 9999 proof carrier - carries OTS proof]
|
||||
K9999 --> RELAY[Published to relays]
|
||||
```
|
||||
|
||||
## PQ key derivation from a BIP39 seed
|
||||
@@ -157,9 +168,11 @@ Future NIPs may register additional `algorithm-id` values and additional BIP32 c
|
||||
}
|
||||
```
|
||||
|
||||
## The kind 11112 wrapper event
|
||||
## The kind 9999 proof carrier event
|
||||
|
||||
A **replaceable** event (kind 11112, in the 10000–19999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. It is signed with the attesting identity's secp256k1 key.
|
||||
A **non-replaceable** event (kind 9999, in the 0–9999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. It is signed with the attesting identity's secp256k1 key.
|
||||
|
||||
Kind 9999 is non-replaceable: each publication is permanent on relays. Upgrades (e.g. pending → confirmed OTS proof) publish a **new** kind 9999 event with an `upgrade_of` tag referencing the previous one. Both events remain on relays. Clients fetch all kind 9999 events for a pubkey and select the one with the earliest valid Bitcoin anchor.
|
||||
|
||||
The kind number is provisional and subject to maintainer allocation.
|
||||
|
||||
@@ -177,7 +190,7 @@ The kind number is provisional and subject to maintainer allocation.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 11112,
|
||||
"kind": 9999,
|
||||
"pubkey": "<attesting identity secp256k1 pubkey, hex>",
|
||||
"content": "<JSON string of the full signed kind 1 event>",
|
||||
"tags": [
|
||||
@@ -203,11 +216,11 @@ With an OTS anchor, the real event is committed to a specific Bitcoin block befo
|
||||
|
||||
### Verification rule: earliest valid anchor wins
|
||||
|
||||
When a client finds multiple kind 11112 wrappers for the same attesting identity, it verifies the OTS proof in each and selects the one whose Bitcoin attestation has the **earliest block height**. That wrapper's embedded kind 1 event is the canonical key-link for that identity. Later wrappers, even if validly signed, are treated as superseding only if they are signed by a PQ key already linked by the earliest wrapper.
|
||||
When a client finds multiple kind 9999 proof carriers for the same attesting identity, it verifies the OTS proof in each and selects the one whose Bitcoin attestation has the **earliest block height**. That proof carrier's embedded kind 1 event is the canonical key-link for that identity. Later proof carriers, even if validly signed, are treated as superseding only if they are signed by a PQ key already linked by the earliest proof carrier.
|
||||
|
||||
### OTS proof states
|
||||
|
||||
An OTS proof may be **pending** (not yet anchored in a Bitcoin block) or **confirmed** (anchored). A pending proof asserts that the digest has been submitted to calendar servers; a confirmed proof asserts a Bitcoin block commitment. Clients SHOULD treat a confirmed proof as authoritative. A pending proof is still useful: it establishes that the digest existed at submission time, and it can be upgraded to a confirmed proof later by re-querying the calendars. The wrapper is republished with the upgraded proof.
|
||||
An OTS proof may be **pending** (not yet anchored in a Bitcoin block) or **confirmed** (anchored). A pending proof is evidence that the digest was submitted to calendar servers; it is **not** an independent timestamp and depends on calendar behavior. A confirmed proof asserts a Bitcoin block commitment. Clients SHOULD treat a confirmed proof as authoritative. A pending proof can be upgraded to a confirmed proof later by re-querying the calendars; when that happens, a **new** proof carrier event is published carrying the upgraded proof and referencing the original via an `upgrade_of` tag. The original event remains on relays permanently.
|
||||
|
||||
### Client-side verification
|
||||
|
||||
@@ -217,10 +230,11 @@ Clients SHOULD perform full client-side OTS verification: parse the proof, walk
|
||||
|
||||
### PQ-aware clients
|
||||
|
||||
A PQ-aware client, when it encounters a kind 11112 wrapper for a pubkey it cares about:
|
||||
A PQ-aware client, when it encounters a kind 9999 proof carrier for a pubkey it cares about:
|
||||
|
||||
1. Parses the embedded kind 1 event from the wrapper's `content`.
|
||||
2. Verifies the attesting identity's secp256k1 signature on both the kind 1 and the kind 11112 events.
|
||||
1. Parses the embedded kind 1 event from the proof carrier's `content`.
|
||||
2. Verifies the attesting identity's secp256k1 signature on both the kind 1 and the kind 9999 events.
|
||||
3. Verifies that the proof carrier's pubkey matches the embedded kind 1 event's pubkey (identity binding).
|
||||
3. Verifies the `sha256` tag matches `sha256(JSON.stringify(kind1Event))`.
|
||||
4. For each `algorithm` tag with a signature, verifies the PQ signature against the pubkey in the tag, over `TextEncoder.encode(kind1Event.content)`.
|
||||
5. Verifies the OTS proof in the `ots` tag against the `sha256` digest.
|
||||
@@ -231,14 +245,14 @@ After a quantum break of secp256k1, the client trusts the PQ keys from the earli
|
||||
|
||||
### Legacy clients
|
||||
|
||||
Legacy clients see the kind 1 announcement as an ordinary text note and the kind 11112 wrapper as an unknown replaceable event. They ignore both. Nothing breaks.
|
||||
Legacy clients see the kind 1 announcement as an ordinary text note and the kind 9999 proof carrier as an unknown event. They ignore both. Nothing breaks.
|
||||
|
||||
## What this NIP proves and does not prove
|
||||
|
||||
**It proves:**
|
||||
|
||||
- Each PQ signature scheme listed in the tags signed the attestation text. Verified by checking each PQ signature against its tag's pubkey.
|
||||
- The attesting identity authorized the link. Verified by the secp256k1 Schnorr signature on the kind 1 and kind 11112 events, valid pre-quantum.
|
||||
- The attesting identity authorized the link. Verified by the secp256k1 Schnorr signature on the kind 1 and kind 9999 events, valid pre-quantum.
|
||||
- The link existed at a specific pre-quantum Bitcoin block. Verified by the OTS proof.
|
||||
|
||||
**It does not prove:**
|
||||
@@ -291,4 +305,15 @@ This NIP builds on and is compatible with several existing community proposals:
|
||||
|
||||
## Reference implementation
|
||||
|
||||
A static, client-side-only web implementation exists at `https://laantungir.net/post-quantum/`. All cryptographic operations (BIP39, BIP32, PQ keygen/sign/verify, OTS submission and verification) happen in the browser. No private key ever touches a server. Source: [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs:1) in this repository.
|
||||
> **⚠️ Research prototype.** The implementation is experimental and is not a complete post-quantum
|
||||
> migration. It links PQ keys to an existing identity and anchors that link in Bitcoin; it does not
|
||||
> yet define PQ authentication for routine events, key rotation/revocation, or PQ encryption.
|
||||
|
||||
A static, client-side-only web implementation exists at `https://laantungir.net/post-quantum/`.
|
||||
Cryptographic operations (BIP39, BIP32, PQ keygen/sign/verify, OTS submission and Merkle-path
|
||||
verification) happen in the browser. Private keys are handled in the browser or via a NIP-07 signer
|
||||
extension, which may be a remote signer (NIP-46); in that case the signing key does not touch the
|
||||
page origin. OTS proof *upgrading* uses a server-side helper, and Bitcoin block-header confirmation
|
||||
relies on a public explorer API (mempool.space) — this is **API-assisted verification**, not a full
|
||||
Bitcoin light-client verification. Source: [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs:1) in this
|
||||
repository.
|
||||
|
||||
Generated
+600
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "post_quantum_nostr",
|
||||
"version": "0.0.14",
|
||||
"version": "0.0.25",
|
||||
"description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Amber Integration Analysis for PQ Nostr Web App
|
||||
|
||||
> **⚠️ Design-only research document.** This is an analysis of a possible integration, not an
|
||||
> implemented feature. The current system is a research prototype and does not make Nostr
|
||||
> identities post-quantum secure by itself.
|
||||
|
||||
## Amber Capabilities (from source code analysis)
|
||||
|
||||
[Amber](https://github.com/greenart7c3/Amber) is an Android Nostr event signer that keeps the nsec segregated in a dedicated app. Based on source code inspection:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Amber Integration Plan: End-User Experience
|
||||
|
||||
> **⚠️ Design-only research document.** This describes a planned user experience, not a shipped
|
||||
> feature. The current system is a research prototype; it links PQ keys to an identity and anchors
|
||||
> that link in Bitcoin, but does not by itself make the identity post-quantum secure.
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the complete end-to-end user experience for migrating a Nostr identity to post-quantum security using the PQ web app and Amber signer. There are **two paths** depending on how the user's account was created in Amber:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Existing Post-Quantum Proposals in the Nostr Ecosystem
|
||||
|
||||
> **⚠️ Research document.** A survey of community discussion; not an implementation spec.
|
||||
|
||||
A survey of pull requests and issues in `nostr-protocol/nips` related to post-quantum cryptography, key migration, and key rotation — and how they compare to our plan in [`README.md`](../README.md).
|
||||
|
||||
Research date: 2026-07-12
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# NIP-QR: Two-Account Flow Architecture
|
||||
|
||||
> **⚠️ Design-only research document.** This describes the proposed architecture; the current
|
||||
> implementation is a research prototype and does not by itself make Nostr identities
|
||||
> post-quantum secure.
|
||||
|
||||
## The Flow (as proposed)
|
||||
|
||||
```
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Post-Quantum Cryptography: JavaScript Package Survey
|
||||
|
||||
> **⚠️ Research document.** A library survey; not an implementation spec.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**We do NOT need to write our own implementations.** There are mature, well-maintained JavaScript packages for all NIST-standardized post-quantum algorithms. The standout recommendation is **[`@noble/post-quantum`](https://www.npmjs.com/package/@noble/post-quantum)** — a pure JavaScript implementation of all FIPS 203/204/205 algorithms, from the highly respected noble cryptography family (335 GitHub stars, MIT licensed, self-audited, actively maintained).
|
||||
|
||||
+303
-4
@@ -31,18 +31,41 @@ const m = await import('../www/js/pq-crypto.mjs');
|
||||
// ============================================================================
|
||||
|
||||
describe('BIP39 seed phrase', () => {
|
||||
test('generateSeedPhrase produces a valid 12-word mnemonic', () => {
|
||||
test('generateSeedPhrase defaults to a valid 24-word mnemonic', () => {
|
||||
const mnemonic = m.generateSeedPhrase();
|
||||
assert.ok(m.isValidMnemonic(mnemonic), 'generated mnemonic should be valid');
|
||||
const words = mnemonic.split(' ');
|
||||
assert.equal(words.length, 12, 'should be 12 words');
|
||||
assert.equal(words.length, 24, 'default should be 24 words');
|
||||
});
|
||||
|
||||
test('generateSeedPhraseWithEntropy produces a valid mnemonic', () => {
|
||||
test('generateSeedPhrase(128) produces a valid 12-word mnemonic', () => {
|
||||
const mnemonic = m.generateSeedPhrase(128);
|
||||
assert.ok(m.isValidMnemonic(mnemonic), 'generated mnemonic should be valid');
|
||||
assert.equal(mnemonic.split(' ').length, 12, 'should be 12 words');
|
||||
});
|
||||
|
||||
test('generateSeedPhrase rejects invalid entropyBits', () => {
|
||||
assert.throws(() => m.generateSeedPhrase(64), /entropyBits must be 128/);
|
||||
assert.throws(() => m.generateSeedPhrase(192), /entropyBits must be 128/);
|
||||
});
|
||||
|
||||
test('generateSeedPhraseWithEntropy defaults to a 24-word mnemonic', () => {
|
||||
const userEntropy = new TextEncoder().encode('test entropy from user');
|
||||
const mnemonic = m.generateSeedPhraseWithEntropy(userEntropy);
|
||||
assert.ok(m.isValidMnemonic(mnemonic), 'mnemonic with entropy should be valid');
|
||||
assert.equal(mnemonic.split(' ').length, 12);
|
||||
assert.equal(mnemonic.split(' ').length, 24, 'default should be 24 words');
|
||||
});
|
||||
|
||||
test('generateSeedPhraseWithEntropy(128) produces a 12-word mnemonic', () => {
|
||||
const userEntropy = new TextEncoder().encode('test entropy from user');
|
||||
const mnemonic = m.generateSeedPhraseWithEntropy(userEntropy, 128);
|
||||
assert.ok(m.isValidMnemonic(mnemonic), 'mnemonic with entropy should be valid');
|
||||
assert.equal(mnemonic.split(' ').length, 12, 'should be 12 words');
|
||||
});
|
||||
|
||||
test('generateSeedPhraseWithEntropy rejects invalid entropyBits', () => {
|
||||
const userEntropy = new TextEncoder().encode('test entropy');
|
||||
assert.throws(() => m.generateSeedPhraseWithEntropy(userEntropy, 64), /entropyBits must be 128/);
|
||||
});
|
||||
|
||||
test('generateSeedPhraseWithEntropy produces different mnemonics for different CSPRNG draws', () => {
|
||||
@@ -288,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 () => {
|
||||
@@ -311,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -696,3 +725,273 @@ describe('G56-03: identity binding (verifyNIPQRContent)', () => {
|
||||
assert.equal(result.identityBound, true, 'without options, identity binding should pass');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// G56-07: Fuzz / property tests — untrusted input must never throw
|
||||
// ============================================================================
|
||||
|
||||
describe('G56-07: verifyNostrEvent never throws on malformed input', () => {
|
||||
const malformedInputs = [
|
||||
null,
|
||||
undefined,
|
||||
'string',
|
||||
42,
|
||||
true,
|
||||
[],
|
||||
{},
|
||||
{ id: 'abc' },
|
||||
{ pubkey: 'x', sig: 'y', created_at: 'not-a-number', kind: 1, tags: [], content: '' },
|
||||
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 'not-a-number', tags: [], content: '' },
|
||||
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 1, tags: 'not-an-array', content: '' },
|
||||
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 1, tags: [], content: 123 },
|
||||
{ pubkey: 123, sig: 'y', created_at: 1, kind: 1, tags: [], content: '' },
|
||||
{ pubkey: 'x', sig: 123, created_at: 1, kind: 1, tags: [], content: '' },
|
||||
];
|
||||
|
||||
for (const input of malformedInputs) {
|
||||
test(`verifyNostrEvent returns false for ${JSON.stringify(input)?.substring(0, 50)}`, () => {
|
||||
assert.equal(m.verifyNostrEvent(input), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('verifyNostrEvent returns false for random-shaped objects (fuzz)', () => {
|
||||
const types = [null, true, false, 0, 1, 'x', '', [], {}, { id: 1 }, { tags: 'x' }];
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const obj = {};
|
||||
const keys = ['id', 'pubkey', 'sig', 'created_at', 'kind', 'tags', 'content', 'extra'];
|
||||
for (const k of keys) {
|
||||
if (Math.random() < 0.6) obj[k] = types[Math.floor(Math.random() * types.length)];
|
||||
}
|
||||
assert.equal(m.verifyNostrEvent(obj), false, `threw or passed for iteration ${i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('G56-07: verifyNIPQRContent never throws on arbitrary input', () => {
|
||||
function randomJson() {
|
||||
const types = [null, true, false, 0, 'x', [], {}, { id: 1 }, { tags: 'not-array' }];
|
||||
return types[Math.floor(Math.random() * types.length)];
|
||||
}
|
||||
|
||||
test('random content + tags never throw and never pass (fuzz)', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const content = Math.random() < 0.5
|
||||
? JSON.stringify(randomJson())
|
||||
: 'not-json-at-' + i;
|
||||
const tags = Math.random() < 0.5
|
||||
? randomJson()
|
||||
: (Math.random() < 0.5 ? [['algorithm', 'x']] : 'not-an-array');
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = m.verifyNIPQRContent(content, tags);
|
||||
}, `threw on iteration ${i}`);
|
||||
assert.equal(result.validForMigration, false, `passed on iteration ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('null / undefined / wrong-typed content returns structured failure', () => {
|
||||
for (const content of [null, undefined, 42, true, {}]) {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = m.verifyNIPQRContent(content, []);
|
||||
});
|
||||
assert.equal(result.validForMigration, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('oversized content is rejected before parsing', () => {
|
||||
const huge = 'x'.repeat(70 * 1024); // > MAX_EVENT_JSON_SIZE (64 KiB)
|
||||
const result = m.verifyNIPQRContent(huge, []);
|
||||
assert.equal(result.validForMigration, false);
|
||||
assert.ok(result.errors.some(e => e.includes('maximum size')), 'should report size error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('G56-07: parseOtsFile never throws on random/truncated/oversized bytes', () => {
|
||||
test('random bytes never throw (fuzz)', () => {
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const len = Math.floor(Math.random() * 2048);
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let j = 0; j < len; j++) bytes[j] = Math.floor(Math.random() * 256);
|
||||
let result;
|
||||
assert.doesNotThrow(() => { result = m.parseOtsFile(bytes); }, `threw on iteration ${i}, len=${len}`);
|
||||
// result is either null or a valid parse object — never throws
|
||||
if (result !== null) {
|
||||
assert.ok(typeof result === 'object' && 'fileHashOp' in result, 'should be null or valid object');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('non-Uint8Array inputs return null without throwing', () => {
|
||||
for (const input of [null, undefined, 'string', 42, true, [], {}, new Int8Array(10)]) {
|
||||
assert.equal(m.parseOtsFile(input), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('oversized input returns null without parsing', () => {
|
||||
const huge = new Uint8Array(2 * 1024 * 1024); // 2 MiB > MAX_OTS_PROOF_SIZE (1 MiB)
|
||||
assert.equal(m.parseOtsFile(huge), null);
|
||||
});
|
||||
|
||||
test('truncated valid-prefix bytes never throw', () => {
|
||||
// OTS magic header as a starting point
|
||||
const magic = m.hexToBytes('004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294');
|
||||
for (let cut = 0; cut <= magic.length; cut++) {
|
||||
const truncated = magic.slice(0, cut);
|
||||
let result;
|
||||
assert.doesNotThrow(() => { result = m.parseOtsFile(truncated); });
|
||||
// short truncations return null (too short); longer ones may parse partially or return null
|
||||
assert.ok(result === null || typeof result === 'object');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('G56-07: isOtsConfirmed never throws on bad input', () => {
|
||||
test('non-Uint8Array and random bytes never throw', () => {
|
||||
for (const input of [null, undefined, 'string', 42, [], {}]) {
|
||||
assert.doesNotThrow(() => m.isOtsConfirmed(input));
|
||||
}
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const len = Math.floor(Math.random() * 512);
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let j = 0; j < len; j++) bytes[j] = Math.floor(Math.random() * 256);
|
||||
assert.doesNotThrow(() => m.isOtsConfirmed(bytes), `threw on iteration ${i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// G56-05: Signer output validation
|
||||
// ============================================================================
|
||||
|
||||
describe('G56-05: validateSignerOutput', () => {
|
||||
const sk = new Uint8Array(32);
|
||||
sk[31] = 5;
|
||||
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
|
||||
const otherSk = new Uint8Array(32);
|
||||
otherSk[31] = 9;
|
||||
const otherPkHex = m.bytesToHex(schnorr.getPublicKey(otherSk));
|
||||
|
||||
// Helper: create a valid template + correctly signed event
|
||||
function makeValidSignedEvent(content = 'test content', tags = [['t', 'x']]) {
|
||||
const template = {
|
||||
pubkey: pkHex,
|
||||
created_at: 1700000000,
|
||||
kind: 1,
|
||||
tags,
|
||||
content
|
||||
};
|
||||
const event = { ...template };
|
||||
event.id = m.computeEventId(event);
|
||||
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
|
||||
return { template, event };
|
||||
}
|
||||
|
||||
test('valid signer output passes validation', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const result = m.validateSignerOutput(event, template, pkHex);
|
||||
assert.equal(result.id, event.id);
|
||||
assert.equal(result.sig, event.sig);
|
||||
});
|
||||
|
||||
test('signer changes content → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, content: 'tampered content' };
|
||||
// Recompute id+sig so the tampered event is internally consistent
|
||||
// (a real malicious signer would do this, but the content must still match the template)
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /content was changed/);
|
||||
});
|
||||
|
||||
test('signer changes tags → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, tags: [['t', 'x'], ['extra', 'tag']] };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tags length changed/);
|
||||
});
|
||||
|
||||
test('signer changes created_at → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, created_at: 9999999999 };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /created_at changed/);
|
||||
});
|
||||
|
||||
test('signer changes kind → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, kind: 9999 };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /kind changed/);
|
||||
});
|
||||
|
||||
test('signer returns wrong pubkey → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
// Sign with a different key, use that key's pubkey
|
||||
const tampered = { ...template, pubkey: otherPkHex };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), otherSk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /pubkey mismatch/);
|
||||
});
|
||||
|
||||
test('signer returns wrong ID → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, id: '0'.repeat(64) };
|
||||
// sig is valid for the real id, not for '0'.repeat(64)
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id mismatch/);
|
||||
});
|
||||
|
||||
test('signer returns invalid signature → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, sig: 'f'.repeat(128) };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /invalid Schnorr signature/);
|
||||
});
|
||||
|
||||
test('signer returns extra field → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, extra: 'malicious' };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /unexpected extra field/);
|
||||
});
|
||||
|
||||
test('signer returns missing field → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const { sig, ...noSig } = event;
|
||||
assert.throws(() => m.validateSignerOutput(noSig, template, pkHex), /missing field 'sig'/);
|
||||
});
|
||||
|
||||
test('signer returns non-object → rejected', () => {
|
||||
const { template } = makeValidSignedEvent();
|
||||
assert.throws(() => m.validateSignerOutput(null, template, pkHex), /non-object/);
|
||||
assert.throws(() => m.validateSignerOutput('string', template, pkHex), /non-object/);
|
||||
});
|
||||
|
||||
test('signer returns malformed id (not 64 hex) → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, id: 'short' };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id must be 64/);
|
||||
});
|
||||
|
||||
test('signer returns malformed sig (not 128 hex) → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, sig: 'short' };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /sig must be 128/);
|
||||
});
|
||||
|
||||
test('no expectedPubkey → pubkey check skipped', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
// Should pass without expectedPubkey (backward compatible)
|
||||
const result = m.validateSignerOutput(event, template);
|
||||
assert.equal(result.id, event.id);
|
||||
});
|
||||
|
||||
test('tag element value changed → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent('test', [['t', 'original']]);
|
||||
const tampered = { ...event, tags: [['t', 'changed']] };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tag 0 element 1 changed/);
|
||||
});
|
||||
});
|
||||
|
||||
+14
-9
@@ -1,5 +1,10 @@
|
||||
# Post-Quantum Nostr: Why, What, How
|
||||
|
||||
> **⚠️ Research prototype.** This document describes an experimental pre-quantum key-commitment
|
||||
> and identity-link system. It does not, by itself, make a Nostr identity post-quantum secure;
|
||||
> complete migration requires companion protocols (PQ event authentication, rotation, encryption,
|
||||
> client adoption) that are not yet specified or implemented.
|
||||
|
||||
## Why: The Problem
|
||||
|
||||
### Quantum computers will break Nostr's cryptography
|
||||
@@ -57,7 +62,7 @@ A migration strategy that brings post-quantum security to Nostr:
|
||||
|
||||
1. **Seed phrase as algorithm-agnostic root of trust** — A BIP39 mnemonic is pure entropy (128/256 bits). It's not tied to any cryptographic algorithm. PQ keys are derived from it deterministically using BIP32 hierarchical deterministic derivation (the same standard used by NIP-06 for secp256k1 keys), with each PQ algorithm at a different child index under the NIP-06 base path. Same seed always produces the same PQ keys. The seed is quantum-safe (symmetric KDF, Grover's quadratic speedup is negligible).
|
||||
|
||||
2. **Multi-scheme cross-signed key-link events** — Two Nostr events (a kind 1 announcement and a kind 11112 wrapper) that link a user's secp256k1 identity to multiple PQ keys simultaneously: ML-DSA-44 and ML-DSA-65 (lattice-based signatures), SLH-DSA-128s (hash-based signatures), Falcon-512 (lattice-based signatures), and ML-KEM-768 (lattice-based KEM for encryption). Each PQ signature scheme independently signs the attestation text. If one scheme is later broken, the others remain valid.
|
||||
2. **Multi-scheme cross-signed key-link events** — Two Nostr events (a kind 1 announcement and a kind 9999 proof carrier) that link a user's secp256k1 identity to multiple PQ keys simultaneously: ML-DSA-44 and ML-DSA-65 (lattice-based signatures), SLH-DSA-128s (hash-based signatures), Falcon-512 (lattice-based signatures), and ML-KEM-768 (lattice-based KEM for encryption). Each PQ signature scheme independently signs the attestation text. If one scheme is later broken, the others remain valid.
|
||||
|
||||
3. **OpenTimestamps for pre-quantum anchoring** — The key-link event is timestamped via NIP-03 (OpenTimestamps), anchoring it to a Bitcoin block. This prevents a future quantum attacker from backdating a fraudulent key-link event, because they cannot produce a valid OTS proof for a past Bitcoin block (re-mining historical blocks is infeasible even with a quantum computer).
|
||||
|
||||
@@ -84,7 +89,7 @@ A migration strategy that brings post-quantum security to Nostr:
|
||||
Account #1 (existing identity, social graph knows this npub)
|
||||
|
|
||||
| signs: "I authorize this migration to these PQ keys"
|
||||
| (secp256k1 Schnorr signature on the kind 1 and kind 11112 events,
|
||||
| (secp256k1 Schnorr signature on the kind 1 and kind 9999 events,
|
||||
| valid pre-quantum)
|
||||
v
|
||||
Kind 1 announcement event (published under Account #1's pubkey)
|
||||
@@ -97,7 +102,7 @@ Kind 1 announcement event (published under Account #1's pubkey)
|
||||
| OpenTimestamps anchor of the kind 1 event hash to Bitcoin block
|
||||
| (proves the event existed at this time, pre-quantum)
|
||||
v
|
||||
Kind 11112 wrapper event (carries the OTS proof, embeds the kind 1 event)
|
||||
Kind 9999 proof carrier event (carries the OTS proof, embeds the kind 1 event)
|
||||
|
|
||||
v
|
||||
Published to Nostr relays
|
||||
@@ -130,11 +135,11 @@ The migration uses **two Nostr events**:
|
||||
|
||||
The content is a human-readable attestation statement. Each PQ signature scheme signs `TextEncoder.encode(content)`. The PQ public keys and signatures go in the `algorithm` tags. ML-KEM-768 has only a pubkey tag (no signature — it's a KEM).
|
||||
|
||||
#### Kind 11112 wrapper (machine-verifiable, carries the OTS proof)
|
||||
#### Kind 9999 proof carrier (machine-verifiable, carries the OTS proof)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 11112,
|
||||
"kind": 9999,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey>",
|
||||
"content": "<JSON string of the full signed kind 1 event>",
|
||||
"tags": [
|
||||
@@ -174,10 +179,10 @@ A static web page (no backend with secrets) that:
|
||||
1. **Signs in** — uses nostr-login-lite (NIP-46 bunker, seed phrase, or browser extension) to authenticate with the user's existing Nostr identity
|
||||
2. **Generates a seed phrase** — 12-word BIP39 mnemonic, generated client-side, never sent to any server
|
||||
3. **Derives PQ keys** — uses `@noble/post-quantum` (pure JavaScript, no WASM) to generate ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, and ML-KEM-768 keypairs from the seed via BIP32 derivation paths
|
||||
4. **Signs the events** — PQ signatures are created in the browser; the secp256k1 signature is requested from the user's signer (via `window.nostr.signEvent()`) for both the kind 1 announcement and the kind 11112 wrapper
|
||||
5. **Timestamps the kind 1 event** — submits the SHA-256 of the full signed kind 1 event JSON to OpenTimestamps and embeds the proof in the kind 11112 wrapper
|
||||
4. **Signs the events** — PQ signatures are created in the browser; the secp256k1 signature is requested from the user's signer (via `window.nostr.signEvent()`) for both the kind 1 announcement and the kind 9999 proof carrier
|
||||
5. **Timestamps the kind 1 event** — submits the SHA-256 of the full signed kind 1 event JSON to OpenTimestamps and embeds the proof in the kind 9999 proof carrier
|
||||
6. **Publishes to relays** — sends both events to Nostr relays via direct WebSocket
|
||||
7. **Polls for Bitcoin confirmation** — upgrades the OTS proof and republishes the kind 11112 wrapper with the confirmed proof
|
||||
7. **Polls for Bitcoin confirmation** — upgrades the OTS proof and republishes the kind 9999 proof carrier with the confirmed proof
|
||||
8. **Shows the result** — displays the complete event JSON and event ID
|
||||
|
||||
All cryptographic operations happen either in the browser (JavaScript/WASM) or in the user's signer (Amber, browser extension, or bunker). No private key ever touches a server.
|
||||
@@ -213,7 +218,7 @@ We use multiple schemes simultaneously because:
|
||||
|
||||
### The demo
|
||||
|
||||
A working demo is deployed at **https://laantungir.net/post-quantum** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 11112 events, publish to relays, and timestamp via OpenTimestamps. It uses:
|
||||
A working demo is deployed at **https://laantungir.net/post-quantum** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 9999 events, publish to relays, and timestamp via OpenTimestamps. It uses:
|
||||
|
||||
- `@noble/post-quantum` for PQ cryptography (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)
|
||||
- `@scure/bip39` for seed phrase generation
|
||||
|
||||
+46
-1256
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+354
-75
@@ -70,11 +70,20 @@ const PQ_SEED_LENGTHS = {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a new 12-word BIP39 mnemonic.
|
||||
* @returns {string} 12-word seed phrase
|
||||
* Generate a new BIP39 mnemonic.
|
||||
*
|
||||
* Defaults to 24 words (256 bits of entropy) so the recovery root is not the
|
||||
* weakest link in a system whose PQ algorithms target NIST Category 1+.
|
||||
* Pass `128` for `entropyBits` to generate a 12-word phrase (e.g. for testing).
|
||||
*
|
||||
* @param {number} [entropyBits=256] - 128 (12 words) or 256 (24 words)
|
||||
* @returns {string} BIP39 seed phrase (24 words by default)
|
||||
*/
|
||||
export function generateSeedPhrase() {
|
||||
return generateMnemonic(wordlist, 128); // 128 bits = 12 words
|
||||
export function generateSeedPhrase(entropyBits = 256) {
|
||||
if (entropyBits !== 128 && entropyBits !== 256) {
|
||||
throw new Error('entropyBits must be 128 (12 words) or 256 (24 words)');
|
||||
}
|
||||
return generateMnemonic(wordlist, entropyBits);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,20 +94,29 @@ export function generateSeedPhrase() {
|
||||
* compromised, the seed remains unpredictable to an attacker who doesn't have
|
||||
* the user's entropy.
|
||||
*
|
||||
* Defaults to 24 words (256 bits). Pass `128` for `entropyBits` to generate a
|
||||
* 12-word phrase.
|
||||
*
|
||||
* @param {Uint8Array} userEntropy - Additional entropy from the user (any length)
|
||||
* @returns {string} A valid 12-word BIP39 mnemonic
|
||||
* @param {number} [entropyBits=256] - 128 (12 words) or 256 (24 words)
|
||||
* @returns {string} A valid BIP39 mnemonic (24 words by default)
|
||||
*/
|
||||
export function generateSeedPhraseWithEntropy(userEntropy) {
|
||||
// Get 16 bytes (128 bits) of CSPRNG entropy
|
||||
const csprngBytes = new Uint8Array(16);
|
||||
export function generateSeedPhraseWithEntropy(userEntropy, entropyBits = 256) {
|
||||
if (entropyBits !== 128 && entropyBits !== 256) {
|
||||
throw new Error('entropyBits must be 128 (12 words) or 256 (24 words)');
|
||||
}
|
||||
const entropyBytes = entropyBits / 8; // 16 or 32
|
||||
|
||||
// Get CSPRNG entropy of the requested size
|
||||
const csprngBytes = new Uint8Array(entropyBytes);
|
||||
crypto.getRandomValues(csprngBytes);
|
||||
|
||||
// Combine CSPRNG entropy with user entropy via SHA-256
|
||||
const combined = concatBytes(csprngBytes, userEntropy);
|
||||
const hash = sha256(combined);
|
||||
|
||||
// Use the first 16 bytes (128 bits) of the hash as the entropy
|
||||
const finalEntropy = hash.slice(0, 16);
|
||||
// Use the first `entropyBytes` bytes of the hash as the entropy
|
||||
const finalEntropy = hash.slice(0, entropyBytes);
|
||||
|
||||
return entropyToMnemonic(finalEntropy, wordlist);
|
||||
}
|
||||
@@ -394,28 +412,162 @@ export function hexToNpub(hexPubkey) {
|
||||
/**
|
||||
* Verify a Nostr event's secp256k1 Schnorr signature (NIP-01).
|
||||
* Checks both the signature validity AND that event.id matches the computed hash.
|
||||
*
|
||||
* G56-07: This function accepts untrusted input (pasted JSON, relay events) and
|
||||
* must never throw on malformed data. It returns `false` for any structurally
|
||||
* invalid event.
|
||||
*
|
||||
* @param {object} event - Nostr event with id, pubkey, created_at, kind, tags, content, sig
|
||||
* @returns {boolean} true if signature is valid and id matches
|
||||
* @returns {boolean} true if signature is valid and id matches; false otherwise (never throws)
|
||||
*/
|
||||
export function verifyNostrEvent(event) {
|
||||
// Build the event hash (NIP-01 serialization)
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content
|
||||
]);
|
||||
const hash = sha256(new TextEncoder().encode(serialized));
|
||||
const computedId = bytesToHex(hash);
|
||||
// F-H2: Verify event.id matches the computed hash
|
||||
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
|
||||
try {
|
||||
// Structural validation — fail closed on wrong shape
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (typeof event.pubkey !== 'string' || typeof event.sig !== 'string') return false;
|
||||
if (typeof event.created_at !== 'number') return false;
|
||||
if (typeof event.kind !== 'number') return false;
|
||||
if (!Array.isArray(event.tags)) return false;
|
||||
if (typeof event.content !== 'string') return false;
|
||||
|
||||
// Build the event hash (NIP-01 serialization)
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content
|
||||
]);
|
||||
const hash = sha256(new TextEncoder().encode(serialized));
|
||||
const computedId = bytesToHex(hash);
|
||||
// F-H2: Verify event.id matches the computed hash
|
||||
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
const sig = hexToBytes(event.sig);
|
||||
const pubkey = hexToBytes(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
} catch (e) {
|
||||
// G56-07: never throw on untrusted malformed input
|
||||
return false;
|
||||
}
|
||||
const sig = hexToBytes(event.sig);
|
||||
const pubkey = hexToBytes(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the output of a NIP-07 signer's signEvent() call (G56-05).
|
||||
*
|
||||
* This ensures a malicious or buggy signer cannot substitute different content,
|
||||
* tags, pubkey, or an invalid signature. It verifies that the signed event
|
||||
* matches the template exactly (for the semantic fields), that the pubkey
|
||||
* equals the expected identity, that the computed ID matches, and that the
|
||||
* Schnorr signature is valid.
|
||||
*
|
||||
* Returns the validated signed event object (the signer's exact object, not a
|
||||
* reconstruction) on success, or throws on any mismatch.
|
||||
*
|
||||
* @param {object} signedEvent - The object returned by window.nostr.signEvent()
|
||||
* @param {object} template - The unsigned template that was passed to signEvent()
|
||||
* @param {string} expectedPubkey - The hex pubkey of the authenticated identity
|
||||
* @returns {object} the validated signed event
|
||||
* @throws {Error} if any field is missing, mismatched, or the signature is invalid
|
||||
*/
|
||||
export function validateSignerOutput(signedEvent, template, expectedPubkey) {
|
||||
if (!signedEvent || typeof signedEvent !== 'object') {
|
||||
throw new Error('validateSignerOutput: signer returned non-object');
|
||||
}
|
||||
|
||||
// 1. Check all required fields are present and correctly typed
|
||||
const required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
|
||||
for (const field of required) {
|
||||
if (!(field in signedEvent)) {
|
||||
throw new Error(`validateSignerOutput: missing field '${field}'`);
|
||||
}
|
||||
}
|
||||
if (typeof signedEvent.id !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.id)) {
|
||||
throw new Error('validateSignerOutput: id must be 64 lowercase hex chars');
|
||||
}
|
||||
if (typeof signedEvent.pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.pubkey)) {
|
||||
throw new Error('validateSignerOutput: pubkey must be 64 lowercase hex chars');
|
||||
}
|
||||
if (typeof signedEvent.sig !== 'string' || !/^[0-9a-f]{128}$/.test(signedEvent.sig)) {
|
||||
throw new Error('validateSignerOutput: sig must be 128 lowercase hex chars');
|
||||
}
|
||||
if (typeof signedEvent.created_at !== 'number') {
|
||||
throw new Error('validateSignerOutput: created_at must be a number');
|
||||
}
|
||||
if (typeof signedEvent.kind !== 'number') {
|
||||
throw new Error('validateSignerOutput: kind must be a number');
|
||||
}
|
||||
if (!Array.isArray(signedEvent.tags)) {
|
||||
throw new Error('validateSignerOutput: tags must be an array');
|
||||
}
|
||||
if (typeof signedEvent.content !== 'string') {
|
||||
throw new Error('validateSignerOutput: content must be a string');
|
||||
}
|
||||
|
||||
// 2. Verify no template field was changed by the signer
|
||||
if (signedEvent.kind !== template.kind) {
|
||||
throw new Error(`validateSignerOutput: kind changed (template=${template.kind}, signed=${signedEvent.kind})`);
|
||||
}
|
||||
if (signedEvent.content !== template.content) {
|
||||
throw new Error('validateSignerOutput: content was changed by signer');
|
||||
}
|
||||
if (signedEvent.created_at !== template.created_at) {
|
||||
throw new Error(`validateSignerOutput: created_at changed (template=${template.created_at}, signed=${signedEvent.created_at})`);
|
||||
}
|
||||
// Deep-compare tags (order-sensitive, as tag order affects the event ID)
|
||||
if (signedEvent.tags.length !== template.tags.length) {
|
||||
throw new Error(`validateSignerOutput: tags length changed (template=${template.tags.length}, signed=${signedEvent.tags.length})`);
|
||||
}
|
||||
for (let i = 0; i < template.tags.length; i++) {
|
||||
const tTag = template.tags[i];
|
||||
const sTag = signedEvent.tags[i];
|
||||
if (!Array.isArray(sTag) || sTag.length !== tTag.length) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} shape changed`);
|
||||
}
|
||||
for (let j = 0; j < tTag.length; j++) {
|
||||
if (sTag[j] !== tTag[j]) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} element ${j} changed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify the pubkey matches the expected identity
|
||||
if (expectedPubkey && signedEvent.pubkey !== expectedPubkey) {
|
||||
throw new Error(`validateSignerOutput: pubkey mismatch (expected=${expectedPubkey?.substring(0, 16)}..., got=${signedEvent.pubkey.substring(0, 16)}...)`);
|
||||
}
|
||||
|
||||
// 4. Recompute the event ID and verify it matches
|
||||
const computedId = computeEventId(signedEvent);
|
||||
if (signedEvent.id !== computedId) {
|
||||
throw new Error(`validateSignerOutput: id mismatch (signed=${signedEvent.id.substring(0, 16)}..., computed=${computedId.substring(0, 16)}...)`);
|
||||
}
|
||||
|
||||
// 5. Verify the Schnorr signature
|
||||
const hash = sha256(new TextEncoder().encode(JSON.stringify([
|
||||
0,
|
||||
signedEvent.pubkey,
|
||||
signedEvent.created_at,
|
||||
signedEvent.kind,
|
||||
signedEvent.tags,
|
||||
signedEvent.content
|
||||
])));
|
||||
const sig = hexToBytes(signedEvent.sig);
|
||||
const pubkey = hexToBytes(signedEvent.pubkey);
|
||||
if (!schnorr.verify(sig, hash, pubkey)) {
|
||||
throw new Error('validateSignerOutput: invalid Schnorr signature');
|
||||
}
|
||||
|
||||
// 6. Reject extra unexpected fields (allow only the 7 NIP-01 fields)
|
||||
const allowedFields = new Set(['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']);
|
||||
for (const key of Object.keys(signedEvent)) {
|
||||
if (!allowedFields.has(key)) {
|
||||
throw new Error(`validateSignerOutput: unexpected extra field '${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -677,6 +829,16 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}
|
||||
const errors = [];
|
||||
const { expectedAuthor, outerPubkey } = options;
|
||||
|
||||
// G56-07: reject oversized content before any parsing/decoding work
|
||||
if (typeof kind11112Content === 'string' && kind11112Content.length > MAX_EVENT_JSON_SIZE) {
|
||||
return {
|
||||
results: [{ algorithm: 'event', valid: false, note: 'content exceeds maximum size' }],
|
||||
kind1Event: null, fullHash: null, sha256Valid: false, eTagValid: false,
|
||||
pqProofsValid: false, policySufficient: false, identityBound: false,
|
||||
validForMigration: false, errors: ['content exceeds maximum size']
|
||||
};
|
||||
}
|
||||
|
||||
// --- Helper: push a result entry and track failures ---
|
||||
function pushResult(algorithm, valid, note) {
|
||||
results.push({ algorithm, valid, note });
|
||||
@@ -1475,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;
|
||||
}
|
||||
@@ -1501,6 +1676,11 @@ class OtsReader {
|
||||
// OTS magic header (31 bytes)
|
||||
const OTS_MAGIC = hexToBytes('004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294');
|
||||
|
||||
// G56-07: Maximum sizes for untrusted inputs. Reject larger inputs before any
|
||||
// parsing or cryptographic work to prevent denial-of-service via oversized data.
|
||||
const MAX_OTS_PROOF_SIZE = 1 << 20; // 1 MiB — generous for heavily upgraded proofs
|
||||
const MAX_EVENT_JSON_SIZE = 1 << 16; // 64 KiB — NIP-QR events are typically 30-50 KiB
|
||||
|
||||
// Attestation tags
|
||||
const BITCOIN_ATTESTATION_TAG = hexToBytes('0588960d73d71901');
|
||||
const LITECOIN_ATTESTATION_TAG = hexToBytes('06869a0d73d71b45');
|
||||
@@ -1522,35 +1702,45 @@ function bytesEqual(a, b) {
|
||||
* that the attestation covers (after walking the op tree from the target).
|
||||
*/
|
||||
export function parseOtsFile(otsBytes) {
|
||||
// G56-07: fail closed on untrusted input — never throw
|
||||
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_MAGIC.length) return null;
|
||||
// G56-07: reject oversized proofs before any parsing work
|
||||
if (otsBytes.length > MAX_OTS_PROOF_SIZE) return null;
|
||||
|
||||
const reader = new OtsReader(otsBytes);
|
||||
try {
|
||||
const reader = new OtsReader(otsBytes);
|
||||
|
||||
// 1. Magic header
|
||||
const magic = reader.readBytes(OTS_MAGIC.length);
|
||||
if (!bytesEqual(magic, OTS_MAGIC)) return null;
|
||||
// 1. Magic header
|
||||
const magic = reader.readBytes(OTS_MAGIC.length);
|
||||
if (!bytesEqual(magic, OTS_MAGIC)) return null;
|
||||
|
||||
// 2. Version (varuint) — we support major version 1
|
||||
const version = reader.readVaruint();
|
||||
if (version !== 1) return null;
|
||||
// 2. Version (varuint) — we support major version 1
|
||||
const version = reader.readVaruint();
|
||||
if (version !== 1) return null;
|
||||
|
||||
// 3. File hash operation tag (1 byte)
|
||||
const hashOpTag = reader.readByte();
|
||||
let fileHashOp = 'unknown';
|
||||
let digestLen = 32;
|
||||
if (hashOpTag === 0x08) { fileHashOp = 'sha256'; digestLen = 32; }
|
||||
else if (hashOpTag === 0x02) { fileHashOp = 'sha1'; digestLen = 20; }
|
||||
else if (hashOpTag === 0x03) { fileHashOp = 'ripemd160'; digestLen = 20; }
|
||||
else { return null; } // unsupported hash op
|
||||
// 3. File hash operation tag (1 byte)
|
||||
const hashOpTag = reader.readByte();
|
||||
let fileHashOp = 'unknown';
|
||||
let digestLen = 32;
|
||||
if (hashOpTag === 0x08) { fileHashOp = 'sha256'; digestLen = 32; }
|
||||
else if (hashOpTag === 0x02) { fileHashOp = 'sha1'; digestLen = 20; }
|
||||
else if (hashOpTag === 0x03) { fileHashOp = 'ripemd160'; digestLen = 20; }
|
||||
else { return null; } // unsupported hash op
|
||||
|
||||
// 4. File digest (the target — what was timestamped)
|
||||
const targetDigest = reader.readBytes(digestLen);
|
||||
// 4. File digest (the target — what was timestamped)
|
||||
const targetDigest = reader.readBytes(digestLen);
|
||||
|
||||
// 5. Timestamp tree
|
||||
const attestations = [];
|
||||
_parseTimestamp(reader, targetDigest, attestations, 0);
|
||||
// 5. Timestamp tree
|
||||
const attestations = [];
|
||||
// G56-13: pass operation/branch budget to prevent DoS
|
||||
const budget = { operations: 0, branches: 0 };
|
||||
_parseTimestamp(reader, targetDigest, attestations, 0, budget);
|
||||
|
||||
return { fileHashOp, targetDigest, attestations };
|
||||
return { fileHashOp, targetDigest, attestations };
|
||||
} catch (e) {
|
||||
// G56-07: never throw on malformed/truncated untrusted input
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1569,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.
|
||||
@@ -1579,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);
|
||||
@@ -1600,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1661,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}` },
|
||||
@@ -1670,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
|
||||
@@ -1694,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) {
|
||||
@@ -1735,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);
|
||||
@@ -1749,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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1763,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());
|
||||
@@ -1793,6 +2060,7 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
targetDigest: targetDigestHex,
|
||||
attestations: parsed.attestations,
|
||||
bitcoinAttestations: verified,
|
||||
trustMode,
|
||||
errors
|
||||
};
|
||||
}
|
||||
@@ -1801,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
|
||||
@@ -1820,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.0.14",
|
||||
"VERSION_NUMBER": "0.0.14",
|
||||
"BUILD_DATE": "2026-07-24T11:16:26.143Z"
|
||||
"VERSION": "v0.0.25",
|
||||
"VERSION_NUMBER": "0.0.25",
|
||||
"BUILD_DATE": "2026-07-24T18:43:29.373Z"
|
||||
}
|
||||
|
||||
+241
-56
@@ -9609,15 +9609,22 @@ var PQ_SEED_LENGTHS = {
|
||||
falcon512: 48,
|
||||
mlKem: 64
|
||||
};
|
||||
function generateSeedPhrase() {
|
||||
return generateMnemonic(wordlist, 128);
|
||||
function generateSeedPhrase(entropyBits = 256) {
|
||||
if (entropyBits !== 128 && entropyBits !== 256) {
|
||||
throw new Error("entropyBits must be 128 (12 words) or 256 (24 words)");
|
||||
}
|
||||
return generateMnemonic(wordlist, entropyBits);
|
||||
}
|
||||
function generateSeedPhraseWithEntropy(userEntropy) {
|
||||
const csprngBytes = new Uint8Array(16);
|
||||
function generateSeedPhraseWithEntropy(userEntropy, entropyBits = 256) {
|
||||
if (entropyBits !== 128 && entropyBits !== 256) {
|
||||
throw new Error("entropyBits must be 128 (12 words) or 256 (24 words)");
|
||||
}
|
||||
const entropyBytes = entropyBits / 8;
|
||||
const csprngBytes = new Uint8Array(entropyBytes);
|
||||
crypto.getRandomValues(csprngBytes);
|
||||
const combined = concatBytes4(csprngBytes, userEntropy);
|
||||
const hash = sha256(combined);
|
||||
const finalEntropy = hash.slice(0, 16);
|
||||
const finalEntropy = hash.slice(0, entropyBytes);
|
||||
return entropyToMnemonic(finalEntropy, wordlist);
|
||||
}
|
||||
function mnemonicToSeed(mnemonic, passphrase = "") {
|
||||
@@ -9754,22 +9761,115 @@ function hexToNpub(hexPubkey) {
|
||||
return bech32.encode("npub", bech32.toWords(bytes));
|
||||
}
|
||||
function verifyNostrEvent(event) {
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content
|
||||
]);
|
||||
const hash = sha256(new TextEncoder().encode(serialized));
|
||||
const computedId = bytesToHex3(hash);
|
||||
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
|
||||
try {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
if (typeof event.pubkey !== "string" || typeof event.sig !== "string") return false;
|
||||
if (typeof event.created_at !== "number") return false;
|
||||
if (typeof event.kind !== "number") return false;
|
||||
if (!Array.isArray(event.tags)) return false;
|
||||
if (typeof event.content !== "string") return false;
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content
|
||||
]);
|
||||
const hash = sha256(new TextEncoder().encode(serialized));
|
||||
const computedId = bytesToHex3(hash);
|
||||
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
const sig = hexToBytes3(event.sig);
|
||||
const pubkey = hexToBytes3(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
const sig = hexToBytes3(event.sig);
|
||||
const pubkey = hexToBytes3(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
}
|
||||
function validateSignerOutput(signedEvent, template, expectedPubkey) {
|
||||
if (!signedEvent || typeof signedEvent !== "object") {
|
||||
throw new Error("validateSignerOutput: signer returned non-object");
|
||||
}
|
||||
const required = ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"];
|
||||
for (const field of required) {
|
||||
if (!(field in signedEvent)) {
|
||||
throw new Error(`validateSignerOutput: missing field '${field}'`);
|
||||
}
|
||||
}
|
||||
if (typeof signedEvent.id !== "string" || !/^[0-9a-f]{64}$/.test(signedEvent.id)) {
|
||||
throw new Error("validateSignerOutput: id must be 64 lowercase hex chars");
|
||||
}
|
||||
if (typeof signedEvent.pubkey !== "string" || !/^[0-9a-f]{64}$/.test(signedEvent.pubkey)) {
|
||||
throw new Error("validateSignerOutput: pubkey must be 64 lowercase hex chars");
|
||||
}
|
||||
if (typeof signedEvent.sig !== "string" || !/^[0-9a-f]{128}$/.test(signedEvent.sig)) {
|
||||
throw new Error("validateSignerOutput: sig must be 128 lowercase hex chars");
|
||||
}
|
||||
if (typeof signedEvent.created_at !== "number") {
|
||||
throw new Error("validateSignerOutput: created_at must be a number");
|
||||
}
|
||||
if (typeof signedEvent.kind !== "number") {
|
||||
throw new Error("validateSignerOutput: kind must be a number");
|
||||
}
|
||||
if (!Array.isArray(signedEvent.tags)) {
|
||||
throw new Error("validateSignerOutput: tags must be an array");
|
||||
}
|
||||
if (typeof signedEvent.content !== "string") {
|
||||
throw new Error("validateSignerOutput: content must be a string");
|
||||
}
|
||||
if (signedEvent.kind !== template.kind) {
|
||||
throw new Error(`validateSignerOutput: kind changed (template=${template.kind}, signed=${signedEvent.kind})`);
|
||||
}
|
||||
if (signedEvent.content !== template.content) {
|
||||
throw new Error("validateSignerOutput: content was changed by signer");
|
||||
}
|
||||
if (signedEvent.created_at !== template.created_at) {
|
||||
throw new Error(`validateSignerOutput: created_at changed (template=${template.created_at}, signed=${signedEvent.created_at})`);
|
||||
}
|
||||
if (signedEvent.tags.length !== template.tags.length) {
|
||||
throw new Error(`validateSignerOutput: tags length changed (template=${template.tags.length}, signed=${signedEvent.tags.length})`);
|
||||
}
|
||||
for (let i = 0; i < template.tags.length; i++) {
|
||||
const tTag = template.tags[i];
|
||||
const sTag = signedEvent.tags[i];
|
||||
if (!Array.isArray(sTag) || sTag.length !== tTag.length) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} shape changed`);
|
||||
}
|
||||
for (let j = 0; j < tTag.length; j++) {
|
||||
if (sTag[j] !== tTag[j]) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} element ${j} changed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expectedPubkey && signedEvent.pubkey !== expectedPubkey) {
|
||||
throw new Error(`validateSignerOutput: pubkey mismatch (expected=${expectedPubkey?.substring(0, 16)}..., got=${signedEvent.pubkey.substring(0, 16)}...)`);
|
||||
}
|
||||
const computedId = computeEventId(signedEvent);
|
||||
if (signedEvent.id !== computedId) {
|
||||
throw new Error(`validateSignerOutput: id mismatch (signed=${signedEvent.id.substring(0, 16)}..., computed=${computedId.substring(0, 16)}...)`);
|
||||
}
|
||||
const hash = sha256(new TextEncoder().encode(JSON.stringify([
|
||||
0,
|
||||
signedEvent.pubkey,
|
||||
signedEvent.created_at,
|
||||
signedEvent.kind,
|
||||
signedEvent.tags,
|
||||
signedEvent.content
|
||||
])));
|
||||
const sig = hexToBytes3(signedEvent.sig);
|
||||
const pubkey = hexToBytes3(signedEvent.pubkey);
|
||||
if (!schnorr.verify(sig, hash, pubkey)) {
|
||||
throw new Error("validateSignerOutput: invalid Schnorr signature");
|
||||
}
|
||||
const allowedFields = /* @__PURE__ */ new Set(["id", "pubkey", "created_at", "kind", "tags", "content", "sig"]);
|
||||
for (const key of Object.keys(signedEvent)) {
|
||||
if (!allowedFields.has(key)) {
|
||||
throw new Error(`validateSignerOutput: unexpected extra field '${key}'`);
|
||||
}
|
||||
}
|
||||
return signedEvent;
|
||||
}
|
||||
var NIP_QR_KIND = 9999;
|
||||
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
|
||||
@@ -9879,6 +9979,20 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}) {
|
||||
const results = [];
|
||||
const errors = [];
|
||||
const { expectedAuthor, outerPubkey } = options;
|
||||
if (typeof kind11112Content === "string" && kind11112Content.length > MAX_EVENT_JSON_SIZE) {
|
||||
return {
|
||||
results: [{ algorithm: "event", valid: false, note: "content exceeds maximum size" }],
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false,
|
||||
pqProofsValid: false,
|
||||
policySufficient: false,
|
||||
identityBound: false,
|
||||
validForMigration: false,
|
||||
errors: ["content exceeds maximum size"]
|
||||
};
|
||||
}
|
||||
function pushResult(algorithm, valid, note) {
|
||||
results.push({ algorithm, valid, note });
|
||||
if (!valid) errors.push(`${algorithm}: ${note || "INVALID"}`);
|
||||
@@ -10403,7 +10517,7 @@ function isOtsConfirmed(otsBytes) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
var OtsReader = class {
|
||||
var _OtsReader = class _OtsReader {
|
||||
constructor(bytes) {
|
||||
this.bytes = bytes;
|
||||
this.pos = 0;
|
||||
@@ -10418,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;
|
||||
}
|
||||
@@ -10438,7 +10561,14 @@ var OtsReader = class {
|
||||
return this.bytes.length - this.pos;
|
||||
}
|
||||
};
|
||||
// G56-13: Maximum varuint value and encoding length to prevent overflow
|
||||
__publicField(_OtsReader, "MAX_VARUINT", 4294967295);
|
||||
// 32-bit max — OTS heights/lengths won't exceed this
|
||||
__publicField(_OtsReader, "MAX_VARUINT_BYTES", 5);
|
||||
var OtsReader = _OtsReader;
|
||||
var OTS_MAGIC = hexToBytes3("004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294");
|
||||
var MAX_OTS_PROOF_SIZE = 1 << 20;
|
||||
var MAX_EVENT_JSON_SIZE = 1 << 16;
|
||||
var BITCOIN_ATTESTATION_TAG = hexToBytes3("0588960d73d71901");
|
||||
var LITECOIN_ATTESTATION_TAG = hexToBytes3("06869a0d73d71b45");
|
||||
var PENDING_ATTESTATION_TAG = hexToBytes3("83dfe30d2ef90c8e");
|
||||
@@ -10449,49 +10579,69 @@ function bytesEqual(a, b) {
|
||||
}
|
||||
function parseOtsFile(otsBytes) {
|
||||
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_MAGIC.length) return null;
|
||||
const reader = new OtsReader(otsBytes);
|
||||
const magic = reader.readBytes(OTS_MAGIC.length);
|
||||
if (!bytesEqual(magic, OTS_MAGIC)) return null;
|
||||
const version = reader.readVaruint();
|
||||
if (version !== 1) return null;
|
||||
const hashOpTag = reader.readByte();
|
||||
let fileHashOp = "unknown";
|
||||
let digestLen = 32;
|
||||
if (hashOpTag === 8) {
|
||||
fileHashOp = "sha256";
|
||||
digestLen = 32;
|
||||
} else if (hashOpTag === 2) {
|
||||
fileHashOp = "sha1";
|
||||
digestLen = 20;
|
||||
} else if (hashOpTag === 3) {
|
||||
fileHashOp = "ripemd160";
|
||||
digestLen = 20;
|
||||
} else {
|
||||
if (otsBytes.length > MAX_OTS_PROOF_SIZE) return null;
|
||||
try {
|
||||
const reader = new OtsReader(otsBytes);
|
||||
const magic = reader.readBytes(OTS_MAGIC.length);
|
||||
if (!bytesEqual(magic, OTS_MAGIC)) return null;
|
||||
const version = reader.readVaruint();
|
||||
if (version !== 1) return null;
|
||||
const hashOpTag = reader.readByte();
|
||||
let fileHashOp = "unknown";
|
||||
let digestLen = 32;
|
||||
if (hashOpTag === 8) {
|
||||
fileHashOp = "sha256";
|
||||
digestLen = 32;
|
||||
} else if (hashOpTag === 2) {
|
||||
fileHashOp = "sha1";
|
||||
digestLen = 20;
|
||||
} else if (hashOpTag === 3) {
|
||||
fileHashOp = "ripemd160";
|
||||
digestLen = 20;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const targetDigest = reader.readBytes(digestLen);
|
||||
const attestations = [];
|
||||
const budget = { operations: 0, branches: 0 };
|
||||
_parseTimestamp(reader, targetDigest, attestations, 0, budget);
|
||||
return { fileHashOp, targetDigest, attestations };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
const targetDigest = reader.readBytes(digestLen);
|
||||
const attestations = [];
|
||||
_parseTimestamp(reader, targetDigest, attestations, 0);
|
||||
return { fileHashOp, targetDigest, attestations };
|
||||
}
|
||||
function _parseTimestamp(reader, msg, attestations, depth) {
|
||||
var OTS_MAX_OPERATIONS = 1e4;
|
||||
var OTS_MAX_BRANCHES = 1e3;
|
||||
function _parseTimestamp(reader, msg, attestations, depth, budget) {
|
||||
if (depth > 512) throw new Error("OTS: recursion limit exceeded");
|
||||
let tag = reader.readByte();
|
||||
while (tag === 255) {
|
||||
if (budget) {
|
||||
budget.branches++;
|
||||
if (budget.branches > OTS_MAX_BRANCHES) {
|
||||
throw new Error(`OTS: branch count exceeds maximum ${OTS_MAX_BRANCHES}`);
|
||||
}
|
||||
}
|
||||
const current = reader.readByte();
|
||||
_processTimestampEntry(reader, current, msg, attestations, depth);
|
||||
_processTimestampEntry(reader, current, msg, attestations, depth, budget);
|
||||
tag = reader.readByte();
|
||||
}
|
||||
_processTimestampEntry(reader, tag, msg, attestations, depth);
|
||||
_processTimestampEntry(reader, tag, msg, attestations, depth, budget);
|
||||
}
|
||||
function _processTimestampEntry(reader, tag, msg, attestations, depth) {
|
||||
function _processTimestampEntry(reader, tag, msg, attestations, depth, budget) {
|
||||
if (budget) {
|
||||
budget.operations++;
|
||||
if (budget.operations > OTS_MAX_OPERATIONS) {
|
||||
throw new Error(`OTS: operation count exceeds maximum ${OTS_MAX_OPERATIONS}`);
|
||||
}
|
||||
}
|
||||
if (tag === 0) {
|
||||
const attTag = reader.readBytes(8);
|
||||
const payload = reader.readVarbytes(8192);
|
||||
_classifyAttestation(attTag, payload, msg, attestations);
|
||||
} else {
|
||||
const result = _applyOp(reader, tag, msg);
|
||||
_parseTimestamp(reader, result, attestations, depth + 1);
|
||||
_parseTimestamp(reader, result, attestations, depth + 1, budget);
|
||||
}
|
||||
}
|
||||
function _applyOp(reader, tag, msg) {
|
||||
@@ -10535,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" } });
|
||||
@@ -10546,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 = [];
|
||||
@@ -10564,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) {
|
||||
@@ -10575,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");
|
||||
@@ -10586,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({
|
||||
@@ -10611,6 +10788,7 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
targetDigest: targetDigestHex,
|
||||
attestations: parsed.attestations,
|
||||
bitcoinAttestations: verified,
|
||||
trustMode,
|
||||
errors
|
||||
};
|
||||
}
|
||||
@@ -10629,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() {
|
||||
@@ -10684,6 +10868,7 @@ export {
|
||||
signWithSLHDSA,
|
||||
timestampEvent,
|
||||
upgradeOts,
|
||||
validateSignerOutput,
|
||||
verifyFalcon,
|
||||
verifyMLDSA44,
|
||||
verifyMLDSA65,
|
||||
|
||||
+27
-624
@@ -4,7 +4,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none';" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none';" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Verify NIP-QR Event</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
@@ -253,13 +254,21 @@
|
||||
|
||||
<div class="pq-card">
|
||||
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
|
||||
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
|
||||
<strong>Research prototype.</strong> Verification confirms key linkage and signature
|
||||
validity; it does not mean the identity is post-quantum secure. Bitcoin OTS confirmation
|
||||
is API-assisted (trusts a public explorer), not a full light-client verification.
|
||||
</div>
|
||||
<div class="pq-info-text">
|
||||
Verify post-quantum migration events (kind 11112) by querying relays for a user's pubkey,
|
||||
or by pasting event JSON directly. The kind 11112 event wraps a kind 1 announcement
|
||||
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks:
|
||||
the kind 11112 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
|
||||
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), all PQ signatures
|
||||
(ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768), and the OpenTimestamps proof.
|
||||
Verify post-quantum migration events (kind 9999) by querying relays for a user's pubkey,
|
||||
or by pasting event JSON directly. The kind 9999 event wraps a kind 1 announcement
|
||||
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks,
|
||||
reported as separate result lines:
|
||||
the kind 9999 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
|
||||
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), each PQ signature
|
||||
individually (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512; ML-KEM-768 has no signature),
|
||||
the PQ algorithm policy (all mandatory algorithms present and verified), identity binding
|
||||
(outer/embedded/expected author match), and the OpenTimestamps proof.
|
||||
</div>
|
||||
<div class="pq-info-text">
|
||||
<a href="./" class="pq-link">Back to migration page</a>
|
||||
@@ -274,7 +283,7 @@
|
||||
<div class="pq-tab-panel pq-tab-panel-active" id="pqPanelRelay">
|
||||
<div class="pq-info-text">
|
||||
Enter a Nostr pubkey (hex or npub) and one or more relay URLs. The page will query the
|
||||
relay(s) for the latest kind 11112 event from that pubkey and verify it.
|
||||
relay(s) for the latest kind 9999 event from that pubkey and verify it.
|
||||
</div>
|
||||
<input type="text" class="pq-input" id="pqPubkeyInput"
|
||||
placeholder="Pubkey (hex or npub), e.g. 3bf0c63f869a8972... or npub1..." />
|
||||
@@ -288,13 +297,13 @@
|
||||
<!-- TAB 2: Paste Event JSON -->
|
||||
<div class="pq-tab-panel" id="pqPanelPaste">
|
||||
<div class="pq-info-text">
|
||||
Paste a kind 11112 event JSON below to verify all signatures.
|
||||
Paste a kind 9999 event JSON below to verify all signatures.
|
||||
</div>
|
||||
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste kind 11112 event JSON here, e.g.:
|
||||
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste kind 9999 event JSON here, e.g.:
|
||||
{
|
||||
"id": "...",
|
||||
"pubkey": "...",
|
||||
"kind": 11112,
|
||||
"kind": 9999,
|
||||
"content": "{\"id\":\"...\",\"pubkey\":\"...\",\"kind\":1,\"content\":\"I am signaling...\",\"tags\":[...],\"sig\":\"...\"}",
|
||||
"tags": [
|
||||
["e", "<kind 1 event id>"],
|
||||
@@ -330,7 +339,7 @@
|
||||
<div id="pqOtsInfo" class="pq-status pq-status-info"></div>
|
||||
<div class="pq-button-row">
|
||||
<button class="pq-button" id="pqOtsUpgradeBtn">Upgrade OTS Proof</button>
|
||||
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Republish with Confirmed Proof</button>
|
||||
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Publish Upgraded Event</button>
|
||||
</div>
|
||||
<div id="pqOtsUpgradeStatus"></div>
|
||||
</div>
|
||||
@@ -347,620 +356,14 @@
|
||||
</div>
|
||||
|
||||
<!-- SCRIPTS -->
|
||||
<script src="/nostr-login-lite/nostr.bundle.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr.bundle.js" integrity="sha384-LNnPDD++DaWxljIhMLmfaoEoKB0B1HmACC6gNGLG+Qnmosprf/AX7u84pVY8xMpM" crossorigin="anonymous"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js" integrity="sha384-IQwa65eDC5trGjKn4cuEazmFiHTHD65gIqsjpzDtouc+j0MlyI927SZgHMWSi2aC" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Version display: fetch version.json and update title + header -->
|
||||
<script>
|
||||
fetch('./js/version.json')
|
||||
.then(r => r.json())
|
||||
.then(v => {
|
||||
const versionText = v.VERSION || '';
|
||||
document.title = 'Verify NIP-QR Event ' + versionText;
|
||||
const header = document.getElementById('divHeaderText');
|
||||
if (header) header.textContent = 'Verify NIP-QR Event ' + versionText;
|
||||
})
|
||||
.catch(() => { /* version.json not found — keep default title */ });
|
||||
</script>
|
||||
<!-- Version display (G56-08: extracted to external file, self-initializing) -->
|
||||
<script src="./js/version-display.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent,
|
||||
NIP_QR_KIND,
|
||||
buildUpgradedEvent,
|
||||
buildProofCarrier,
|
||||
selectCanonicalProofCarrier,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
hexToBytes,
|
||||
bytesToHex,
|
||||
isOtsConfirmed,
|
||||
verifyOtsProof,
|
||||
upgradeOts,
|
||||
isDetachedOtsFile
|
||||
} from './pq-crypto.bundle.js';
|
||||
|
||||
/* ================================================================
|
||||
TAB MANAGEMENT
|
||||
================================================================ */
|
||||
const tabRelay = document.getElementById('pqTabRelay');
|
||||
const tabPaste = document.getElementById('pqTabPaste');
|
||||
const panelRelay = document.getElementById('pqPanelRelay');
|
||||
const panelPaste = document.getElementById('pqPanelPaste');
|
||||
|
||||
function switchTab(which) {
|
||||
const isRelay = which === 'relay';
|
||||
tabRelay.classList.toggle('pq-tab-active', isRelay);
|
||||
tabPaste.classList.toggle('pq-tab-active', !isRelay);
|
||||
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
|
||||
panelPaste.classList.toggle('pq-tab-panel-active', !isRelay);
|
||||
}
|
||||
|
||||
tabRelay.addEventListener('click', () => switchTab('relay'));
|
||||
tabPaste.addEventListener('click', () => switchTab('paste'));
|
||||
|
||||
/* ================================================================
|
||||
SHARED DOM + STATE
|
||||
================================================================ */
|
||||
const resultsWrap = document.getElementById('pqResultsWrap');
|
||||
const resultList = document.getElementById('pqResultList');
|
||||
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
|
||||
const downloadEventBtn = document.getElementById('pqDownloadEventBtn');
|
||||
const eventJsonEl = document.getElementById('pqEventJson');
|
||||
const otsBadge = document.getElementById('pqOtsBadge');
|
||||
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
|
||||
const otsInfo = document.getElementById('pqOtsInfo');
|
||||
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
|
||||
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
|
||||
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
|
||||
|
||||
let currentEvent = null;
|
||||
let currentOtsBytes = null;
|
||||
let currentExpectedAuthor = null;
|
||||
|
||||
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
|
||||
function setStatus(element, type, message) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `pq-status pq-status-${type}`;
|
||||
div.textContent = message;
|
||||
element.innerHTML = '';
|
||||
element.appendChild(div);
|
||||
}
|
||||
|
||||
function addResult(algorithm, valid, detail) {
|
||||
const item = document.createElement('div');
|
||||
item.className = `pq-result-item ${valid ? 'pq-result-valid' : 'pq-result-invalid'}`;
|
||||
item.textContent = `${valid ? 'PASS' : 'FAIL'} — ${algorithm}${detail ? ': ' + detail : ''}`;
|
||||
resultList.appendChild(item);
|
||||
}
|
||||
|
||||
// F-H3: Helper to set OTS badge (static HTML, safe) and info (textContent, safe)
|
||||
function setOtsBadge(className, text) {
|
||||
otsBadge.innerHTML = `<span class="pq-ots-badge ${className}"></span>`;
|
||||
otsBadge.querySelector('span').textContent = text;
|
||||
}
|
||||
function setOtsInfo(text) {
|
||||
otsInfo.textContent = text;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
NPUB <-> HEX CONVERSION (NIP-19)
|
||||
================================================================ */
|
||||
function npubToHex(npub) {
|
||||
try {
|
||||
const decoded = window.NostrTools.nip19.decode(npub);
|
||||
if (decoded.type === 'npub') return decoded.data;
|
||||
} catch (e) { /* fall through */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function hexToNpub(hex) {
|
||||
try {
|
||||
return window.NostrTools.nip19.npubEncode(hex);
|
||||
} catch (e) { return hex; }
|
||||
}
|
||||
|
||||
function normalizePubkey(input) {
|
||||
const trimmed = input.trim();
|
||||
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase();
|
||||
if (trimmed.startsWith('npub1')) {
|
||||
const hex = npubToHex(trimmed);
|
||||
if (hex) return hex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
QUERY RELAY FOR PROOF CARRIER EVENTS (fetch ALL candidates)
|
||||
================================================================ */
|
||||
function queryRelayForEvents(pubkeyHex, relayUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(relayUrl);
|
||||
let resolved = false;
|
||||
const events = [];
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
try { ws.close(); } catch (e) {}
|
||||
resolve(events);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
ws.onopen = () => {
|
||||
// REQ for all proof carrier events — no limit (G56-02 fix)
|
||||
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
|
||||
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex] }]));
|
||||
};
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data[0] === 'EVENT') {
|
||||
if (data[2]) events.push(data[2]);
|
||||
} else if (data[0] === 'EOSE') {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
try { ws.close(); } catch (e) {}
|
||||
resolve(events);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('connection error'));
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
VERIFY EVENT (shared by both tabs)
|
||||
================================================================ */
|
||||
async function verifyEvent(event) {
|
||||
currentEvent = event;
|
||||
resultList.innerHTML = '';
|
||||
resultsWrap.style.display = 'none';
|
||||
eventJsonWrap.style.display = 'none';
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
otsBadge.innerHTML = '';
|
||||
|
||||
// Display full proof carrier JSON
|
||||
eventJsonEl.textContent = JSON.stringify(event, null, 2);
|
||||
eventJsonWrap.style.display = 'block';
|
||||
|
||||
let allValid = true;
|
||||
|
||||
// 1. Verify proof carrier secp256k1 signature
|
||||
let secpValid = false;
|
||||
try {
|
||||
secpValid = verifyNostrEvent(event);
|
||||
} catch (e) {
|
||||
secpValid = false;
|
||||
addResult('secp256k1 (proof carrier)', false, `verification threw: ${e.message}`);
|
||||
}
|
||||
if (secpValid) {
|
||||
addResult('secp256k1 (proof carrier)', true, 'valid');
|
||||
} else if (!resultsWrap.querySelector('.pq-result-item')) {
|
||||
addResult('secp256k1 (proof carrier)', false, 'INVALID');
|
||||
}
|
||||
if (!secpValid) allValid = false;
|
||||
|
||||
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag (strict policy)
|
||||
// G56-03: pass outer pubkey and expected author for identity binding
|
||||
let pqResults;
|
||||
try {
|
||||
pqResults = verifyNIPQRContent(event.content, event.tags, {
|
||||
outerPubkey: event.pubkey,
|
||||
expectedAuthor: currentExpectedAuthor || undefined
|
||||
});
|
||||
} catch (e) {
|
||||
pqResults = {
|
||||
valid: false,
|
||||
results: [{ algorithm: 'verifyNIPQRContent', valid: false, note: `threw: ${e.message}` }],
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false,
|
||||
pqProofsValid: false,
|
||||
policySufficient: false,
|
||||
validForMigration: false,
|
||||
errors: [`verifyNIPQRContent threw: ${e.message}`]
|
||||
};
|
||||
}
|
||||
for (const r of pqResults.results) {
|
||||
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
|
||||
if (!r.valid) allValid = false;
|
||||
}
|
||||
|
||||
// 2b. Display policy sufficiency as a distinct result (G56-01)
|
||||
if (pqResults.kind1Event) {
|
||||
addResult(
|
||||
'PQ algorithm policy',
|
||||
pqResults.policySufficient,
|
||||
pqResults.policySufficient
|
||||
? 'all mandatory algorithms present and verified'
|
||||
: (pqResults.errors.length > 0 ? pqResults.errors.join('; ') : 'insufficient PQ evidence')
|
||||
);
|
||||
if (!pqResults.policySufficient) allValid = false;
|
||||
}
|
||||
|
||||
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
|
||||
// First do a quick structural check for immediate UI feedback, then
|
||||
// run full cryptographic verification (async — fetches Bitcoin block
|
||||
// headers and validates the Merkle path).
|
||||
const otsTag = event.tags.find(t => t[0] === 'ots');
|
||||
const sha256Tag = event.tags.find(t => t[0] === 'sha256');
|
||||
if (otsTag && otsTag[1]) {
|
||||
try {
|
||||
currentOtsBytes = base64ToBytes(otsTag[1]);
|
||||
const hasBitcoinAttestation = isOtsConfirmed(currentOtsBytes);
|
||||
const validFile = isDetachedOtsFile(currentOtsBytes);
|
||||
const fullHash = pqResults.fullHash;
|
||||
const hashMatchNote = (sha256Tag && fullHash)
|
||||
? (sha256Tag[1].toLowerCase() === fullHash.toLowerCase()
|
||||
? `Hash matches full kind 1 event: ${fullHash.substring(0, 16)}...`
|
||||
: `WARNING: sha256 tag (${sha256Tag[1].substring(0, 16)}...) does not match computed hash (${fullHash.substring(0, 16)}...)`)
|
||||
: 'No sha256 tag found';
|
||||
|
||||
// Quick structural display first
|
||||
if (!validFile) {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: Invalid format');
|
||||
setOtsInfo(`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
} else if (hasBitcoinAttestation) {
|
||||
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verifying...');
|
||||
setOtsInfo(`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
|
||||
otsUpgradeWrap.style.display = 'block';
|
||||
otsUpgradeBtn.style.display = 'none';
|
||||
otsRepublishBtn.style.display = 'none';
|
||||
|
||||
// Run full async verification: parse the proof, bind the target
|
||||
// digest to the sha256 tag, walk the Merkle path, and check the
|
||||
// block header against a Bitcoin API.
|
||||
verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined).then(result => {
|
||||
if (result.verified && result.bitcoinAttestations.length > 0) {
|
||||
const att = result.bitcoinAttestations[0];
|
||||
const date = new Date(att.time * 1000).toISOString().substring(0, 19);
|
||||
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
|
||||
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
|
||||
otsUpgradeBtn.style.display = 'none';
|
||||
} else {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
|
||||
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
|
||||
setOtsInfo(`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
|
||||
otsUpgradeBtn.style.display = 'inline-block';
|
||||
}
|
||||
}).catch(err => {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
|
||||
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
|
||||
otsUpgradeBtn.style.display = 'inline-block';
|
||||
});
|
||||
} else {
|
||||
setOtsBadge('pq-ots-badge-pending', 'OTS: Pending');
|
||||
setOtsInfo(`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
|
||||
otsUpgradeWrap.style.display = 'block';
|
||||
otsUpgradeBtn.style.display = 'inline-block';
|
||||
otsRepublishBtn.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
|
||||
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: None');
|
||||
setOtsInfo('No OpenTimestamps proof tag found in this event.');
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
}
|
||||
|
||||
resultsWrap.style.display = 'block';
|
||||
return allValid;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
TAB 1: QUERY RELAY
|
||||
================================================================ */
|
||||
const queryBtn = document.getElementById('pqQueryBtn');
|
||||
const queryStatus = document.getElementById('pqQueryStatus');
|
||||
|
||||
queryBtn.addEventListener('click', async () => {
|
||||
queryBtn.disabled = true;
|
||||
setStatus(queryStatus, 'info', 'Querying relays...');
|
||||
|
||||
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
|
||||
const relayInput = document.getElementById('pqRelayInput').value.trim();
|
||||
|
||||
const pubkeyHex = normalizePubkey(pubkeyInput);
|
||||
if (!pubkeyHex) {
|
||||
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
|
||||
queryBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (relayUrls.length === 0) {
|
||||
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
|
||||
queryBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let allCandidates = [];
|
||||
let lastError = null;
|
||||
let successCount = 0;
|
||||
|
||||
// G56-02: Query relays in parallel, collect ALL candidates (not limit: 1)
|
||||
const promises = relayUrls.map(async (url) => {
|
||||
try {
|
||||
const events = await queryRelayForEvents(pubkeyHex, url);
|
||||
if (events && events.length > 0) {
|
||||
successCount++;
|
||||
allCandidates.push(...events);
|
||||
}
|
||||
return { url, ok: true, count: events ? events.length : 0 };
|
||||
} catch (e) {
|
||||
lastError = e.message;
|
||||
return { url, ok: false, error: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Deduplicate by event ID
|
||||
const seenIds = new Set();
|
||||
allCandidates = allCandidates.filter(e => {
|
||||
if (!e || !e.id || seenIds.has(e.id)) return false;
|
||||
seenIds.add(e.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (allCandidates.length === 0) {
|
||||
setStatus(queryStatus, 'error', `No proof carrier events found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
|
||||
queryBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const npub = hexToNpub(pubkeyHex);
|
||||
setStatus(queryStatus, 'info', `Found ${allCandidates.length} proof carrier event(s) from ${npub.substring(0, 20)}... across ${successCount}/${relayUrls.length} relay(s). Selecting canonical (earliest Bitcoin anchor)...`);
|
||||
|
||||
// G56-02: Select the canonical proof carrier (earliest valid Bitcoin anchor)
|
||||
// First, parse the kind 1 event from the first candidate to use for selection
|
||||
let kind1Event = null;
|
||||
try {
|
||||
kind1Event = JSON.parse(allCandidates[0].content);
|
||||
} catch (e) {
|
||||
// Try other candidates
|
||||
for (const c of allCandidates) {
|
||||
try {
|
||||
kind1Event = JSON.parse(c.content);
|
||||
break;
|
||||
} catch (e2) { /* keep trying */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (!kind1Event) {
|
||||
setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.');
|
||||
queryBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the expected author for G56-03 identity binding
|
||||
currentExpectedAuthor = pubkeyHex;
|
||||
|
||||
try {
|
||||
const selection = await selectCanonicalProofCarrier(allCandidates, kind1Event, pubkeyHex);
|
||||
|
||||
if (selection.canonical) {
|
||||
const heightNote = selection.canonicalBitcoinHeight ? `Bitcoin block ${selection.canonicalBitcoinHeight}` : 'pending (no Bitcoin anchor yet)';
|
||||
const confirmedCount = selection.confirmedCandidates.length;
|
||||
const pendingCount = selection.pendingCandidates.length;
|
||||
setStatus(queryStatus, 'success', `Selected canonical proof carrier: ${selection.canonical.id.substring(0, 16)}... (${heightNote}). ${confirmedCount} confirmed, ${pendingCount} pending, ${allCandidates.length} total candidate(s).`);
|
||||
await verifyEvent(selection.canonical);
|
||||
} else {
|
||||
setStatus(queryStatus, 'error', `No valid proof carrier found among ${allCandidates.length} candidate(s). Errors: ${selection.errors.join('; ')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(queryStatus, 'error', `Canonical selection failed: ${e.message}`);
|
||||
}
|
||||
|
||||
queryBtn.disabled = false;
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
TAB 2: PASTE EVENT JSON
|
||||
================================================================ */
|
||||
const verifyBtn = document.getElementById('pqVerifyBtn');
|
||||
const eventInput = document.getElementById('pqEventInput');
|
||||
const verifyStatus = document.getElementById('pqVerifyStatus');
|
||||
|
||||
verifyBtn.addEventListener('click', async () => {
|
||||
verifyBtn.disabled = true;
|
||||
setStatus(verifyStatus, 'info', 'Verifying...');
|
||||
|
||||
try {
|
||||
const event = JSON.parse(eventInput.value.trim());
|
||||
if (!event || !event.pubkey || !event.sig || !event.content || !event.tags) {
|
||||
throw new Error('Invalid event: missing required fields (pubkey, sig, content, tags)');
|
||||
}
|
||||
if (event.kind !== NIP_QR_KIND) {
|
||||
setStatus(verifyStatus, 'error', `Warning: event kind is ${event.kind}, expected ${NIP_QR_KIND}. Verifying anyway...`);
|
||||
}
|
||||
const allValid = await verifyEvent(event);
|
||||
if (allValid) {
|
||||
setStatus(verifyStatus, 'success', 'All signatures and PQ algorithm policy verified successfully!');
|
||||
} else {
|
||||
setStatus(verifyStatus, 'error', 'Some checks failed verification. See results below for details.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[verify] Error:', error);
|
||||
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
|
||||
} finally {
|
||||
verifyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
DOWNLOAD EVENT JSON
|
||||
================================================================ */
|
||||
downloadEventBtn.addEventListener('click', () => {
|
||||
if (!currentEvent) return;
|
||||
const blob = new Blob([JSON.stringify(currentEvent, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (currentEvent.id ? currentEvent.id.substring(0, 16) : 'event') + '.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
OTS UPGRADE (from verify page)
|
||||
================================================================ */
|
||||
otsUpgradeBtn.addEventListener('click', async () => {
|
||||
if (!currentOtsBytes) return;
|
||||
otsUpgradeBtn.disabled = true;
|
||||
setStatus(otsUpgradeStatus, 'info', 'Sending .ots proof to upgrade service...');
|
||||
|
||||
try {
|
||||
const result = await upgradeOts(currentOtsBytes);
|
||||
currentOtsBytes = result.proof;
|
||||
// After upgrading, run full cryptographic verification to confirm
|
||||
// the Bitcoin attestation is real (not just a byte-pattern match).
|
||||
const sha256Tag = currentEvent && currentEvent.tags ? currentEvent.tags.find(t => t[0] === 'sha256') : null;
|
||||
const verifyResult = await verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
|
||||
const confirmed = verifyResult.verified;
|
||||
|
||||
if (confirmed) {
|
||||
const att = verifyResult.bitcoinAttestations[0];
|
||||
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
|
||||
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can republish the event with the confirmed proof below.`);
|
||||
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 republish.');
|
||||
}
|
||||
|
||||
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
|
||||
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
|
||||
|
||||
// Publish to the relays from the relay input field
|
||||
const relayInput = document.getElementById('pqRelayInput').value.trim();
|
||||
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (relayUrls.length === 0) {
|
||||
throw new Error('No relay URLs specified in the relay input field.');
|
||||
}
|
||||
|
||||
const eventJson = JSON.stringify(['EVENT', signedEvent]);
|
||||
const publishResults = await Promise.all(relayUrls.map(url => {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const ws = new WebSocket(url);
|
||||
let done = false;
|
||||
const t = setTimeout(() => { if (!done) { done = true; try { ws.close(); } catch(e){} resolve({ url, success: false, error: 'timeout' }); } }, 15000);
|
||||
ws.onopen = () => ws.send(eventJson);
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data[0] === 'OK' && data[1] === signedEvent.id) {
|
||||
if (!done) { done = true; clearTimeout(t); try { ws.close(); } catch(e){} resolve({ url, success: true }); }
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
ws.onclose = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'closed without OK' }); } };
|
||||
ws.onerror = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'error' }); } };
|
||||
} catch (e) { resolve({ url, success: false, error: e.message }); }
|
||||
});
|
||||
}));
|
||||
|
||||
const ok = publishResults.filter(r => r.success).length;
|
||||
const fail = publishResults.filter(r => !r.success).length;
|
||||
|
||||
if (ok === 0) {
|
||||
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
|
||||
}
|
||||
|
||||
currentEvent = signedEvent;
|
||||
setStatus(otsUpgradeStatus, 'success', `Upgraded kind 11112 event published to ${ok} relay(s) (${fail} failed).`);
|
||||
// Re-verify the new event
|
||||
await verifyEvent(signedEvent);
|
||||
} catch (error) {
|
||||
setStatus(otsUpgradeStatus, 'error', `Republish 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 11112 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>
|
||||
|
||||
Reference in New Issue
Block a user