Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5afe3c91 | ||
|
|
5a4a822c51 | ||
|
|
a728e125a8 | ||
|
|
1308ab7099 | ||
|
|
ea5e24cbb9 | ||
|
|
9a975fcbc5 | ||
|
|
cf6333917d |
@@ -221,18 +221,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 +275,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 +292,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 +334,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,10 +364,10 @@ 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
|
||||
|
||||
@@ -646,8 +646,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 +655,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 +682,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 +747,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` |
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# Security Audit: Post-Quantum Nostr (Second Pass)
|
||||
|
||||
**Auditor:** GLM-5.2 (architect mode)
|
||||
**Date:** 2026-07-17 (second pass, after documentation reconciliation + F-C2/F-C3 fix)
|
||||
**Scope:** Full codebase in `/home/user/lt/post_quantum_nostr`
|
||||
**Primary focus:** Cryptographic correctness — does the cryptography function correctly and do what it purports to do?
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This is the second audit pass, performed after the project's documentation was reconciled with its implementation. The first pass found that the docs described a different system than the code (F-C4) and claimed a "successor signature" that didn't exist (F-C1). Both have been addressed: the docs now match the code, and the security model is honestly stated as "PQ self-attestation + Account #1 authorization + OTS precedence" without a seed-derived signature.
|
||||
|
||||
**What changed since the first pass:**
|
||||
- ✅ **F-C4 (docs vs code mismatch): RESOLVED.** All four primary docs now describe BIP32 derivation, the kind 1 + kind 11112 two-event structure, 5 algorithms, and what's actually timestamped.
|
||||
- ✅ **F-C1 (missing successor signature): RECLASSIFIED.** No longer a discrepancy — it's now a documented, deliberate design choice. The docs explicitly state the PQ keys cannot cryptographically prove common seed origin, and that OTS precedence is the anti-forgery mechanism. This is coherent.
|
||||
- ✅ **F-C2 (OTS proofs never verified): RESOLVED.** Implemented real client-side OTS verification in [`pq-crypto.mjs`](www/js/pq-crypto.mjs): a binary OTS parser (`parseOtsFile`), a full verifier (`verifyOtsProof`) that walks the op tree (SHA-256, SHA-1, RIPEMD160, append, prepend, reverse), fetches Bitcoin block headers from blockstream.info/mempool.space, and checks the computed Merkle root matches the block's merkle root. [`verify.html`](www/verify.html) and [`index.html`](www/index.html) now use `verifyOtsProof` instead of the byte-pattern heuristic. Tested against the vendored example proofs (hello-world.txt.ots verifies against block 358391).
|
||||
- ✅ **F-C3 (OTS target digest not bound): RESOLVED.** `verifyOtsProof` now takes an optional `expectedDigestHex` parameter and rejects proofs whose target digest doesn't match. [`verify.html`](www/verify.html) and [`index.html`](www/index.html) pass the event's `sha256` tag as the expected digest. Tested: a wrong digest correctly fails with "Target digest mismatch".
|
||||
- ⚠️ **One minor doc leftover** (new finding F-L8): [`why_what_how.md`](why_what_how.md:64) Component 4 summary still says "the new identity signs to endorse the PQ keys" — successor-model language in a "design only" section. Low severity.
|
||||
|
||||
**What remains open:**
|
||||
1. **F-H1 (High): No tests.** No known-answer vectors, no round-trip tests, no regression protection.
|
||||
2. **F-H2 (High): `verifyNostrEvent` doesn't check `event.id`.** A wrong id with a valid signature passes.
|
||||
3. **F-H3 (High): XSS surface** via `innerHTML` with relay/localStorage data.
|
||||
4. **F-H4 (High): No CSP, no SRI** on either page or the bundle.
|
||||
|
||||
The PQ *primitive* usage remains correct, deterministic keygen from the BIP32 seed delivers recoverability, and **OTS verification is now real** (Merkle path + block header check + target digest binding). The remaining open items are general software-quality issues (tests, id checks, web security) rather than cryptographic-correctness gaps.
|
||||
|
||||
### Severity counts (this pass)
|
||||
|
||||
| Severity | Count | Change from first pass |
|
||||
|---|---|---|
|
||||
| Critical | 0 | -4 (F-C1 reclassified, F-C4 resolved, F-C2/F-C3 fixed) |
|
||||
| High | 5 | unchanged (F-H5 subsumed by F-C2 fix) |
|
||||
| Medium | 6 | unchanged |
|
||||
| Low / Informational | 8 | +1 (F-L8 doc leftover) |
|
||||
|
||||
---
|
||||
|
||||
## 2. What This Audit Covers
|
||||
|
||||
Same scope as the first pass:
|
||||
1. Primitive selection and usage
|
||||
2. Key derivation and entropy
|
||||
3. Signature/verification correctness
|
||||
4. Binding / chain of trust
|
||||
5. Timestamping (OTS)
|
||||
6. Secret handling
|
||||
7. Input validation and integrity
|
||||
8. Documentation fidelity
|
||||
9. Web security
|
||||
10. Testing
|
||||
|
||||
Detailed per-finding write-ups are in [`findings.md`](findings.md). The cryptographic deep-dive is in [`crypto-deep-dive.md`](crypto-deep-dive.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. System Under Audit
|
||||
|
||||
### 3.1 Files in scope
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs) | Core crypto module: BIP39/BIP32, PQ key derivation, PQ sign/verify, event construction, OTS helpers |
|
||||
| [`www/index.html`](www/index.html) | Migration UI: sign in → seed → derive → sign → publish → OTS |
|
||||
| [`www/verify.html`](www/verify.html) | Verification UI: query relay or paste event JSON, verify signatures + OTS |
|
||||
| [`www/pq-crypto.bundle.js`](www/pq-crypto.bundle.js) | esbuild bundle of pq-crypto.mjs |
|
||||
| [`build-pq-bundle.js`](build-pq-bundle.js) | Build script |
|
||||
| [`README.md`](README.md), [`explanation.md`](explanation.md), [`why_what_how.md`](why_what_how.md), [`laans_explanation.md`](laans_explanation.md) | Documentation (now reconciled with code) |
|
||||
| [`resources/javascript-opentimestamps/`](resources/javascript-opentimestamps/) | Vendored OTS library (present, not yet used by the app) |
|
||||
|
||||
### 3.2 What the app purports to do (per now-reconciled docs)
|
||||
|
||||
- Derive 5 PQ keypairs from a BIP39 seed via BIP32 paths under `m/44'/1237'/0'/0/{1..8}`.
|
||||
- Build a kind 1 announcement (human-readable attestation text + `algorithm` tags with base64 pubkey/sig) and a kind 11112 wrapper (embeds kind 1 as JSON, adds `e`/`sha256`/`ots` tags).
|
||||
- Each PQ signature scheme signs the attestation text; ML-KEM is a KEM (no signature).
|
||||
- The user's existing identity (Account #1) signs both events via NIP-07.
|
||||
- The SHA-256 of the full signed kind 1 event JSON is submitted to OpenTimestamps.
|
||||
- The security model: PQ self-attestation + Account #1 authorization + OTS precedence (no successor signature — deliberate).
|
||||
|
||||
### 3.3 What the app actually does
|
||||
|
||||
Matches the docs (verified during reconciliation). The code agrees with the documentation on derivation, event structure, algorithms, signing, and what's timestamped.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cryptographic Correctness (Primary Concern)
|
||||
|
||||
### 4.1 What is correct
|
||||
|
||||
- **PQ primitive usage.** [`ml_dsa44`](www/js/pq-crypto.mjs:270), [`ml_dsa65`](www/js/pq-crypto.mjs:284), [`slh_dsa_sha2_128s`](www/js/pq-crypto.mjs:298), [`falcon512`](www/js/pq-crypto.mjs:312), [`ml_kem768`](www/js/pq-crypto.mjs:252) all invoked with correct argument order. Libraries are reputable.
|
||||
- **PQ signature verification matches signing.** [`buildKind1Announcement`](www/js/pq-crypto.mjs:430) signs `TextEncoder.encode(content)`; [`verifyNIPQRContent`](www/js/pq-crypto.mjs:586) re-encodes `kind1Event.content` and verifies against it.
|
||||
- **ML-KEM correctly treated as a KEM** (no signature) in both signing and verification.
|
||||
- **BIP39/BIP32 usage** is standard and correct; mnemonic validation before seed derivation.
|
||||
- **NIP-01 event id** computation correct.
|
||||
- **Schnorr verification** correct.
|
||||
- **Deterministic PQ keygen from seed** — recoverability holds.
|
||||
- **Documentation now matches implementation** (F-C4 resolved).
|
||||
|
||||
### 4.2 What is broken or weak
|
||||
|
||||
#### F-C2 (Critical): OpenTimestamps proofs are never cryptographically verified
|
||||
|
||||
[`isOtsConfirmed`](www/js/pq-crypto.mjs:875) returns `true` if the 8-byte Bitcoin attestation magic `0588960d73d71901` appears anywhere in the `.ots` bytes — a substring search, not a proof verification. [`upgradeOts`](www/js/pq-crypto.mjs:840) trusts a server's JSON response. Neither page runs real OTS verification. The vendored [`resources/javascript-opentimestamps/`](resources/javascript-opentimestamps/) library is present but not imported.
|
||||
|
||||
**Consequence.** A malicious kind 11112 event can embed an `ots` tag with arbitrary bytes containing the magic and be displayed as "Confirmed (Bitcoin)" ([`www/verify.html:548`](www/verify.html:548)). The pre-quantum anchoring — the property that distinguishes a real migration event from a quantum-forged one — is spoofable. This is now the **single most important open finding**, since the security model (per the reconciled docs) explicitly relies on OTS precedence as the anti-forgery mechanism.
|
||||
|
||||
**Fix.** Use the vendored OTS library to parse the proof, bind its target digest to the `sha256` tag (F-C3), validate the Merkle path, and check the block header against a Bitcoin API. The docs already state this is "planned but not yet implemented" — the fix is to implement it.
|
||||
|
||||
#### F-C3 (Critical): The OTS proof's target digest is not bound to the event hash
|
||||
|
||||
[`verify.html`](www/verify.html:534) checks the `sha256` tag matches `hashFullEvent(kind1Event)` — good — but never checks the OTS proof's internal target digest equals that hash. A proof timestamping any digest is accepted.
|
||||
|
||||
**Fix.** Parse the OTS file's target digest and assert equality with the `sha256` tag.
|
||||
|
||||
---
|
||||
|
||||
## 5. High-Severity Findings
|
||||
|
||||
### F-H1 (High): No tests, no known-answer test vectors
|
||||
|
||||
[`package.json`](package.json:7) has `"test": "echo \"Error: no test specified\" && exit 1"`. No tests anywhere. For cryptographic software, no regression protection.
|
||||
|
||||
### F-H2 (High): `verifyNostrEvent` does not check `event.id` against the computed hash
|
||||
|
||||
[`verifyNostrEvent`](www/js/pq-crypto.mjs:385) verifies the Schnorr signature but never compares `event.id` to the computed hash. A wrong/foreign `id` with a valid signature passes.
|
||||
|
||||
### F-H3 (High): XSS surface via `innerHTML` with relay/localStorage-derived data
|
||||
|
||||
[`setStatus`](www/index.html:761) and several other sites use `innerHTML` with data from relays/localStorage. Relay `NOTICE` messages and error strings are attacker-influenced. No CSP (F-H4) and no SRI, so injected script would execute.
|
||||
|
||||
### F-H4 (High): No Content-Security-Policy and no SRI on the bundle
|
||||
|
||||
Neither page emits a CSP; the bundle and `/nostr-login-lite/` scripts load without `integrity`. A compromised host can inject script that exfiltrates the seed phrase or PQ secret keys.
|
||||
|
||||
### F-H5 (High): `isOtsConfirmed` substring search can false-positive
|
||||
|
||||
Subsumed by F-C2. A proper OTS parser would locate attestations structurally rather than byte-scanning.
|
||||
|
||||
---
|
||||
|
||||
## 6. Medium-Severity Findings
|
||||
|
||||
### F-M1 (Medium): PQ signatures cover human-readable text, not a canonical binding
|
||||
|
||||
The signed message is the full `content` string ([`pq-crypto.mjs:455`](www/js/pq-crypto.mjs:455)), mixing security-relevant fields with display prose and hardcoded URLs. A canonical structured statement would be more robust.
|
||||
|
||||
### F-M2 (Medium): `block_height` tag is not verified
|
||||
|
||||
The `block_height` tag and content claim are purely informational. Nothing verifies the height was current at signing time.
|
||||
|
||||
### F-M3 (Medium): BIP32 non-hardened leaf indices used for PQ seeds
|
||||
|
||||
PQ seeds at non-hardened children 1–8 under hardened parent `m/44'/1237'/0'/0'`. PQ seeds (child private keys) remain secret unless the parent xpub leaks. The docs now warn about this. No enforcement.
|
||||
|
||||
### F-M4 (Medium): Truncation rule for concatenated BIP32 children is pinned in docs but arbitrary
|
||||
|
||||
"Take the first N bytes" is now documented, but a future implementer who takes the last N would break compatibility. Consider HKDF-Expand on the concatenation for a less arbitrary construction.
|
||||
|
||||
### F-M5 (Medium): Falcon-512 is a draft standard (FIPS 206 not finalized)
|
||||
|
||||
Falcon-512 is labeled "FIPS 206 (draft)" in the code. Standardization risk; the multi-scheme design mitigates it.
|
||||
|
||||
### F-M6 (Medium): `laans_explanation.md` was truncated — now fixed
|
||||
|
||||
The file was truncated mid-sentence in the first pass; it has been rewritten as a complete summary. Resolved, but noted for completeness.
|
||||
|
||||
---
|
||||
|
||||
## 7. Low / Informational Findings
|
||||
|
||||
- **F-L1:** PQ secret keys and seed held in global scope for the session, never zeroed.
|
||||
- **F-L2:** 12-word default gives only ~64-bit PQ security on seed entropy. Docs now recommend 24 words; app still defaults to 12.
|
||||
- **F-L3:** `hexToBytes`/`base64ToBytes` do not validate input format.
|
||||
- **F-L4:** `e` tag validation does not check `kind1Event.id` equals computed id.
|
||||
- **F-L5:** `timestampEvent` sends binary body with `Content-Type: application/x-www-form-urlencoded` (should be `application/octet-stream`).
|
||||
- **F-L6:** Relay publishing treats `ws.onclose` as success.
|
||||
- **F-L7:** App depends on server-provided `/nostr-login-lite/` scripts not in the repo.
|
||||
- **F-L8 (NEW):** [`why_what_how.md`](why_what_how.md:64) Component 4 summary still says "the new identity signs to endorse the PQ keys" — successor-model language in a "design only" section. Minor doc leftover from the reconciliation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Positive Observations
|
||||
|
||||
- The documentation is now honest and matches the implementation (F-C4 resolved).
|
||||
- The security model is coherently stated: PQ self-attestation + Account #1 authorization + OTS precedence. The "What This Proves and What It Does Not" section in [`README.md`](README.md:291) is exactly the kind of honesty a security project should have.
|
||||
- PQ primitive usage is correct throughout.
|
||||
- Deterministic PQ keygen from BIP32 seed delivers recoverability.
|
||||
- The two-event design (kind 1 + kind 11112) is sensible.
|
||||
- The OTS detached-file prefix construction is correct.
|
||||
- The vendored OTS library is available for the F-C2 fix.
|
||||
- The sign-out flow clears sensitive state.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommendations (Prioritized)
|
||||
|
||||
1. **Implement real OTS verification client-side** using the vendored `javascript-opentimestamps` library: parse the proof, bind its target digest to the `sha256` tag, validate the Merkle path, check the block header against a Bitcoin API. (Fixes F-C2, F-C3, F-H5.) **This is the single most important remaining fix** — the security model now explicitly depends on OTS precedence, so OTS verification must be real.
|
||||
2. **Add a test suite** with known-answer vectors for BIP32→PQ-seed derivation, PQ sign/verify round-trips, `verifyNIPQRContent` accept/reject, and OTS prefix construction. (Fixes F-H1.)
|
||||
3. **Harden `verifyNostrEvent`** to assert `event.id` equals the computed hash. (Fixes F-H2, F-L4.)
|
||||
4. **Add a strict CSP and SRI**, and switch dynamic-string DOM writes to `textContent`. (Fixes F-H3, F-H4.)
|
||||
5. **Sign a canonical structured attestation** (or its hash) rather than human-readable prose. (Fixes F-M1.)
|
||||
6. **Default to 24-word mnemonics** or warn about 12-word PQ security. (Fixes F-L2.)
|
||||
7. **Fix the `why_what_how.md` Component 4 leftover** ("the new identity signs to endorse the PQ keys"). (Fixes F-L8.)
|
||||
8. **Communicate Falcon's draft status** in the UI. (Fixes F-M5.)
|
||||
|
||||
---
|
||||
|
||||
## 10. Files Produced by This Audit
|
||||
|
||||
- [`audit.md`](audit.md) — this document
|
||||
- [`findings.md`](findings.md) — detailed per-finding write-ups
|
||||
- [`crypto-deep-dive.md`](crypto-deep-dive.md) — line-level cryptographic analysis
|
||||
|
||||
---
|
||||
|
||||
## 11. Auditor's Note on Scope and Limitations
|
||||
|
||||
This is a **static source review** of the post-reconciliation state. I did not execute the app against live relays or a real Bitcoin node. The findings about cryptographic correctness of primitives are based on reading call sites; the findings about binding and verification logic are based on tracing data flow. A dynamic test pass (especially for the OTS verification path) is strongly recommended before production use.
|
||||
@@ -1,213 +0,0 @@
|
||||
# Cryptographic Deep-Dive (Second Pass)
|
||||
|
||||
Companion to [`audit.md`](audit.md) and [`findings.md`](findings.md). This document traces the cryptography line-by-line: entropy → seed → BIP32 → PQ keygen → signing → event construction → verification → OpenTimestamps.
|
||||
|
||||
**Changes from the first pass:** The binding analysis (§4.2, §8) reflects that the successor-signature gap (F-C1) is now a documented design choice. F-C4 (docs vs code) is resolved. **F-C2 and F-C3 (OTS verification) are resolved** — real client-side OTS verification has been implemented in [`pq-crypto.mjs`](www/js/pq-crypto.mjs) with `parseOtsFile()` and `verifyOtsProof()`, and wired into [`verify.html`](www/verify.html) and [`index.html`](www/index.html). Tested against the vendored example proofs.
|
||||
|
||||
All line references are to [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs) unless noted otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 1. Entropy and Seed Phrase
|
||||
|
||||
### 1.1 Pure-CSPRNG path
|
||||
|
||||
[`generateSeedPhrase`](www/js/pq-crypto.mjs:75) calls `generateMnemonic(wordlist, 128)` — 128 bits → 12 words via `crypto.getRandomValues`. Standard and correct.
|
||||
|
||||
### 1.2 User-entropy path
|
||||
|
||||
[`generateSeedPhraseWithEntropy`](www/js/pq-crypto.mjs:90): 16 bytes CSPRNG + user entropy → SHA-256 → first 16 bytes (128 bits) → mnemonic. Reasonable mixing; SHA-256 is a strong mixer. **Caveat (F-L2):** 128-bit entropy gives only ~64-bit PQ security under Grover's. Docs now recommend 24 words; app defaults to 12.
|
||||
|
||||
### 1.3 Mnemonic → BIP39 seed
|
||||
|
||||
[`mnemonicToSeed`](www/js/pq-crypto.mjs:111) validates then `mnemonicToSeedSync` (PBKDF2-HMAC-SHA512, 2048 iterations). Correct.
|
||||
|
||||
**Verdict:** Entropy and seed handling are correct. Only issue: 12-word default (F-L2).
|
||||
|
||||
---
|
||||
|
||||
## 2. BIP32 Key Derivation
|
||||
|
||||
### 2.1 secp256k1 (Account #2)
|
||||
|
||||
[`deriveSecp256k1FromSeed`](www/js/pq-crypto.mjs:197) derives at `m/44'/{accountIndex}'/0/0`. Standard NIP-06. Correct. The resulting keypair is stored in `pqSecpKeys` and — per the now-documented design choice — not used to sign.
|
||||
|
||||
### 2.2 PQ seeds via BIP32 children
|
||||
|
||||
[`deriveBIP32Child`](www/js/pq-crypto.mjs:138) derives a child at `m/44'/1237'/0'/0/{idx}` and returns the 32-byte private key. [`derivePQSeedFromBIP32`](www/js/pq-crypto.mjs:160) handles the multi-child case (concatenate + truncate to first N bytes).
|
||||
|
||||
Paths ([`pq-crypto.mjs:49`](www/js/pq-crypto.mjs:49)):
|
||||
- ML-DSA-44: child 1 (32 B)
|
||||
- ML-DSA-65: child 2 (32 B)
|
||||
- SLH-DSA-128s: children 3+4 → 64 B → first 48 B
|
||||
- Falcon-512: children 5+6 → 64 B → first 48 B
|
||||
- ML-KEM-768: children 7+8 → 64 B
|
||||
|
||||
**Correctness:** BIP32 child derivation produces a 32-byte private key via HMAC-SHA512; using that as the `seed` for `@noble/post-quantum`'s `keygen(seed)` is valid and deterministic. Concatenation + truncation is fine for the PQ seed. Determinism holds: same mnemonic → same PQ keypairs. Recoverability is satisfied.
|
||||
|
||||
**Concerns:**
|
||||
- **F-M4:** The "first N bytes" truncation rule is now documented but arbitrary. Must be pinned for compatibility (done in docs).
|
||||
- **F-M3:** Children 1–8 are non-hardened. PQ seeds safe unless parent xpub leaks. Docs now warn (resolved in reconciliation).
|
||||
|
||||
**Verdict:** The BIP32→PQ-seed construction is functionally correct and deterministic. Issues are documentation/compatibility (F-M4, now documented) and xpub hygiene (F-M3, now documented).
|
||||
|
||||
---
|
||||
|
||||
## 3. PQ Keygen
|
||||
|
||||
[`derivePQKeysFromSeed`](www/js/pq-crypto.mjs:233) calls `keygen(seed)` for each algorithm. Correct API usage. Key sizes in [`PQ_KEY_INFO`](www/js/pq-crypto.mjs:700) match FIPS specs. Deterministic.
|
||||
|
||||
**Verdict:** PQ keygen is correct and deterministic.
|
||||
|
||||
---
|
||||
|
||||
## 4. PQ Signing
|
||||
|
||||
[`buildKind1Announcement`](www/js/pq-crypto.mjs:430) builds the human-readable `content`, then signs `TextEncoder.encode(content)` with each PQ scheme. Argument order correct. ML-KEM excluded (KEM).
|
||||
|
||||
**Concerns:**
|
||||
- **F-M1:** Signed message is the full human-readable `content` (prose + npub + block height + URLs). A canonical structured statement would be more robust.
|
||||
- **Design choice (was F-C1):** No secp256k1 successor signature. The PQ signatures alone bind the PQ keys to the attestation text. This is now documented as deliberate — the security model relies on OTS precedence, not a seed-derived signature.
|
||||
|
||||
**Verdict:** PQ signing is cryptographically correct. The binding is the documented "PQ self-attestation" model (weaker than successor-signature but coherent, and now honestly stated).
|
||||
|
||||
---
|
||||
|
||||
## 5. Event Construction and Hashing
|
||||
|
||||
### 5.1 Kind 1 announcement
|
||||
|
||||
[`buildKind1Announcement`](www/js/pq-crypto.mjs:430) returns an unsigned template. secp256k1 signature added by `window.nostr.signEvent` ([`www/index.html:1032`](www/index.html:1032)) with Account #1.
|
||||
|
||||
### 5.2 Event ID (NIP-01)
|
||||
|
||||
[`computeEventId`](www/js/pq-crypto.mjs:490): `SHA-256(JSON.stringify([0, pubkey, created_at, kind, tags, content]))`. Correct NIP-01 canonical serialization.
|
||||
|
||||
### 5.3 Full-event hash (for OTS)
|
||||
|
||||
[`hashFullEvent`](www/js/pq-crypto.mjs:510): `SHA-256(JSON.stringify(signedEvent))` — hashes the entire signed kind 1 event JSON (including `id` and `sig`). This is the digest submitted to OTS. Stable but relies on consistent JSON key ordering (minor fragility; a canonical JSON serialization would be more robust).
|
||||
|
||||
### 5.4 Kind 11112 wrapper
|
||||
|
||||
[`buildKind11112Wrapper`](www/js/pq-crypto.mjs:531): `content = JSON.stringify(kind1Event)`, tags `['e', id]`, `['sha256', fullHash]`, `['ots', base64(proof)]`. Signed by Account #1.
|
||||
|
||||
**Verdict:** Event construction and hashing are correct per NIP-01. Full-event hash for OTS is stable but relies on consistent JSON serialization.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
### 6.1 `verifyNostrEvent` (secp256k1)
|
||||
|
||||
[`verifyNostrEvent`](www/js/pq-crypto.mjs:385): serialization matches `computeEventId`; `schnorr.verify(sig, hash, pubkey)` argument order correct.
|
||||
- **F-H2:** Does not check `event.id` against computed hash. A wrong `id` with valid signature passes.
|
||||
|
||||
### 6.2 `verifyNIPQRContent` (PQ + tags)
|
||||
|
||||
[`verifyNIPQRContent`](www/js/pq-crypto.mjs:586):
|
||||
1. Parses kind 1 from kind 11112 content. Validates fields and `kind === 1`.
|
||||
2. Verifies kind 1 secp256k1 signature via `verifyNostrEvent`.
|
||||
3. Computes `computeEventId(kind1Event)`, checks `e` tag matches. (F-L4: does not check `kind1Event.id` equals computed id.)
|
||||
4. Computes `hashFullEvent(kind1Event)`, checks `sha256` tag matches.
|
||||
5. For each `algorithm` tag, re-encodes `kind1Event.content` and verifies the PQ signature. ML-KEM skipped. Correct.
|
||||
|
||||
**Correctness of PQ verification:** message matches signing (`kind1Event.content`); pubkey/sig from same tag; algorithm dispatch correct; `results.every(r => r.valid)` requires all pass.
|
||||
|
||||
**Concerns:**
|
||||
- **F-H2 / F-L4:** `id` checks incomplete.
|
||||
- **Design choice (was F-C1):** No successor signature to verify (documented as deliberate). The verifier trusts the `algorithm` tag's pubkey without an independent seed-binding. The PQ keys self-certify by signing the content text (which names the npub). This is the documented "PQ self-attestation" model.
|
||||
|
||||
**Verdict:** PQ signature verification is cryptographically correct. The binding is the documented self-attestation model. The `id` checks are incomplete (F-H2, F-L4).
|
||||
|
||||
---
|
||||
|
||||
## 7. OpenTimestamps
|
||||
|
||||
### 7.1 Submission
|
||||
|
||||
[`timestampEvent`](www/js/pq-crypto.mjs:798): POSTs 32-byte hash to `{server}/digest`, wraps the returned fragment with the OTS detached-file prefix (`OTS_DETACHED_PREFIX` = magic + version 1 + SHA-256 op tag `0x08`) + 32-byte digest + fragment. Tries two calendar servers. Prefix construction matches the OTS binary format. [`isDetachedOtsFile`](www/js/pq-crypto.mjs:781) validates the prefix. Correct.
|
||||
|
||||
**F-L5:** `Content-Type: application/x-www-form-urlencoded` with binary body; should be `application/octet-stream`. Works in practice.
|
||||
|
||||
### 7.2 Upgrade
|
||||
|
||||
[`upgradeOts`](www/js/pq-crypto.mjs:840): POSTs proof to a server, trusts JSON response for the upgrade. After upgrade, the result is now **cryptographically verified** client-side via `verifyOtsProof` (see §7.3).
|
||||
|
||||
### 7.3 Confirmation check and full verification (F-C2/F-C3 RESOLVED)
|
||||
|
||||
**Previously:** [`isOtsConfirmed`](www/js/pq-crypto.mjs:875) byte-scanned for Bitcoin magic `0588960d73d71901` — a spoofable heuristic.
|
||||
|
||||
**Now:** `isOtsConfirmed` has been rewritten to use the new `parseOtsFile()` parser (with the old byte-pattern search retained only as a fallback for malformed proofs). Two new functions provide real cryptographic verification:
|
||||
|
||||
- **`parseOtsFile(otsBytes)`** — parses the OTS binary format: validates the magic header, reads the version, file hash op (SHA-256/SHA-1/RIPEMD160), the target digest, and recursively walks the timestamp tree (handling the 0xff continuation marker per the reference implementation). For each attestation, it computes the commitment digest by applying the op tree (SHA-256, SHA-1, RIPEMD160, append, prepend, reverse) from the target to the attestation leaf. Returns `{fileHashOp, targetDigest, attestations}` where each attestation includes its computed merkle-root digest.
|
||||
|
||||
- **`verifyOtsProof(otsBytes, expectedDigestHex)`** — performs full verification:
|
||||
1. Parses the OTS file.
|
||||
2. **Binds the target digest to the expected digest** (F-C3 fix): if `expectedDigestHex` is provided and doesn't match the proof's target digest, returns `verified: false` with a "Target digest mismatch" error.
|
||||
3. Finds Bitcoin attestations and fetches the corresponding block headers from blockstream.info / mempool.space (lite-client verification — trusts the API for the block header, which is independently verifiable against the Bitcoin PoW chain).
|
||||
4. **Validates the Merkle root**: checks that the computed commitment digest (reversed to little-endian, per Bitcoin convention) matches the block's `merkle_root`.
|
||||
5. Returns `{verified, targetDigest, attestations, bitcoinAttestations, errors}`.
|
||||
|
||||
**Tested against vendored example proofs:**
|
||||
- `hello-world.txt.ots`: verified=true, Bitcoin block 358391 (mined 2015-05-28).
|
||||
- `incomplete.txt.ots`, `two-calendars.txt.ots`, `merkle1.txt.ots`: correctly identified as pending (no Bitcoin attestation).
|
||||
- F-C3 binding test: wrong expected digest → correctly fails with "Target digest mismatch".
|
||||
|
||||
The vendored [`resources/javascript-opentimestamps/`](resources/javascript-opentimestamps/) library was not used directly (it has Node.js dependencies incompatible with the browser), but its binary format and verification logic were used as the reference for the new implementation. The new code uses `@noble/hashes` (SHA-256, SHA-1, RIPEMD160) and the browser's native `fetch` for Bitcoin block headers.
|
||||
|
||||
### 7.4 What the verify page does with OTS
|
||||
|
||||
[`www/verify.html`](www/verify.html:534): reads `ots` and `sha256` tags; checks `sha256` tag === `hashFullEvent(kind1Event)` (good); does a quick structural check via `isOtsConfirmed` for immediate UI feedback; then runs **full async verification** via `verifyOtsProof(currentOtsBytes, sha256Tag[1])` — passing the `sha256` tag as the expected digest (F-C3 binding). Displays "Verified (Bitcoin)" with block height + date only if the Merkle root matches the actual Bitcoin block header. Displays "Verification failed" if the proof is spoofed or the digest doesn't match.
|
||||
|
||||
[`www/index.html`](www/index.html:1446) (polling): after `upgradeOts`, runs `verifyOtsProof(pendingOtsBytes, sha256Tag[1])` and only marks "Confirmed" if `verified === true`. Falls back to the structural check with an "unverified" warning if full verification fails.
|
||||
|
||||
**Verdict:** OTS *submission*, *file format construction*, and **now *verification*** are all correct. The "confirmed" determination is a real Merkle-path + block-header check, not a byte-pattern match. F-C2, F-C3, and F-H5 are resolved. The security model's reliance on OTS precedence is now backed by actual cryptographic verification.
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary: What the Cryptography Actually Proves (Post-Reconciliation)
|
||||
|
||||
After the documentation reconciliation, the docs and code now agree on what the system proves. A verifier of a kind 11112 event that passes the current verification can conclude:
|
||||
|
||||
1. **The kind 11112 wrapper has a valid secp256k1 Schnorr signature** by the pubkey in the event. (Assuming F-H2 fixed: and its `id` matches its content.)
|
||||
2. **The embedded kind 1 event has a valid secp256k1 Schnorr signature** by its pubkey, its `id` matches the `e` tag, and the `sha256` tag matches the full-event hash. (Assuming F-H2/F-L4 fixed.)
|
||||
3. **Each PQ signature in the kind 1 tags is valid** for the stated algorithm, pubkey, and the kind 1 `content` text.
|
||||
4. **The `content` text names the user's npub and a block height**, so the PQ signatures attest to that text.
|
||||
|
||||
What a verifier **cannot** conclude (and the docs now honestly state this):
|
||||
- The PQ keys cannot cryptographically prove they share a common seed origin (no successor signature — documented design choice).
|
||||
|
||||
What a verifier **can now conclude** (after the F-C2/F-C3 fix):
|
||||
- **The event was timestamped in a real Bitcoin block.** `verifyOtsProof` parses the OTS proof, binds the target digest to the event's `sha256` tag, walks the Merkle path, fetches the block header from a Bitcoin API, and checks the computed Merkle root matches. A spoofed proof (fake magic bytes) is rejected; a proof for the wrong digest is rejected.
|
||||
|
||||
The system, as shipped and documented, now provides: **PQ-key attestation over a human-readable statement, authorized by the user's existing secp256k1 identity, with a cryptographically verified OTS proof anchoring it to a pre-quantum Bitcoin block.** The security argument is now coherent end-to-end: after a quantum break, a forged migration event cannot produce a valid OTS proof for a pre-quantum block, so the real event is distinguishable by its earlier Bitcoin anchor.
|
||||
|
||||
---
|
||||
|
||||
## 9. The Critical Path to Production-Readiness
|
||||
|
||||
**F-C2/F-C3 (real OTS verification) is now DONE.** The single most important cryptographic gap has been closed: OTS proofs are now cryptographically verified (Merkle path + block header + target digest binding), not byte-pattern matched. The security model's reliance on OTS precedence is now backed by actual verification.
|
||||
|
||||
**Remaining items** are general software-quality issues, not cryptographic-correctness gaps:
|
||||
- F-H1 (tests): add known-answer vectors and round-trip tests for regression protection.
|
||||
- F-H2 (id checks): `verifyNostrEvent` should assert `event.id` matches the computed hash.
|
||||
- F-H3/F-H4 (web security): add CSP/SRI and fix `innerHTML` XSS surface.
|
||||
- F-L8 (doc leftover): fix `why_what_how.md` Component 4 successor language.
|
||||
|
||||
---
|
||||
|
||||
## 10. Positive Cryptographic Properties
|
||||
|
||||
- Reputable libraries (`@noble/*`, `@scure/*`) used with correct API calls.
|
||||
- PQ sign/verify argument order and message bytes match.
|
||||
- ML-KEM correctly treated as a KEM throughout.
|
||||
- BIP39 mnemonic validation before seed derivation.
|
||||
- NIP-01 event-id serialization correct.
|
||||
- OTS detached-file prefix construction correct.
|
||||
- Deterministic PQ keygen from BIP32 seed delivers recoverability.
|
||||
- Two-event structure (kind 1 + kind 11112) is sensible.
|
||||
- Verifier re-derives the kind 1 id and full hash rather than trusting tags blindly (for the parts it checks).
|
||||
- **Documentation now matches implementation** (F-C4 resolved) — the security model is honestly stated, including what it does and does not prove.
|
||||
- **OTS proofs are now cryptographically verified** (F-C2/F-C3 resolved) — `parseOtsFile` + `verifyOtsProof` parse the binary format, walk the op tree, fetch Bitcoin block headers, and validate the Merkle root. Tested against vendored example proofs.
|
||||
|
||||
These provide a solid foundation; the remaining gaps are general software quality (F-H1–F-H4, F-L8), not cryptographic correctness.
|
||||
@@ -1,248 +0,0 @@
|
||||
# Detailed Findings (Second Pass)
|
||||
|
||||
Companion to [`audit.md`](audit.md). Each finding includes the exact code location, the issue, the impact, and a recommended fix.
|
||||
|
||||
Severity legend: **C** = Critical, **H** = High, **M** = Medium, **L** = Low/Informational.
|
||||
|
||||
**Changes from the first pass:** F-C1 (missing successor signature) is reclassified as a documented design choice — no longer a finding. F-C4 (docs vs code mismatch) is resolved — no longer a finding. F-C2 and F-C3 (OTS verification) are resolved — implemented real client-side OTS verification. F-M6 (truncated laans_explanation.md) is resolved. F-L8 is new (minor doc leftover).
|
||||
|
||||
---
|
||||
|
||||
## F-C2 (Critical) — RESOLVED: OpenTimestamps proofs are now cryptographically verified
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:875`](www/js/pq-crypto.mjs:875) (`isOtsConfirmed`), [`www/js/pq-crypto.mjs:840`](www/js/pq-crypto.mjs:840) (`upgradeOts`), [`www/verify.html:539`](www/verify.html:539), [`www/index.html:1455`](www/index.html:1455).
|
||||
|
||||
**Issue.** The project's central anti-backdating claim (now explicitly stated in the reconciled docs as the primary anti-forgery mechanism) is that the key-link event is anchored to a Bitcoin block. Verification of this anchor is performed in two ways, both inadequate:
|
||||
|
||||
1. `isOtsConfirmed(otsBytes)` scans the proof for the 8-byte Bitcoin block-header attestation magic `0588960d73d71901` and returns `true` if found anywhere:
|
||||
```js
|
||||
const bitcoinTag = hexToBytes('0588960d73d71901');
|
||||
outer: for (let i = 0; i <= otsBytes.length - bitcoinTag.length; i++) {
|
||||
for (let j = 0; j < bitcoinTag.length; j++) {
|
||||
if (otsBytes[i + j] !== bitcoinTag[j]) continue outer;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
```
|
||||
This is a substring search, not a proof verification. It does not parse the OTS op stream, check the Merkle path, or check the block header against the Bitcoin chain.
|
||||
|
||||
2. `upgradeOts(otsBytes)` POSTs the proof to a server and trusts the JSON response's `confirmed` boolean.
|
||||
|
||||
Neither page runs a real OTS verification. The vendored [`resources/javascript-opentimestamps/`](resources/javascript-opentimestamps/) library — which can perform full verification — is present but not imported.
|
||||
|
||||
**Impact.** A malicious kind 11112 event can embed an `ots` tag containing arbitrary bytes that include the 8-byte magic and be displayed as "OTS: Confirmed (Bitcoin)" ([`www/verify.html:548`](www/verify.html:548)). The pre-quantum anchoring is spoofable. Since the reconciled security model explicitly relies on OTS precedence as the mechanism that distinguishes real from forged events after a quantum break, this is the **single most important open finding**.
|
||||
|
||||
**Fix.** Use the vendored OTS library to:
|
||||
1. Parse the `.ots` file into its op stream.
|
||||
2. Confirm the proof's target digest equals the event's `sha256` tag (see F-C3).
|
||||
3. Validate the Merkle path to a Bitcoin block header.
|
||||
4. Check the block header (hash + height) against a Bitcoin node or API.
|
||||
Only then display "confirmed." Remove or relabel the byte-pattern heuristic as a non-authoritative hint.
|
||||
|
||||
---
|
||||
|
||||
## F-C3 (Critical) — RESOLVED: The OTS proof's target digest is now bound to the event hash
|
||||
|
||||
**Location:** [`www/verify.html:534`](www/verify.html:534), [`www/js/pq-crypto.mjs:875`](www/js/pq-crypto.mjs:875).
|
||||
|
||||
**Issue.** [`verify.html`](www/verify.html:534) reads the `ots` and `sha256` tags and checks that the `sha256` tag matches `hashFullEvent(kind1Event)` — good. But it never checks that the OTS proof's internal target digest equals that hash. `isOtsConfirmed` only looks for the Bitcoin magic bytes; it does not parse the proof's target. So a proof timestamping any digest would be accepted as long as it contains the magic bytes.
|
||||
|
||||
**Impact.** Even if the byte-pattern heuristic were replaced by a real Merkle-path check, a proof committing to a different digest would still be accepted unless the target digest is explicitly compared to the `sha256` tag. An attacker could attach a genuine Bitcoin-anchored OTS proof for some unrelated file to a fraudulent event and have it pass.
|
||||
|
||||
**Fix.** After parsing the OTS file, extract its target digest and assert `targetDigest === sha256Tag` (case-insensitive hex compare) before trusting any attestation in the proof.
|
||||
|
||||
---
|
||||
|
||||
## F-H1 (High) — No tests, no known-answer test vectors
|
||||
|
||||
**Location:** [`package.json:7`](package.json:7).
|
||||
|
||||
**Issue.** `package.json` has `"test": "echo \"Error: no test specified\" && exit 1"`. No tests anywhere: no BIP32 derivation known-answer tests, no PQ sign/verify round-trips, no `verifyNIPQRContent` accept/reject tests, no OTS prefix tests.
|
||||
|
||||
**Impact.** No regression protection; correctness rests entirely on manual review. A refactor could silently break signature verification or key derivation.
|
||||
|
||||
**Fix.** Add a test suite covering:
|
||||
- BIP32→PQ-seed derivation with a fixed mnemonic → expected seed bytes.
|
||||
- PQ keygen determinism (same seed → same pubkey).
|
||||
- PQ sign→verify round-trip for each algorithm; tampering fails.
|
||||
- `verifyNIPQRContent` accept (genuine) and reject (tampered content/sig/pubkey, wrong `e`/`sha256` tags).
|
||||
- `computeEventId`/`verifyNostrEvent` against a known Nostr test vector.
|
||||
- OTS prefix construction (`isDetachedOtsFile` true/false).
|
||||
|
||||
---
|
||||
|
||||
## F-H2 (High) — `verifyNostrEvent` does not check `event.id` against the computed hash
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:385`](www/js/pq-crypto.mjs:385).
|
||||
|
||||
**Issue.** `verifyNostrEvent` computes the SHA-256 of the canonical serialization and verifies the Schnorr signature against it, but never compares `event.id` to the computed hash. An event with a wrong/foreign `id` but a valid signature passes. The kind 11112 wrapper is checked only via `verifyNostrEvent` in [`verify.html:522`](www/verify.html:522), so a spoofed `id` on the wrapper is not detected.
|
||||
|
||||
**Fix.** Add:
|
||||
```js
|
||||
const computedId = bytesToHex(hash);
|
||||
if (event.id && event.id.toLowerCase() !== computedId) return false;
|
||||
```
|
||||
and have callers require `id` presence.
|
||||
|
||||
---
|
||||
|
||||
## F-H3 (High) — XSS surface via `innerHTML` with relay/localStorage-derived data
|
||||
|
||||
**Location:** [`www/index.html:761`](www/index.html:761) (`setStatus`), [`www/index.html:750`](www/index.html:750) (otsNotice), [`www/verify.html:403`](www/verify.html:403) (`setStatus`), [`www/verify.html:627`](www/verify.html:627).
|
||||
|
||||
**Issue.** Multiple sites use `element.innerHTML = \`...${variable}...\`` with data from relays or localStorage. Relay `NOTICE` messages and error strings are attacker-influenced. No CSP (F-H4) and no SRI, so injected script would execute.
|
||||
|
||||
**Fix.** Use `textContent` for all dynamic strings, or escape rigorously. Add a strict CSP and SRI (F-H4).
|
||||
|
||||
---
|
||||
|
||||
## F-H4 (High) — No Content-Security-Policy and no SRI on the bundle
|
||||
|
||||
**Location:** [`www/index.html`](www/index.html), [`www/verify.html`](www/verify.html).
|
||||
|
||||
**Issue.** Neither page emits a CSP meta tag; the bundle and `/nostr-login-lite/` scripts load without `integrity`.
|
||||
|
||||
**Impact.** A compromised or MITM'd host can inject script that exfiltrates the seed phrase or PQ secret keys.
|
||||
|
||||
**Fix.** Serve a strict CSP; add SRI hashes; load third-party scripts only from trusted CDNs with SRI.
|
||||
|
||||
---
|
||||
|
||||
## F-H5 (High) — `isOtsConfirmed` substring search can false-positive
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:875`](www/js/pq-crypto.mjs:875).
|
||||
|
||||
**Issue.** `isOtsConfirmed` scans the entire proof for the 8-byte magic. Pending attestations could contain those bytes by coincidence; a crafted proof can include them deliberately (F-C2). A proper parser would walk the OTS op stream.
|
||||
|
||||
**Fix.** Replace with a real OTS parser. (Subsumed by the F-C2 fix.)
|
||||
|
||||
---
|
||||
|
||||
## F-M1 (Medium) — PQ signatures cover human-readable text, not a canonical binding
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:455`](www/js/pq-crypto.mjs:455).
|
||||
|
||||
**Issue.** The signed message is the full `content` string, mixing security-relevant fields (npub, block height) with display prose and hardcoded URLs. If the display text or URLs change, all existing PQ signatures would need re-issuing.
|
||||
|
||||
**Fix.** Sign a canonical structured attestation (or its hash) — e.g., `JSON.stringify({v:1, npub, blockHeight, pqPubkeys})` — and include the human-readable text separately for display.
|
||||
|
||||
---
|
||||
|
||||
## F-M2 (Medium) — `block_height` tag is not verified
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:465`](www/js/pq-crypto.mjs:465).
|
||||
|
||||
**Issue.** The `block_height` tag and content claim are purely informational. Nothing verifies the height was current at signing time.
|
||||
|
||||
**Fix.** Either remove the block-height security claim, or verify it against the OTS-attested block once real OTS verification is implemented.
|
||||
|
||||
---
|
||||
|
||||
## F-M3 (Medium) — BIP32 non-hardened leaf indices used for PQ seeds
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:49`](www/js/pq-crypto.mjs:49).
|
||||
|
||||
**Issue.** PQ seeds at non-hardened children 1–8 under hardened parent `m/44'/1237'/0'/0'`. PQ seeds (child private keys) remain secret unless the parent xpub leaks. The docs now warn about this (resolved in reconciliation). No enforcement.
|
||||
|
||||
**Fix.** Document the requirement never to publish the xpub at `m/44'/1237'/0'/0'` (done). Consider hardened indices for PQ children if wallet-compatibility is not required.
|
||||
|
||||
---
|
||||
|
||||
## F-M4 (Medium) — Truncation rule for concatenated BIP32 children is arbitrary
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:160`](www/js/pq-crypto.mjs:160).
|
||||
|
||||
**Issue.** "Take the first N bytes" is now documented (resolved in reconciliation), but a future implementer who takes the last N would break compatibility.
|
||||
|
||||
**Fix.** Document the exact rule (done). Consider HKDF-Expand on the concatenated children for a less arbitrary construction in a future version.
|
||||
|
||||
---
|
||||
|
||||
## F-M5 (Medium) — Falcon-512 is a draft standard (FIPS 206 not finalized)
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:728`](www/js/pq-crypto.mjs:728).
|
||||
|
||||
**Issue.** Falcon-512 is labeled "FIPS 206 (draft)." Standardization risk; the multi-scheme design mitigates it.
|
||||
|
||||
**Fix.** Communicate Falcon's draft status in the UI. Consider deferring Falcon until FIPS 206 is finalized.
|
||||
|
||||
---
|
||||
|
||||
## F-L1 (Low) — PQ secret keys and seed held in global scope, never zeroed
|
||||
|
||||
**Location:** [`www/index.html:641`](www/index.html:641).
|
||||
|
||||
**Issue.** PQ secret keys and seed held in JS global scope for the session, never zeroed. A compromised page (F-H3/F-H4) can read them.
|
||||
|
||||
**Fix.** Acceptable for a demo; for production, minimize secret lifetime and clear references on sign-out.
|
||||
|
||||
---
|
||||
|
||||
## F-L2 (Low) — 12-word default gives only ~64-bit PQ security on seed entropy
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:76`](www/js/pq-crypto.mjs:76), [`README.md`](README.md) (now recommends 24 words).
|
||||
|
||||
**Issue.** `generateSeedPhrase` defaults to 128 bits (12 words). Docs now recommend 24 words; app still defaults to 12.
|
||||
|
||||
**Fix.** Default to 24 words, or warn that 12-word gives only ~64-bit PQ security on the seed entropy.
|
||||
|
||||
---
|
||||
|
||||
## F-L3 (Low) — `hexToBytes`/`base64ToBytes` do not validate input
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:362`](www/js/pq-crypto.mjs:362), [`www/js/pq-crypto.mjs:341`](www/js/pq-crypto.mjs:341).
|
||||
|
||||
**Issue.** No input validation; malformed input produces `NaN` bytes or throws late. The verify page accepts pasted event JSON from users.
|
||||
|
||||
**Fix.** Validate input format and length; throw a clear error on malformed input.
|
||||
|
||||
---
|
||||
|
||||
## F-L4 (Low) — `e` tag validation does not check `kind1Event.id` equals computed id
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:624`](www/js/pq-crypto.mjs:624).
|
||||
|
||||
**Issue.** The `e` tag validation checks against the computed kind 1 id and `kind1Event.id` if present — but does not check that `kind1Event.id` itself equals the computed id.
|
||||
|
||||
**Fix.** Assert `kind1Event.id` (if present) equals the computed id, or ignore `kind1Event.id` and rely on the computed id.
|
||||
|
||||
---
|
||||
|
||||
## F-L5 (Low) — `timestampEvent` sends binary body with wrong Content-Type
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:798`](www/js/pq-crypto.mjs:798).
|
||||
|
||||
**Issue.** `Content-Type: application/x-www-form-urlencoded` with a raw binary body. Should be `application/octet-stream`. Works in practice.
|
||||
|
||||
**Fix.** Use `Content-Type: application/octet-stream` (or omit the header).
|
||||
|
||||
---
|
||||
|
||||
## F-L6 (Low) — Relay publishing treats `ws.onclose` as success
|
||||
|
||||
**Location:** [`www/index.html:1139`](www/index.html:1139).
|
||||
|
||||
**Issue.** `publishToRelays` treats `ws.onclose` as success. A relay that closes without sending `OK` is counted as successful.
|
||||
|
||||
**Fix.** Only count as success on an explicit `OK` message.
|
||||
|
||||
---
|
||||
|
||||
## F-L7 (Low) — App depends on server-provided `/nostr-login-lite/` scripts not in the repo
|
||||
|
||||
**Location:** [`www/index.html:606`](www/index.html:606), [`www/verify.html:346`](www/verify.html:346).
|
||||
|
||||
**Issue.** The pages load `/nostr-login-lite/nostr.bundle.js` and `nostr-lite.js` from absolute paths. These files are not in the repo.
|
||||
|
||||
**Fix.** Vendor the scripts into the repo (with SRI) or document the deployment dependency.
|
||||
|
||||
---
|
||||
|
||||
## F-L8 (Low, NEW) — `why_what_how.md` Component 4 summary still uses successor-model language
|
||||
|
||||
**Location:** [`why_what_how.md:64`](why_what_how.md:64).
|
||||
|
||||
**Issue.** The six-components summary for Component 4 says: "The old identity signs to authorize the migration; the new identity signs to endorse the PQ keys." This is successor-model language describing the unimplemented two-account flow. Component 4 is marked "Design only" in the README, but `why_what_how.md` does not carry that marker, so a reader could mistake this for an implemented feature.
|
||||
|
||||
**Fix.** Either add a "design only" note to `why_what_how.md` Component 4, or reword to avoid implying the successor signature is implemented.
|
||||
@@ -0,0 +1,671 @@
|
||||
# GPT-5.6 Comprehensive Security and Usability Audit
|
||||
|
||||
**Audit date:** 2026-07-20
|
||||
**Audited workspace:** `post_quantum_nostr` at root commit `9a975fcbc56e455a68ae16d734feeed76ba304b6`
|
||||
**Application version:** `0.0.11`
|
||||
**Auditor:** GPT-5.6
|
||||
**Scope:** first-party application code, cryptographic and Nostr/OTS protocol design, browser security, dependencies, tests, documentation claims, live public deployment, and the OpenTimestamps submodule boundary.
|
||||
|
||||
## Executive verdict
|
||||
|
||||
**Do not use this implementation to protect a real Nostr identity yet.** It is a useful research prototype with working PQ key generation/signature primitives and a promising OTS proof parser, but its verifier currently accepts events with **no PQ signatures**, treats missing signatures on declared signature algorithms as successful KEM-style entries, does not bind the outer wrapper identity to the embedded announcement identity, and does not implement its defining **earliest valid OTS anchor wins** selection rule. These are protocol-critical failures, not polish issues.
|
||||
|
||||
**Conceptual verdict:** the project is **conditionally sound as a pre-quantum key-commitment and identity-link protocol**, but it is **not, by itself, a complete post-quantum migration for Nostr**. Its central idea—have the current identity authorize PQ keys while secp256k1 is still trustworthy, anchor that authorization in Bitcoin, and later prefer the earliest valid anchor—is defensible if the protocol is redesigned to enforce immutable discovery, strict verification, key possession/continuity, and PQ-authorized rotation. Fixing only the implementation bugs would not be enough. Fixing the architectural requirements in the “Conceptual soundness assessment” below could yield a valid and useful project, but the result should initially be described as a **PQ key pre-commitment/link registry**, not as making the existing Nostr identity fully quantum-resistant.
|
||||
|
||||
The project did well in several areas:
|
||||
|
||||
- All 38 existing tests passed, including two live checks against Bitcoin API data.
|
||||
- A fresh browser bundle was byte-for-byte identical to the committed bundle.
|
||||
- `npm audit --omit=dev` reported no advisories for the currently installed root dependency graph.
|
||||
- The OTS target digest is bound to the expected event hash during full verification.
|
||||
- The live bundle exactly matched the audited local bundle.
|
||||
- A live calendar submission succeeded to all five configured calendars and parsed as a detached multi-calendar proof.
|
||||
|
||||
Those strengths do not compensate for fail-open acceptance in the key-link verifier. A user-facing statement such as “All signatures verified successfully” can currently be produced for a signed wrapper whose embedded announcement has zero PQ keys. The application also presents a latest replaceable event from relays, whereas the proposal says the canonical link is the event with the earliest verified Bitcoin anchor. That mismatch defeats the core post-quantum migration rule.
|
||||
|
||||
## Severity summary
|
||||
|
||||
| Severity | Count | IDs |
|
||||
|---|---:|---|
|
||||
| Critical | 2 | G56-01, G56-02 |
|
||||
| High | 6 | G56-03 through G56-08 |
|
||||
| Medium | 8 | G56-09 through G56-16 |
|
||||
| Low / informational | 4 | G56-17 through G56-20 |
|
||||
|
||||
## Findings at a glance
|
||||
|
||||
| ID | Severity | Finding | Confidence |
|
||||
|---|---|---|---|
|
||||
| G56-01 | Critical | Verifier accepts a migration with no PQ signatures and accepts missing signature fields as valid | Confirmed dynamically |
|
||||
| G56-02 | Critical | Earliest-valid-anchor rule is not implemented; relay discovery selects the latest replaceable event | Confirmed |
|
||||
| G56-03 | High | Outer wrapper identity is not bound to the embedded announcement identity | Confirmed dynamically |
|
||||
| G56-04 | High | OTS “verification” trusts public HTTPS APIs and does not validate Bitcoin headers or proof of work | Confirmed |
|
||||
| G56-05 | High | Signer output is reconstructed without validating returned identity, event ID, signature, or exact signed fields | Confirmed |
|
||||
| G56-06 | High | The system creates a new seed for any signed-in identity without proving seed continuity or a successor key relationship | Confirmed design limitation |
|
||||
| G56-07 | High | Verification is not robust against malformed untrusted relay/paste input and can throw | Confirmed dynamically |
|
||||
| G56-08 | High | Browser origin is the key-generation trust boundary, but deploy hardening and signer assets are unaudited/unpinned | Confirmed |
|
||||
| G56-09 | Medium | Twelve-word seed is the only generated option and its warning is removed in the audited workspace/live page | Confirmed |
|
||||
| G56-10 | Medium | Secret material is retained in ordinary JavaScript objects/DOM/clipboard and is not zeroized | Confirmed |
|
||||
| G56-11 | Medium | Publish workflow reports success and advances even when no relay accepts one or both events | Confirmed |
|
||||
| G56-12 | Medium | Resume/upgrade state can fetch an attacker-controlled latest wrapper and lacks robust event/workflow binding | Confirmed |
|
||||
| G56-13 | Medium | OTS parser lacks total input/operation budgets and uses unsafe 32-bit varuint arithmetic | Confirmed |
|
||||
| G56-14 | Medium | Dependency resolution is not reproducible from the repository; lockfile is ignored and inconsistent | Confirmed |
|
||||
| G56-15 | Medium | OpenTimestamps submodule is not clonable normally because `.gitmodules` is absent; historical dependency risk is unresolved | Confirmed |
|
||||
| G56-16 | Medium | Documentation and UI overclaim implementation status and security properties | Confirmed |
|
||||
| G56-17 | Low | Block-height statement is not validated and can be zero/untrusted | Confirmed |
|
||||
| G56-18 | Low | Relay URL policy permits insecure `ws://` and arbitrary endpoints | Confirmed |
|
||||
| G56-19 | Low | Missing event IDs may be accepted by the library verifier | Confirmed dynamically |
|
||||
| G56-20 | Informational | Large events may be rejected by relays; interoperability testing is absent | Confirmed size; relay behavior contextual |
|
||||
|
||||
---
|
||||
|
||||
## Conceptual soundness assessment
|
||||
|
||||
### Direct answer
|
||||
|
||||
**Is the idea flawed from the start? No—but its valid scope is narrower than the current product language suggests.**
|
||||
|
||||
The project starts from a real migration problem: after secp256k1 is broken, a fresh secp signature cannot distinguish the legitimate owner from an attacker. Creating and timestamping an authorization **before** that break is a legitimate way to establish historical precedence. A PQ key that signs the same canonical authorization provides durable proof that the corresponding PQ private key participated. Bitcoin/OpenTimestamps can provide an externally ordered existence proof without changing Nostr relays.
|
||||
|
||||
That gives a conceptually useful primitive:
|
||||
|
||||
> Before quantum compromise, identity A authorized PQ key set K; the authorization existed no later than Bitcoin block H; future clients can use a deterministic conflict rule to resolve later forged claims.
|
||||
|
||||
However, that primitive does **not** automatically make identity A quantum-resistant. A complete migration also needs a standardized way to authenticate future events with K, rotate/revoke keys after secp256k1 is distrusted, encrypt to PQ keys, discover all historical claims despite relay replacement/deletion, and recover from lost or compromised PQ keys. Those pieces are currently outside the implementation.
|
||||
|
||||
### What remains valid after remediation
|
||||
|
||||
If all audit recommendations and the architectural requirements in [`audit_mitigation.md`](audit_mitigation.md:1) are implemented, the project can validly provide:
|
||||
|
||||
1. **Pre-quantum commitment:** proof that a Nostr identity authorized a canonical PQ key set before a verified Bitcoin block.
|
||||
2. **PQ proof of possession:** proof that each linked signature key participated in the canonical authorization, and an explicit KEM possession mechanism if ML-KEM ownership is claimed.
|
||||
3. **Deterministic conflict resolution:** selection of the earliest valid immutable anchor rather than the newest relay event.
|
||||
4. **A migration root:** a trustworthy starting key set for a separate PQ-event, PQ-encryption, and key-rotation protocol.
|
||||
5. **Backward-compatible publication:** legacy relays can carry opaque events without understanding PQ cryptography, subject to size and retention limits.
|
||||
|
||||
### What remediation cannot make true without additional protocols
|
||||
|
||||
Even a perfectly implemented key-link protocol does not itself provide:
|
||||
|
||||
- PQ authentication for ordinary kind 0/1/3/etc. events;
|
||||
- PQ confidentiality for NIP-04 or NIP-44 messages;
|
||||
- automatic preservation of the social graph in clients that do not adopt identity-alias/successor semantics;
|
||||
- revocation or rotation after secp256k1 becomes untrustworthy;
|
||||
- availability if all relays and user archives lose the immutable announcement/proof;
|
||||
- protection from seed theft, malicious signers, compromised web origins, or broken PQ implementations;
|
||||
- consensus across Nostr clients about algorithm policy or canonical identity succession.
|
||||
|
||||
The project therefore needs companion specifications for PQ-signed events, key lifecycle, encryption, and client identity mapping before it can accurately claim a complete Nostr PQ migration.
|
||||
|
||||
### Architectural conditions for a sound protocol
|
||||
|
||||
The concept is sound only if all of these invariants are made normative and implemented:
|
||||
|
||||
1. **Canonical signed object:** PQ signatures and the current secp identity must cover one domain-separated, versioned, deterministic structure containing the old identity, complete ordered key set, algorithm identifiers, protocol version, creation context, and rotation policy. Human-readable prose may be displayed but must not be the normative signed representation.
|
||||
2. **Strict proof policy:** a verifier must distinguish structural validity, cryptographic validity, algorithm-policy sufficiency, OTS validity, and canonical-selection status. Missing signatures can never become successful KEM entries.
|
||||
3. **Identity equality:** queried author, wrapper author, embedded author, and canonical old-identity field must match.
|
||||
4. **Immutable candidates:** the initial authorization must not depend on a replaceable event that can hide prior history. An upgraded proof may reference the same immutable authorization digest, but cannot replace the candidate itself.
|
||||
5. **Complete discovery:** clients must collect candidates across relays/archives and apply a deterministic earliest-valid-anchor rule, including tie-breaks and confirmation-depth policy.
|
||||
6. **Possession and recoverability:** every signature key must prove possession. If the project promises seed recovery, the canonical statement must bind a seed-derived successor proof or the UI must explicitly avoid claiming common seed origin. KEM possession requires a defined challenge/decapsulation proof or must be described only as an authorized encryption key, not proven possession.
|
||||
7. **Post-break authority:** after the transition cutoff, secp-only updates must not supersede the canonical PQ root. Rotation/revocation must require a threshold or policy over already-authorized PQ keys.
|
||||
8. **Future event semantics:** a separate specification must define exactly how PQ keys authenticate future Nostr events and how those events bind to NIP-01 IDs, kinds, tags, replay domains, and old identities.
|
||||
9. **Availability:** users and clients must export, mirror, and validate the immutable authorization plus OTS proof independently of any one relay or web service.
|
||||
10. **Trust-explicit Bitcoin verification:** implementations must state whether they use a local node, a validated header chain, or trusted explorer APIs. Security labels must match that model.
|
||||
11. **Algorithm agility with policy:** multiple algorithms may hedge cryptanalytic risk, but clients need mandatory minimum sets, deprecation rules, downgrade resistance, and deterministic handling of unknown schemes.
|
||||
12. **Secure key boundary:** production derivation/signing should occur in a dedicated signer or hardware boundary, not an updateable general-purpose web origin holding a long-lived seed.
|
||||
|
||||
### Bottom line
|
||||
|
||||
With those conditions and the finding-specific fixes, this can become a valid **PQ migration foundation**. Without them, repairing individual code defects would leave a system that generates valid PQ signatures but lacks a trustworthy rule for what those signatures mean over time. The current project is therefore **salvageable and conceptually promising, but architecturally incomplete**.
|
||||
|
||||
## Scope, baseline, and trust boundaries
|
||||
|
||||
### First-party runtime surface
|
||||
|
||||
- Cryptographic module: [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1)
|
||||
- Generated browser bundle: [`pq-crypto.bundle.js`](../../www/pq-crypto.bundle.js)
|
||||
- Migration page and state machine: [`index.html`](../../www/index.html:1)
|
||||
- Verification page and relay query flow: [`verify.html`](../../www/verify.html:1)
|
||||
- Tests: [`pq-crypto.test.mjs`](../../test/pq-crypto.test.mjs:1)
|
||||
- Build script: [`build-pq-bundle.js`](../../build-pq-bundle.js:1)
|
||||
- Protocol proposal: [`nip_proposal.md`](../../nip_proposal.md:1)
|
||||
- User-facing claims: [`README.md`](../../README.md:1)
|
||||
|
||||
### External trust boundaries
|
||||
|
||||
1. **Web origin / deployment operator** controls the JavaScript that receives or generates a BIP39 mnemonic and PQ private keys.
|
||||
2. **nostr-login-lite / NIP-07/NIP-46 signer** supplies the current identity and signs events.
|
||||
3. **Nostr relays** receive, retain, filter, and return untrusted events.
|
||||
4. **OTS calendars** receive event digests and return pending timestamp fragments.
|
||||
5. **Same-origin `/ots-upgrade` helper** receives OTS proof bytes and returns upgraded bytes; its implementation is absent from the repository.
|
||||
6. **Blockstream and mempool.space APIs** supply the purported Bitcoin block hash, Merkle root, and timestamp.
|
||||
7. **npm dependencies and committed generated bundle** implement all cryptographic primitives in the browser.
|
||||
8. **OpenTimestamps submodule** supplies example proofs used by tests, but is not runtime-integrated.
|
||||
|
||||
### Audit limitations
|
||||
|
||||
- No browser automation framework or complete signer fixture is present, so the real NIP-07/NIP-46 modal flow was reviewed statically rather than driven end to end.
|
||||
- The `/ots-upgrade` server source and nginx/deployment configuration are not in the repository. Only black-box HTTP behavior could be checked.
|
||||
- No real Nostr identity was used and no migration event was published during the audit.
|
||||
- The workspace was already dirty: user changes existed in [`index.html`](../../www/index.html:1), [`verify.html`](../../www/verify.html:1), and [`nip_proposal.md`](../../nip_proposal.md:1), plus unrelated deleted audit files. The report evaluates the working tree, not a pristine commit.
|
||||
- Root advisory results describe the installed graph at audit time, not every graph permitted by semver ranges.
|
||||
|
||||
---
|
||||
|
||||
## Detailed findings
|
||||
|
||||
### G56-01 — Verifier accepts no PQ signatures and missing signatures as successful
|
||||
|
||||
**Severity:** Critical
|
||||
**Confidence:** Confirmed dynamically
|
||||
|
||||
The central verifier initializes results with only secp256k1, `e`, and SHA-256 checks, then loops over whatever `algorithm` tags happen to exist. It never requires any algorithm tag, never requires the four expected signature algorithms, and never validates uniqueness or policy. See [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:606) and its unconditional aggregate at [`results.every()`](../../www/js/pq-crypto.mjs:706).
|
||||
|
||||
Worse, any algorithm tag lacking a fourth field is marked valid as a KEM, regardless of its algorithm identifier:
|
||||
|
||||
```js
|
||||
if (algorithm === 'ml-kem-768' || !sigBase64) {
|
||||
results.push({ algorithm, valid: true, note: 'KEM (no signature to verify)' });
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
This logic is at [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:683).
|
||||
|
||||
Dynamic probes established:
|
||||
|
||||
- A validly secp-signed embedded kind 1 event with `tags: []` returned `valid: true`.
|
||||
- `ml-dsa-44`, `ml-dsa-65`, `slh-dsa-128s`, and `falcon-512` tags containing only a public-key field each returned `valid: true` with note `KEM (no signature to verify)`.
|
||||
- An arbitrary unknown algorithm with no signature also returned valid.
|
||||
|
||||
The UI then presents `All signatures verified successfully!` when this incomplete result is true at [`verify.html`](../../www/verify.html:710).
|
||||
|
||||
**Impact:** The verifier can bless an event that provides no post-quantum authentication at all. Such an event does not establish the advertised migration and cannot support post-quantum continuity.
|
||||
|
||||
**Remediation:** Define and enforce a verification policy. At minimum:
|
||||
|
||||
1. Require exactly one tag for each mandatory supported algorithm.
|
||||
2. Require exact field counts and decoded byte lengths.
|
||||
3. Permit no-signature handling only for the exact `ml-kem-768` identifier.
|
||||
4. Reject duplicate known algorithms.
|
||||
5. Treat unknown algorithms as ignored—not valid evidence—and report them separately.
|
||||
6. Return distinct structural validity, cryptographic validity, policy sufficiency, and OTS validity fields.
|
||||
7. Add adversarial tests for empty tags, missing signatures, duplicate algorithms, unknown algorithms, and malformed encodings.
|
||||
|
||||
**Verification criterion:** No event can receive a migration-valid result unless the configured required PQ signature set verifies successfully.
|
||||
|
||||
### G56-02 — The defining earliest-valid-anchor rule is not implemented
|
||||
|
||||
**Severity:** Critical
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The proposal makes earliest verified Bitcoin anchor selection the mechanism that defeats a later quantum forgery at [`nip_proposal.md`](../../nip_proposal.md:204). The implementation instead queries kind 11112 with `limit: 1` and describes it as “latest” at [`verify.html`](../../www/verify.html:487). Across relays, it chooses the largest `created_at` value at [`verify.html`](../../www/verify.html:660). The resume path also requests one event at [`index.html`](../../www/index.html:1201) and selects newest `created_at` across relays at [`index.html`](../../www/index.html:758).
|
||||
|
||||
Kind 11112 is replaceable, as documented in [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:425). A later forged or accidental replacement can therefore hide the historical event from ordinary relay queries. Even before a quantum break, a compromised current key can publish a newer wrapper. After a break, the attacker can do so trivially. The application does not gather all candidate announcements/proofs, verify them, compare Bitcoin heights, or preserve a stable immutable discovery record.
|
||||
|
||||
**Impact:** The implemented discovery behavior can select the exact later event that the OTS precedence design says must lose. This breaks the central migration security argument.
|
||||
|
||||
**Remediation:** Redesign discovery and storage before production use:
|
||||
|
||||
- Use a non-replaceable canonical announcement or an append-only indexed form for immutable candidates.
|
||||
- Fetch all candidates from multiple relays, not `limit: 1`.
|
||||
- Fully validate each candidate and select the earliest valid Bitcoin anchor according to a precise tie-break rule.
|
||||
- Define how upgraded OTS proofs refer to an immutable announcement without replacing or obscuring history.
|
||||
- Cache/export the canonical proof so relay retention does not determine identity continuity.
|
||||
- Implement PQ-authorized rotation separately.
|
||||
|
||||
**Verification criterion:** Given multiple candidates—including a later forged secp-valid candidate—the client deterministically chooses the candidate with the earliest valid Bitcoin anchor.
|
||||
|
||||
### G56-03 — Outer wrapper identity is not bound to embedded identity
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed dynamically
|
||||
|
||||
[`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:606) validates the embedded kind 1 event and tags but receives no outer event object and therefore cannot require `outer.pubkey === embedded.pubkey`. [`verifyEvent()`](../../www/verify.html:530) separately validates the outer signature and calls the content verifier without performing this identity equality check.
|
||||
|
||||
A dynamic probe built a valid embedded event signed by key A, wrapped it in a valid kind 11112 event signed by key B, and observed:
|
||||
|
||||
- outer signature valid: `true`
|
||||
- embedded verification valid: `true`
|
||||
|
||||
**Impact:** A wrapper authored by one identity can carry another identity’s announcement and appear as a collection of passing checks. Relay query by author B can therefore display A’s embedded link under B’s result page. OTS upgrading/republishing may further re-sign mismatched content.
|
||||
|
||||
**Remediation:** Require equality among:
|
||||
|
||||
- requested relay-query author,
|
||||
- outer wrapper `pubkey`,
|
||||
- embedded kind 1 `pubkey`,
|
||||
- identity named in the human-readable content (or preferably a canonical structured identity field).
|
||||
|
||||
Also require correct outer kind and exact tag cardinality.
|
||||
|
||||
### G56-04 — Bitcoin verification trusts public API JSON and does not validate block headers or proof of work
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed
|
||||
|
||||
[`fetchBitcoinBlockHeader()`](../../www/js/pq-crypto.mjs:1235) fetches a block hash by height and then a block JSON object from one of two public APIs. It extracts only a Merkle root and timestamp at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1244). [`verifyOtsProof()`](../../www/js/pq-crypto.mjs:1323) compares the OTS commitment to that API-provided Merkle root.
|
||||
|
||||
Despite comments calling this “independently verifiable against the Bitcoin PoW chain” at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1226), the implementation does not fetch raw 80-byte headers, hash a header, check its hash against target, verify chain linkage, validate accumulated work, or pin checkpoints. Both providers are contacted over HTTPS and are simply trusted to report honest data.
|
||||
|
||||
**Impact:** Compromise, interception at a trusted origin, coordinated false responses, or API semantic changes can cause a false “Bitcoin verified” result. Two fallback URLs are resilience, not trust minimization, because acceptance requires only one response.
|
||||
|
||||
**Remediation:** Accurately label the current mode as “verified against a trusted block explorer.” For stronger verification, use a local Bitcoin node or a real light client with headers, chainwork, checkpoints, and consensus validation. At minimum compare multiple independent providers and fail on disagreement, but do not call that trustless cryptographic Bitcoin verification.
|
||||
|
||||
### G56-05 — Signer output is trusted and reconstructed without validation
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed
|
||||
|
||||
After `window.nostr.signEvent`, the migration flow copies `id`, `pubkey`, and `sig` from the signer but takes `created_at`, `kind`, `content`, and `tags` from the local template at [`index.html`](../../www/index.html:1078). It does not check:
|
||||
|
||||
- signer-returned pubkey equals the authenticated `currentPubkey`,
|
||||
- returned ID equals [`computeEventId()`](../../www/js/pq-crypto.mjs:510) for the final reconstructed event,
|
||||
- returned signature verifies,
|
||||
- signer did not alter fields,
|
||||
- the wrapper signer matches the announcement signer.
|
||||
|
||||
The same trust is repeated for the wrapper at [`index.html`](../../www/index.html:1118) and upgraded events at [`index.html`](../../www/index.html:1713). The verify page republishes signer output directly at [`verify.html`](../../www/verify.html:784), but also does not validate it before publishing.
|
||||
|
||||
**Impact:** A buggy, malicious, or incompatible signer can cause invalid or wrong-identity events to be displayed and published as successful. Reconstructing a hybrid object can pair a signer’s signature with different fields.
|
||||
|
||||
**Remediation:** Treat signer output as untrusted. Validate complete returned events, exact field equality against approved templates, pubkey equality, event ID, and Schnorr signature before any display, persistence, or publish.
|
||||
|
||||
### G56-06 — New seed continuity is asserted, not proven
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed design limitation
|
||||
|
||||
The app generates or accepts an arbitrary seed after signing into an existing identity. It derives a seed-based secp key at [`deriveSecp256k1FromSeed()`](../../www/js/pq-crypto.mjs:198) but does not use that key to sign, and does not verify that an entered seed derives the currently signed-in Nostr identity. The documentation acknowledges no successor signature at [`README.md`](../../README.md:298).
|
||||
|
||||
For a raw-nsec identity, an old-identity signature plus pre-quantum OTS can establish that the old identity authorized listed PQ keys. It cannot prove those keys are recoverable from the mnemonic shown to the user or that all listed keys share one seed. For a mnemonic-based existing identity, failing to compare the derived secp public key wastes a strong continuity check.
|
||||
|
||||
**Impact:** A compromised browser can show one mnemonic while publishing attacker-chosen PQ keys; the old signer approves only a very large event whose key material is hard for a human to inspect. The user may believe the written mnemonic is a recoverable root when it is not cryptographically bound as such.
|
||||
|
||||
**Remediation:** Separate two modes:
|
||||
|
||||
1. **Existing mnemonic identity:** derive the NIP-06 secp key and require it to equal the signer pubkey before proceeding.
|
||||
2. **Raw-nsec migration:** define a canonical structured migration statement and add a proof-of-possession/successor signature from a seed-derived key, plus recovery self-test before publishing.
|
||||
|
||||
A hardware signer or dedicated PQ signer should ideally derive/sign without exposing the mnemonic to a web origin.
|
||||
|
||||
### G56-07 — Untrusted verifier input can throw instead of failing closed
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed dynamically
|
||||
|
||||
The verification page accepts pasted JSON and relay events. [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:606) assumes arrays and valid base64. Examples confirmed during testing:
|
||||
|
||||
- malformed base64 threw `InvalidCharacterError`,
|
||||
- non-array wrapper tags threw `TypeError: kind11112Tags.find is not a function`,
|
||||
- missing signatures caused [`verifyNostrEvent()`](../../www/js/pq-crypto.mjs:400) to throw,
|
||||
- `null` or wrong-shaped events threw.
|
||||
|
||||
Relay-driven verification at [`verify.html`](../../www/verify.html:679) invokes the verifier without the paste handler’s `try/catch`; malformed relay content can reject the click callback and leave inconsistent UI. PQ decoders can also receive attacker-selected large or malformed data without preflight size checks.
|
||||
|
||||
**Impact:** Remote relays can cause denial of service, misleading partial results, or expensive parsing/cryptographic work.
|
||||
|
||||
**Remediation:** Add strict schemas and byte-size limits before cryptography. Every exported verification function should return structured failure for untrusted input, never throw for ordinary malformed data. Add fuzz/property tests and maximum event/proof sizes.
|
||||
|
||||
### G56-08 — Browser origin security is insufficient for handling a root seed
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The page states the seed is processed entirely in the browser at [`index.html`](../../www/index.html:364). That means the web origin—not merely the reviewed source—is trusted with the seed. The live response lacked HSTS, `X-Frame-Options`/CSP `frame-ancestors`, Referrer-Policy, Permissions-Policy, and other defense-in-depth headers. The meta CSP allows `'unsafe-inline'` scripts at [`index.html`](../../www/index.html:7) and [`verify.html`](../../www/verify.html:7), materially weakening XSS protection.
|
||||
|
||||
Two large same-origin signer scripts are loaded outside this repository at [`index.html`](../../www/index.html:577) and [`verify.html`](../../www/verify.html:350). They have no SRI and could not be source-audited here. Live sizes were 328,444 and 230,498 bytes. Any server compromise or update to these assets can exfiltrate the mnemonic and all PQ keys. The source for `/ots-upgrade` and server configuration is also absent.
|
||||
|
||||
**Impact:** A single origin compromise defeats the entire root-of-trust design. HTTPS alone does not make web-based seed entry equivalent to a hardware or native signer.
|
||||
|
||||
**Remediation:** Do not recommend entering a valuable existing mnemonic into this web app. Move derivation and PQ signing into a dedicated signer/hardware boundary. Also extract inline scripts, enforce nonce/hash CSP without `'unsafe-inline'`, add `frame-ancestors 'none'`, HSTS, Referrer-Policy, Permissions-Policy, pinned/vendored signer assets, reviewed deployment configuration, and release integrity documentation.
|
||||
|
||||
### G56-09 — Generated seed defaults to 12 words and the visible warning is absent
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
[`generateSeedPhrase()`](../../www/js/pq-crypto.mjs:76) and [`generateSeedPhraseWithEntropy()`](../../www/js/pq-crypto.mjs:91) always produce 128-bit entropy/12 words. The project itself estimates only about 64-bit security under an idealized Grover model at [`README.md`](../../README.md:172). The audited working tree removed the prior visible warning from the generation screen, and the live page matched this working tree.
|
||||
|
||||
**Impact:** Users receive “quantum-safe root of trust” messaging while the only generated option falls below the project’s own preferred PQ security level.
|
||||
|
||||
**Remediation:** Default to 24 words for a claimed long-term PQ root, retain a prominent explanation, and provide a deliberate entropy-strength selector. Be careful not to imply that BIP39’s low iteration count protects weak user-created phrases.
|
||||
|
||||
### G56-10 — Secret lifetime and exposure are not minimized
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The mnemonic, BIP39 seed, derived secp private key, and five PQ secret keys are retained in global JavaScript variables at [`index.html`](../../www/index.html:623). The mnemonic is rendered in the DOM at [`index.html`](../../www/index.html:934) and can be copied to the system clipboard at [`index.html`](../../www/index.html:1373). Sign-out nulls references at [`index.html`](../../www/index.html:1477), but does not overwrite typed arrays or clear the seed display/input/clipboard. Ordinary JS garbage collection offers no reliable erasure.
|
||||
|
||||
**Impact:** Browser extensions, injected scripts, devtools, clipboard managers, page snapshots, and memory inspection can recover high-value long-lived secrets.
|
||||
|
||||
**Remediation:** Prefer signer-contained keys. If browser handling remains, minimize scope/lifetime, avoid deriving an unused secp private key, overwrite typed arrays where possible, clear all DOM/input references immediately after use, avoid clipboard encouragement, add mnemonic confirmation/recovery challenge, and document that zeroization cannot be guaranteed in JavaScript.
|
||||
|
||||
### G56-11 — Publication advances despite zero relay acceptance
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
After publishing, the code counts successes and failures but unconditionally displays `Published!`, disables the button, marks step 5 done, and exposes the next step at [`index.html`](../../www/index.html:1391). There is no requirement that either event reach any relay.
|
||||
|
||||
**Impact:** Users can believe migration succeeded when the announcement, wrapper, or both are unavailable. The two-event pair can also be split across relays.
|
||||
|
||||
**Remediation:** Require policy thresholds for both events, distinguish partial failure, retain retry controls, verify by querying accepted events back, and provide downloadable backups. Do not mark complete unless at least one configured durable relay stores both exact events.
|
||||
|
||||
### G56-12 — Resume workflow is insufficiently bound to the original event
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
Only OTS metadata/proof is persisted by [`savePendingOts()`](../../www/js/pq-crypto.mjs:1362), not the complete immutable original wrapper. On resume, the code queries relays for the latest kind 11112 and adopts it at [`index.html`](../../www/index.html:749). It does not require that the fetched event ID equal the persisted ID, that its SHA-256 target matches the persisted proof, or that it passes complete verification before becoming `pqEvent`.
|
||||
|
||||
There is also an ID transition bug: after an upgraded replacement is signed, [`savePendingOts()`](../../www/index.html:1726) stores the new wrapper event ID. The OTS proof, however, targets the embedded kind 1 hash, while comments and metadata call `eventId` “The NIP-QR event id” at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1358). This overloaded identifier complicates safe recovery.
|
||||
|
||||
**Impact:** A resume can upgrade or republish the wrong wrapper/content, especially after replacements or hostile relay responses.
|
||||
|
||||
**Remediation:** Persist the complete signed announcement, wrapper, target digest, owner, and exact relay receipts under separate immutable IDs. On resume, validate every binding before signing or publishing. Never substitute a merely newer relay event.
|
||||
|
||||
### G56-13 — OTS parser resource and integer limits are incomplete
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The parser allows recursion depth 512 at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1126), but has no overall proof-size limit, total operation count, total branches, or total hash-work budget. [`readVaruint()`](../../www/js/pq-crypto.mjs:1032) uses JavaScript bitwise operations, which are signed 32-bit and wrap for sufficiently large encodings. The continuation loop can process many branches, and each operation allocates/hash-processes attacker-controlled data.
|
||||
|
||||
**Impact:** Pasted or relay-sourced proofs can consume excessive CPU/memory or create malformed length/height semantics.
|
||||
|
||||
**Remediation:** Reject proofs above a small protocol maximum; cap operations, branches, argument bytes, and total hashing; parse varints with safe-number checks or `BigInt`; reject non-canonical and overlong encodings; require complete input consumption; fuzz the parser.
|
||||
|
||||
### G56-14 — Root dependency graph is not reproducible from the repository
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
[`package.json`](../../package.json:13) uses caret ranges for all dependencies. [`package-lock.json`](../../package-lock.json) exists locally but is explicitly ignored by [`.gitignore`](../../.gitignore:8) and is not tracked. Its root metadata says version `1.0.0`, whereas the manifest says `0.0.11`, showing it is stale or generated under a different package state.
|
||||
|
||||
`npm audit --omit=dev` found zero vulnerabilities for the installed graph, and the bundle rebuilt identically. Those are positive observations for this machine only. A clean install later may resolve different code, and there is no committed integrity map connecting source dependencies to the released bundle.
|
||||
|
||||
**Remediation:** Commit an up-to-date lockfile, use `npm ci`, pin release dependencies, add CI audit/build/test jobs, produce an SBOM/provenance attestation, and document how the deployed bundle hash maps to a source revision.
|
||||
|
||||
### G56-15 — OpenTimestamps submodule and historical dependencies are not reproducible
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The root tree records [`resources/javascript-opentimestamps/`](../../resources/javascript-opentimestamps/) as gitlink commit `c07ba8be0dae4e8721a84f32820abf7b2547a6ce`, but the repository has no `.gitmodules` mapping. `git submodule status` failed with “no submodule mapping found.” The already-present nested repository points to the upstream GitHub project, but a fresh clone cannot initialize it normally.
|
||||
|
||||
Its package metadata is version 0.4.9 and contains old ranges including `request`, `moment-timezone`, `minimatch`, and other historical packages. No lockfile is present, so its historical dependency graph could not be audited reproducibly. This code is not runtime-integrated, but its example proofs are required by root tests.
|
||||
|
||||
**Remediation:** Restore a valid `.gitmodules` entry or vendor a clearly licensed immutable snapshot. Pin and document the commit, remove claims that it is integrated, and either modernize/audit its dependency graph or retain only the test vectors with provenance and hashes.
|
||||
|
||||
### G56-16 — Documentation and UI overclaim status and guarantees
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
Examples:
|
||||
|
||||
- [`README.md`](../../README.md:372) says full client-side OTS verification is not implemented, but code now implements an API-trusting Merkle-root check.
|
||||
- [`README.md`](../../README.md:758) still lists full OTS verification, target binding, and the test suite as not implemented, although all exist in some form.
|
||||
- [`nip_proposal.md`](../../nip_proposal.md:294) says all OTS verification happens in the browser and “No private key ever touches a server,” but signer modes may be remote and the implementation relies on an unreviewed server helper and public APIs.
|
||||
- [`index.html`](../../www/index.html:323) says “Make your Nostr identity quantum-resistant,” while the project only links keys and does not define PQ signing for routine events or PQ messaging.
|
||||
- [`verify.html`](../../www/verify.html:255) says it verifies all signatures, despite G56-01.
|
||||
- The claim that a pending OTS proof establishes submission time at [`nip_proposal.md`](../../nip_proposal.md:208) is too strong: a pending calendar attestation is not a Bitcoin timestamp and depends on calendar behavior.
|
||||
|
||||
**Impact:** Users and reviewers may treat the prototype as production-ready or misunderstand its trust model.
|
||||
|
||||
**Remediation:** Maintain an explicit security model and implementation matrix generated from tests. Use “research prototype,” distinguish API-assisted verification from Bitcoin light-client verification, distinguish key linkage from identity becoming quantum-resistant, and label design-only research documents prominently.
|
||||
|
||||
### G56-17 — Claimed Bitcoin block height is unverified
|
||||
|
||||
**Severity:** Low
|
||||
**Confidence:** Confirmed
|
||||
|
||||
Block height comes from one mempool.space request at [`index.html`](../../www/index.html:808). Failure returns zero, yet signing continues at [`index.html`](../../www/index.html:1063). The verifier does not compare the `block_height` tag or content statement to the OTS attested block.
|
||||
|
||||
**Remediation:** Remove the claimed height from signed content or validate it against the eventual OTS result. Do not sign a “pre-quantum at height 0” statement.
|
||||
|
||||
### G56-18 — Insecure/arbitrary relay endpoints are allowed
|
||||
|
||||
**Severity:** Low
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The add-relay validator explicitly accepts `ws://` at [`index.html`](../../www/index.html:1436). The verify page does not validate schemes before `new WebSocket()` at [`verify.html`](../../www/verify.html:474).
|
||||
|
||||
**Impact:** On an HTTP/local deployment, users can transmit public metadata to plaintext relays; arbitrary endpoints also increase privacy leakage and browser-network probing surface.
|
||||
|
||||
**Remediation:** Require `wss://` outside explicit localhost development, canonicalize URLs, cap count/length, and explain that relay queries reveal identity interest and client IP.
|
||||
|
||||
### G56-19 — Event ID is optional in library verification
|
||||
|
||||
**Severity:** Low
|
||||
**Confidence:** Confirmed dynamically
|
||||
|
||||
[`verifyNostrEvent()`](../../www/js/pq-crypto.mjs:400) checks ID equality only when `event.id` is truthy at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:412). A dynamically created event with a valid signature but omitted `id` returned true.
|
||||
|
||||
The embedded verifier currently requires neither `id` explicitly in its structural condition at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:624) nor at the core verifier. While the `e` tag later supplies some binding for wrappers, this exported primitive’s behavior is surprising and unsafe for callers.
|
||||
|
||||
**Remediation:** Require exact NIP-01 shape and a 64-hex ID by default. If ID-less template verification is needed, expose a separate clearly named function.
|
||||
|
||||
### G56-20 — Event-size interoperability is untested
|
||||
|
||||
**Severity:** Informational
|
||||
**Confidence:** Confirmed size; relay behavior contextual
|
||||
|
||||
A fixed-mnemonic unsigned kind 1 announcement was approximately 27,500 bytes before `id`/`sig`; the wrapper embeds that JSON and adds proof data. Relay limits vary, and the application’s default relays may reject large events. Existing tests do not publish/query a representative event or enforce a size budget.
|
||||
|
||||
**Remediation:** Test against target relay policies, report exact size before signer approval, select suitable relays, and consider a canonical compact binary or referenced commitment format while preserving independent availability.
|
||||
|
||||
---
|
||||
|
||||
## Cryptographic and protocol assessment
|
||||
|
||||
### Positive observations
|
||||
|
||||
- BIP39 generation uses the dependency’s CSPRNG-backed [`generateMnemonic()`](../../www/js/pq-crypto.mjs:76).
|
||||
- User entropy is mixed with fresh CSPRNG bytes through SHA-256 rather than replacing the CSPRNG at [`generateSeedPhraseWithEntropy()`](../../www/js/pq-crypto.mjs:91).
|
||||
- PQ derivation is deterministic and domain-separated by child paths at [`PQ_DERIVATION_PATHS`](../../www/js/pq-crypto.mjs:50).
|
||||
- The first-48-byte concatenation rule is explicit at [`derivePQSeedFromBIP32()`](../../www/js/pq-crypto.mjs:161).
|
||||
- Four PQ sign/verify round trips and tampering cases passed.
|
||||
- NIP-01 event serialization is conventional at [`computeEventId()`](../../www/js/pq-crypto.mjs:510).
|
||||
- Full-event hashing consistently uses `JSON.stringify` of the embedded signed object at [`hashFullEvent()`](../../www/js/pq-crypto.mjs:530), and the exact JSON string is embedded in the wrapper.
|
||||
- OTS target digest binding is enforced before Bitcoin-attestation processing at [`verifyOtsProof()`](../../www/js/pq-crypto.mjs:1300).
|
||||
- The known historical OTS proof verified against live API data, and pending proofs were rejected as Bitcoin-unconfirmed.
|
||||
|
||||
### Interoperability and design cautions
|
||||
|
||||
- There are no independent known-answer values for the BIP32 child private keys or resulting PQ public keys. Existing “determinism” tests compare the implementation with itself, not another implementation.
|
||||
- Deriving standardized PQ seeds directly from BIP32 secp scalars is bespoke and should receive cryptographic review. Rejection behavior in BIP32 means output is not simply an unconstrained PRF byte stream; HKDF with explicit labels may be easier to specify independently.
|
||||
- Falcon remains a draft/complex implementation choice. Multi-scheme hedging helps only if verification policy actually requires and distinguishes schemes.
|
||||
- The attestation signs human-readable content while keys live in tags. The secp signature binds tags, but each PQ signature does not bind its own algorithm identifier, public-key set, wrapper kind, or OTS digest. A canonical structured statement would make cross-client policy safer.
|
||||
- ML-KEM ownership is not proven by a KEM operation. The old secp event authorizes the public key at publication time, but no decapsulation proof establishes possession.
|
||||
- The security argument depends on long-term availability of an immutable announcement and proof. Replaceable events and relay retention are poor foundations without export/archive mechanisms.
|
||||
|
||||
---
|
||||
|
||||
## Browser and operational assessment
|
||||
|
||||
### Live deployment observations
|
||||
|
||||
At audit time:
|
||||
|
||||
- `https://laantungir.net/post-quantum/` and `verify.html` returned HTTP 200.
|
||||
- Live HTML content lengths matched local working-tree lengths.
|
||||
- Live bundle SHA-256 exactly matched local: `c14d40088d02e89f0d9aa8f1643ce31d687a4837a2eb0ab1b19406b69ecbfc32`.
|
||||
- The server exposed nginx `1.24.0 (Ubuntu)` and permissive `Access-Control-Allow-Origin: *`.
|
||||
- No HSTS, frame restriction, Referrer-Policy, Permissions-Policy, or other reviewed security headers appeared in HEAD responses.
|
||||
- `/ots-upgrade` rejected an empty POST with HTTP 400 and `{"error": "invalid body length"}`, which is a positive input check. Its implementation and broader limits remain unaudited.
|
||||
- Public mempool.space and Blockstream endpoints were reachable.
|
||||
|
||||
### State-machine concerns
|
||||
|
||||
- OTS polling can overlap if an upgrade request lasts longer than the 60-second interval; no in-flight lock exists at [`startOtsPolling()`](../../www/index.html:1636).
|
||||
- Sign-out calls `localStorage.clear()` and `sessionStorage.clear()` at [`index.html`](../../www/index.html:1490), which can delete unrelated same-origin application data.
|
||||
- The OTS helper response is accepted as arbitrary base64 proof bytes and then parsed/verified; this is acceptable only if parser budgets are fixed.
|
||||
- The verify page can offer republishing after OTS verification, but does not require the connected signer identity to equal the original wrapper identity before requesting a signature.
|
||||
- The relay query accepts the first `EVENT` without checking subscription ID or filter conformance at [`verify.html`](../../www/verify.html:493); a malicious relay can send arbitrary events, relying on later validation that is presently incomplete.
|
||||
|
||||
---
|
||||
|
||||
## Dependency and supply-chain assessment
|
||||
|
||||
### Root graph
|
||||
|
||||
Installed direct versions were:
|
||||
|
||||
- `@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
|
||||
- `esbuild` 0.28.1
|
||||
|
||||
All reported MIT licenses. `esbuild` has an install script/native platform package, which is normal but relevant to build trust. No root runtime advisory was reported by npm for the installed graph.
|
||||
|
||||
### Conclusions
|
||||
|
||||
- The committed browser bundle reduces runtime npm resolution risk for the deployed static page, and it rebuilt identically on the audit machine.
|
||||
- However, ignored/stale lock metadata means future builds are not reproducible.
|
||||
- The same-origin signer scripts are a major additional supply-chain surface not represented in [`package.json`](../../package.json:1).
|
||||
- The nested OpenTimestamps repository is a broken gitlink boundary without root submodule metadata.
|
||||
- No SBOM, signed release, SRI, or CI provenance connects the live assets to a reviewed revision.
|
||||
|
||||
---
|
||||
|
||||
## Test and execution evidence
|
||||
|
||||
### Environment
|
||||
|
||||
- Node: `v24.14.1`
|
||||
- npm: `11.11.0`
|
||||
- Root commit: `9a975fcbc56e455a68ae16d734feeed76ba304b6`
|
||||
- Nested OTS commit: `c07ba8be0dae4e8721a84f32820abf7b2547a6ce`
|
||||
|
||||
### Existing tests
|
||||
|
||||
Command: `npm test`
|
||||
|
||||
Result: **38 passed, 0 failed**, duration about 22.7 seconds.
|
||||
|
||||
Coverage included:
|
||||
|
||||
- BIP39 generation and validation
|
||||
- deterministic PQ key generation
|
||||
- four PQ sign/verify/tamper suites
|
||||
- basic NIP-01 ID handling
|
||||
- hex input checks
|
||||
- OTS fixture parsing
|
||||
- OTS target digest mismatch rejection
|
||||
- live block-explorer checks for historical Bitcoin block 358391
|
||||
|
||||
Important gaps:
|
||||
|
||||
- no fully valid kind 1 + wrapper construction/verification round trip,
|
||||
- no required-algorithm policy tests,
|
||||
- no malformed/fuzz tests,
|
||||
- no wrapper/embedded identity mismatch test,
|
||||
- no signer-return validation test,
|
||||
- no earliest-anchor candidate selection test,
|
||||
- no real browser/signer/relay end-to-end test,
|
||||
- no independent PQ derivation known-answer vectors,
|
||||
- no OTS calendar-fragment compatibility test in the committed suite.
|
||||
|
||||
### Build reproducibility
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
cp www/pq-crypto.bundle.js /tmp/post_quantum_nostr.bundle.before.js
|
||||
node build-pq-bundle.js
|
||||
cmp /tmp/post_quantum_nostr.bundle.before.js www/pq-crypto.bundle.js
|
||||
```
|
||||
|
||||
Result: **byte-for-byte identical**. Both SHA-256 values were:
|
||||
|
||||
`c14d40088d02e89f0d9aa8f1643ce31d687a4837a2eb0ab1b19406b69ecbfc32`
|
||||
|
||||
### Live OTS calendar submission
|
||||
|
||||
A synthetic 32-byte digest was submitted through [`timestampEvent()`](../../www/js/pq-crypto.mjs:862). All five configured calendars returned fragments. The merged detached proof was 1,078 bytes, parsed with the exact submitted target digest, and contained five pending branches. This validates current network reachability and basic merge/parser compatibility, not future Bitcoin confirmation or trustlessness.
|
||||
|
||||
### Adversarial probes
|
||||
|
||||
The following were executed against the module without modifying source:
|
||||
|
||||
- empty PQ algorithm set accepted as valid,
|
||||
- signature algorithms with omitted signatures accepted as KEM/no-signature entries,
|
||||
- unknown omitted-signature algorithm accepted,
|
||||
- outer/embedded identity mismatch accepted by separate checks,
|
||||
- missing event ID accepted if signature was valid,
|
||||
- malformed base64 and wrong-shaped tags caused exceptions.
|
||||
|
||||
These probes are the basis for G56-01, G56-03, G56-07, and G56-19.
|
||||
|
||||
### Audited file hashes
|
||||
|
||||
| File | SHA-256 |
|
||||
|---|---|
|
||||
| [`package.json`](../../package.json) | `63882474ff056e46bfcf9194f9a0eb8d56662f0042b3692b35c8e1b8d2ce62e3` |
|
||||
| local ignored [`package-lock.json`](../../package-lock.json) | `60cfaa1dce7de67763a8fae3695cc173f8910a3de7ca53890ae9269f4ead00b9` |
|
||||
| [`index.html`](../../www/index.html) | `0ae7436b621526662f8315b892ddc921c3e00b6dcac7e08c2d5dba1c7d75717b` |
|
||||
| [`verify.html`](../../www/verify.html) | `70069d0f3998864a411ce4a52269de9212b2121888bf40c93e3e71edd58494a3` |
|
||||
| [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs) | `241cb3fb2d4ef3b7b05c7aa1757c67361a4e0a1c809c3dbafe3153d84e3bd7e5` |
|
||||
| [`pq-crypto.bundle.js`](../../www/pq-crypto.bundle.js) | `c14d40088d02e89f0d9aa8f1643ce31d687a4837a2eb0ab1b19406b69ecbfc32` |
|
||||
| [`pq-crypto.test.mjs`](../../test/pq-crypto.test.mjs) | `107f9d04e4dd77e4f37ab203ace804c64bfa4d84e8334101586d322bdd311351` |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized remediation roadmap
|
||||
|
||||
### Release blocker: protocol validity
|
||||
|
||||
1. Fix G56-01 with a strict schema and mandatory algorithm policy.
|
||||
2. Fix G56-03 by binding all identities and requested authors.
|
||||
3. Implement G56-02 with immutable candidate discovery and earliest-valid-anchor selection.
|
||||
4. Validate signer output and exact event fields (G56-05).
|
||||
5. Make every verifier fail closed under malformed input and add budgets (G56-07/G56-13).
|
||||
|
||||
### Release blocker: security model
|
||||
|
||||
6. Decide and formally specify mnemonic-existing-identity versus raw-nsec migration modes (G56-06).
|
||||
7. Replace API-trusting “Bitcoin cryptographic verification” claims or implement a real node/light-client trust model (G56-04).
|
||||
8. Move seed/PQ private-key operations into a signer boundary; until then, label the web origin risk prominently (G56-08/G56-10).
|
||||
|
||||
### Operational hardening
|
||||
|
||||
9. Require relay acceptance for both events and verify read-back (G56-11).
|
||||
10. Persist immutable complete workflow artifacts with exact bindings (G56-12).
|
||||
11. Default to 24 words and restore warning/confirmation UX (G56-09).
|
||||
12. Enforce WSS and privacy-aware endpoint validation (G56-18).
|
||||
13. Add deployment headers, strict CSP, asset pinning, reviewed `/ots-upgrade` source, and release integrity.
|
||||
|
||||
### Reproducibility and assurance
|
||||
|
||||
14. Commit a correct lockfile and repair the OpenTimestamps submodule (G56-14/G56-15).
|
||||
15. Add independent derivation vectors, adversarial tests, browser integration tests, relay tests, and fuzzing.
|
||||
16. Reconcile all documentation and UI claims with actual behavior (G56-16).
|
||||
17. Obtain independent cryptographic/protocol review before users anchor irreversible identity claims.
|
||||
|
||||
---
|
||||
|
||||
## Safe-use recommendation
|
||||
|
||||
At its current stage, the project is suitable for:
|
||||
|
||||
- local experimentation with throwaway identities,
|
||||
- generating test vectors,
|
||||
- researching Nostr/PQ migration formats,
|
||||
- exercising OpenTimestamps parsing and API-assisted checks.
|
||||
|
||||
It is **not** suitable for:
|
||||
|
||||
- entering an existing valuable BIP39 mnemonic,
|
||||
- making irreversible migration claims for a real identity,
|
||||
- relying on the verifier’s green/success result,
|
||||
- asserting that an identity is now quantum-resistant,
|
||||
- treating the OTS result as independently validated Bitcoin consensus,
|
||||
- relying on relay discovery to recover the canonical earliest migration.
|
||||
|
||||
A prudent user should wait until the critical/high findings are fixed, the architectural invariants above are implemented, a new adversarial test suite passes, deployment/source provenance is reproducible, and an independent human cryptography/protocol audit validates the revised design.
|
||||
@@ -0,0 +1,539 @@
|
||||
# GPT-5.6 Audit Mitigation and Redesign Plan
|
||||
|
||||
**Companion to:** [`audit.md`](audit.md:1)
|
||||
**Purpose:** turn the current research prototype into a defensible post-quantum key pre-commitment/link system and define the additional work required for a complete Nostr migration.
|
||||
|
||||
## 1. Target outcome
|
||||
|
||||
The immediate target should **not** be marketed as “Nostr is now quantum-resistant.” The achievable first production milestone is:
|
||||
|
||||
> A client can prove that, before a verified Bitcoin block, an existing Nostr identity authorized and demonstrated possession of a canonical set of PQ keys; all clients deterministically resolve competing claims to the same earliest valid authorization; future PQ protocols can use that key set as their migration root.
|
||||
|
||||
A complete migration is a later milestone requiring standardized PQ-signed events, PQ encryption, identity mapping, revocation, rotation, recovery, and client adoption.
|
||||
|
||||
## 2. Non-negotiable security invariants
|
||||
|
||||
Implement these before addressing UI polish. Every design and test should trace back to at least one invariant.
|
||||
|
||||
| Invariant | Required property |
|
||||
|---|---|
|
||||
| I-01 Canonical authorization | All authenticators sign one versioned, domain-separated deterministic byte representation—not free-form prose or partially overlapping objects. |
|
||||
| I-02 Strict policy | Missing, duplicated, malformed, downgraded, or unknown evidence cannot accidentally count as successful evidence. |
|
||||
| I-03 Identity binding | Query author, wrapper author, embedded author, and old-identity field are equal. |
|
||||
| I-04 Immutable history | An attacker cannot hide the initial candidate by publishing a newer replaceable event. |
|
||||
| I-05 Earliest-anchor resolution | Every conforming client chooses the same earliest fully valid Bitcoin anchor under defined tie-break rules. |
|
||||
| I-06 PQ possession | Each PQ signature key proves possession; KEM ownership is either proven by a specified mechanism or described only as authorization. |
|
||||
| I-07 Post-break authority | After migration, secp-only claims cannot replace or revoke the canonical PQ root. |
|
||||
| I-08 Availability | The canonical authorization and proof can be exported, mirrored, and verified without the original website or one relay. |
|
||||
| I-09 Trust transparency | “Bitcoin verified,” “API checked,” and “structurally present” are separate states. |
|
||||
| I-10 Secure key boundary | Production seed derivation and PQ signing occur in a dedicated signer/hardware boundary where feasible. |
|
||||
|
||||
## 3. Recommended protocol redesign
|
||||
|
||||
This redesign should precede patching the existing two-event format, because several critical flaws arise from format semantics.
|
||||
|
||||
### 3.1 Define a canonical authorization object
|
||||
|
||||
Add a protocol module—for example, a new `www/js/nip-qr-schema.mjs` file—rather than embedding schema logic in [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1).
|
||||
|
||||
Use deterministic CBOR or an explicitly specified canonical JSON encoding. A conceptual structure is:
|
||||
|
||||
```text
|
||||
{
|
||||
protocol: "nostr-pq-link",
|
||||
version: 1,
|
||||
network: "nostr-main",
|
||||
old_pubkey: <32 bytes>,
|
||||
successor_pubkey: <optional 32-byte seed-derived Schnorr key>,
|
||||
created_at: <integer>,
|
||||
nonce: <32 random bytes>,
|
||||
keys: [
|
||||
{ algorithm: "ml-dsa-44", public_key: <bytes>, role: "sign" },
|
||||
{ algorithm: "ml-dsa-65", public_key: <bytes>, role: "sign" },
|
||||
{ algorithm: "slh-dsa-sha2-128s", public_key: <bytes>, role: "sign" },
|
||||
{ algorithm: "falcon-512", public_key: <bytes>, role: "sign" },
|
||||
{ algorithm: "ml-kem-768", public_key: <bytes>, role: "encrypt" }
|
||||
],
|
||||
policy: {
|
||||
required_signature_algorithms: [...],
|
||||
rotation_threshold: <defined rule>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Normative signing input:
|
||||
|
||||
```text
|
||||
UTF8("NOSTR-PQ-LINK\0") || protocol_version || canonical_encode(object)
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- Sort key entries by registered algorithm ID.
|
||||
- Reject duplicate algorithms.
|
||||
- Pin exact public-key and signature lengths by version.
|
||||
- PQ signatures sign the complete object, including all sibling keys and policy.
|
||||
- The old secp identity signs a Nostr event committing to the exact canonical object digest.
|
||||
- If common seed origin/recoverability is claimed, a seed-derived successor key signs the same object.
|
||||
- Human-readable content is generated from the object and is explicitly non-normative.
|
||||
|
||||
### 3.2 Separate immutable authorization from mutable proof transport
|
||||
|
||||
Do not make the canonical authorization only a replaceable kind 11112 event.
|
||||
|
||||
Recommended structure:
|
||||
|
||||
1. **Immutable authorization event:** a regular, non-replaceable event containing or committing to the canonical authorization and all possession proofs.
|
||||
2. **OTS proof-carrier event:** may be parameterized/replaceable by authorization ID, but every version must reference the same immutable authorization event ID and target digest.
|
||||
3. **Archive package:** downloadable canonical object, signed authorization event, OTS proof, and verification metadata.
|
||||
|
||||
If kind allocation is not standardized, use an experimental range and clearly version it. Do not overload kind 1 as the sole normative migration record merely for feed visibility; a separate optional kind 1 announcement can reference the immutable authorization.
|
||||
|
||||
### 3.3 Define canonical conflict resolution
|
||||
|
||||
Specify the algorithm before coding it:
|
||||
|
||||
1. Gather all immutable authorization candidates for `old_pubkey` from all configured relays, local archives, and user-provided packages.
|
||||
2. Validate schema, old secp signature, identity equality, PQ proofs, algorithm policy, and OTS target binding.
|
||||
3. Require a Bitcoin attestation with configured confirmation depth.
|
||||
4. Sort valid candidates by:
|
||||
- lowest verified Bitcoin block height,
|
||||
- then lowest authorization digest lexicographically for deterministic same-block ties.
|
||||
5. The first candidate becomes the genesis PQ root.
|
||||
6. Later rotation is valid only if authorized under the canonical root’s PQ rotation policy.
|
||||
7. A later secp-only authorization never supersedes the genesis root after the defined transition policy activates.
|
||||
|
||||
Document the limitation that a previously unknown earlier proof can change resolution. Mitigate this with archive discovery, checkpointing, and proof gossip. Consider defining a stabilization period before treating a candidate as final.
|
||||
|
||||
### 3.4 Define PQ rotation and future event authentication
|
||||
|
||||
A linked key with no use protocol is only a commitment. Create separate specifications for:
|
||||
|
||||
- PQ authorization envelopes for future Nostr events;
|
||||
- replay/domain separation and binding to the NIP-01 event ID;
|
||||
- key rotation and revocation thresholds;
|
||||
- algorithm deprecation/emergency removal;
|
||||
- recovery after seed loss;
|
||||
- PQ encryption and KEM key rotation;
|
||||
- how clients map the old npub to a successor key without splitting the social graph.
|
||||
|
||||
A practical compatibility design is a normal NIP-01 event plus a standardized tag or companion event containing a PQ signature over a domain-separated NIP-01 event ID. Relays can remain unaware initially; PQ-aware clients enforce the companion proof. This needs independent protocol review before adoption.
|
||||
|
||||
## 4. Staged remediation plan
|
||||
|
||||
## Phase 0 — Freeze unsafe production claims
|
||||
|
||||
**Goal:** prevent real-user harm while redesign proceeds.
|
||||
|
||||
1. Add a prominent research-prototype warning to [`index.html`](../../www/index.html:320) and [`verify.html`](../../www/verify.html:254).
|
||||
2. Replace “Make your Nostr identity quantum-resistant” with “Create an experimental pre-quantum PQ key commitment.”
|
||||
3. Disable or gate real relay publication behind an explicit experimental acknowledgement.
|
||||
4. Warn users never to enter a valuable existing mnemonic into the web page.
|
||||
5. Restore the 12-word PQ-strength warning and default new generation to 24 words.
|
||||
6. Change every success state to distinguish:
|
||||
- event signatures valid,
|
||||
- PQ policy sufficient,
|
||||
- OTS pending,
|
||||
- OTS API-checked,
|
||||
- canonical earliest candidate selected.
|
||||
7. Mark [`README.md`](../../README.md:1), [`nip_proposal.md`](../../nip_proposal.md:1), and research documents with implementation-status banners.
|
||||
|
||||
**Release gate:** the public deployment cannot imply complete quantum resistance.
|
||||
|
||||
## Phase 1 — Replace the verifier with strict schema/policy validation
|
||||
|
||||
**Findings addressed:** G56-01, G56-03, G56-07, G56-17, G56-19, part of G56-20.
|
||||
|
||||
### Implementation instructions
|
||||
|
||||
1. Create a schema validator that checks before cryptography:
|
||||
- object type and exact required fields,
|
||||
- integer ranges,
|
||||
- exact kind values,
|
||||
- arrays and tag element types,
|
||||
- exact lowercase hex lengths,
|
||||
- strict canonical base64 with decoded byte limits,
|
||||
- maximum content/event/proof sizes,
|
||||
- exact tag cardinality,
|
||||
- no duplicate recognized tags/algorithms.
|
||||
2. Refactor [`verifyNostrEvent()`](../../www/js/pq-crypto.mjs:400) to require `id`, `sig`, `pubkey`, `created_at`, `kind`, `tags`, and `content` by default. Return `{ valid, errors }`; do not throw on untrusted malformed input.
|
||||
3. Replace the fail-open branch at [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:683):
|
||||
- only `ml-kem-768` may omit a signature;
|
||||
- all signature algorithms require signatures;
|
||||
- unknown algorithms are `ignored` evidence, never successful evidence;
|
||||
- mandatory algorithms are evaluated after parsing all tags.
|
||||
4. Pass the full outer event and expected query author into the verifier. Require:
|
||||
- `outer.kind` is the expected wrapper kind,
|
||||
- `outer.pubkey === embedded.pubkey`,
|
||||
- `outer.pubkey === expectedAuthor` when supplied,
|
||||
- `e` and SHA-256 tags are unique and exact.
|
||||
5. Return a structured result:
|
||||
|
||||
```text
|
||||
{
|
||||
structurallyValid,
|
||||
secpValid,
|
||||
pqProofsValid,
|
||||
policySufficient,
|
||||
otsTargetBound,
|
||||
otsBitcoinStatus,
|
||||
canonicalSelectionStatus,
|
||||
validForMigration,
|
||||
errors,
|
||||
warnings
|
||||
}
|
||||
```
|
||||
|
||||
6. Make [`verify.html`](../../www/verify.html:530) display these separate states and never infer overall migration validity from `results.every(...)`.
|
||||
7. Validate the claimed block height against the verified OTS height or remove it from the normative authorization.
|
||||
|
||||
### Required tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- zero algorithm tags,
|
||||
- each missing mandatory algorithm,
|
||||
- missing signature on every signature algorithm,
|
||||
- arbitrary unknown no-signature algorithm,
|
||||
- duplicate algorithm tags,
|
||||
- duplicate `e`, SHA-256, and OTS tags in both orders,
|
||||
- wrong decoded key/signature lengths,
|
||||
- malformed base64/hex,
|
||||
- null/wrong-shaped objects,
|
||||
- missing event ID,
|
||||
- wrong wrapper kind,
|
||||
- wrapper/embedded/query-author mismatch,
|
||||
- oversized event and proof,
|
||||
- valid optional unknown algorithm ignored without policy credit,
|
||||
- exact valid fixture passing all layers.
|
||||
|
||||
**Acceptance criterion:** malformed input never throws, and no event without the full configured PQ policy can be labeled migration-valid.
|
||||
|
||||
## Phase 2 — Implement immutable candidate discovery and earliest-anchor selection
|
||||
|
||||
**Findings addressed:** G56-02, G56-12, G56-16.
|
||||
|
||||
### Implementation instructions
|
||||
|
||||
1. Replace `limit: 1` queries at [`verify.html`](../../www/verify.html:487) and [`index.html`](../../www/index.html:1201) with candidate collection until EOSE from each relay.
|
||||
2. Do not select by `created_at`.
|
||||
3. Verify every candidate independently and retain relay provenance.
|
||||
4. Introduce a pure function such as:
|
||||
|
||||
```text
|
||||
selectCanonicalAuthorization(candidates, policy)
|
||||
```
|
||||
|
||||
It must sort only fully valid confirmed candidates by verified block height and deterministic digest tie-break.
|
||||
5. Store immutable complete artifacts locally:
|
||||
- old identity,
|
||||
- authorization event JSON/bytes,
|
||||
- canonical authorization digest,
|
||||
- OTS target digest,
|
||||
- pending/confirmed proof,
|
||||
- proof-carrier event(s),
|
||||
- relay receipts.
|
||||
6. Replace the overloaded `eventId` metadata in [`savePendingOts()`](../../www/js/pq-crypto.mjs:1362) with explicit `authorizationEventId`, `wrapperEventId`, and `targetDigest`.
|
||||
7. On resume, require every persisted/fetched binding to match before signing or publishing.
|
||||
8. Add export/import of a self-contained archive package.
|
||||
|
||||
### Required tests
|
||||
|
||||
- later secp-valid forged candidate loses to earlier valid anchor,
|
||||
- earlier invalid PQ candidate is rejected,
|
||||
- earlier proof with wrong target is rejected,
|
||||
- same-block tie resolves identically regardless of relay order,
|
||||
- malicious relay sends events outside subscription/filter,
|
||||
- one relay hides the earlier candidate but another supplies it,
|
||||
- resume rejects a different event ID/digest/owner,
|
||||
- upgraded proof preserves immutable authorization target.
|
||||
|
||||
**Acceptance criterion:** candidate input ordering and relay response ordering cannot change the selected canonical root.
|
||||
|
||||
## Phase 3 — Validate signer output and bind seed continuity
|
||||
|
||||
**Findings addressed:** G56-05, G56-06, G56-09, G56-10.
|
||||
|
||||
### Implementation instructions
|
||||
|
||||
1. Add a single signer-validation helper used after every [`window.nostr.signEvent()`](../../www/index.html:1078) call:
|
||||
- verify returned object has exact fields,
|
||||
- verify no approved template field changed,
|
||||
- verify returned pubkey equals authenticated identity,
|
||||
- recompute ID,
|
||||
- verify Schnorr signature,
|
||||
- reject extra/missing semantic fields.
|
||||
2. Never reconstruct an event from a mixture of signer-returned and local fields. Validate and retain one exact signed object.
|
||||
3. Split onboarding into explicit modes:
|
||||
- **Existing mnemonic identity:** entered seed must derive the connected NIP-06 pubkey; otherwise stop.
|
||||
- **Raw-nsec identity:** generate a new 24-word seed and require a successor proof over the canonical authorization.
|
||||
4. Run a recovery self-test before publication: discard derived objects, rederive from mnemonic, and compare all public keys/deterministic identifiers.
|
||||
5. Prefer moving derivation/signing into a dedicated signer API. Define capabilities such as:
|
||||
- `pq_get_public_keys(protocolVersion, account)`,
|
||||
- `pq_sign_authorization(protocolVersion, digest, algorithms)`,
|
||||
- `pq_rotate_keys(...)`.
|
||||
6. Until signer support exists:
|
||||
- scope secrets locally rather than globals,
|
||||
- do not derive the unused secp key except for continuity/proof,
|
||||
- overwrite secret typed arrays where possible,
|
||||
- clear seed DOM/input immediately after confirmation,
|
||||
- avoid clipboard as the primary backup path,
|
||||
- warn about extensions/devtools/clipboard managers.
|
||||
|
||||
### Required tests
|
||||
|
||||
- signer changes content/tag/timestamp/pubkey,
|
||||
- signer returns wrong ID,
|
||||
- signer returns invalid signature,
|
||||
- connected pubkey changes between requests,
|
||||
- existing mnemonic derives wrong identity,
|
||||
- raw-nsec successor proof missing/invalid,
|
||||
- recovery rederivation mismatch,
|
||||
- cancellation at each signer prompt leaves no publishable partial state.
|
||||
|
||||
**Acceptance criterion:** nothing is persisted or published unless the exact signer-returned object is locally valid and bound to the intended identity/template.
|
||||
|
||||
## Phase 4 — Make OTS and Bitcoin verification trust-explicit and robust
|
||||
|
||||
**Findings addressed:** G56-04, G56-13, G56-17, part of G56-08.
|
||||
|
||||
### Parser hardening
|
||||
|
||||
In [`OtsReader`](../../www/js/pq-crypto.mjs:1014) and [`parseOtsFile()`](../../www/js/pq-crypto.mjs:1078):
|
||||
|
||||
1. Set maximum detached-proof size, for example 1 MiB unless evidence supports another bound.
|
||||
2. Track and cap:
|
||||
- recursion depth,
|
||||
- operation count,
|
||||
- branches,
|
||||
- cumulative append/prepend bytes,
|
||||
- cumulative hash input bytes,
|
||||
- attestations.
|
||||
3. Replace 32-bit bitwise varuint parsing at [`readVaruint()`](../../www/js/pq-crypto.mjs:1032) with safe arithmetic/`BigInt` plus maximum value and canonical-encoding checks.
|
||||
4. Require complete stream consumption.
|
||||
5. Reject unsupported operation/hash types explicitly.
|
||||
6. Fuzz with random, truncated, deeply branched, and overlong inputs.
|
||||
|
||||
### Bitcoin trust modes
|
||||
|
||||
Expose one of these explicit modes:
|
||||
|
||||
- `local-node-verified`: query a user-controlled Bitcoin node and validate block data;
|
||||
- `header-chain-verified`: validate raw headers, PoW, linkage, difficulty, chainwork, and checkpoints;
|
||||
- `multi-explorer-checked`: compare independent APIs and reject disagreement;
|
||||
- `single-explorer-checked`: current behavior, clearly labeled trusted API;
|
||||
- `structural-only`: no Bitcoin validity claim.
|
||||
|
||||
Do not use “cryptographically verified on Bitcoin” for explorer-only modes. Modify [`verifyOtsProof()`](../../www/js/pq-crypto.mjs:1284) to return the trust mode and evidence source.
|
||||
|
||||
### Upgrade helper
|
||||
|
||||
1. Add the `/ots-upgrade` server source and deployment config to the repository.
|
||||
2. Enforce body size, content type, timeouts, process isolation, rate limits, no arbitrary URI access, and sanitized output.
|
||||
3. The browser must still bind and validate returned proofs; never trust the helper’s `confirmed` boolean alone.
|
||||
4. Add server unit/integration tests and deployment documentation.
|
||||
|
||||
**Acceptance criterion:** the UI accurately states the Bitcoin trust model, malicious proofs stay within resource budgets, and a helper/API compromise cannot validate a proof for the wrong digest.
|
||||
|
||||
## Phase 5 — Fix publishing, relay, and recovery operations
|
||||
|
||||
**Findings addressed:** G56-11, G56-12, G56-18, G56-20.
|
||||
|
||||
### Implementation instructions
|
||||
|
||||
1. Validate relay URLs with `URL`; allow only `wss:` in production and `ws://localhost` under explicit development mode.
|
||||
2. Cap relay count, URL length, event size, and concurrent sockets.
|
||||
3. Require relay `OK` acceptance for both immutable authorization and proof carrier.
|
||||
4. Set a publication policy, for example:
|
||||
- at least two independent relays accept the authorization,
|
||||
- at least two accept the proof carrier,
|
||||
- at least one read-back query returns each exact event.
|
||||
5. Keep retry controls after partial failure; never disable publication as “complete” with zero acceptance.
|
||||
6. Bind subscription IDs and verify returned events match requested authors/kinds.
|
||||
7. Test representative 30–50 KiB events against target relays and document limits.
|
||||
8. Export the archive package before/after publish and encourage independent storage.
|
||||
9. Add multi-tab locking using `BroadcastChannel` or Web Locks so two tabs cannot race upgrades/publication.
|
||||
10. Prevent overlapping OTS polls with an in-flight guard or recursive timeout.
|
||||
11. Replace origin-wide `localStorage.clear()`/`sessionStorage.clear()` with deletion of application-owned keys only.
|
||||
|
||||
**Acceptance criterion:** the app cannot report completion without durable, read-back-confirmed publication and a user-exportable verification package.
|
||||
|
||||
## Phase 6 — Harden the browser deployment and supply chain
|
||||
|
||||
**Findings addressed:** G56-08, G56-10, G56-14, G56-15.
|
||||
|
||||
### Application/deployment instructions
|
||||
|
||||
1. Move inline JavaScript from [`index.html`](../../www/index.html:580) and [`verify.html`](../../www/verify.html:353) into reviewed module files.
|
||||
2. Enforce a CSP without script `'unsafe-inline'`; use hashes/nonces only where unavoidable.
|
||||
3. Add:
|
||||
- `frame-ancestors 'none'`,
|
||||
- HSTS with an appropriate rollout,
|
||||
- `X-Content-Type-Options: nosniff`,
|
||||
- strict Referrer-Policy,
|
||||
- restrictive Permissions-Policy,
|
||||
- appropriate COOP/CORP where compatible.
|
||||
4. Vendor and audit nostr-login-lite assets or pin reviewed immutable files with release hashes/SRI.
|
||||
5. Remove unnecessary permissive CORS headers from static pages/helper responses.
|
||||
6. Stop exposing avoidable server version information.
|
||||
7. Add deployment configuration and a security-header integration test.
|
||||
|
||||
### Dependency instructions
|
||||
|
||||
1. Remove [`package-lock.json`](../../package-lock.json) from [`.gitignore`](../../.gitignore:10), regenerate it so root version matches [`package.json`](../../package.json:3), and commit it.
|
||||
2. Use `npm ci` in CI/releases.
|
||||
3. Pin security-sensitive dependencies and review upgrades deliberately.
|
||||
4. Generate an SBOM and signed provenance with bundle SHA-256.
|
||||
5. Repair `.gitmodules` for [`resources/javascript-opentimestamps/`](../../resources/javascript-opentimestamps/) or replace the broken gitlink with a documented immutable test-vector snapshot.
|
||||
6. Run tests and advisories in CI; do not claim the old OTS package is integrated when it is only a fixture source.
|
||||
|
||||
**Acceptance criterion:** a clean clone can reproduce the bundle and tests from tracked metadata, and the deployed asset hashes can be tied to a source revision.
|
||||
|
||||
## Phase 7 — Documentation and protocol standardization
|
||||
|
||||
**Findings addressed:** G56-16 and conceptual completeness.
|
||||
|
||||
1. Rewrite [`nip_proposal.md`](../../nip_proposal.md:1) around the canonical authorization, immutable history, exact verification state machine, trust modes, and rotation semantics.
|
||||
2. Clearly separate:
|
||||
- implemented,
|
||||
- experimental,
|
||||
- design-only,
|
||||
- required companion standards.
|
||||
3. Replace “identity is quantum-resistant” with precise claims about linked keys and anchor status.
|
||||
4. Do not claim pending calendar proofs establish an external time.
|
||||
5. Publish test vectors:
|
||||
- canonical object bytes/digest,
|
||||
- BIP39/BIP32 child seeds,
|
||||
- PQ public keys/signatures,
|
||||
- valid Nostr authorization event,
|
||||
- pending and confirmed OTS proof,
|
||||
- candidate conflict-resolution examples.
|
||||
6. Obtain at least two independent implementations for canonical encoding and verification before standardization.
|
||||
7. Seek independent review from Nostr protocol experts, Bitcoin/OTS experts, and PQ cryptographers.
|
||||
|
||||
**Acceptance criterion:** two independent implementations produce identical vectors and canonical candidate selection.
|
||||
|
||||
## 5. Finding-to-work mapping
|
||||
|
||||
| Finding | Primary mitigation phase | Release-blocking? |
|
||||
|---|---|---|
|
||||
| G56-01 | Phase 1 | Yes |
|
||||
| G56-02 | Phase 2 / protocol redesign | Yes |
|
||||
| G56-03 | Phase 1 | Yes |
|
||||
| G56-04 | Phase 4 | Yes for strong Bitcoin claims |
|
||||
| G56-05 | Phase 3 | Yes |
|
||||
| G56-06 | Protocol redesign / Phase 3 | Yes |
|
||||
| G56-07 | Phase 1 | Yes |
|
||||
| G56-08 | Phase 0 and Phase 6 | Yes for real seeds |
|
||||
| G56-09 | Phase 0 / Phase 3 | Yes for PQ-strength claim |
|
||||
| G56-10 | Phase 3 / Phase 6 | Yes for production web custody |
|
||||
| G56-11 | Phase 5 | Yes |
|
||||
| G56-12 | Phase 2 / Phase 5 | Yes |
|
||||
| G56-13 | Phase 4 | Yes for untrusted proofs |
|
||||
| G56-14 | Phase 6 | Yes for reproducible release |
|
||||
| G56-15 | Phase 6 | Required for clean clone/tests |
|
||||
| G56-16 | Phase 0 / Phase 7 | Yes for public claims |
|
||||
| G56-17 | Phase 1 / Phase 4 | No if removed from claims |
|
||||
| G56-18 | Phase 5 | No for local research; yes for production |
|
||||
| G56-19 | Phase 1 | Yes |
|
||||
| G56-20 | Phase 5 / protocol redesign | Yes if target relays reject events |
|
||||
|
||||
## 6. Test strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
- canonical encoding and rejection of non-canonical forms,
|
||||
- strict event/schema validation,
|
||||
- exact algorithm policy,
|
||||
- signer-return validation,
|
||||
- candidate sorting/tie-breaks,
|
||||
- persistence binding,
|
||||
- OTS parser limits and varints,
|
||||
- trust-mode labeling.
|
||||
|
||||
### Property and fuzz tests
|
||||
|
||||
- arbitrary JSON/tag shapes never throw,
|
||||
- arbitrary OTS bytes stay within CPU/memory/time budgets,
|
||||
- candidate ordering does not alter selection,
|
||||
- encode/decode round trips preserve canonical bytes,
|
||||
- mutation of any signed canonical field invalidates all applicable proofs.
|
||||
|
||||
### Integration tests
|
||||
|
||||
- mock signer approves/cancels/mutates events,
|
||||
- mock relays reorder, hide, duplicate, reject, and inject events,
|
||||
- mock calendars partially fail and return malformed fragments,
|
||||
- mock upgrade helper returns wrong-target or oversized proofs,
|
||||
- explorer disagreement and outage behavior,
|
||||
- resume after every workflow checkpoint,
|
||||
- multiple browser tabs.
|
||||
|
||||
### End-to-end tests
|
||||
|
||||
Use throwaway identities against controlled relays:
|
||||
|
||||
1. create 24-word seed,
|
||||
2. derive and recover keys,
|
||||
3. sign canonical authorization,
|
||||
4. publish and read back,
|
||||
5. timestamp and upgrade,
|
||||
6. verify through each trust mode,
|
||||
7. inject a later forged candidate,
|
||||
8. confirm earliest valid anchor remains canonical,
|
||||
9. rotate using PQ authorization,
|
||||
10. verify archive import with the website offline.
|
||||
|
||||
### Independent vectors
|
||||
|
||||
At least one second implementation in another language must reproduce the canonical encoding, derivation, signatures where deterministic, event IDs, and selection result. Self-comparison is insufficient.
|
||||
|
||||
## 7. Release gates
|
||||
|
||||
### Gate A — Research demo
|
||||
|
||||
- Phase 0 warnings complete.
|
||||
- No real-security claims.
|
||||
- Existing and adversarial tests pass.
|
||||
- Throwaway identities only.
|
||||
|
||||
### Gate B — Experimental key commitment
|
||||
|
||||
- Protocol redesign, Phases 1–5 complete.
|
||||
- Strict verification and earliest-anchor selection independently tested.
|
||||
- Archive export/import works.
|
||||
- Explorer trust is labeled accurately.
|
||||
- No valuable mnemonic entry into the web origin.
|
||||
|
||||
### Gate C — Production pre-commitment registry
|
||||
|
||||
- Dedicated signer/hardware integration.
|
||||
- Phase 6 reproducibility/deployment hardening.
|
||||
- Independent implementation and security audit.
|
||||
- Kind/format coordination with Nostr community.
|
||||
- Durable relay/archive strategy.
|
||||
|
||||
### Gate D — Complete PQ migration
|
||||
|
||||
All Gate C requirements plus standardized and deployed:
|
||||
|
||||
- PQ authentication for future events,
|
||||
- PQ key rotation/revocation/recovery,
|
||||
- client identity mapping/social-graph behavior,
|
||||
- PQ encryption protocol,
|
||||
- algorithm deprecation governance,
|
||||
- broad client adoption.
|
||||
|
||||
Only at Gate D should the project claim to make ongoing Nostr identity use post-quantum secure.
|
||||
|
||||
## 8. Conceptual conclusion
|
||||
|
||||
The project is **not doomed by its premise**. Pre-quantum authorization plus independently ordered timestamping is a reasonable bootstrap for migration away from a signature algorithm that may later become forgeable. Multi-algorithm commitments can provide useful hedging.
|
||||
|
||||
The original implementation does, however, conflate three different achievements:
|
||||
|
||||
1. generating PQ keys,
|
||||
2. historically linking those keys to an old identity,
|
||||
3. making the identity’s future Nostr activity post-quantum secure.
|
||||
|
||||
Only the first is currently strong; the second is incomplete; the third requires companion protocols. Implementing this document would make the second defensible and create a valid foundation for the third. It would not eliminate the need to standardize and deploy future PQ event, rotation, encryption, availability, and client identity semantics.
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
# NIP-QR: Post-Quantum Key-Link Attestations
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
This NIP defines a way for a Nostr user to **link** their existing secp256k1 identity to one or more post-quantum (PQ) public keys, **without** changing how events are signed, **without** requiring the network to agree on a single PQ algorithm, and **without** users abandoning their identity or social graph.
|
||||
|
||||
It is intentionally additive. It does not modify NIP-01's event format, signature scheme, or relay behavior. Legacy clients continue to work unchanged; PQ-aware clients gain the ability to verify a pre-quantum, Bitcoin-anchored link between an identity and a set of PQ keys.
|
||||
|
||||
## Motivation
|
||||
|
||||
### The threat
|
||||
|
||||
Nostr's identity, authentication, and encryption (NIP-04, NIP-44) all rest on secp256k1. Shor's algorithm breaks secp256k1 in polynomial time on a sufficiently large fault-tolerant quantum computer. An attacker who has recorded any Nostr event can recover the private key, forge signatures, impersonate the user, and decrypt every NIP-04/NIP-44 message ever sent to them. NIP-44 itself states: "No post-quantum security."
|
||||
|
||||
This includes a **collect-now-decrypt-later** threat: encrypted messages on public relays can be stored today and decrypted once a quantum computer exists. The symmetric ciphers are quantum-safe; the secp256k1 ECDH key agreement is not.
|
||||
|
||||
### Why Bitcoin's hash-the-pubkey trick does not help Nostr
|
||||
|
||||
Bitcoin has partial quantum resistance because addresses are hashes of pubkeys and pubkeys are only revealed at spend time. Nostr cannot do this: the pubkey *is* the identity, it must be revealed on every event for Schnorr verification, and it is reused across thousands of events over years. Hashing the pubkey would give zero seconds of quantum resistance — the pubkey is revealed the moment the first event is published.
|
||||
|
||||
The fix is post-quantum signatures and post-quantum key agreement, not hashing the pubkey. This NIP describes how to *link* PQ keys to an existing identity so that the link is established **now**, while secp256k1 can still be trusted, and is later verifiable even after secp256k1 is broken.
|
||||
|
||||
### Why a link, not a swap
|
||||
|
||||
Replacing secp256k1 event signatures with PQ signatures (as proposed elsewhere) requires the whole network to agree on an algorithm, change relay validation, and re-onboard every user. This NIP takes a different approach: the user publishes a **one-time attestation** that links their secp256k1 identity to a set of PQ keys. The attestation is signed by the existing identity (valid now) and by each PQ signature scheme (valid forever). It is anchored to the Bitcoin blockchain via OpenTimestamps (NIP-03) so it cannot be backdated.
|
||||
|
||||
After a quantum break, an attacker who forges the secp256k1 key can publish a *fraudulent* link event, but they cannot produce an OpenTimestamps proof anchoring it to a Bitcoin block earlier than the real one. The real link is cryptographically distinguishable by its earlier Bitcoin anchor.
|
||||
|
||||
### Why multiple schemes
|
||||
|
||||
The PQ standardization landscape is still young. SIKE was a NIST finalist broken in 2022 by a classical algorithm that ran in an hour on a laptop. Picking a single PQ algorithm now is risky. This NIP allows a user to link to **multiple** PQ schemes simultaneously. If one is later broken, the others remain valid. Clients verify whichever subset they support. No community-wide algorithm consensus is required to proceed.
|
||||
|
||||
## Design summary
|
||||
|
||||
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 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.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SEED[BIP39 seed - quantum-safe root of trust] --> 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 --> K9999[Kind 9999 proof carrier - carries OTS proof]
|
||||
K9999 --> RELAY[Published to relays]
|
||||
```
|
||||
|
||||
## PQ key derivation from a BIP39 seed
|
||||
|
||||
All PQ keys are derived from a BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard NIP-06 uses for secp256k1 keys. PQ keys live at fixed child indices under the NIP-06 base path.
|
||||
|
||||
**Base path:** `m/44'/1237'/0'/0/` (NIP-06 account 0, change 0)
|
||||
|
||||
| Child index/indices | Algorithm | Seed length needed | BIP32 path | Derivation |
|
||||
|---|---|---|---|---|
|
||||
| 0 | secp256k1 (NIP-06) | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32; private key used directly |
|
||||
| 1 | ML-DSA-44 | 32 bytes | `m/44'/1237'/0'/0/1` | Single child; 32-byte private key is the PQ seed |
|
||||
| 2 | ML-DSA-65 | 32 bytes | `m/44'/1237'/0'/0/2` | Single child; 32-byte private key is the PQ seed |
|
||||
| 3 + 4 | SLH-DSA-128s | 48 bytes | `m/44'/1237'/0'/0/3` + `m/44'/1237'/0'/0/4` | Two children concatenated (64 bytes), first 48 used |
|
||||
| 5 + 6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
|
||||
| 7 + 8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
|
||||
|
||||
### Concatenation and truncation rule (normative)
|
||||
|
||||
BIP32 child derivation produces exactly 32 bytes per child. Some PQ algorithms need more than 32 bytes for their `keygen()` seed:
|
||||
|
||||
- For a 48-byte seed, derive **two** children, concatenate them in **child-index order** (lower index first) to produce 64 bytes, and use the **first 48 bytes**.
|
||||
- For a 64-byte seed, derive **two** children, concatenate them in **child-index order**, and use all 64 bytes.
|
||||
|
||||
Implementations MUST use the first N bytes after concatenation in ascending child-index order. A future implementer who takes the *last* 48 bytes, or concatenates in the opposite order, will produce different keys and break seed-phrase recoverability. This rule is pinned here precisely so that two independent implementations produce the same PQ keys from the same seed.
|
||||
|
||||
### Why BIP32 and not HKDF
|
||||
|
||||
BIP32 is the HD-wallet standard already used by NIP-06. Using BIP32 paths for PQ keys means:
|
||||
|
||||
- Consistency with NIP-06 — PQ keys are derived the same way as secp256k1 keys, at different child indices.
|
||||
- Wallet compatibility — HD wallet infrastructure (hardware wallets, Amber-style signers, bunkers) can derive these same paths.
|
||||
- Compartmentalization — hardened derivation at the account level means breaking one leaf key does not compromise siblings; the parent chain code is a secret symmetric value that a quantum computer cannot recover from a leaf public key.
|
||||
|
||||
### Seed phrase entropy
|
||||
|
||||
A 12-word BIP39 mnemonic carries 128 bits of entropy (~64 bits of post-quantum security under Grover's quadratic speedup). A 24-word mnemonic carries 256 bits (~128 bits PQ). Implementations SHOULD support 24-word mnemonics for users concerned about quantum attacks on the seed entropy itself. The dominant risk to seed phrases remains classical (theft, phishing), not quantum.
|
||||
|
||||
## The kind 1 announcement event
|
||||
|
||||
A regular Nostr text note (kind 1) that serves as a public, human-readable attestation. It is signed with the user's **existing** secp256k1 identity (the attesting identity), via NIP-07 (`window.nostr.signEvent`) or any conforming signer.
|
||||
|
||||
### Content
|
||||
|
||||
The `content` is a human-readable attestation statement. The exact wording is not normative, but it MUST include the attesting identity's npub and hex pubkey, the Bitcoin block height at signing time, and a list of the PQ algorithms whose keys appear in the tags. Each PQ signature scheme signs `TextEncoder.encode(content)` — the UTF-8 bytes of the content string.
|
||||
|
||||
Example content:
|
||||
|
||||
```
|
||||
I am signaling that the post-quantum public keys listed in the tags of this event
|
||||
were generated by me and I hold the private keys. I may use these keys in the future
|
||||
as successors to my current Nostr identity.
|
||||
|
||||
My current identity:
|
||||
npub: <npub>
|
||||
hex: <hex pubkey>
|
||||
|
||||
This attestation is established pre-quantum at Bitcoin block height <height>.
|
||||
|
||||
Post-quantum public keys in tags:
|
||||
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
|
||||
ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3)
|
||||
SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1)
|
||||
Falcon-512 (FIPS 206 draft, NIST Level 1)
|
||||
ML-KEM-768 (Kyber, FIPS 203, NIST Level 3)
|
||||
|
||||
Each post-quantum key has cryptographically signed this attestation. This event is
|
||||
pending timestamp on the Bitcoin blockchain via OpenTimestamps.
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
- `["block_height", "<height>"]` — the Bitcoin block height at signing time, as a decimal string.
|
||||
- `["algorithm", "<algorithm-id>", "<base64 pubkey>", "<base64 signature>"]` — one tag per PQ **signature** scheme. The signature is over `TextEncoder.encode(content)`.
|
||||
- `["algorithm", "ml-kem-768", "<base64 pubkey>"]` — for ML-KEM-768. ML-KEM is a KEM, not a signature scheme, so it has no signature field. Its ownership is asserted by the attestation text and authorized by the attesting identity's secp256k1 signature over the kind 1 event (which covers the tags, including the ML-KEM pubkey).
|
||||
|
||||
### Algorithm identifiers
|
||||
|
||||
The following `algorithm-id` strings are defined by this NIP:
|
||||
|
||||
| `algorithm-id` | FIPS | Type | Public key size | Signature size |
|
||||
|---|---|---|---|---|
|
||||
| `ml-dsa-44` | 204 | Signature | 1312 bytes | 2420 bytes |
|
||||
| `ml-dsa-65` | 204 | Signature | 1952 bytes | 3309 bytes |
|
||||
| `slh-dsa-128s` | 205 | Signature | 32 bytes | 7856 bytes |
|
||||
| `falcon-512` | 206 (draft) | Signature | 897 bytes | ~666 bytes |
|
||||
| `ml-kem-768` | 203 | KEM | 1184 bytes | n/a (ciphertext 1088 bytes) |
|
||||
|
||||
Future NIPs may register additional `algorithm-id` values and additional BIP32 child indices. Implementations MUST ignore `algorithm` tags whose `algorithm-id` they do not recognize.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1,
|
||||
"pubkey": "<attesting identity secp256k1 pubkey, hex>",
|
||||
"content": "I am signaling that the post-quantum public keys ...",
|
||||
"tags": [
|
||||
["block_height", "840000"],
|
||||
["algorithm", "ml-dsa-44", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "ml-dsa-65", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "slh-dsa-128s", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "falcon-512", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "ml-kem-768", "<base64 pubkey>"]
|
||||
],
|
||||
"sig": "<attesting identity Schnorr signature>"
|
||||
}
|
||||
```
|
||||
|
||||
## The kind 9999 proof carrier event
|
||||
|
||||
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.
|
||||
|
||||
### Content
|
||||
|
||||
`JSON.stringify(kind1Event)` — the full signed kind 1 event embedded as a JSON string. This lets verifiers validate the kind 1 event, its PQ signatures, and its secp256k1 signature without fetching it from relays.
|
||||
|
||||
### Tags
|
||||
|
||||
- `["e", "<kind 1 event id>"]` — reference to the kind 1 announcement.
|
||||
- `["sha256", "<hex SHA-256 of the full signed kind 1 event JSON>"]` — the digest that was submitted to OpenTimestamps.
|
||||
- `["ots", "<base64 .ots proof>"]` — the OpenTimestamps proof (pending or confirmed).
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 9999,
|
||||
"pubkey": "<attesting identity secp256k1 pubkey, hex>",
|
||||
"content": "<JSON string of the full signed kind 1 event>",
|
||||
"tags": [
|
||||
["e", "<kind 1 event id>"],
|
||||
["sha256", "<hex SHA-256 of full signed kind 1 event JSON>"],
|
||||
["ots", "<base64 .ots proof>"]
|
||||
],
|
||||
"sig": "<attesting identity Schnorr signature>"
|
||||
}
|
||||
```
|
||||
|
||||
## OpenTimestamps anchoring
|
||||
|
||||
The kind 1 announcement MUST be timestamped via OpenTimestamps (NIP-03). The digest submitted to the calendar servers is the **SHA-256 of the full signed kind 1 event JSON** — i.e. `sha256(JSON.stringify(kind1Event))`, where the serialized event includes its `id` and `sig` fields. This digest is recorded in the wrapper's `sha256` tag, and the resulting `.ots` proof is embedded in the wrapper's `ots` tag.
|
||||
|
||||
The wrapper *carries* the proof; the kind 1 event *is* what is timestamped.
|
||||
|
||||
### Why the kind 1 event must be anchored
|
||||
|
||||
Without an OTS anchor on the key-link event, a future quantum attacker who breaks the secp256k1 key can publish a fraudulent key-link event linking the identity to the attacker's PQ keys. Both the real and fraudulent events would have valid secp256k1 signatures (the key is compromised), and `created_at` is forgeable, so clients could not distinguish them.
|
||||
|
||||
With an OTS anchor, the real event is committed to a specific Bitcoin block before quantum computers exist. A fraudulent event published later cannot produce an OTS proof for that block or any earlier block — doing so would require re-mining historical Bitcoin blocks (infeasible even with a quantum computer, due to cumulative proof-of-work) or finding a SHA-256 preimage (still infeasible under Grover's quadratic speedup on 256-bit hashes).
|
||||
|
||||
### Verification rule: earliest valid anchor wins
|
||||
|
||||
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.
|
||||
|
||||
### Client-side verification
|
||||
|
||||
Clients SHOULD perform full client-side OTS verification: parse the proof, walk the op tree, validate the Merkle path, and check the attested Merkle root against the actual Bitcoin block header for the attested height. A client that cannot perform full verification MAY fall back to checking that a Bitcoin attestation is structurally present in the proof, but MUST label such a link as unverified.
|
||||
|
||||
## Client behavior
|
||||
|
||||
### PQ-aware clients
|
||||
|
||||
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 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.
|
||||
6. Records the link: attesting identity → {set of PQ pubkeys}, anchored at the verified Bitcoin block height.
|
||||
7. If multiple wrappers exist for the same identity, applies the earliest-valid-anchor rule.
|
||||
|
||||
After a quantum break of secp256k1, the client trusts the PQ keys from the earliest-anchored link for any future PQ-signed events or PQ-encrypted messages from that identity.
|
||||
|
||||
### Legacy clients
|
||||
|
||||
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 9999 events, valid pre-quantum.
|
||||
- The link existed at a specific pre-quantum Bitcoin block. Verified by the OTS proof.
|
||||
|
||||
**It does not prove:**
|
||||
|
||||
- The PQ keys share a common seed origin. Common-origin is *asserted* in the attestation text and *authorized* by the attesting identity's signature, but it is not proven by a seed-derived signature. The security model relies on OTS precedence, not on a cryptographic proof of common origin.
|
||||
- The attesting identity still controls the PQ private keys. The link only proves the keys existed and were authorized at anchor time.
|
||||
|
||||
This is a deliberate design choice. The threat model is backdating of fraudulent links, which OTS defeats. Forging a link *after* a quantum break produces a later anchor than the real link, so the real link is cryptographically distinguishable.
|
||||
|
||||
## Revocation and broken schemes
|
||||
|
||||
If a specific PQ scheme is later found to be weak (as SIKE was in 2022):
|
||||
|
||||
1. Clients stop trusting the broken scheme — no revocation event is required. Clients simply ignore `algorithm` tags whose `algorithm-id` is on a locally-maintained broken-scheme list.
|
||||
2. The remaining links stay valid.
|
||||
3. A user MAY publish a new key-link event omitting the broken scheme, signed by the still-valid PQ keys from the prior link.
|
||||
|
||||
## Algorithms: why this set
|
||||
|
||||
| Algorithm | Why included |
|
||||
|---|---|
|
||||
| ML-DSA-44 | Lattice-based signature, FIPS 204, NIST Category 2. Compatibility with proposals that pick ML-DSA-44 alone. |
|
||||
| ML-DSA-65 | Lattice-based signature, FIPS 204, NIST Category 3. Primary PQ signature. |
|
||||
| SLH-DSA-128s | Hash-based signature, FIPS 205, NIST Category 1. Very conservative assumptions; the fallback if all lattice schemes break. Large signatures (7856 bytes) are acceptable for a one-time event. |
|
||||
| Falcon-512 | Lattice-based signature, FIPS 206 draft, NIST Category 1. Compact signatures (~666 bytes). |
|
||||
| ML-KEM-768 | Lattice-based KEM, FIPS 203, NIST Category 3. For future PQ encryption (NIP-04/NIP-44 successors). Cannot sign; pubkey only. |
|
||||
|
||||
Multiple schemes are used simultaneously so that if one class is broken, the others remain. The community does not need to agree on a single algorithm; clients verify whichever subset they support.
|
||||
|
||||
## Out of scope
|
||||
|
||||
The following are explicitly **not** part of this NIP. They may be addressed by separate future NIPs:
|
||||
|
||||
- **PQ signatures on routine events.** This NIP links keys; it does not change how events are signed. PQ-signed events are a separate problem.
|
||||
- **PQ encryption of messages.** ML-KEM-768 pubkeys are linked here so future encryption NIPs can use them, but this NIP does not define a NIP-04/NIP-44 replacement.
|
||||
- **Migration of users with a raw nsec and no seed phrase.** That is a key-management problem orthogonal to the link format defined here.
|
||||
- **Quantum-safe self-storage** (e.g. one-time pad or seed-derived symmetric encryption of kind 30078 application data). Orthogonal to identity linking.
|
||||
- **HD wallet compartmentalization.** A property of BIP32/NIP-06, not something this NIP re-specifies.
|
||||
|
||||
## Prior art
|
||||
|
||||
This NIP builds on and is compatible with several existing community proposals:
|
||||
|
||||
- **PR #391** (eznix86, "NIP-101 Algorithm Transition") introduced the cross-signed transition event concept. This NIP extends it with OpenTimestamps anchoring, multi-scheme hedging, and seed-phrase-derived keys.
|
||||
- **PR #2185** (trbouma, "Add NIP for PQ") proposes swapping event signatures to ML-DSA-44. This NIP is compatible with that work — a future PQ-signature NIP can use the PQ keys linked here.
|
||||
- **Issue #1971** (paulmillr, "NIP-44: post-quantum security") is the main community discussion. The "whole architecture" problem identified there (breaking nsec breaks everything derived from it) is why this NIP uses a BIP39 seed phrase as the root of trust rather than deriving PQ keys from nsec.
|
||||
- **PR #1647** (fiatjaf, "nip4e: Decoupling encryption from identity") separates encryption keys from identity keys. This NIP's ML-KEM-768 link is compatible with that pattern.
|
||||
- **NIP-03** (OpenTimestamps Attestations for Events) provides the anchoring primitive this NIP relies on.
|
||||
- **NIP-06** (Basic key derivation from seed) defines the BIP32 base path under which this NIP allocates PQ child indices.
|
||||
|
||||
## 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.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "post_quantum_nostr",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.15",
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# G56-02 Implementation Plan: Non-Replaceable Proof Carrier with Append-Only Upgrades
|
||||
|
||||
**Date:** 2026-07-24
|
||||
**Finding:** G56-02 — Earliest-valid-anchor rule is not implemented; relay discovery selects the latest replaceable event
|
||||
**Approach:** Switch the proof carrier from replaceable kind 11112 to non-replaceable kind 9999, with append-only upgrade events and earliest-anchor selection
|
||||
**No migration needed:** the project has not been publicly released
|
||||
|
||||
## Problem summary
|
||||
|
||||
Kind 11112 is in the 10000–19999 range, which Nostr relays treat as **replaceable** — they keep only the latest event per pubkey. This means:
|
||||
|
||||
1. An attacker who breaks secp256k1 can publish a new kind 11112 that replaces the original on relays
|
||||
2. The code queries with `limit: 1` and selects the newest `created_at`, which is the opposite of the proposal's "earliest valid Bitcoin anchor wins" rule
|
||||
3. The original event — with its earlier Bitcoin anchor — can be hidden
|
||||
|
||||
## Design
|
||||
|
||||
### Two-event structure (unchanged conceptually, changed kind)
|
||||
|
||||
**Event 1: Kind 1 Announcement** (non-replaceable, already correct)
|
||||
- Contains the human-readable attestation text and PQ keys/signatures in `algorithm` tags
|
||||
- Signed by the user's existing secp256k1 identity
|
||||
- Its full signed JSON hash is what gets submitted to OpenTimestamps
|
||||
- No changes needed — kind 1 is already in the 0–9999 non-replaceable range
|
||||
|
||||
**Event 2: Kind 9999 Proof Carrier** (non-replaceable, changed from 11112)
|
||||
- References Event 1 by `e` tag
|
||||
- Carries the `sha256` tag (hash of Event 1's full signed JSON)
|
||||
- Carries the `ots` tag (base64 .ots proof)
|
||||
- New `ots_status` tag: `"pending"` or `"confirmed"`
|
||||
- New `upgrade_of` tag: references the previous proof carrier event ID (only on upgrade events)
|
||||
- Published as append-only — upgrades publish a NEW event, never replace
|
||||
|
||||
### Append-only upgrade flow
|
||||
|
||||
```
|
||||
1. User signs in → generates seed → derives PQ keys → signs kind 1 announcement
|
||||
2. Submit kind 1 hash to OTS calendars → get pending .ots proof
|
||||
3. Publish kind 1 announcement (permanent, non-replaceable)
|
||||
4. Publish kind 9999 proof carrier with ots_status="pending" (permanent, non-replaceable)
|
||||
5. Wait 10-30 min for Bitcoin confirmation
|
||||
6. Upgrade the .ots proof via /ots-upgrade helper
|
||||
7. Publish a NEW kind 9999 proof carrier with ots_status="confirmed" and upgrade_of="<pending event id>"
|
||||
8. Both proof carriers remain on relays permanently — client picks the one with the earliest valid Bitcoin anchor
|
||||
```
|
||||
|
||||
### Re-migration flow (user lost seed, runs process again years later)
|
||||
|
||||
```
|
||||
1. User signs in with same Nostr identity → generates new seed → derives new PQ keys
|
||||
2. Publishes a new kind 1 announcement (new PQ keys, new block height)
|
||||
3. Publishes new kind 9999 proof carriers (pending, then confirmed)
|
||||
4. Client sees multiple kind 1 announcements + multiple proof carriers
|
||||
5. Earliest valid Bitcoin anchor across all of them is the canonical root
|
||||
6. Later migrations are valid if published while secp256k1 is still trustworthy
|
||||
```
|
||||
|
||||
### Earliest-anchor selection logic
|
||||
|
||||
A new pure function `selectCanonicalProofCarrier(candidates, kind1Event)`:
|
||||
|
||||
1. Input: array of kind 9999 events for a pubkey, plus the kind 1 announcement they reference
|
||||
2. Filter: only candidates whose `e` tag matches the kind 1 event ID
|
||||
3. Filter: only candidates whose `sha256` tag matches `hashFullEvent(kind1Event)`
|
||||
4. Filter: only candidates with a valid secp256k1 signature
|
||||
5. For each remaining candidate, parse and verify its OTS proof:
|
||||
- If `ots_status` is "confirmed" and the OTS proof has a valid Bitcoin attestation → record the Bitcoin block height
|
||||
- If `ots_status` is "pending" or the OTS proof has no Bitcoin attestation → skip (not yet anchored)
|
||||
6. Sort valid candidates by Bitcoin block height (ascending)
|
||||
7. Return the one with the earliest block height
|
||||
8. If tie, sort by event ID lexicographically (deterministic tie-break)
|
||||
9. If no candidate has a confirmed Bitcoin anchor, return the best pending candidate with a note
|
||||
|
||||
## Files to change
|
||||
|
||||
### 1. `www/js/pq-crypto.mjs`
|
||||
|
||||
**Change `NIP_QR_KIND`:**
|
||||
- Line 430: change `11112` to `9999`
|
||||
- Update comment: "Kind 9999 is a non-replaceable event (0–9999 range). Each publication is permanent. Upgrades publish a new event with `upgrade_of` referencing the previous one."
|
||||
|
||||
**Update `buildKind11112Wrapper()` → rename to `buildProofCarrier()`:**
|
||||
- Add `['ots_status', 'pending']` tag (or 'confirmed' when applicable)
|
||||
- Add optional `['upgrade_of', previousEventId]` tag when upgrading
|
||||
- Keep existing `e`, `sha256`, `ots` tags
|
||||
|
||||
**Update `buildUpgradedEvent()`:**
|
||||
- Instead of copying tags and replacing the `ots` tag, build a completely new proof carrier event
|
||||
- Include `ots_status: 'confirmed'`
|
||||
- Include `upgrade_of: <original proof carrier event id>`
|
||||
- Keep the same `e` and `sha256` tags (they reference the same kind 1 event)
|
||||
|
||||
**Add `selectCanonicalProofCarrier(candidates, kind1Event)`:**
|
||||
- Pure function, no network calls
|
||||
- Takes array of proof carrier events and the kind 1 announcement
|
||||
- Returns `{ canonical, allCandidates, errors }` where `canonical` is the event with the earliest valid Bitcoin anchor
|
||||
- OTS verification is async, so this function should be async and call `verifyOtsProof()` for each candidate's OTS proof
|
||||
|
||||
### 2. `www/verify.html`
|
||||
|
||||
**Update `queryRelayForEvent()`:**
|
||||
- Change kind from `NIP_QR_KIND` (which will be 9999) — already uses the constant, so this is automatic
|
||||
- Remove `limit: 1` — fetch all events
|
||||
- Return an array of events instead of a single event
|
||||
|
||||
**Update `verifyEvent()`:**
|
||||
- Accept an array of proof carrier candidates
|
||||
- Call `selectCanonicalProofCarrier()` to pick the canonical one
|
||||
- Display all candidates in the UI with their OTS status and Bitcoin anchor height
|
||||
- Highlight the canonical (earliest-anchor) one
|
||||
- Still verify the kind 1 announcement and PQ signatures as before
|
||||
|
||||
**Update the relay query tab:**
|
||||
- Query for both kind 1 and kind 9999 events for the pubkey
|
||||
- Match proof carriers to announcements by `e` tag
|
||||
- Display the full migration history
|
||||
|
||||
### 3. `www/index.html`
|
||||
|
||||
**Update `queryRelayForKind11112()` → rename to `queryRelayForProofCarriers()`:**
|
||||
- Remove `limit: 1`
|
||||
- Return an array
|
||||
|
||||
**Update the sign/publish flow:**
|
||||
- After signing the kind 1 announcement and getting the pending OTS proof, publish the kind 9999 proof carrier with `ots_status: 'pending'`
|
||||
|
||||
**Update `publishUpgradedEvent()`:**
|
||||
- Build a NEW proof carrier event (not a replacement)
|
||||
- Include `ots_status: 'confirmed'` and `upgrade_of: <pending event id>`
|
||||
- Publish it as a new event
|
||||
- Do NOT try to replace the old event
|
||||
|
||||
**Update the resume workflow:**
|
||||
- Fetch all kind 9999 proof carriers for the pubkey
|
||||
- Find the one that matches the persisted kind 1 event hash
|
||||
- If a confirmed proof carrier already exists, the workflow is complete
|
||||
- If only a pending proof carrier exists, continue the upgrade polling
|
||||
|
||||
**Update `savePendingOts()`:**
|
||||
- Store both the pending proof carrier event ID and the kind 1 event ID
|
||||
- Store the `sha256` target digest explicitly
|
||||
- On resume, validate that fetched events match persisted IDs
|
||||
|
||||
### 4. `test/pq-crypto.test.mjs`
|
||||
|
||||
**Update existing tests:**
|
||||
- Any test referencing kind 11112 → change to 9999
|
||||
|
||||
**Add new tests:**
|
||||
- `buildProofCarrier` includes `ots_status` tag
|
||||
- `buildUpgradedEvent` includes `ots_status: 'confirmed'` and `upgrade_of` tag
|
||||
- `selectCanonicalProofCarrier` returns the event with the earliest Bitcoin anchor
|
||||
- `selectCanonicalProofCarrier` handles ties deterministically
|
||||
- `selectCanonicalProofCarrier` skips pending-only candidates when confirmed ones exist
|
||||
- `selectCanonicalProofCarrier` rejects candidates with wrong `e` tag or `sha256` tag
|
||||
- `selectCanonicalProofCarrier` rejects candidates with invalid secp signatures
|
||||
- Multiple migration attempts (re-migration) — earliest anchor wins across all
|
||||
|
||||
### 5. `nip_proposal.md`
|
||||
|
||||
- Change kind 11112 to kind 9999
|
||||
- Update the description: "non-replaceable event (0–9999 range)"
|
||||
- Add `ots_status` and `upgrade_of` tags to the tag specification
|
||||
- Update the verification rule: "fetch all kind 9999 events, select earliest valid Bitcoin anchor"
|
||||
- Update the proof states section: upgrades publish new events, not replacements
|
||||
|
||||
### 6. `README.md`
|
||||
|
||||
- Update any reference to kind 11112 → kind 9999
|
||||
- Update the migration flow description
|
||||
- Update the implementation status table
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. No event in the system is replaceable — both kind 1 and kind 9999 are in the 0–9999 range
|
||||
2. Upgrades publish new events with `upgrade_of` referencing the previous one — no event is ever replaced
|
||||
3. Relay queries fetch all candidates, not `limit: 1`
|
||||
4. `selectCanonicalProofCarrier` deterministically picks the earliest valid Bitcoin anchor
|
||||
5. A later forged proof carrier cannot hide or supersede an earlier valid one
|
||||
6. Re-migration (publishing a second migration attempt) works naturally — all events are permanent
|
||||
7. All existing tests pass (updated for kind 9999)
|
||||
8. New tests for append-only upgrades and earliest-anchor selection pass
|
||||
9. The verify page displays the full candidate history and highlights the canonical one
|
||||
@@ -21,6 +21,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { schnorr } from '@noble/curves/secp256k1.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const m = await import('../www/js/pq-crypto.mjs');
|
||||
@@ -312,3 +313,386 @@ describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
|
||||
assert.ok(result.errors.some(e => e.includes('no Bitcoin attestations') || e.includes('pending')), 'should report no Bitcoin attestations');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// G56-01: STRICT PQ ALGORITHM POLICY (verifyNIPQRContent)
|
||||
// ============================================================================
|
||||
//
|
||||
// These tests verify that the verifier enforces a mandatory algorithm
|
||||
// policy and does NOT accept events with missing PQ signatures, missing
|
||||
// signature fields, unknown algorithms treated as valid, or duplicate
|
||||
// algorithms.
|
||||
|
||||
describe('G56-01: strict PQ algorithm policy (verifyNIPQRContent)', () => {
|
||||
// Use a fixed key for signing test events
|
||||
const sk = new Uint8Array(32);
|
||||
sk[31] = 1;
|
||||
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
|
||||
|
||||
// Helper: sign a kind 1 event with a real Schnorr signature
|
||||
async function signedKind1(content, tags) {
|
||||
const event = {
|
||||
pubkey: pkHex,
|
||||
created_at: 1700000000,
|
||||
kind: 1,
|
||||
tags: tags || [],
|
||||
content: content || 'test attestation'
|
||||
};
|
||||
event.id = m.computeEventId(event);
|
||||
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
|
||||
return event;
|
||||
}
|
||||
|
||||
// Helper: build wrapper tags for a given kind 1 event
|
||||
function wrapperTags(kind1Event) {
|
||||
return [
|
||||
['e', kind1Event.id],
|
||||
['sha256', m.hashFullEvent(kind1Event)]
|
||||
];
|
||||
}
|
||||
|
||||
// Helper: build a complete valid kind 1 event with all 5 PQ algorithms
|
||||
async function validKind1WithAllPQKeys() {
|
||||
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const seed = m.mnemonicToSeed(mnemonic);
|
||||
const keys = m.derivePQKeysFromSeed(seed);
|
||||
const event = m.buildKind1Announcement(pkHex, 958927, keys);
|
||||
// buildKind1Announcement returns an unsigned template — sign it
|
||||
event.id = m.computeEventId(event);
|
||||
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
|
||||
return event;
|
||||
}
|
||||
|
||||
test('valid event with all 5 PQ algorithms passes strict policy', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
const tags = wrapperTags(kind1);
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
assert.equal(result.policySufficient, true, 'policy should be sufficient with all 5 algorithms');
|
||||
assert.equal(result.pqProofsValid, true, 'all PQ proofs should verify');
|
||||
assert.equal(result.validForMigration, true, 'should be valid for migration');
|
||||
});
|
||||
|
||||
test('G56-01: event with NO algorithm tags fails policy', async () => {
|
||||
const kind1 = await signedKind1('attestation with no PQ keys', []);
|
||||
const tags = wrapperTags(kind1);
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
assert.equal(result.policySufficient, false, 'policy must fail with no algorithms');
|
||||
assert.equal(result.validForMigration, false, 'must not be valid for migration');
|
||||
assert.ok(result.errors.some(e => e.includes('missing mandatory')), 'should report missing mandatory algorithms');
|
||||
});
|
||||
|
||||
test('G56-01: signature algorithm with missing signature field fails', async () => {
|
||||
// Build a kind 1 with ml-dsa-44 tag but no signature field
|
||||
const kind1 = await signedKind1('attestation with missing sig', [
|
||||
['algorithm', 'ml-dsa-44', m.bytesToBase64(new Uint8Array(1312))], // no 4th field
|
||||
]);
|
||||
const tags = wrapperTags(kind1);
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
assert.equal(result.pqProofsValid, false, 'missing signature must fail PQ proof');
|
||||
assert.equal(result.policySufficient, false, 'policy must fail');
|
||||
assert.equal(result.validForMigration, false);
|
||||
// The ml-dsa-44 result should be invalid
|
||||
const mlDsa44Result = result.results.find(r => r.algorithm === 'ml-dsa-44');
|
||||
assert.ok(mlDsa44Result, 'should have an ml-dsa-44 result');
|
||||
assert.equal(mlDsa44Result.valid, false, 'ml-dsa-44 with no signature must be invalid');
|
||||
});
|
||||
|
||||
test('G56-01: each signature algorithm with missing signature fails', async () => {
|
||||
const sigAlgs = ['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512'];
|
||||
for (const alg of sigAlgs) {
|
||||
const keyLen = { 'ml-dsa-44': 1312, 'ml-dsa-65': 1952, 'slh-dsa-128s': 32, 'falcon-512': 897 }[alg];
|
||||
const kind1 = await signedKind1(`test ${alg} missing sig`, [
|
||||
['algorithm', alg, m.bytesToBase64(new Uint8Array(keyLen))],
|
||||
]);
|
||||
const tags = wrapperTags(kind1);
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
const algResult = result.results.find(r => r.algorithm === alg);
|
||||
assert.ok(algResult, `should have a result for ${alg}`);
|
||||
assert.equal(algResult.valid, false, `${alg} with no signature must be invalid`);
|
||||
assert.equal(result.pqProofsValid, false, `${alg} missing sig must fail PQ proofs`);
|
||||
}
|
||||
});
|
||||
|
||||
test('G56-01: unknown algorithm with no signature is NOT valid evidence', async () => {
|
||||
const kind1 = await signedKind1('attestation with unknown alg', [
|
||||
['algorithm', 'future-pq-scheme', m.bytesToBase64(new Uint8Array(64))],
|
||||
]);
|
||||
const tags = wrapperTags(kind1);
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
const unknownResult = result.results.find(r => r.algorithm === 'future-pq-scheme');
|
||||
assert.ok(unknownResult, 'should have a result for the unknown algorithm');
|
||||
assert.equal(unknownResult.valid, false, 'unknown algorithm must not be valid evidence');
|
||||
assert.equal(result.policySufficient, false, 'policy must fail with only an unknown algorithm');
|
||||
});
|
||||
|
||||
test('G56-01: duplicate algorithm tags fail', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
// Duplicate the ml-dsa-44 tag
|
||||
const dupTag = kind1.tags.find(t => t[1] === 'ml-dsa-44');
|
||||
kind1.tags.push([...dupTag]);
|
||||
// Re-sign because tags changed
|
||||
kind1.id = m.computeEventId(kind1);
|
||||
kind1.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(kind1.id), sk));
|
||||
const tags = wrapperTags(kind1);
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
assert.equal(result.policySufficient, false, 'duplicate algorithms must fail policy');
|
||||
assert.ok(result.errors.some(e => e.includes('duplicate')), 'should report duplicate');
|
||||
});
|
||||
|
||||
test('G56-01: malformed base64 in public key fails closed (does not throw)', async () => {
|
||||
const kind1 = await signedKind1('attestation with malformed base64', [
|
||||
['algorithm', 'ml-dsa-44', '%%%not-valid-base64%%%', m.bytesToBase64(new Uint8Array(2420))],
|
||||
]);
|
||||
const tags = wrapperTags(kind1);
|
||||
// Must not throw — must return a structured failure
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
assert.equal(result.pqProofsValid, false, 'malformed base64 must fail PQ proofs');
|
||||
assert.equal(result.validForMigration, false);
|
||||
const algResult = result.results.find(r => r.algorithm === 'ml-dsa-44');
|
||||
assert.ok(algResult, 'should have an ml-dsa-44 result');
|
||||
assert.equal(algResult.valid, false, 'malformed base64 must be invalid');
|
||||
});
|
||||
|
||||
test('G56-01: malformed base64 in signature fails closed (does not throw)', async () => {
|
||||
const kind1 = await signedKind1('attestation with malformed sig base64', [
|
||||
['algorithm', 'ml-dsa-44', m.bytesToBase64(new Uint8Array(1312)), '%%%not-valid-base64%%%'],
|
||||
]);
|
||||
const tags = wrapperTags(kind1);
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
assert.equal(result.pqProofsValid, false, 'malformed signature base64 must fail');
|
||||
const algResult = result.results.find(r => r.algorithm === 'ml-dsa-44');
|
||||
assert.equal(algResult.valid, false, 'malformed signature must be invalid');
|
||||
});
|
||||
|
||||
test('G56-01: wrong public key length fails', async () => {
|
||||
const kind1 = await signedKind1('attestation with wrong key length', [
|
||||
['algorithm', 'ml-dsa-44', m.bytesToBase64(new Uint8Array(100)), m.bytesToBase64(new Uint8Array(2420))],
|
||||
]);
|
||||
const tags = wrapperTags(kind1);
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
const algResult = result.results.find(r => r.algorithm === 'ml-dsa-44');
|
||||
assert.equal(algResult.valid, false, 'wrong key length must be invalid');
|
||||
assert.equal(result.pqProofsValid, false);
|
||||
});
|
||||
|
||||
test('G56-01: non-array wrapper tags fail closed (does not throw)', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
// Pass a non-array for wrapper tags — must not throw
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), {});
|
||||
assert.equal(result.validForMigration, false);
|
||||
assert.equal(result.eTagValid, false);
|
||||
});
|
||||
|
||||
test('G56-01: result has structured fields', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
const tags = wrapperTags(kind1);
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
assert.ok('pqProofsValid' in result, 'should have pqProofsValid field');
|
||||
assert.ok('policySufficient' in result, 'should have policySufficient field');
|
||||
assert.ok('validForMigration' in result, 'should have validForMigration field');
|
||||
assert.ok('errors' in result, 'should have errors field');
|
||||
assert.ok(Array.isArray(result.errors), 'errors should be an array');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// G56-02: NON-REPLACEABLE PROOF CARRIER (kind 9999) AND EARLIEST-ANCHOR SELECTION
|
||||
// ============================================================================
|
||||
//
|
||||
// Tests for the new non-replaceable proof carrier event kind, append-only
|
||||
// upgrades, and the selectCanonicalProofCarrier function.
|
||||
|
||||
describe('G56-02: non-replaceable proof carrier (kind 9999)', () => {
|
||||
const sk = new Uint8Array(32);
|
||||
sk[31] = 1;
|
||||
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
|
||||
|
||||
async function signedKind1(content, tags) {
|
||||
const event = {
|
||||
pubkey: pkHex,
|
||||
created_at: 1700000000,
|
||||
kind: 1,
|
||||
tags: tags || [],
|
||||
content: content || 'test attestation'
|
||||
};
|
||||
event.id = m.computeEventId(event);
|
||||
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
|
||||
return event;
|
||||
}
|
||||
|
||||
async function validKind1WithAllPQKeys() {
|
||||
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const seed = m.mnemonicToSeed(mnemonic);
|
||||
const keys = m.derivePQKeysFromSeed(seed);
|
||||
const event = m.buildKind1Announcement(pkHex, 958927, keys);
|
||||
event.id = m.computeEventId(event);
|
||||
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
|
||||
return event;
|
||||
}
|
||||
|
||||
async function signedProofCarrier(kind1Event, otsProof, options = {}) {
|
||||
const template = m.buildProofCarrier(kind1Event, otsProof, options);
|
||||
template.id = m.computeEventId(template);
|
||||
template.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(template.id), sk));
|
||||
return template;
|
||||
}
|
||||
|
||||
test('NIP_QR_KIND is 9999 (non-replaceable range)', () => {
|
||||
assert.equal(m.NIP_QR_KIND, 9999, 'kind should be 9999 (0-9999 non-replaceable range)');
|
||||
});
|
||||
|
||||
test('buildProofCarrier includes ots_status tag', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
const carrier = m.buildProofCarrier(kind1, new Uint8Array(100), { otsStatus: 'pending' });
|
||||
assert.equal(carrier.kind, 9999);
|
||||
const otsStatusTag = carrier.tags.find(t => t[0] === 'ots_status');
|
||||
assert.ok(otsStatusTag, 'should have ots_status tag');
|
||||
assert.equal(otsStatusTag[1], 'pending');
|
||||
});
|
||||
|
||||
test('buildProofCarrier includes upgrade_of tag when provided', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
const carrier = m.buildProofCarrier(kind1, new Uint8Array(100), {
|
||||
otsStatus: 'confirmed',
|
||||
upgradeOf: 'abc123'
|
||||
});
|
||||
const upgradeTag = carrier.tags.find(t => t[0] === 'upgrade_of');
|
||||
assert.ok(upgradeTag, 'should have upgrade_of tag');
|
||||
assert.equal(upgradeTag[1], 'abc123');
|
||||
});
|
||||
|
||||
test('buildUpgradedEvent creates append-only event with upgrade_of', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
const pendingCarrier = await signedProofCarrier(kind1, new Uint8Array(100), { otsStatus: 'pending' });
|
||||
const upgraded = m.buildUpgradedEvent(pendingCarrier, new Uint8Array(200));
|
||||
|
||||
// Should be a new event, not a replacement
|
||||
assert.equal(upgraded.kind, 9999);
|
||||
const otsStatusTag = upgraded.tags.find(t => t[0] === 'ots_status');
|
||||
assert.equal(otsStatusTag[1], 'confirmed', 'upgraded event should have ots_status confirmed');
|
||||
|
||||
const upgradeTag = upgraded.tags.find(t => t[0] === 'upgrade_of');
|
||||
assert.ok(upgradeTag, 'should have upgrade_of tag');
|
||||
assert.equal(upgradeTag[1], pendingCarrier.id, 'upgrade_of should reference original event ID');
|
||||
|
||||
// e and sha256 tags should be the same as the original
|
||||
const eTag = upgraded.tags.find(t => t[0] === 'e');
|
||||
const origETag = pendingCarrier.tags.find(t => t[0] === 'e');
|
||||
assert.equal(eTag[1], origETag[1], 'e tag should be preserved');
|
||||
});
|
||||
|
||||
test('selectCanonicalProofCarrier returns null for empty candidates', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
const result = await m.selectCanonicalProofCarrier([], kind1);
|
||||
assert.equal(result.canonical, null);
|
||||
});
|
||||
|
||||
test('selectCanonicalProofCarrier rejects wrong e tag', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
const carrier = await signedProofCarrier(kind1, new Uint8Array(100));
|
||||
// Tamper the e tag
|
||||
carrier.tags = carrier.tags.map(t => t[0] === 'e' ? ['e', '0'.repeat(64)] : t);
|
||||
carrier.id = m.computeEventId(carrier);
|
||||
carrier.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(carrier.id), sk));
|
||||
const result = await m.selectCanonicalProofCarrier([carrier], kind1);
|
||||
assert.equal(result.canonical, null);
|
||||
assert.ok(result.errors.some(e => e.includes('e tag')), 'should report e tag mismatch');
|
||||
});
|
||||
|
||||
test('selectCanonicalProofCarrier rejects wrong sha256 tag', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
const carrier = await signedProofCarrier(kind1, new Uint8Array(100));
|
||||
// Tamper the sha256 tag
|
||||
carrier.tags = carrier.tags.map(t => t[0] === 'sha256' ? ['sha256', '0'.repeat(64)] : t);
|
||||
carrier.id = m.computeEventId(carrier);
|
||||
carrier.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(carrier.id), sk));
|
||||
const result = await m.selectCanonicalProofCarrier([carrier], kind1);
|
||||
assert.equal(result.canonical, null);
|
||||
assert.ok(result.errors.some(e => e.includes('sha256')), 'should report sha256 mismatch');
|
||||
});
|
||||
|
||||
test('selectCanonicalProofCarrier rejects wrong expected author', async () => {
|
||||
const kind1 = await validKind1WithAllPQKeys();
|
||||
const carrier = await signedProofCarrier(kind1, new Uint8Array(100));
|
||||
const otherKey = '0'.repeat(64);
|
||||
const result = await m.selectCanonicalProofCarrier([carrier], kind1, otherKey);
|
||||
assert.equal(result.canonical, null);
|
||||
assert.ok(result.errors.some(e => e.includes('expected author')), 'should report author mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// G56-03: IDENTITY BINDING (outer.pubkey === embedded.pubkey === expectedAuthor)
|
||||
// ============================================================================
|
||||
|
||||
describe('G56-03: identity binding (verifyNIPQRContent)', () => {
|
||||
const skA = new Uint8Array(32);
|
||||
skA[31] = 1;
|
||||
const pkA = m.bytesToHex(schnorr.getPublicKey(skA));
|
||||
const skB = new Uint8Array(32);
|
||||
skB[31] = 2;
|
||||
const pkB = m.bytesToHex(schnorr.getPublicKey(skB));
|
||||
|
||||
async function signedKind1(pubkey, sk, content, tags) {
|
||||
const event = {
|
||||
pubkey,
|
||||
created_at: 1700000000,
|
||||
kind: 1,
|
||||
tags: tags || [],
|
||||
content: content || 'test attestation'
|
||||
};
|
||||
event.id = m.computeEventId(event);
|
||||
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
|
||||
return event;
|
||||
}
|
||||
|
||||
test('G56-03: matching identities pass identity binding', async () => {
|
||||
const kind1 = await signedKind1(pkA, skA, 'test', []);
|
||||
const tags = [
|
||||
['e', kind1.id],
|
||||
['sha256', m.hashFullEvent(kind1)]
|
||||
];
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags, {
|
||||
outerPubkey: pkA,
|
||||
expectedAuthor: pkA
|
||||
});
|
||||
assert.equal(result.identityBound, true, 'matching identities should pass');
|
||||
});
|
||||
|
||||
test('G56-03: mismatched outer/embedded identity fails', async () => {
|
||||
// Kind 1 signed by A, but outer pubkey is B
|
||||
const kind1 = await signedKind1(pkA, skA, 'test', []);
|
||||
const tags = [
|
||||
['e', kind1.id],
|
||||
['sha256', m.hashFullEvent(kind1)]
|
||||
];
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags, {
|
||||
outerPubkey: pkB
|
||||
});
|
||||
assert.equal(result.identityBound, false, 'mismatched outer/embedded should fail');
|
||||
assert.equal(result.validForMigration, false, 'should not be valid for migration');
|
||||
});
|
||||
|
||||
test('G56-03: mismatched expected author fails', async () => {
|
||||
const kind1 = await signedKind1(pkA, skA, 'test', []);
|
||||
const tags = [
|
||||
['e', kind1.id],
|
||||
['sha256', m.hashFullEvent(kind1)]
|
||||
];
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags, {
|
||||
expectedAuthor: pkB
|
||||
});
|
||||
assert.equal(result.identityBound, false, 'mismatched expected author should fail');
|
||||
assert.equal(result.validForMigration, false);
|
||||
});
|
||||
|
||||
test('G56-03: no identity options → identityBound is true (backward compatible)', async () => {
|
||||
const kind1 = await signedKind1(pkA, skA, 'test', []);
|
||||
const tags = [
|
||||
['e', kind1.id],
|
||||
['sha256', m.hashFullEvent(kind1)]
|
||||
];
|
||||
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
|
||||
assert.equal(result.identityBound, true, 'without options, identity binding should pass');
|
||||
});
|
||||
});
|
||||
|
||||
+96
-63
@@ -182,7 +182,30 @@
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin: 15px 0; font-size: 14px; cursor: pointer; color: var(--primary-color);
|
||||
}
|
||||
.pq-checkbox-row input { width: 18px; height: 18px; cursor: pointer; }
|
||||
.pq-checkbox-row input {
|
||||
width: 18px; height: 18px; cursor: pointer;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
border: var(--border-width) solid var(--primary-color);
|
||||
background: transparent;
|
||||
display: inline-grid;
|
||||
place-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.pq-checkbox-row input::before {
|
||||
content: "";
|
||||
width: 100%; height: 100%;
|
||||
transform: scale(0);
|
||||
transition: transform 0.1s ease-in-out;
|
||||
background: #d00;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.pq-checkbox-row input:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
.pq-checkbox-row input:checked {
|
||||
border-color: #d00;
|
||||
background: #d00;
|
||||
}
|
||||
|
||||
.pq-seed-toggle {
|
||||
flex: 1;
|
||||
@@ -371,12 +394,6 @@
|
||||
<div class="pq-button-row">
|
||||
<button class="pq-button pq-button-secondary pq-hidden" id="pqRegenerateWithEntropyBtn" disabled>Generate with Extra Entropy</button>
|
||||
</div>
|
||||
<div class="pq-warning" style="font-size: 12px; margin: 10px 0;">
|
||||
<strong>Note:</strong> This 12-word seed provides ~64-bit post-quantum security on the seed entropy itself
|
||||
(under Grover's algorithm). For maximum post-quantum security, consider using a 24-word seed phrase
|
||||
(available via "I Have a Seed Phrase" above). The bigger risk to seed phrases is classical (theft, phishing),
|
||||
not quantum.
|
||||
</div>
|
||||
<label class="pq-checkbox-row">
|
||||
<input type="checkbox" id="pqSeedConfirmed" />
|
||||
I have written down my seed phrase
|
||||
@@ -497,14 +514,11 @@
|
||||
<div class="pq-button-row">
|
||||
<button class="pq-button pq-button-secondary pq-hidden" id="pqOtsContinueBtn">Continue to OpenTimestamps</button>
|
||||
</div>
|
||||
<div class="pq-button-row">
|
||||
<button class="pq-button pq-button-secondary" id="pqDoneBtn">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 6: OPENTIMESTAMPS -->
|
||||
<div id="pqOtsStep" class="pq-card pq-hidden">
|
||||
<div class="pq-card-title">OpenTimestamps Attestation</div>
|
||||
<div class="pq-card-title">OpenTimestamps Attestation - Leave This Page Open</div>
|
||||
<div class="pq-info-text">
|
||||
Your event hash has already been submitted to OpenTimestamps in the previous step, and a
|
||||
<strong>pending</strong> timestamp proof is embedded in the kind 11112 event you just published.
|
||||
@@ -512,7 +526,8 @@
|
||||
point in time — preventing a future quantum attacker from backdating a fraudulent event.
|
||||
</div>
|
||||
<div class="pq-info-text">
|
||||
Bitcoin confirmation typically takes <strong>10-30 minutes</strong>. You can leave this page open
|
||||
Bitcoin confirmation typically takes <strong>10-30 minutes</strong>. You can
|
||||
<strong style="text-decoration: underline;">leave this page open</strong>
|
||||
to watch the progress below — the polling log updates every minute. Once the Bitcoin attestation is
|
||||
confirmed, this page will automatically re-sign and republish the kind 11112 event with the upgraded
|
||||
proof. You're free to close the page; the workflow resumes on your next visit.
|
||||
@@ -525,11 +540,11 @@
|
||||
|
||||
<div class="pq-checklist" style="margin-bottom: 15px;">
|
||||
<div class="pq-checklist-item pq-done" id="pqOtsSubmit">
|
||||
<span class="pq-checklist-box">✓</span>
|
||||
<span class="pq-checklist-box"></span>
|
||||
<span>Submit hash to OpenTimestamps — pending .ots proof created</span>
|
||||
</div>
|
||||
<div class="pq-checklist-item pq-done" id="pqOtsPendingPublish">
|
||||
<span class="pq-checklist-box">✓</span>
|
||||
<span class="pq-checklist-box"></span>
|
||||
<span>Pending proof embedded in kind 11112 event</span>
|
||||
</div>
|
||||
<div class="pq-checklist-item pq-active" id="pqOtsConfirm">
|
||||
@@ -550,9 +565,6 @@
|
||||
<button class="pq-button pq-button-secondary" id="pqOtsDownloadBtn">Download .ots file</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pq-button-row" style="margin-top: 15px;">
|
||||
<button class="pq-button pq-button-secondary" id="pqOtsDoneBtn">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -591,6 +603,7 @@
|
||||
derivePQKeysFromSeed,
|
||||
buildKind1Announcement,
|
||||
buildKind11112Wrapper,
|
||||
buildProofCarrier,
|
||||
computeEventId,
|
||||
hashFullEvent,
|
||||
verifyNIPQRContent,
|
||||
@@ -601,6 +614,7 @@
|
||||
PQ_KEY_INFO,
|
||||
NIP_QR_KIND,
|
||||
buildUpgradedEvent,
|
||||
selectCanonicalProofCarrier,
|
||||
timestampEvent,
|
||||
upgradeOts,
|
||||
isOtsConfirmed,
|
||||
@@ -737,24 +751,38 @@
|
||||
e.preventDefault();
|
||||
for (let i = 1; i <= 5; i++) setStepDone(i);
|
||||
setStepActive(6);
|
||||
// Fetch the full kind 11112 event from relays so that pqEvent has
|
||||
// the tags and content needed for buildUpgradedEvent() when the
|
||||
// Bitcoin attestation is confirmed and we republish.
|
||||
// G56-02: Fetch ALL proof carrier events from relays and select
|
||||
// the canonical one (earliest valid Bitcoin anchor)
|
||||
if (!pqEvent.tags || !pqEvent.content) {
|
||||
otsLog('Resuming OTS workflow: fetching full event from relays...');
|
||||
otsLog('Resuming OTS workflow: fetching proof carrier events from relays...');
|
||||
const relayUrls = getRelayUrls();
|
||||
let fetched = null;
|
||||
let allCandidates = [];
|
||||
await Promise.all(relayUrls.map(async (url) => {
|
||||
try {
|
||||
const ev = await queryRelayForKind11112(currentPubkey, url);
|
||||
if (ev && (!fetched || ev.created_at > fetched.created_at)) fetched = ev;
|
||||
const events = await queryRelayForProofCarriers(currentPubkey, url);
|
||||
if (events && events.length > 0) allCandidates.push(...events);
|
||||
} catch (e) { /* ignore */ }
|
||||
}));
|
||||
if (fetched) {
|
||||
pqEvent = fetched;
|
||||
otsLog(`Resuming OTS workflow: fetched event ${pqEvent.id.substring(0, 16)}... from relays.`);
|
||||
// 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) {
|
||||
// Try to find the one matching our persisted event ID
|
||||
const matching = allCandidates.find(e => e.id === pqEvent.id);
|
||||
if (matching) {
|
||||
pqEvent = matching;
|
||||
otsLog(`Resuming OTS workflow: fetched event ${pqEvent.id.substring(0, 16)}... from relays (${allCandidates.length} total candidates).`);
|
||||
} else {
|
||||
// Use the first candidate as fallback
|
||||
pqEvent = allCandidates[0];
|
||||
otsLog(`Resuming OTS workflow: persisted event not found, using ${pqEvent.id.substring(0, 16)}... (${allCandidates.length} total candidates).`);
|
||||
}
|
||||
} else {
|
||||
otsLog('WARNING: Could not fetch the full event from relays. Republishing may fail.');
|
||||
otsLog('WARNING: Could not fetch any proof carrier events from relays. Republishing may fail.');
|
||||
}
|
||||
}
|
||||
await startOtsFlow();
|
||||
@@ -774,15 +802,23 @@
|
||||
function setKeyIcon(elementId, icon) {
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
// Supports both the old .pq-key-icon elements and the new checklist
|
||||
// .pq-checklist-box elements (used in Step 3 and Step 6).
|
||||
const iconEl = el.querySelector('.pq-key-icon') || el.querySelector('.pq-checklist-box');
|
||||
if (iconEl) iconEl.textContent = icon;
|
||||
if (icon && el.classList.contains('pq-checklist-item')) {
|
||||
el.classList.add('pq-done');
|
||||
} else if (!icon && el.classList.contains('pq-checklist-item')) {
|
||||
el.classList.remove('pq-done');
|
||||
// For checklist items (Step 3, Step 6), toggle the pq-done class to
|
||||
// show a solid red box — no text inside the box, matching the
|
||||
// Migration Steps checklist style.
|
||||
if (el.classList.contains('pq-checklist-item')) {
|
||||
const box = el.querySelector('.pq-checklist-box');
|
||||
if (box) box.textContent = '';
|
||||
if (icon) {
|
||||
el.classList.add('pq-done');
|
||||
el.classList.remove('pq-active');
|
||||
} else {
|
||||
el.classList.remove('pq-done');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Legacy .pq-key-icon elements (no longer used but kept for safety)
|
||||
const iconEl = el.querySelector('.pq-key-icon');
|
||||
if (iconEl) iconEl.textContent = icon;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
@@ -1085,31 +1121,31 @@
|
||||
otsLog(`WARNING: OTS submission failed: ${otsError.message}. Kind 11112 event will not have OTS proof yet.`);
|
||||
}
|
||||
|
||||
// ---- Phase 3: Build and sign the kind 11112 wrapper event ----
|
||||
setStatus(pqSignStatus, 'info', ' Requesting secp256k1 signature for kind 11112 wrapper...');
|
||||
// ---- Phase 3: Build and sign the proof carrier event (kind 9999) ----
|
||||
setStatus(pqSignStatus, 'info', ' Requesting secp256k1 signature for proof carrier...');
|
||||
|
||||
let kind11112Template;
|
||||
let proofCarrierTemplate;
|
||||
if (otsProof && otsProof.length > 0) {
|
||||
kind11112Template = buildKind11112Wrapper(kind1Event, otsProof);
|
||||
proofCarrierTemplate = buildProofCarrier(kind1Event, otsProof, { otsStatus: 'pending' });
|
||||
} else {
|
||||
// No OTS proof — build wrapper without ots tag (will be added later)
|
||||
kind11112Template = buildKind11112Wrapper(kind1Event, new Uint8Array(0));
|
||||
// No OTS proof — build carrier without ots/ots_status tags (will be added later)
|
||||
proofCarrierTemplate = buildProofCarrier(kind1Event, new Uint8Array(0), { otsStatus: 'pending' });
|
||||
// Remove the empty ots tag
|
||||
kind11112Template.tags = kind11112Template.tags.filter(t => t[0] !== 'ots');
|
||||
proofCarrierTemplate.tags = proofCarrierTemplate.tags.filter(t => t[0] !== 'ots');
|
||||
}
|
||||
|
||||
const kind11112Signed = await window.nostr.signEvent(kind11112Template);
|
||||
const proofCarrierSigned = await window.nostr.signEvent(proofCarrierTemplate);
|
||||
|
||||
pqEvent = {
|
||||
id: kind11112Signed.id,
|
||||
pubkey: kind11112Signed.pubkey,
|
||||
created_at: kind11112Template.created_at,
|
||||
id: proofCarrierSigned.id,
|
||||
pubkey: proofCarrierSigned.pubkey,
|
||||
created_at: proofCarrierTemplate.created_at,
|
||||
kind: NIP_QR_KIND,
|
||||
content: kind11112Template.content,
|
||||
tags: kind11112Template.tags,
|
||||
sig: kind11112Signed.sig
|
||||
content: proofCarrierTemplate.content,
|
||||
tags: proofCarrierTemplate.tags,
|
||||
sig: proofCarrierSigned.sig
|
||||
};
|
||||
otsLog(`Kind 11112 wrapper signed. Event ID: ${pqEvent.id.substring(0, 32)}...`);
|
||||
otsLog(`Proof carrier signed. Event ID: ${pqEvent.id.substring(0, 32)}...`);
|
||||
|
||||
// Save the pending OTS proof for the OTS step
|
||||
if (otsProof && otsProof.length > 0) {
|
||||
@@ -1170,27 +1206,29 @@
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
RELAY QUERY (fetch kind 11112 event for OTS resume)
|
||||
RELAY QUERY (fetch ALL proof carrier events for OTS resume)
|
||||
G56-02: fetch all candidates, not limit: 1
|
||||
================================================================ */
|
||||
function queryRelayForKind11112(pubkeyHex, relayUrl) {
|
||||
function queryRelayForProofCarriers(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) {} reject(new Error('timeout')); }
|
||||
if (!resolved) { resolved = true; try { ws.close(); } catch (e) {} resolve(events); }
|
||||
}, 15000);
|
||||
ws.onopen = () => {
|
||||
const subId = 'pq_resume_' + Math.random().toString(36).substring(7);
|
||||
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex], limit: 1 }]));
|
||||
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 (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve(data[2]); }
|
||||
if (data[2]) events.push(data[2]);
|
||||
} else if (data[0] === 'EOSE') {
|
||||
if (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve(null); }
|
||||
if (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve(events); }
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
@@ -1234,9 +1272,8 @@
|
||||
if (!row) return;
|
||||
const box = row.querySelector(`.pq-relay-status-${kind}`);
|
||||
if (box) {
|
||||
box.textContent = ok ? '✓' : '';
|
||||
box.textContent = '';
|
||||
box.style.background = ok ? 'var(--accent-color)' : 'transparent';
|
||||
box.style.color = ok ? 'var(--secondary-color)' : 'var(--primary-color)';
|
||||
box.style.borderColor = ok ? 'var(--accent-color)' : 'var(--muted-color)';
|
||||
}
|
||||
}
|
||||
@@ -1409,8 +1446,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('pqDoneBtn').addEventListener('click', () => { showAuthedView(); });
|
||||
|
||||
// Continue to OpenTimestamps step (shown after successful publish)
|
||||
document.getElementById('pqOtsContinueBtn').addEventListener('click', () => { prepareOtsStep(); });
|
||||
|
||||
@@ -1741,8 +1776,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('pqOtsDoneBtn').addEventListener('click', () => { showAuthedView(); });
|
||||
|
||||
/* ================================================================
|
||||
INITIALIZATION
|
||||
================================================================ */
|
||||
|
||||
+529
-83
@@ -423,11 +423,14 @@ export function verifyNostrEvent(event) {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* NIP-QR event kind. Kind 11112 is a replaceable event (10000-19999 range),
|
||||
* so each pubkey can have only one event of this kind. No `d` tag needed.
|
||||
* Publishing a new kind 11112 event automatically replaces the old one on relays.
|
||||
* NIP-QR proof carrier event kind. Kind 9999 is a non-replaceable event
|
||||
* (0–9999 range). Each publication is permanent — relays cannot replace it.
|
||||
* Upgrades (e.g. pending → confirmed OTS proof) publish a NEW kind 9999 event
|
||||
* with an `upgrade_of` tag referencing the previous one. Clients fetch all
|
||||
* kind 9999 events for a pubkey and select the one with the earliest valid
|
||||
* Bitcoin anchor.
|
||||
*/
|
||||
export const NIP_QR_KIND = 11112;
|
||||
export const NIP_QR_KIND = 9999;
|
||||
|
||||
/**
|
||||
* Build the kind 1 announcement event (unsigned template).
|
||||
@@ -533,78 +536,160 @@ export function hashFullEvent(signedEvent) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the kind 11112 wrapper event (unsigned template).
|
||||
* Build the kind 9999 proof carrier event (unsigned template).
|
||||
*
|
||||
* This event 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). The tags reference the kind 1 event ID and contain the OTS proof.
|
||||
*
|
||||
* Kind 9999 is non-replaceable. Each publication is permanent. Upgrades publish
|
||||
* a new event with `upgrade_of` referencing the previous one.
|
||||
*
|
||||
* Tags:
|
||||
* - ['e', '<kind1_event_id>'] — reference to the kind 1 announcement
|
||||
* - ['sha256', '<hex hash of full kind 1 event JSON>'] — what was timestamped
|
||||
* - ['ots', '<base64 .ots proof>'] — the OpenTimestamps proof
|
||||
* - ['ots_status', 'pending'|'confirmed'] — quick UI indicator
|
||||
* - ['upgrade_of', '<previous_proof_carrier_event_id>'] — only on upgrade events
|
||||
*
|
||||
* @param {object} kind1Event - The fully signed kind 1 event
|
||||
* @param {Uint8Array} otsProof - The OTS proof bytes (pending or confirmed)
|
||||
* @param {object} [options] - Optional parameters
|
||||
* @param {string} [options.otsStatus='pending'] - 'pending' or 'confirmed'
|
||||
* @param {string} [options.upgradeOf] - Previous proof carrier event ID (for upgrades)
|
||||
* @returns {{kind: number, content: string, tags: Array, pubkey: string, created_at: number}}
|
||||
*/
|
||||
export function buildKind11112Wrapper(kind1Event, otsProof) {
|
||||
export function buildProofCarrier(kind1Event, otsProof, options = {}) {
|
||||
const fullHash = hashFullEvent(kind1Event);
|
||||
const otsStatus = options.otsStatus || 'pending';
|
||||
|
||||
const tags = [
|
||||
['e', kind1Event.id],
|
||||
['sha256', fullHash],
|
||||
['ots', bytesToBase64(otsProof)],
|
||||
['ots_status', otsStatus]
|
||||
];
|
||||
|
||||
if (options.upgradeOf) {
|
||||
tags.push(['upgrade_of', options.upgradeOf]);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: NIP_QR_KIND,
|
||||
content: JSON.stringify(kind1Event),
|
||||
tags: [
|
||||
['e', kind1Event.id],
|
||||
['sha256', fullHash],
|
||||
['ots', bytesToBase64(otsProof)]
|
||||
],
|
||||
tags,
|
||||
pubkey: kind1Event.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an upgraded kind 11112 event from an existing one.
|
||||
* Copies all tags and content from the original event, replaces the OTS proof tag
|
||||
* with the upgraded proof, and updates the timestamp. The content (the kind 1
|
||||
* event JSON) and the 'e' and 'sha256' tags stay the same — only the 'ots' tag
|
||||
* changes.
|
||||
* Backward-compatible alias for buildProofCarrier.
|
||||
* @deprecated Use buildProofCarrier() instead.
|
||||
*/
|
||||
export function buildKind11112Wrapper(kind1Event, otsProof) {
|
||||
return buildProofCarrier(kind1Event, otsProof);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an upgraded proof carrier event (append-only, non-replacing).
|
||||
*
|
||||
* @param {object} originalEvent - The original kind 11112 event
|
||||
* Instead of replacing the original event, this builds a NEW kind 9999 event
|
||||
* with the upgraded OTS proof. The new event references the original via the
|
||||
* `upgrade_of` tag. Both events remain on relays permanently.
|
||||
*
|
||||
* The `e` and `sha256` tags stay the same — they reference the same kind 1
|
||||
* announcement. Only the `ots` proof and `ots_status` change.
|
||||
*
|
||||
* @param {object} originalEvent - The original proof carrier event (pending)
|
||||
* @param {Uint8Array} upgradedOtsProof - The upgraded/confirmed OTS proof bytes
|
||||
* @returns {{kind: number, created_at: number, tags: Array, content: string, pubkey: string}}
|
||||
*/
|
||||
export function buildUpgradedEvent(originalEvent, upgradedOtsProof) {
|
||||
// Copy all tags except 'ots', then add the upgraded 'ots' tag
|
||||
const tags = originalEvent.tags.filter(tag => tag[0] !== 'ots');
|
||||
tags.push(['ots', bytesToBase64(upgradedOtsProof)]);
|
||||
// Extract the kind 1 event from the original content to rebuild tags cleanly
|
||||
let kind1Event;
|
||||
try {
|
||||
kind1Event = JSON.parse(originalEvent.content);
|
||||
} catch (e) {
|
||||
// If we can't parse, fall back to copying tags (shouldn't happen in practice)
|
||||
const tags = originalEvent.tags
|
||||
.filter(t => t[0] !== 'ots' && t[0] !== 'ots_status' && t[0] !== 'upgrade_of')
|
||||
.map(t => [...t]);
|
||||
tags.push(['ots', bytesToBase64(upgradedOtsProof)]);
|
||||
tags.push(['ots_status', 'confirmed']);
|
||||
tags.push(['upgrade_of', originalEvent.id]);
|
||||
return {
|
||||
kind: NIP_QR_KIND,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: originalEvent.content,
|
||||
pubkey: originalEvent.pubkey
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: NIP_QR_KIND,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: tags,
|
||||
content: originalEvent.content,
|
||||
pubkey: originalEvent.pubkey
|
||||
};
|
||||
// Build a clean new proof carrier with the upgraded proof
|
||||
return buildProofCarrier(kind1Event, upgradedOtsProof, {
|
||||
otsStatus: 'confirmed',
|
||||
upgradeOf: originalEvent.id
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a NIP-QR kind 11112 event.
|
||||
* Verify a NIP-QR proof carrier event's content and tags.
|
||||
*
|
||||
* The kind 11112 content is the full kind 1 event as JSON. This function:
|
||||
* The proof carrier content is the full kind 1 event as JSON. This function:
|
||||
* 1. Parses the kind 1 event from the content
|
||||
* 2. Verifies the kind 1 secp256k1 signature
|
||||
* 3. Verifies the 'e' tag matches the kind 1 event ID
|
||||
* 4. Verifies the 'sha256' tag matches SHA-256 of the full kind 1 event JSON
|
||||
* 5. Verifies each PQ signature in the kind 1 tags against the content text
|
||||
* 6. (G56-03) If expectedAuthor is provided, verifies identity binding:
|
||||
* the proof carrier pubkey, embedded kind 1 pubkey, and expected author
|
||||
* must all be equal.
|
||||
*
|
||||
* @param {string} kind11112Content - The kind 11112 content (JSON string of kind 1 event)
|
||||
* @param {Array} kind11112Tags - The kind 11112 tags array
|
||||
* @returns {{valid: boolean, results: Array, kind1Event: object|null, fullHash: string|null, sha256Valid: boolean, eTagValid: boolean}}
|
||||
* @param {string} kind11112Content - The proof carrier content (JSON string of kind 1 event)
|
||||
* @param {Array} kind11112Tags - The proof carrier tags array
|
||||
* @param {object} [options] - Optional parameters
|
||||
* @param {string} [options.expectedAuthor] - Expected author pubkey (hex) for G56-03 identity binding
|
||||
* @param {string} [options.outerPubkey] - The proof carrier event's pubkey (for G56-03 identity binding)
|
||||
* @returns {{
|
||||
* valid: boolean,
|
||||
* results: Array,
|
||||
* kind1Event: object|null,
|
||||
* fullHash: string|null,
|
||||
* sha256Valid: boolean,
|
||||
* eTagValid: boolean,
|
||||
* pqProofsValid: boolean,
|
||||
* policySufficient: boolean,
|
||||
* validForMigration: boolean,
|
||||
* identityBound: boolean,
|
||||
* errors: Array
|
||||
* }}
|
||||
*
|
||||
* `valid` is retained for backward compatibility and means "every individual
|
||||
* check that ran passed". `validForMigration` is the strict policy-gated
|
||||
* result: it is true only when the secp signature, e tag, sha256 tag, all
|
||||
* present PQ proofs, the mandatory algorithm policy, AND identity binding
|
||||
* are all satisfied.
|
||||
*/
|
||||
export function verifyNIPQRContent(kind11112Content, kind11112Tags) {
|
||||
export function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}) {
|
||||
const results = [];
|
||||
const errors = [];
|
||||
const { expectedAuthor, outerPubkey } = options;
|
||||
|
||||
// --- Helper: push a result entry and track failures ---
|
||||
function pushResult(algorithm, valid, note) {
|
||||
results.push({ algorithm, valid, note });
|
||||
if (!valid) errors.push(`${algorithm}: ${note || 'INVALID'}`);
|
||||
}
|
||||
|
||||
// --- Helper: safe base64 decode (G56-07: fail closed, never throw) ---
|
||||
function safeBase64ToBytes(b64, label) {
|
||||
if (typeof b64 !== 'string' || b64.length === 0) {
|
||||
throw new Error(`${label}: missing or empty`);
|
||||
}
|
||||
return base64ToBytes(b64); // may throw on malformed input
|
||||
}
|
||||
|
||||
// 1. Parse the kind 1 event from the kind 11112 content
|
||||
let kind1Event;
|
||||
@@ -617,99 +702,460 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) {
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false
|
||||
eTagValid: false,
|
||||
pqProofsValid: false,
|
||||
policySufficient: false,
|
||||
validForMigration: false,
|
||||
identityBound: false,
|
||||
errors: ['Content is not valid JSON']
|
||||
};
|
||||
}
|
||||
|
||||
if (!kind1Event.pubkey || !kind1Event.sig || !kind1Event.content || !kind1Event.tags || kind1Event.kind !== 1) {
|
||||
if (!kind1Event || typeof kind1Event !== 'object' ||
|
||||
!kind1Event.pubkey || !kind1Event.sig || !kind1Event.content ||
|
||||
!Array.isArray(kind1Event.tags) || kind1Event.kind !== 1) {
|
||||
return {
|
||||
valid: false,
|
||||
results: [{ algorithm: 'kind 1 content', valid: false, note: 'Embedded event is not a valid kind 1 event' }],
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false
|
||||
eTagValid: false,
|
||||
pqProofsValid: false,
|
||||
policySufficient: false,
|
||||
validForMigration: false,
|
||||
identityBound: false,
|
||||
errors: ['Embedded event is not a valid kind 1 event']
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Verify the kind 1 secp256k1 signature
|
||||
const kind1SigValid = verifyNostrEvent(kind1Event);
|
||||
results.push({
|
||||
algorithm: 'secp256k1 (kind 1 announcement)',
|
||||
valid: kind1SigValid,
|
||||
note: kind1SigValid ? 'valid' : 'INVALID'
|
||||
});
|
||||
let kind1SigValid = false;
|
||||
try {
|
||||
kind1SigValid = verifyNostrEvent(kind1Event);
|
||||
} catch (e) {
|
||||
kind1SigValid = false;
|
||||
errors.push(`secp256k1 (kind 1): ${e.message}`);
|
||||
}
|
||||
pushResult('secp256k1 (kind 1 announcement)', kind1SigValid, kind1SigValid ? 'valid' : 'INVALID');
|
||||
|
||||
// 2b. G56-03: Identity binding check
|
||||
//
|
||||
// The proof carrier pubkey, the embedded kind 1 pubkey, and (if
|
||||
// provided) the expected author must all be equal. Without this,
|
||||
// identity A can wrap identity B's announcement and the verifier
|
||||
// would say "valid" for both.
|
||||
let identityBound = true;
|
||||
const embeddedPubkey = kind1Event.pubkey.toLowerCase();
|
||||
|
||||
if (outerPubkey && outerPubkey.toLowerCase() !== embeddedPubkey) {
|
||||
identityBound = false;
|
||||
errors.push(`identity binding: outer pubkey ${outerPubkey.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`);
|
||||
}
|
||||
|
||||
if (expectedAuthor && expectedAuthor.toLowerCase() !== embeddedPubkey) {
|
||||
identityBound = false;
|
||||
errors.push(`identity binding: expected author ${expectedAuthor.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`);
|
||||
}
|
||||
|
||||
pushResult(
|
||||
'identity binding (outer = embedded = author)',
|
||||
identityBound,
|
||||
identityBound ? 'all identities match' : 'identity mismatch'
|
||||
);
|
||||
|
||||
// 3. Verify the 'e' tag matches the kind 1 event ID
|
||||
const computedKind1Id = computeEventId(kind1Event);
|
||||
const eTag = kind11112Tags.find(t => t[0] === 'e');
|
||||
const eTags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter(t => Array.isArray(t) && t[0] === 'e');
|
||||
let eTagValid = false;
|
||||
if (eTag && eTag[1]) {
|
||||
eTagValid = (eTag[1].toLowerCase() === computedKind1Id.toLowerCase());
|
||||
// F-L4: Also check that kind1Event.id (if present) matches the computed id
|
||||
if (kind1Event.id && kind1Event.id.toLowerCase() !== computedKind1Id.toLowerCase()) {
|
||||
eTagValid = false;
|
||||
if (eTags.length !== 1) {
|
||||
pushResult('e tag (kind 1 event ID)', false, eTags.length === 0 ? 'tag missing' : `duplicate e tags (${eTags.length})`);
|
||||
} else {
|
||||
const eTag = eTags[0];
|
||||
if (eTag[1]) {
|
||||
eTagValid = (eTag[1].toLowerCase() === computedKind1Id.toLowerCase());
|
||||
// F-L4: Also check that kind1Event.id (if present) matches the computed id
|
||||
if (kind1Event.id && kind1Event.id.toLowerCase() !== computedKind1Id.toLowerCase()) {
|
||||
eTagValid = false;
|
||||
}
|
||||
}
|
||||
pushResult('e tag (kind 1 event ID)', eTagValid,
|
||||
eTagValid ? `matches (${computedKind1Id.substring(0, 16)}...)` :
|
||||
`mismatch: e tag=${(eTag[1] || '').substring(0, 16)}... computed=${computedKind1Id.substring(0, 16)}...`);
|
||||
}
|
||||
results.push({
|
||||
algorithm: 'e tag (kind 1 event ID)',
|
||||
valid: eTagValid,
|
||||
note: eTagValid ? `matches (${computedKind1Id.substring(0, 16)}...)` : (eTag ? `mismatch: e tag=${eTag[1].substring(0, 16)}... computed=${computedKind1Id.substring(0, 16)}...` : 'tag missing')
|
||||
});
|
||||
|
||||
// 4. Verify the 'sha256' tag matches SHA-256 of the full kind 1 event JSON
|
||||
const fullHash = hashFullEvent(kind1Event);
|
||||
const sha256Tag = kind11112Tags.find(t => t[0] === 'sha256');
|
||||
const sha256Tags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter(t => Array.isArray(t) && t[0] === 'sha256');
|
||||
let sha256Valid = false;
|
||||
if (sha256Tag && sha256Tag[1]) {
|
||||
sha256Valid = (sha256Tag[1].toLowerCase() === fullHash.toLowerCase());
|
||||
if (sha256Tags.length !== 1) {
|
||||
pushResult('sha256 (full kind 1 event hash)', false, sha256Tags.length === 0 ? 'tag missing' : `duplicate sha256 tags (${sha256Tags.length})`);
|
||||
} else {
|
||||
const sha256Tag = sha256Tags[0];
|
||||
if (sha256Tag[1]) {
|
||||
sha256Valid = (sha256Tag[1].toLowerCase() === fullHash.toLowerCase());
|
||||
}
|
||||
pushResult('sha256 (full kind 1 event hash)', sha256Valid,
|
||||
sha256Valid ? `matches (${fullHash.substring(0, 16)}...)` :
|
||||
`mismatch: tag=${(sha256Tag[1] || '').substring(0, 16)}... computed=${fullHash.substring(0, 16)}...`);
|
||||
}
|
||||
results.push({
|
||||
algorithm: 'sha256 (full kind 1 event hash)',
|
||||
valid: sha256Valid,
|
||||
note: sha256Valid ? `matches (${fullHash.substring(0, 16)}...)` : (sha256Tag ? `mismatch: tag=${sha256Tag[1].substring(0, 16)}... computed=${fullHash.substring(0, 16)}...` : 'tag missing')
|
||||
});
|
||||
|
||||
// 5. Verify each PQ signature in the kind 1 tags against the content text
|
||||
// 5. Verify PQ signatures with strict algorithm policy (G56-01 fix)
|
||||
//
|
||||
// Policy: the kind 1 event MUST contain exactly one tag for each
|
||||
// mandatory signature algorithm and exactly one tag for the KEM
|
||||
// algorithm. Missing signatures on signature algorithms are FAILURES,
|
||||
// not silent KEM-style successes. Unknown algorithms are reported as
|
||||
// ignored (not valid evidence, not a failure unless duplicates of a
|
||||
// known algorithm are present).
|
||||
|
||||
const MANDATORY_SIGNATURE_ALGORITHMS = ['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512'];
|
||||
const KEM_ALGORITHMS = ['ml-kem-768'];
|
||||
const KNOWN_ALGORITHMS = new Set([...MANDATORY_SIGNATURE_ALGORITHMS, ...KEM_ALGORITHMS]);
|
||||
|
||||
// Expected public-key byte lengths for each known algorithm
|
||||
const EXPECTED_PUBKEY_LENGTHS = {
|
||||
'ml-dsa-44': 1312,
|
||||
'ml-dsa-65': 1952,
|
||||
'slh-dsa-128s': 32,
|
||||
'falcon-512': 897,
|
||||
'ml-kem-768': 1184,
|
||||
};
|
||||
|
||||
const msg = new TextEncoder().encode(kind1Event.content);
|
||||
|
||||
// Collect all algorithm tags, grouped by algorithm ID
|
||||
const algorithmTags = []; // [{algorithm, pubKeyBase64, sigBase64, tag}]
|
||||
const algorithmCounts = {}; // algorithm -> count
|
||||
|
||||
for (const tag of kind1Event.tags) {
|
||||
if (tag[0] !== 'algorithm') continue;
|
||||
|
||||
if (!Array.isArray(tag) || tag[0] !== 'algorithm') continue;
|
||||
const algorithm = tag[1];
|
||||
const pubKeyBase64 = tag[2];
|
||||
const sigBase64 = tag[3]; // undefined for ML-KEM
|
||||
if (typeof algorithm !== 'string') {
|
||||
pushResult('algorithm tag', false, 'algorithm identifier is not a string');
|
||||
continue;
|
||||
}
|
||||
algorithmCounts[algorithm] = (algorithmCounts[algorithm] || 0) + 1;
|
||||
algorithmTags.push({
|
||||
algorithm,
|
||||
pubKeyBase64: tag[2],
|
||||
sigBase64: tag[3],
|
||||
tag
|
||||
});
|
||||
}
|
||||
|
||||
if (algorithm === 'ml-kem-768' || !sigBase64) {
|
||||
// KEM can't sign — skip verification
|
||||
results.push({ algorithm, valid: true, note: 'KEM (no signature to verify)' });
|
||||
// Check for duplicates of known algorithms
|
||||
for (const alg of KNOWN_ALGORITHMS) {
|
||||
if (algorithmCounts[alg] > 1) {
|
||||
pushResult(alg, false, `duplicate algorithm tags (${algorithmCounts[alg]})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each known algorithm tag
|
||||
let pqProofsValid = true; // all present PQ proofs verify
|
||||
|
||||
for (const entry of algorithmTags) {
|
||||
const { algorithm, pubKeyBase64, sigBase64 } = entry;
|
||||
|
||||
// Unknown algorithms: report as ignored, not valid evidence
|
||||
if (!KNOWN_ALGORITHMS.has(algorithm)) {
|
||||
pushResult(algorithm, false, 'unknown algorithm (ignored — not counted as valid evidence)');
|
||||
continue;
|
||||
}
|
||||
|
||||
const pubKey = base64ToBytes(pubKeyBase64);
|
||||
const sig = base64ToBytes(sigBase64);
|
||||
const isKEM = KEM_ALGORITHMS.includes(algorithm);
|
||||
const isSignature = MANDATORY_SIGNATURE_ALGORITHMS.includes(algorithm);
|
||||
|
||||
let valid = false;
|
||||
if (algorithm === 'ml-dsa-44') {
|
||||
valid = verifyMLDSA44(sig, msg, pubKey);
|
||||
} else if (algorithm === 'ml-dsa-65') {
|
||||
valid = verifyMLDSA65(sig, msg, pubKey);
|
||||
} else if (algorithm === 'slh-dsa-128s') {
|
||||
valid = verifySLHDSA(sig, msg, pubKey);
|
||||
} else if (algorithm === 'falcon-512') {
|
||||
valid = verifyFalcon(sig, msg, pubKey);
|
||||
// Validate public key presence and decode
|
||||
let pubKey;
|
||||
try {
|
||||
pubKey = safeBase64ToBytes(pubKeyBase64, `${algorithm} public key`);
|
||||
} catch (e) {
|
||||
pushResult(algorithm, false, `public key decode failed: ${e.message}`);
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ algorithm, valid });
|
||||
// Validate public key length
|
||||
const expectedLen = EXPECTED_PUBKEY_LENGTHS[algorithm];
|
||||
if (pubKey.length !== expectedLen) {
|
||||
pushResult(algorithm, false, `public key length mismatch: got ${pubKey.length}, expected ${expectedLen}`);
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isKEM) {
|
||||
// KEM: no signature to verify. Report as present and valid
|
||||
// (the pubkey is authorized by the secp signature over the
|
||||
// event). This is NOT a cryptographic proof of possession.
|
||||
pushResult(algorithm, true, 'KEM public key present (no signature — authorized by secp event signature)');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Signature algorithm: signature is MANDATORY
|
||||
if (!sigBase64) {
|
||||
pushResult(algorithm, false, 'missing signature (signature algorithms MUST have a signature field)');
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let sig;
|
||||
try {
|
||||
sig = safeBase64ToBytes(sigBase64, `${algorithm} signature`);
|
||||
} catch (e) {
|
||||
pushResult(algorithm, false, `signature decode failed: ${e.message}`);
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify the PQ signature
|
||||
let valid = false;
|
||||
try {
|
||||
if (algorithm === 'ml-dsa-44') {
|
||||
valid = verifyMLDSA44(sig, msg, pubKey);
|
||||
} else if (algorithm === 'ml-dsa-65') {
|
||||
valid = verifyMLDSA65(sig, msg, pubKey);
|
||||
} else if (algorithm === 'slh-dsa-128s') {
|
||||
valid = verifySLHDSA(sig, msg, pubKey);
|
||||
} else if (algorithm === 'falcon-512') {
|
||||
valid = verifyFalcon(sig, msg, pubKey);
|
||||
}
|
||||
} catch (e) {
|
||||
pushResult(algorithm, false, `verification threw: ${e.message}`);
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
pushResult(algorithm, valid, valid ? 'valid' : 'INVALID signature');
|
||||
if (!valid) pqProofsValid = false;
|
||||
}
|
||||
|
||||
// 6. Check mandatory algorithm policy (G56-01 core fix)
|
||||
//
|
||||
// policySufficient is true only when every mandatory signature
|
||||
// algorithm AND the KEM algorithm are present exactly once and all
|
||||
// present PQ proofs verify.
|
||||
const policyErrors = [];
|
||||
for (const alg of MANDATORY_SIGNATURE_ALGORITHMS) {
|
||||
const count = algorithmCounts[alg] || 0;
|
||||
if (count === 0) {
|
||||
policyErrors.push(`missing mandatory algorithm: ${alg}`);
|
||||
} else if (count > 1) {
|
||||
policyErrors.push(`duplicate algorithm: ${alg} (${count} tags)`);
|
||||
}
|
||||
}
|
||||
for (const alg of KEM_ALGORITHMS) {
|
||||
const count = algorithmCounts[alg] || 0;
|
||||
if (count === 0) {
|
||||
policyErrors.push(`missing KEM algorithm: ${alg}`);
|
||||
} else if (count > 1) {
|
||||
policyErrors.push(`duplicate algorithm: ${alg} (${count} tags)`);
|
||||
}
|
||||
}
|
||||
|
||||
const policySufficient = policyErrors.length === 0 && pqProofsValid;
|
||||
if (policyErrors.length > 0) {
|
||||
for (const e of policyErrors) errors.push(e);
|
||||
}
|
||||
|
||||
// 7. Compute final results
|
||||
const allChecksPassed = results.every(r => r.valid);
|
||||
const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient && identityBound;
|
||||
|
||||
return {
|
||||
valid: results.every(r => r.valid),
|
||||
valid: allChecksPassed,
|
||||
results,
|
||||
kind1Event,
|
||||
fullHash,
|
||||
sha256Valid,
|
||||
eTagValid
|
||||
eTagValid,
|
||||
pqProofsValid,
|
||||
policySufficient,
|
||||
validForMigration,
|
||||
identityBound,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CANONICAL PROOF CARRIER SELECTION (G56-02)
|
||||
// ============================================================================
|
||||
//
|
||||
// When multiple proof carrier events exist for the same pubkey (e.g. a
|
||||
// pending one and a later confirmed one, or multiple migration attempts),
|
||||
// the client must select the one with the earliest valid Bitcoin anchor.
|
||||
// This implements the "earliest valid anchor wins" rule from the NIP proposal.
|
||||
|
||||
/**
|
||||
* Select the canonical proof carrier from an array of candidates.
|
||||
*
|
||||
* This function:
|
||||
* 1. Filters candidates whose `e` tag matches the kind 1 event ID
|
||||
* 2. Filters candidates whose `sha256` tag matches the kind 1 event hash
|
||||
* 3. Filters candidates with a valid secp256k1 signature
|
||||
* 4. For each remaining candidate, verifies its OTS proof against the
|
||||
* expected digest (the sha256 tag)
|
||||
* 5. Among candidates with a verified Bitcoin attestation, selects the
|
||||
* one with the earliest block height (deterministic tie-break by
|
||||
* event ID lexicographically)
|
||||
* 6. If no candidate has a confirmed Bitcoin anchor, returns the best
|
||||
* pending candidate
|
||||
*
|
||||
* @param {Array} candidates - Array of proof carrier events (kind 9999)
|
||||
* @param {object} kind1Event - The kind 1 announcement event they reference
|
||||
* @param {string} [expectedAuthor] - Expected author pubkey (hex) for identity binding
|
||||
* @returns {Promise<{canonical: object|null, allCandidates: Array, confirmedCandidates: Array, pendingCandidates: Array, errors: Array}>}
|
||||
*/
|
||||
export async function selectCanonicalProofCarrier(candidates, kind1Event, expectedAuthor) {
|
||||
const errors = [];
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
return { canonical: null, allCandidates: [], confirmedCandidates: [], pendingCandidates: [], errors: ['No candidates provided'] };
|
||||
}
|
||||
if (!kind1Event || !kind1Event.id) {
|
||||
return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors: ['No kind 1 event provided'] };
|
||||
}
|
||||
|
||||
const expectedKind1Id = kind1Event.id.toLowerCase();
|
||||
const expectedHash = hashFullEvent(kind1Event).toLowerCase();
|
||||
|
||||
// Step 1-3: Filter candidates by e tag, sha256 tag, and secp signature
|
||||
const validCandidates = [];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== 'object') continue;
|
||||
if (candidate.kind !== NIP_QR_KIND) {
|
||||
errors.push(`candidate ${candidate.id || '?'}: wrong kind ${candidate.kind}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check e tag matches kind 1 event ID
|
||||
const eTag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'e');
|
||||
if (!eTag || !eTag[1] || eTag[1].toLowerCase() !== expectedKind1Id) {
|
||||
errors.push(`candidate ${candidate.id || '?'}: e tag does not match kind 1 event ID`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check sha256 tag matches kind 1 event hash
|
||||
const sha256Tag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'sha256');
|
||||
if (!sha256Tag || !sha256Tag[1] || sha256Tag[1].toLowerCase() !== expectedHash) {
|
||||
errors.push(`candidate ${candidate.id || '?'}: sha256 tag does not match kind 1 event hash`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check secp signature
|
||||
let secpValid = false;
|
||||
try {
|
||||
secpValid = verifyNostrEvent(candidate);
|
||||
} catch (e) {
|
||||
secpValid = false;
|
||||
}
|
||||
if (!secpValid) {
|
||||
errors.push(`candidate ${candidate.id || '?'}: invalid secp256k1 signature`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// G56-03: Check identity binding if expectedAuthor is provided
|
||||
if (expectedAuthor && candidate.pubkey && candidate.pubkey.toLowerCase() !== expectedAuthor.toLowerCase()) {
|
||||
errors.push(`candidate ${candidate.id || '?'}: pubkey does not match expected author`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validCandidates.push(candidate);
|
||||
}
|
||||
|
||||
if (validCandidates.length === 0) {
|
||||
return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors };
|
||||
}
|
||||
|
||||
// Step 4: Verify OTS proof for each valid candidate
|
||||
const confirmedCandidates = [];
|
||||
const pendingCandidates = [];
|
||||
|
||||
for (const candidate of validCandidates) {
|
||||
const otsTag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'ots');
|
||||
if (!otsTag || !otsTag[1]) {
|
||||
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: ['no ots tag'] });
|
||||
continue;
|
||||
}
|
||||
|
||||
let otsBytes;
|
||||
try {
|
||||
otsBytes = base64ToBytes(otsTag[1]);
|
||||
} catch (e) {
|
||||
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots decode failed: ${e.message}`] });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const verifyResult = await verifyOtsProof(otsBytes, expectedHash);
|
||||
if (verifyResult.verified && verifyResult.bitcoinAttestations.length > 0) {
|
||||
confirmedCandidates.push({
|
||||
event: candidate,
|
||||
bitcoinHeight: verifyResult.bitcoinAttestations[0].height,
|
||||
bitcoinTime: verifyResult.bitcoinAttestations[0].time,
|
||||
errors: []
|
||||
});
|
||||
} else {
|
||||
pendingCandidates.push({
|
||||
event: candidate,
|
||||
bitcoinHeight: null,
|
||||
errors: verifyResult.errors
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots verification threw: ${e.message}`] });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Among confirmed candidates, select earliest Bitcoin block height
|
||||
if (confirmedCandidates.length > 0) {
|
||||
confirmedCandidates.sort((a, b) => {
|
||||
// Primary sort: lowest Bitcoin block height
|
||||
if (a.bitcoinHeight !== b.bitcoinHeight) {
|
||||
return a.bitcoinHeight - b.bitcoinHeight;
|
||||
}
|
||||
// Tie-break: lowest event ID lexicographically (deterministic)
|
||||
return (a.event.id || '').localeCompare(b.event.id || '');
|
||||
});
|
||||
|
||||
return {
|
||||
canonical: confirmedCandidates[0].event,
|
||||
canonicalBitcoinHeight: confirmedCandidates[0].bitcoinHeight,
|
||||
canonicalBitcoinTime: confirmedCandidates[0].bitcoinTime,
|
||||
allCandidates: candidates,
|
||||
confirmedCandidates,
|
||||
pendingCandidates,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
// Step 6: No confirmed candidates — return the best pending one
|
||||
// (earliest created_at, tie-break by event ID)
|
||||
if (pendingCandidates.length > 0) {
|
||||
pendingCandidates.sort((a, b) => {
|
||||
if (a.event.created_at !== b.event.created_at) {
|
||||
return a.event.created_at - b.event.created_at;
|
||||
}
|
||||
return (a.event.id || '').localeCompare(b.event.id || '');
|
||||
});
|
||||
return {
|
||||
canonical: pendingCandidates[0].event,
|
||||
canonicalBitcoinHeight: null,
|
||||
canonicalBitcoinTime: null,
|
||||
allCandidates: candidates,
|
||||
confirmedCandidates: [],
|
||||
pendingCandidates,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canonical: null,
|
||||
canonicalBitcoinHeight: null,
|
||||
canonicalBitcoinTime: null,
|
||||
allCandidates: candidates,
|
||||
confirmedCandidates: [],
|
||||
pendingCandidates: [],
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.0.9",
|
||||
"VERSION_NUMBER": "0.0.9",
|
||||
"BUILD_DATE": "2026-07-19T12:29:27.131Z"
|
||||
"VERSION": "v0.0.15",
|
||||
"VERSION_NUMBER": "0.0.15",
|
||||
"BUILD_DATE": "2026-07-24T11:17:37.498Z"
|
||||
}
|
||||
|
||||
+357
-65
@@ -9771,7 +9771,7 @@ function verifyNostrEvent(event) {
|
||||
const pubkey = hexToBytes3(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
}
|
||||
var NIP_QR_KIND = 11112;
|
||||
var NIP_QR_KIND = 9999;
|
||||
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
|
||||
const npub = hexToNpub(hexPubkey);
|
||||
const content = `I am signaling that the post-quantum public keys listed in the tags of this event were generated by me and I hold the private keys. I may use these keys in the future as successors to my current Nostr identity.
|
||||
@@ -9830,33 +9830,65 @@ function hashFullEvent(signedEvent) {
|
||||
const json = JSON.stringify(signedEvent);
|
||||
return bytesToHex3(sha256(new TextEncoder().encode(json)));
|
||||
}
|
||||
function buildKind11112Wrapper(kind1Event, otsProof) {
|
||||
function buildProofCarrier(kind1Event, otsProof, options = {}) {
|
||||
const fullHash = hashFullEvent(kind1Event);
|
||||
const otsStatus = options.otsStatus || "pending";
|
||||
const tags = [
|
||||
["e", kind1Event.id],
|
||||
["sha256", fullHash],
|
||||
["ots", bytesToBase64(otsProof)],
|
||||
["ots_status", otsStatus]
|
||||
];
|
||||
if (options.upgradeOf) {
|
||||
tags.push(["upgrade_of", options.upgradeOf]);
|
||||
}
|
||||
return {
|
||||
kind: NIP_QR_KIND,
|
||||
content: JSON.stringify(kind1Event),
|
||||
tags: [
|
||||
["e", kind1Event.id],
|
||||
["sha256", fullHash],
|
||||
["ots", bytesToBase64(otsProof)]
|
||||
],
|
||||
tags,
|
||||
pubkey: kind1Event.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1e3)
|
||||
};
|
||||
}
|
||||
function buildUpgradedEvent(originalEvent, upgradedOtsProof) {
|
||||
const tags = originalEvent.tags.filter((tag) => tag[0] !== "ots");
|
||||
tags.push(["ots", bytesToBase64(upgradedOtsProof)]);
|
||||
return {
|
||||
kind: NIP_QR_KIND,
|
||||
created_at: Math.floor(Date.now() / 1e3),
|
||||
tags,
|
||||
content: originalEvent.content,
|
||||
pubkey: originalEvent.pubkey
|
||||
};
|
||||
function buildKind11112Wrapper(kind1Event, otsProof) {
|
||||
return buildProofCarrier(kind1Event, otsProof);
|
||||
}
|
||||
function verifyNIPQRContent(kind11112Content, kind11112Tags) {
|
||||
function buildUpgradedEvent(originalEvent, upgradedOtsProof) {
|
||||
let kind1Event;
|
||||
try {
|
||||
kind1Event = JSON.parse(originalEvent.content);
|
||||
} catch (e) {
|
||||
const tags = originalEvent.tags.filter((t) => t[0] !== "ots" && t[0] !== "ots_status" && t[0] !== "upgrade_of").map((t) => [...t]);
|
||||
tags.push(["ots", bytesToBase64(upgradedOtsProof)]);
|
||||
tags.push(["ots_status", "confirmed"]);
|
||||
tags.push(["upgrade_of", originalEvent.id]);
|
||||
return {
|
||||
kind: NIP_QR_KIND,
|
||||
created_at: Math.floor(Date.now() / 1e3),
|
||||
tags,
|
||||
content: originalEvent.content,
|
||||
pubkey: originalEvent.pubkey
|
||||
};
|
||||
}
|
||||
return buildProofCarrier(kind1Event, upgradedOtsProof, {
|
||||
otsStatus: "confirmed",
|
||||
upgradeOf: originalEvent.id
|
||||
});
|
||||
}
|
||||
function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}) {
|
||||
const results = [];
|
||||
const errors = [];
|
||||
const { expectedAuthor, outerPubkey } = options;
|
||||
function pushResult(algorithm, valid, note) {
|
||||
results.push({ algorithm, valid, note });
|
||||
if (!valid) errors.push(`${algorithm}: ${note || "INVALID"}`);
|
||||
}
|
||||
function safeBase64ToBytes(b64, label) {
|
||||
if (typeof b64 !== "string" || b64.length === 0) {
|
||||
throw new Error(`${label}: missing or empty`);
|
||||
}
|
||||
return base64ToBytes(b64);
|
||||
}
|
||||
let kind1Event;
|
||||
try {
|
||||
kind1Event = JSON.parse(kind11112Content);
|
||||
@@ -9867,81 +9899,339 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) {
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false
|
||||
eTagValid: false,
|
||||
pqProofsValid: false,
|
||||
policySufficient: false,
|
||||
validForMigration: false,
|
||||
identityBound: false,
|
||||
errors: ["Content is not valid JSON"]
|
||||
};
|
||||
}
|
||||
if (!kind1Event.pubkey || !kind1Event.sig || !kind1Event.content || !kind1Event.tags || kind1Event.kind !== 1) {
|
||||
if (!kind1Event || typeof kind1Event !== "object" || !kind1Event.pubkey || !kind1Event.sig || !kind1Event.content || !Array.isArray(kind1Event.tags) || kind1Event.kind !== 1) {
|
||||
return {
|
||||
valid: false,
|
||||
results: [{ algorithm: "kind 1 content", valid: false, note: "Embedded event is not a valid kind 1 event" }],
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false
|
||||
eTagValid: false,
|
||||
pqProofsValid: false,
|
||||
policySufficient: false,
|
||||
validForMigration: false,
|
||||
identityBound: false,
|
||||
errors: ["Embedded event is not a valid kind 1 event"]
|
||||
};
|
||||
}
|
||||
const kind1SigValid = verifyNostrEvent(kind1Event);
|
||||
results.push({
|
||||
algorithm: "secp256k1 (kind 1 announcement)",
|
||||
valid: kind1SigValid,
|
||||
note: kind1SigValid ? "valid" : "INVALID"
|
||||
});
|
||||
let kind1SigValid = false;
|
||||
try {
|
||||
kind1SigValid = verifyNostrEvent(kind1Event);
|
||||
} catch (e) {
|
||||
kind1SigValid = false;
|
||||
errors.push(`secp256k1 (kind 1): ${e.message}`);
|
||||
}
|
||||
pushResult("secp256k1 (kind 1 announcement)", kind1SigValid, kind1SigValid ? "valid" : "INVALID");
|
||||
let identityBound = true;
|
||||
const embeddedPubkey = kind1Event.pubkey.toLowerCase();
|
||||
if (outerPubkey && outerPubkey.toLowerCase() !== embeddedPubkey) {
|
||||
identityBound = false;
|
||||
errors.push(`identity binding: outer pubkey ${outerPubkey.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`);
|
||||
}
|
||||
if (expectedAuthor && expectedAuthor.toLowerCase() !== embeddedPubkey) {
|
||||
identityBound = false;
|
||||
errors.push(`identity binding: expected author ${expectedAuthor.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`);
|
||||
}
|
||||
pushResult(
|
||||
"identity binding (outer = embedded = author)",
|
||||
identityBound,
|
||||
identityBound ? "all identities match" : "identity mismatch"
|
||||
);
|
||||
const computedKind1Id = computeEventId(kind1Event);
|
||||
const eTag = kind11112Tags.find((t) => t[0] === "e");
|
||||
const eTags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter((t) => Array.isArray(t) && t[0] === "e");
|
||||
let eTagValid = false;
|
||||
if (eTag && eTag[1]) {
|
||||
eTagValid = eTag[1].toLowerCase() === computedKind1Id.toLowerCase();
|
||||
if (kind1Event.id && kind1Event.id.toLowerCase() !== computedKind1Id.toLowerCase()) {
|
||||
eTagValid = false;
|
||||
if (eTags.length !== 1) {
|
||||
pushResult("e tag (kind 1 event ID)", false, eTags.length === 0 ? "tag missing" : `duplicate e tags (${eTags.length})`);
|
||||
} else {
|
||||
const eTag = eTags[0];
|
||||
if (eTag[1]) {
|
||||
eTagValid = eTag[1].toLowerCase() === computedKind1Id.toLowerCase();
|
||||
if (kind1Event.id && kind1Event.id.toLowerCase() !== computedKind1Id.toLowerCase()) {
|
||||
eTagValid = false;
|
||||
}
|
||||
}
|
||||
pushResult(
|
||||
"e tag (kind 1 event ID)",
|
||||
eTagValid,
|
||||
eTagValid ? `matches (${computedKind1Id.substring(0, 16)}...)` : `mismatch: e tag=${(eTag[1] || "").substring(0, 16)}... computed=${computedKind1Id.substring(0, 16)}...`
|
||||
);
|
||||
}
|
||||
results.push({
|
||||
algorithm: "e tag (kind 1 event ID)",
|
||||
valid: eTagValid,
|
||||
note: eTagValid ? `matches (${computedKind1Id.substring(0, 16)}...)` : eTag ? `mismatch: e tag=${eTag[1].substring(0, 16)}... computed=${computedKind1Id.substring(0, 16)}...` : "tag missing"
|
||||
});
|
||||
const fullHash = hashFullEvent(kind1Event);
|
||||
const sha256Tag = kind11112Tags.find((t) => t[0] === "sha256");
|
||||
const sha256Tags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter((t) => Array.isArray(t) && t[0] === "sha256");
|
||||
let sha256Valid = false;
|
||||
if (sha256Tag && sha256Tag[1]) {
|
||||
sha256Valid = sha256Tag[1].toLowerCase() === fullHash.toLowerCase();
|
||||
if (sha256Tags.length !== 1) {
|
||||
pushResult("sha256 (full kind 1 event hash)", false, sha256Tags.length === 0 ? "tag missing" : `duplicate sha256 tags (${sha256Tags.length})`);
|
||||
} else {
|
||||
const sha256Tag = sha256Tags[0];
|
||||
if (sha256Tag[1]) {
|
||||
sha256Valid = sha256Tag[1].toLowerCase() === fullHash.toLowerCase();
|
||||
}
|
||||
pushResult(
|
||||
"sha256 (full kind 1 event hash)",
|
||||
sha256Valid,
|
||||
sha256Valid ? `matches (${fullHash.substring(0, 16)}...)` : `mismatch: tag=${(sha256Tag[1] || "").substring(0, 16)}... computed=${fullHash.substring(0, 16)}...`
|
||||
);
|
||||
}
|
||||
results.push({
|
||||
algorithm: "sha256 (full kind 1 event hash)",
|
||||
valid: sha256Valid,
|
||||
note: sha256Valid ? `matches (${fullHash.substring(0, 16)}...)` : sha256Tag ? `mismatch: tag=${sha256Tag[1].substring(0, 16)}... computed=${fullHash.substring(0, 16)}...` : "tag missing"
|
||||
});
|
||||
const MANDATORY_SIGNATURE_ALGORITHMS = ["ml-dsa-44", "ml-dsa-65", "slh-dsa-128s", "falcon-512"];
|
||||
const KEM_ALGORITHMS = ["ml-kem-768"];
|
||||
const KNOWN_ALGORITHMS = /* @__PURE__ */ new Set([...MANDATORY_SIGNATURE_ALGORITHMS, ...KEM_ALGORITHMS]);
|
||||
const EXPECTED_PUBKEY_LENGTHS = {
|
||||
"ml-dsa-44": 1312,
|
||||
"ml-dsa-65": 1952,
|
||||
"slh-dsa-128s": 32,
|
||||
"falcon-512": 897,
|
||||
"ml-kem-768": 1184
|
||||
};
|
||||
const msg = new TextEncoder().encode(kind1Event.content);
|
||||
const algorithmTags = [];
|
||||
const algorithmCounts = {};
|
||||
for (const tag of kind1Event.tags) {
|
||||
if (tag[0] !== "algorithm") continue;
|
||||
if (!Array.isArray(tag) || tag[0] !== "algorithm") continue;
|
||||
const algorithm = tag[1];
|
||||
const pubKeyBase64 = tag[2];
|
||||
const sigBase64 = tag[3];
|
||||
if (algorithm === "ml-kem-768" || !sigBase64) {
|
||||
results.push({ algorithm, valid: true, note: "KEM (no signature to verify)" });
|
||||
if (typeof algorithm !== "string") {
|
||||
pushResult("algorithm tag", false, "algorithm identifier is not a string");
|
||||
continue;
|
||||
}
|
||||
const pubKey = base64ToBytes(pubKeyBase64);
|
||||
const sig = base64ToBytes(sigBase64);
|
||||
let valid = false;
|
||||
if (algorithm === "ml-dsa-44") {
|
||||
valid = verifyMLDSA44(sig, msg, pubKey);
|
||||
} else if (algorithm === "ml-dsa-65") {
|
||||
valid = verifyMLDSA65(sig, msg, pubKey);
|
||||
} else if (algorithm === "slh-dsa-128s") {
|
||||
valid = verifySLHDSA(sig, msg, pubKey);
|
||||
} else if (algorithm === "falcon-512") {
|
||||
valid = verifyFalcon(sig, msg, pubKey);
|
||||
}
|
||||
results.push({ algorithm, valid });
|
||||
algorithmCounts[algorithm] = (algorithmCounts[algorithm] || 0) + 1;
|
||||
algorithmTags.push({
|
||||
algorithm,
|
||||
pubKeyBase64: tag[2],
|
||||
sigBase64: tag[3],
|
||||
tag
|
||||
});
|
||||
}
|
||||
for (const alg of KNOWN_ALGORITHMS) {
|
||||
if (algorithmCounts[alg] > 1) {
|
||||
pushResult(alg, false, `duplicate algorithm tags (${algorithmCounts[alg]})`);
|
||||
}
|
||||
}
|
||||
let pqProofsValid = true;
|
||||
for (const entry of algorithmTags) {
|
||||
const { algorithm, pubKeyBase64, sigBase64 } = entry;
|
||||
if (!KNOWN_ALGORITHMS.has(algorithm)) {
|
||||
pushResult(algorithm, false, "unknown algorithm (ignored \u2014 not counted as valid evidence)");
|
||||
continue;
|
||||
}
|
||||
const isKEM = KEM_ALGORITHMS.includes(algorithm);
|
||||
const isSignature = MANDATORY_SIGNATURE_ALGORITHMS.includes(algorithm);
|
||||
let pubKey;
|
||||
try {
|
||||
pubKey = safeBase64ToBytes(pubKeyBase64, `${algorithm} public key`);
|
||||
} catch (e) {
|
||||
pushResult(algorithm, false, `public key decode failed: ${e.message}`);
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
const expectedLen = EXPECTED_PUBKEY_LENGTHS[algorithm];
|
||||
if (pubKey.length !== expectedLen) {
|
||||
pushResult(algorithm, false, `public key length mismatch: got ${pubKey.length}, expected ${expectedLen}`);
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
if (isKEM) {
|
||||
pushResult(algorithm, true, "KEM public key present (no signature \u2014 authorized by secp event signature)");
|
||||
continue;
|
||||
}
|
||||
if (!sigBase64) {
|
||||
pushResult(algorithm, false, "missing signature (signature algorithms MUST have a signature field)");
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
let sig;
|
||||
try {
|
||||
sig = safeBase64ToBytes(sigBase64, `${algorithm} signature`);
|
||||
} catch (e) {
|
||||
pushResult(algorithm, false, `signature decode failed: ${e.message}`);
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
let valid = false;
|
||||
try {
|
||||
if (algorithm === "ml-dsa-44") {
|
||||
valid = verifyMLDSA44(sig, msg, pubKey);
|
||||
} else if (algorithm === "ml-dsa-65") {
|
||||
valid = verifyMLDSA65(sig, msg, pubKey);
|
||||
} else if (algorithm === "slh-dsa-128s") {
|
||||
valid = verifySLHDSA(sig, msg, pubKey);
|
||||
} else if (algorithm === "falcon-512") {
|
||||
valid = verifyFalcon(sig, msg, pubKey);
|
||||
}
|
||||
} catch (e) {
|
||||
pushResult(algorithm, false, `verification threw: ${e.message}`);
|
||||
pqProofsValid = false;
|
||||
continue;
|
||||
}
|
||||
pushResult(algorithm, valid, valid ? "valid" : "INVALID signature");
|
||||
if (!valid) pqProofsValid = false;
|
||||
}
|
||||
const policyErrors = [];
|
||||
for (const alg of MANDATORY_SIGNATURE_ALGORITHMS) {
|
||||
const count = algorithmCounts[alg] || 0;
|
||||
if (count === 0) {
|
||||
policyErrors.push(`missing mandatory algorithm: ${alg}`);
|
||||
} else if (count > 1) {
|
||||
policyErrors.push(`duplicate algorithm: ${alg} (${count} tags)`);
|
||||
}
|
||||
}
|
||||
for (const alg of KEM_ALGORITHMS) {
|
||||
const count = algorithmCounts[alg] || 0;
|
||||
if (count === 0) {
|
||||
policyErrors.push(`missing KEM algorithm: ${alg}`);
|
||||
} else if (count > 1) {
|
||||
policyErrors.push(`duplicate algorithm: ${alg} (${count} tags)`);
|
||||
}
|
||||
}
|
||||
const policySufficient = policyErrors.length === 0 && pqProofsValid;
|
||||
if (policyErrors.length > 0) {
|
||||
for (const e of policyErrors) errors.push(e);
|
||||
}
|
||||
const allChecksPassed = results.every((r) => r.valid);
|
||||
const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient && identityBound;
|
||||
return {
|
||||
valid: results.every((r) => r.valid),
|
||||
valid: allChecksPassed,
|
||||
results,
|
||||
kind1Event,
|
||||
fullHash,
|
||||
sha256Valid,
|
||||
eTagValid
|
||||
eTagValid,
|
||||
pqProofsValid,
|
||||
policySufficient,
|
||||
validForMigration,
|
||||
identityBound,
|
||||
errors
|
||||
};
|
||||
}
|
||||
async function selectCanonicalProofCarrier(candidates, kind1Event, expectedAuthor) {
|
||||
const errors = [];
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
return { canonical: null, allCandidates: [], confirmedCandidates: [], pendingCandidates: [], errors: ["No candidates provided"] };
|
||||
}
|
||||
if (!kind1Event || !kind1Event.id) {
|
||||
return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors: ["No kind 1 event provided"] };
|
||||
}
|
||||
const expectedKind1Id = kind1Event.id.toLowerCase();
|
||||
const expectedHash = hashFullEvent(kind1Event).toLowerCase();
|
||||
const validCandidates = [];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== "object") continue;
|
||||
if (candidate.kind !== NIP_QR_KIND) {
|
||||
errors.push(`candidate ${candidate.id || "?"}: wrong kind ${candidate.kind}`);
|
||||
continue;
|
||||
}
|
||||
const eTag = (candidate.tags || []).find((t) => Array.isArray(t) && t[0] === "e");
|
||||
if (!eTag || !eTag[1] || eTag[1].toLowerCase() !== expectedKind1Id) {
|
||||
errors.push(`candidate ${candidate.id || "?"}: e tag does not match kind 1 event ID`);
|
||||
continue;
|
||||
}
|
||||
const sha256Tag = (candidate.tags || []).find((t) => Array.isArray(t) && t[0] === "sha256");
|
||||
if (!sha256Tag || !sha256Tag[1] || sha256Tag[1].toLowerCase() !== expectedHash) {
|
||||
errors.push(`candidate ${candidate.id || "?"}: sha256 tag does not match kind 1 event hash`);
|
||||
continue;
|
||||
}
|
||||
let secpValid = false;
|
||||
try {
|
||||
secpValid = verifyNostrEvent(candidate);
|
||||
} catch (e) {
|
||||
secpValid = false;
|
||||
}
|
||||
if (!secpValid) {
|
||||
errors.push(`candidate ${candidate.id || "?"}: invalid secp256k1 signature`);
|
||||
continue;
|
||||
}
|
||||
if (expectedAuthor && candidate.pubkey && candidate.pubkey.toLowerCase() !== expectedAuthor.toLowerCase()) {
|
||||
errors.push(`candidate ${candidate.id || "?"}: pubkey does not match expected author`);
|
||||
continue;
|
||||
}
|
||||
validCandidates.push(candidate);
|
||||
}
|
||||
if (validCandidates.length === 0) {
|
||||
return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors };
|
||||
}
|
||||
const confirmedCandidates = [];
|
||||
const pendingCandidates = [];
|
||||
for (const candidate of validCandidates) {
|
||||
const otsTag = (candidate.tags || []).find((t) => Array.isArray(t) && t[0] === "ots");
|
||||
if (!otsTag || !otsTag[1]) {
|
||||
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: ["no ots tag"] });
|
||||
continue;
|
||||
}
|
||||
let otsBytes;
|
||||
try {
|
||||
otsBytes = base64ToBytes(otsTag[1]);
|
||||
} catch (e) {
|
||||
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots decode failed: ${e.message}`] });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const verifyResult = await verifyOtsProof(otsBytes, expectedHash);
|
||||
if (verifyResult.verified && verifyResult.bitcoinAttestations.length > 0) {
|
||||
confirmedCandidates.push({
|
||||
event: candidate,
|
||||
bitcoinHeight: verifyResult.bitcoinAttestations[0].height,
|
||||
bitcoinTime: verifyResult.bitcoinAttestations[0].time,
|
||||
errors: []
|
||||
});
|
||||
} else {
|
||||
pendingCandidates.push({
|
||||
event: candidate,
|
||||
bitcoinHeight: null,
|
||||
errors: verifyResult.errors
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots verification threw: ${e.message}`] });
|
||||
}
|
||||
}
|
||||
if (confirmedCandidates.length > 0) {
|
||||
confirmedCandidates.sort((a, b) => {
|
||||
if (a.bitcoinHeight !== b.bitcoinHeight) {
|
||||
return a.bitcoinHeight - b.bitcoinHeight;
|
||||
}
|
||||
return (a.event.id || "").localeCompare(b.event.id || "");
|
||||
});
|
||||
return {
|
||||
canonical: confirmedCandidates[0].event,
|
||||
canonicalBitcoinHeight: confirmedCandidates[0].bitcoinHeight,
|
||||
canonicalBitcoinTime: confirmedCandidates[0].bitcoinTime,
|
||||
allCandidates: candidates,
|
||||
confirmedCandidates,
|
||||
pendingCandidates,
|
||||
errors
|
||||
};
|
||||
}
|
||||
if (pendingCandidates.length > 0) {
|
||||
pendingCandidates.sort((a, b) => {
|
||||
if (a.event.created_at !== b.event.created_at) {
|
||||
return a.event.created_at - b.event.created_at;
|
||||
}
|
||||
return (a.event.id || "").localeCompare(b.event.id || "");
|
||||
});
|
||||
return {
|
||||
canonical: pendingCandidates[0].event,
|
||||
canonicalBitcoinHeight: null,
|
||||
canonicalBitcoinTime: null,
|
||||
allCandidates: candidates,
|
||||
confirmedCandidates: [],
|
||||
pendingCandidates,
|
||||
errors
|
||||
};
|
||||
}
|
||||
return {
|
||||
canonical: null,
|
||||
canonicalBitcoinHeight: null,
|
||||
canonicalBitcoinTime: null,
|
||||
allCandidates: candidates,
|
||||
confirmedCandidates: [],
|
||||
pendingCandidates: [],
|
||||
errors
|
||||
};
|
||||
}
|
||||
var PQ_KEY_INFO = {
|
||||
@@ -10367,6 +10657,7 @@ export {
|
||||
base64ToBytes,
|
||||
buildKind11112Wrapper,
|
||||
buildKind1Announcement,
|
||||
buildProofCarrier,
|
||||
buildUpgradedEvent,
|
||||
bytesToBase64,
|
||||
bytesToHex3 as bytesToHex,
|
||||
@@ -10386,6 +10677,7 @@ export {
|
||||
mnemonicToSeed,
|
||||
parseOtsFile,
|
||||
savePendingOts,
|
||||
selectCanonicalProofCarrier,
|
||||
signWithFalcon,
|
||||
signWithMLDSA44,
|
||||
signWithMLDSA65,
|
||||
|
||||
+189
-91
@@ -11,6 +11,9 @@
|
||||
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
|
||||
|
||||
<style>
|
||||
#divHeaderText {
|
||||
font-size: calc(max(var(--header-height), var(--header-min-height)) * 0.5) !important;
|
||||
}
|
||||
#divBody {
|
||||
flex-direction: column !important;
|
||||
flex-wrap: nowrap !important;
|
||||
@@ -145,8 +148,9 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
background: var(--primary-color);
|
||||
color: var(--secondary-color);
|
||||
background: var(--secondary-color);
|
||||
color: var(--primary-color);
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -160,8 +164,9 @@
|
||||
}
|
||||
|
||||
.pq-event-preview {
|
||||
background: var(--primary-color);
|
||||
color: var(--secondary-color);
|
||||
background: var(--secondary-color);
|
||||
color: var(--primary-color);
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 15px;
|
||||
font-size: 11px;
|
||||
@@ -311,14 +316,12 @@
|
||||
<div class="pq-result-list" id="pqResultList"></div>
|
||||
</div>
|
||||
|
||||
<div id="pqEventDisplayWrap" style="display: none; margin-top: 15px;">
|
||||
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Event content:</strong></div>
|
||||
<div class="pq-event-preview" id="pqEventContent"></div>
|
||||
</div>
|
||||
|
||||
<div id="pqEventJsonWrap" style="display: none; margin-top: 15px;">
|
||||
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Full event JSON:</strong></div>
|
||||
<div class="pq-event-preview" id="pqEventJson"></div>
|
||||
<div class="pq-button-row" style="margin-top: 10px;">
|
||||
<button class="pq-button pq-button-secondary" id="pqDownloadEventBtn">Download Event JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OTS upgrade section -->
|
||||
@@ -366,6 +369,8 @@
|
||||
verifyNostrEvent,
|
||||
NIP_QR_KIND,
|
||||
buildUpgradedEvent,
|
||||
buildProofCarrier,
|
||||
selectCanonicalProofCarrier,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
hexToBytes,
|
||||
@@ -400,9 +405,8 @@
|
||||
================================================================ */
|
||||
const resultsWrap = document.getElementById('pqResultsWrap');
|
||||
const resultList = document.getElementById('pqResultList');
|
||||
const eventDisplayWrap = document.getElementById('pqEventDisplayWrap');
|
||||
const eventContent = document.getElementById('pqEventContent');
|
||||
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');
|
||||
@@ -413,6 +417,7 @@
|
||||
|
||||
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) {
|
||||
@@ -467,60 +472,56 @@
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
QUERY RELAY FOR KIND 11112 EVENT
|
||||
QUERY RELAY FOR PROOF CARRIER EVENTS (fetch ALL candidates)
|
||||
================================================================ */
|
||||
function queryRelayForEvent(pubkeyHex, relayUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(relayUrl);
|
||||
let resolved = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
try { ws.close(); } catch (e) {}
|
||||
reject(new Error('timeout'));
|
||||
}
|
||||
}, 15000);
|
||||
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 kind 11112, limit 1 (latest)
|
||||
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
|
||||
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex], limit: 1 }]));
|
||||
};
|
||||
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 (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
try { ws.close(); } catch (e) {}
|
||||
resolve(data[2]);
|
||||
}
|
||||
} else if (data[0] === 'EOSE') {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
try { ws.close(); } catch (e) {}
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
ws.onerror = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('connection error'));
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
VERIFY EVENT (shared by both tabs)
|
||||
@@ -529,36 +530,70 @@
|
||||
currentEvent = event;
|
||||
resultList.innerHTML = '';
|
||||
resultsWrap.style.display = 'none';
|
||||
eventDisplayWrap.style.display = 'none';
|
||||
eventJsonWrap.style.display = 'none';
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
otsBadge.innerHTML = '';
|
||||
|
||||
// Display content (the embedded kind 1 event, pretty-printed) and full kind 11112 JSON
|
||||
try {
|
||||
const parsed = JSON.parse(event.content);
|
||||
eventContent.textContent = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
eventContent.textContent = event.content;
|
||||
}
|
||||
eventDisplayWrap.style.display = 'block';
|
||||
// Display full proof carrier JSON
|
||||
eventJsonEl.textContent = JSON.stringify(event, null, 2);
|
||||
eventJsonWrap.style.display = 'block';
|
||||
|
||||
let allValid = true;
|
||||
|
||||
// 1. Verify kind 11112 secp256k1 signature
|
||||
const secpValid = verifyNostrEvent(event);
|
||||
addResult('secp256k1 (kind 11112 wrapper)', secpValid, secpValid ? 'valid' : 'INVALID');
|
||||
// 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
|
||||
const pqResults = verifyNIPQRContent(event.content, event.tags);
|
||||
// 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
|
||||
@@ -659,21 +694,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
let foundEvent = null;
|
||||
let allCandidates = [];
|
||||
let lastError = null;
|
||||
let successCount = 0;
|
||||
|
||||
// Query relays in parallel, take the first event returned
|
||||
// G56-02: Query relays in parallel, collect ALL candidates (not limit: 1)
|
||||
const promises = relayUrls.map(async (url) => {
|
||||
try {
|
||||
const event = await queryRelayForEvent(pubkeyHex, url);
|
||||
if (event) {
|
||||
const events = await queryRelayForEvents(pubkeyHex, url);
|
||||
if (events && events.length > 0) {
|
||||
successCount++;
|
||||
if (!foundEvent || (event.created_at > foundEvent.created_at)) {
|
||||
foundEvent = event;
|
||||
}
|
||||
allCandidates.push(...events);
|
||||
}
|
||||
return { url, ok: true, event };
|
||||
return { url, ok: true, count: events ? events.length : 0 };
|
||||
} catch (e) {
|
||||
lastError = e.message;
|
||||
return { url, ok: false, error: e.message };
|
||||
@@ -682,12 +715,61 @@
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
if (foundEvent) {
|
||||
const npub = hexToNpub(pubkeyHex);
|
||||
setStatus(queryStatus, 'success', `Found kind 11112 event from ${npub.substring(0, 20)}... (created_at: ${foundEvent.created_at}, queried ${successCount}/${relayUrls.length} relays).`);
|
||||
await verifyEvent(foundEvent);
|
||||
} else {
|
||||
setStatus(queryStatus, 'error', `No kind 11112 event found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
|
||||
// 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;
|
||||
@@ -714,9 +796,9 @@
|
||||
}
|
||||
const allValid = await verifyEvent(event);
|
||||
if (allValid) {
|
||||
setStatus(verifyStatus, 'success', 'All signatures verified successfully!');
|
||||
setStatus(verifyStatus, 'success', 'All signatures and PQ algorithm policy verified successfully!');
|
||||
} else {
|
||||
setStatus(verifyStatus, 'error', 'Some signatures failed verification.');
|
||||
setStatus(verifyStatus, 'error', 'Some checks failed verification. See results below for details.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[verify] Error:', error);
|
||||
@@ -726,6 +808,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
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)
|
||||
================================================================ */
|
||||
|
||||
Reference in New Issue
Block a user