Compare commits

...
5 Commits
14 changed files with 1354 additions and 287 deletions
+24 -21
View File
@@ -108,7 +108,7 @@ The BIP39 seed is a 64-byte entropy string derived from a mnemonic via PBKDF2-HM
```mermaid
flowchart TD
SEED[BIP39 Seed - 64 bytes, quantum-safe]
SEED[BIP39 Seed - 64 bytes, recovery root]
SEED --> BIP32[BIP32 derivation - secp256k1 keypair]
SEED --> PQKEM[BIP32 derivation - ML-KEM keypair]
SEED --> PQDSA[BIP32 derivation - ML-DSA keypair]
@@ -173,10 +173,13 @@ This is not a separate or weaker construction — it IS BIP32. Using BIP32 paths
| Mnemonic length | Entropy | Post-quantum security (Grover's) | Assessment |
|---|---|---|---|
| 12 words | 128 bits | ~64 bits | Borderline |
| 24 words | 256 bits | ~128 bits | Adequate |
| 12 words | 128 bits | ~64 bits | Testing only — weaker under a quantum brute-force model |
| 24 words | 256 bits | ~128 bits | Recommended for a PQ recovery root |
For users concerned about quantum attacks on the seed entropy itself, 24-word mnemonics are recommended. The bigger risk to seed phrases is classical (theft, phishing), not quantum. (Note: the current implementation defaults to 12-word mnemonics; 24-word support is recommended for production use.)
The implementation defaults to 24-word (256-bit) mnemonics, with a 12-word option available for
testing. Users concerned about quantum attacks on the seed entropy itself should use 24 words. The
bigger risk to seed phrases is classical (theft, phishing), not quantum. Note that BIP39's PBKDF2
iteration count is low; the seed phrase should be treated as a high-value secret and stored offline.
---
@@ -221,18 +224,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 +278,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 1000019999 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 09999 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 +295,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 +337,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 +367,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 +649,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 +658,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 +685,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 +750,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` |
+93
View File
@@ -0,0 +1,93 @@
# GPT-5.6 Audit Remediation Progress
**Audit:** [`audits/GPT5.6/audit.md`](audit.md:1)
**Mitigation plan:** [`audits/GPT5.6/audit_mitigation.md`](audit_mitigation.md:1)
**Last updated:** 2026-07-24
## Summary
| Severity | Total | Fixed | In Progress | Not Started |
|---|---:|---:|---:|---:|
| Critical | 2 | 2 | 0 | 0 |
| High | 6 | 1 | 0 | 5 |
| Medium | 8 | 1 | 0 | 7 |
| Low / Info | 4 | 0 | 0 | 4 |
| **Total** | **20** | **4** | **0** | **16** |
## Findings status
| ID | Severity | Finding | Status | Version | Notes |
|---|---|---|---|---|---|
| G56-01 | Critical | Verifier accepts no PQ signatures and missing signatures as successful | ✅ Fixed | v0.0.12 | Strict mandatory algorithm policy, fail-closed on malformed input, structured result fields, 11 adversarial tests |
| G56-02 | Critical | Earliest-valid-anchor rule not implemented; relay discovery selects latest replaceable event | ✅ Fixed | v0.0.14v0.0.16 | Switched to non-replaceable kind 9999, append-only upgrades with `upgrade_of` tag, `selectCanonicalProofCarrier()` function, removed `limit: 1` from relay queries, updated all docs |
| G56-03 | High | Outer wrapper identity not bound to embedded identity | ✅ Fixed | v0.0.14 | Added `outerPubkey` and `expectedAuthor` options to `verifyNIPQRContent()`, `identityBound` field in result, `validForMigration` requires identity binding, 4 identity binding tests |
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ❌ Not started | — | Needs trust-mode labeling or real header validation |
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ❌ Not started | — | Needs signer-validation helper |
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ❌ Not started | — | Protocol design decision needed |
| G56-07 | High | Verifier throws on malformed relay/paste input | ⚠️ Partially fixed | v0.0.12 | G56-01 fix wrapped base64/hex decoding in try/catch and made verifyNIPQRContent fail closed. Broader call-site wrapping in verify.html also done. Remaining: fuzz testing, all export functions |
| G56-08 | High | Browser origin security insufficient for seed handling | ❌ Not started | — | Needs CSP hardening, header fixes, asset pinning |
| G56-09 | Medium | 12-word seed default, warning removed | ✅ Fixed | v0.0.17 | Defaulted `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` to 24 words (256-bit); added 12/24-word selector UI; restored entropy-strength warning; removed "quantum-safe root of trust" / "Make your Nostr identity quantum-resistant" claims from index.html, README.md, nip_proposal.md; 4 new tests |
| G56-10 | Medium | Secret material not zeroized, retained in globals/DOM/clipboard | ❌ Not started | — | Minimize secret lifetime, overwrite typed arrays |
| G56-11 | Medium | Publication advances despite zero relay acceptance | ❌ Not started | — | Require relay OK for both events |
| G56-12 | Medium | Resume workflow insufficiently bound to original event | ⚠️ Partially fixed | v0.0.14 | Resume now fetches all candidates and matches by event ID. Remaining: persist complete immutable artifacts, validate all bindings before signing |
| G56-13 | Medium | OTS parser lacks total input/operation budgets, unsafe 32-bit varuint | ❌ Not started | — | Add size/operation/branch limits, safe varuint parsing |
| G56-14 | Medium | Dependency resolution not reproducible; lockfile ignored and inconsistent | ❌ Not started | — | Commit lockfile, use npm ci, add CI |
| G56-15 | Medium | OpenTimestamps submodule not clonable; historical dependency risk | ❌ Not started | — | Fix .gitmodules or vendor immutable snapshot |
| G56-16 | Medium | Documentation and UI overclaim implementation status | ⚠️ Partially fixed | v0.0.14v0.0.16 | Updated nip_proposal.md, README.md, explanation.md, why_what_how.md, laans_explanation.md for kind 9999. Remaining: broader status reconciliation |
| G56-17 | Low | Block-height statement not validated | ❌ Not started | — | Validate against OTS height or remove from signed content |
| G56-18 | Low | Relay URL policy permits insecure ws:// | ❌ Not started | — | Require wss:// in production |
| G56-19 | Low | Missing event IDs accepted by library verifier | ❌ Not started | — | Require exact NIP-01 shape with 64-hex ID |
| G56-20 | Info | Large events may be rejected by relays; interoperability untested | ❌ Not started | — | Test against target relay policies |
## Changes by version
### v0.0.12 — G56-01 fix
- Rewrote `verifyNIPQRContent()` with strict mandatory algorithm policy
- Missing signatures on signature algorithms now fail (not treated as KEM successes)
- Unknown algorithms reported as ignored, not valid evidence
- Duplicate algorithm detection
- Public key length validation
- All base64/verification wrapped in try/catch (fail closed)
- Added `pqProofsValid`, `policySufficient`, `validForMigration`, `errors` fields to result
- Updated `verify.html` to display "PQ algorithm policy" result line
- Added 11 adversarial tests
### v0.0.13 — Checkbox UI fix
- Fixed checkbox fill (background red when checked, not just border)
### v0.0.14 — G56-02 + G56-03 fix
- Changed `NIP_QR_KIND` from 11112 (replaceable) to 9999 (non-replaceable)
- Added `buildProofCarrier()` with `ots_status` and `upgrade_of` tags
- `buildUpgradedEvent()` now creates append-only events (not replacements)
- Added `selectCanonicalProofCarrier()` for earliest-valid-anchor selection
- Removed `limit: 1` from relay queries in verify.html and index.html
- Added identity binding (`outerPubkey`, `expectedAuthor`) to `verifyNIPQRContent()`
- Added `identityBound` field to verification result
- Updated resume workflow to fetch all candidates
- Added 12 new tests (8 for G56-02, 4 for G56-03)
### v0.0.15 — Documentation update
- Updated nip_proposal.md and README.md for kind 9999
### v0.0.16 — Eliminate old references
- Eliminated all remaining "kind 11112" references from user-facing files and docs
- Updated explanation.md, laans_explanation.md, why_what_how.md
- Cleaned up button IDs and import statements in index.html
### v0.0.17 — G56-09 fix
- `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` now default to 24 words (256-bit entropy)
- Both accept an `entropyBits` argument (128 or 256) and validate it
- Added 12/24-word radio selector to the seed-generation panel in `index.html`; toggling regenerates the seed
- Restored entropy-strength warning (12 words = ~64-bit under Grover, testing only; 24 words recommended)
- Removed inaccurate claims: "Make your Nostr identity quantum-resistant" → "Create an experimental pre-quantum PQ key commitment"; "quantum-safe root of trust" → "recovery root"; "quantum-safe seed phrase" → "seed phrase"
- Added research-prototype warning to the unauthenticated landing card
- Updated README.md entropy table and nip_proposal.md mermaid diagram
- Added 4 new tests (24-word default, 12-word option, invalid entropyBits rejection for both functions)
## Test count
- **Before remediation:** 38 tests
- **After G56-01:** 49 tests
- **After G56-02+03:** 61 tests
- **After G56-09:** 65 tests
- **All passing:** ✅
+5 -5
View File
@@ -175,13 +175,13 @@ full_hash → OpenTimestamps calendar → pending .ots proof
This hash is what gets anchored to the Bitcoin blockchain.
## Step 11: Build and sign the kind 11112 wrapper event
## Step 11: Build and sign the kind 9999 proof carrier event
A kind 11112 wrapper event is built that embeds the full kind 1 event as JSON content and carries the OTS proof:
A kind 9999 proof carrier event is built that embeds the full kind 1 event as JSON content and carries the OTS proof:
```json
{
"kind": 11112,
"kind": 9999,
"pubkey": "<Account #1 secp256k1 pubkey (hex)>",
"created_at": 1720780000,
"content": "<JSON string of the full signed kind 1 event>",
@@ -197,7 +197,7 @@ This wrapper is sent to the user's signer (`window.nostr.signEvent()`), which si
## Step 12: Publish both events
Both the kind 1 announcement and the kind 11112 wrapper are published to Nostr relays. The client then polls the OpenTimestamps upgrade service for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof once the Bitcoin attestation is received.
Both the kind 1 announcement and the kind 9999 proof carrier are published to Nostr relays. The client then polls the OpenTimestamps upgrade service for Bitcoin confirmation and republishes the kind 9999 proof carrier with the confirmed OTS proof once the Bitcoin attestation is received.
---
@@ -210,7 +210,7 @@ Both the kind 1 announcement and the kind 11112 wrapper are published to Nostr r
| SLH-DSA-128s private key | The attestation text (content) | PQ signature (hash-based, Cat 1) | Conservative fallback if lattice schemes break |
| Falcon-512 private key | The attestation text (content) | PQ signature (lattice, Cat 1) | Compact signatures, compatible with trbouma's PR |
| Account #1 secp256k1 key | The kind 1 announcement event | Schnorr signature | Existing identity authorizes the migration |
| Account #1 secp256k1 key | The kind 11112 wrapper event | Schnorr signature | Existing identity authorizes the wrapper + OTS proof |
| Account #1 secp256k1 key | The kind 9999 proof carrier event | Schnorr signature | Existing identity authorizes the wrapper + OTS proof |
| ML-KEM-768 | (nothing — KEM, not signature) | N/A | Ownership asserted by attestation text; authorized by Account #1's signature covering the tags |
## Summary: BIP32 Key Derivation Tree
+2 -2
View File
@@ -17,10 +17,10 @@ A short summary of how the post-quantum Nostr migration works, matching the impl
4. **Each PQ key signs the attestation.** Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signs the attestation text. ML-KEM-768 does NOT sign — it's a KEM (Key Encapsulation Mechanism), not a signature scheme. Its public key is included in a tag without a signature.
5. **Your existing identity signs the events.** The kind 1 announcement (with PQ signatures in its tags) and a kind 11112 wrapper (which embeds the kind 1 event as JSON and carries the OpenTimestamps proof) are both signed by your **existing** Nostr identity via `window.nostr.signEvent()`. This authorizes the migration.
5. **Your existing identity signs the events.** The kind 1 announcement (with PQ signatures in its tags) and a kind 9999 proof carrier (which embeds the kind 1 event as JSON and carries the OpenTimestamps proof) are both signed by your **existing** Nostr identity via `window.nostr.signEvent()`. This authorizes the migration.
6. **Timestamp the kind 1 event.** The SHA-256 of the full signed kind 1 event JSON is submitted to OpenTimestamps, anchoring it to the Bitcoin blockchain. This proves the event existed at this point in time, pre-quantum.
7. **Publish both events to relays.** The kind 1 announcement and the kind 11112 wrapper are published. The client then polls for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof.
7. **Publish both events to relays.** The kind 1 announcement and the kind 9999 proof carrier are published. The client then polls for Bitcoin confirmation and republishes the kind 9999 proof carrier with the confirmed OTS proof.
**Note:** The seed-derived secp256k1 key (at `m/44'/1237'/0'/0/0`) is derived but not used to sign. There is no "successor signature." The security model relies on OTS precedence — the real event is anchored pre-quantum, and a forged event cannot be backdated to before that anchor.
+16 -13
View File
@@ -35,19 +35,19 @@ The PQ standardization landscape is still young. SIKE was a NIST finalist broken
A user with an existing Nostr identity (the **attesting identity**) publishes two events:
1. A **kind 1 announcement** — a human-readable attestation whose `content` is signed by each PQ signature scheme, with the PQ public keys and signatures carried in `algorithm` tags.
2. A **kind 11112 wrapper** — a replaceable event that embeds the full signed kind 1 event as JSON content and carries an OpenTimestamps proof anchoring the kind 1 event to a Bitcoin block.
2. A **kind 9999 proof carrier** — a non-replaceable event that embeds the full signed kind 1 event as JSON content and carries an OpenTimestamps proof anchoring the kind 1 event to a Bitcoin block. Upgrades (pending → confirmed) publish a new kind 9999 event with an `upgrade_of` tag referencing the previous one; both remain on relays permanently.
The PQ keys are derived deterministically from a BIP39 seed phrase via BIP32, at fixed child indices under the NIP-06 base path. This means a user who has backed up their seed phrase can always recover the same PQ keys, on any conforming implementation.
```mermaid
flowchart TD
SEED[BIP39 seed - quantum-safe root of trust] --> SECP[secp256k1 keypair - child 0]
SEED[BIP39 seed - recovery root] --> SECP[secp256k1 keypair - child 0]
SEED --> PQ[PQ keypairs - children 1..8]
SECP --> K1[Kind 1 announcement - attesting identity signs]
PQ --> K1
K1 --> OTS[OpenTimestamps anchor to Bitcoin block]
OTS --> K11112[Kind 11112 wrapper - carries OTS proof]
K11112 --> RELAY[Published to relays]
OTS --> K9999[Kind 9999 proof carrier - carries OTS proof]
K9999 --> RELAY[Published to relays]
```
## PQ key derivation from a BIP39 seed
@@ -157,9 +157,11 @@ Future NIPs may register additional `algorithm-id` values and additional BIP32 c
}
```
## The kind 11112 wrapper event
## The kind 9999 proof carrier event
A **replaceable** event (kind 11112, in the 1000019999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. It is signed with the attesting identity's secp256k1 key.
A **non-replaceable** event (kind 9999, in the 09999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. It is signed with the attesting identity's secp256k1 key.
Kind 9999 is non-replaceable: each publication is permanent on relays. Upgrades (e.g. pending → confirmed OTS proof) publish a **new** kind 9999 event with an `upgrade_of` tag referencing the previous one. Both events remain on relays. Clients fetch all kind 9999 events for a pubkey and select the one with the earliest valid Bitcoin anchor.
The kind number is provisional and subject to maintainer allocation.
@@ -177,7 +179,7 @@ The kind number is provisional and subject to maintainer allocation.
```json
{
"kind": 11112,
"kind": 9999,
"pubkey": "<attesting identity secp256k1 pubkey, hex>",
"content": "<JSON string of the full signed kind 1 event>",
"tags": [
@@ -203,7 +205,7 @@ With an OTS anchor, the real event is committed to a specific Bitcoin block befo
### Verification rule: earliest valid anchor wins
When a client finds multiple kind 11112 wrappers for the same attesting identity, it verifies the OTS proof in each and selects the one whose Bitcoin attestation has the **earliest block height**. That wrapper's embedded kind 1 event is the canonical key-link for that identity. Later wrappers, even if validly signed, are treated as superseding only if they are signed by a PQ key already linked by the earliest wrapper.
When a client finds multiple kind 9999 proof carriers for the same attesting identity, it verifies the OTS proof in each and selects the one whose Bitcoin attestation has the **earliest block height**. That proof carrier's embedded kind 1 event is the canonical key-link for that identity. Later proof carriers, even if validly signed, are treated as superseding only if they are signed by a PQ key already linked by the earliest proof carrier.
### OTS proof states
@@ -217,10 +219,11 @@ Clients SHOULD perform full client-side OTS verification: parse the proof, walk
### PQ-aware clients
A PQ-aware client, when it encounters a kind 11112 wrapper for a pubkey it cares about:
A PQ-aware client, when it encounters a kind 9999 proof carrier for a pubkey it cares about:
1. Parses the embedded kind 1 event from the wrapper's `content`.
2. Verifies the attesting identity's secp256k1 signature on both the kind 1 and the kind 11112 events.
1. Parses the embedded kind 1 event from the proof carrier's `content`.
2. Verifies the attesting identity's secp256k1 signature on both the kind 1 and the kind 9999 events.
3. Verifies that the proof carrier's pubkey matches the embedded kind 1 event's pubkey (identity binding).
3. Verifies the `sha256` tag matches `sha256(JSON.stringify(kind1Event))`.
4. For each `algorithm` tag with a signature, verifies the PQ signature against the pubkey in the tag, over `TextEncoder.encode(kind1Event.content)`.
5. Verifies the OTS proof in the `ots` tag against the `sha256` digest.
@@ -231,14 +234,14 @@ After a quantum break of secp256k1, the client trusts the PQ keys from the earli
### Legacy clients
Legacy clients see the kind 1 announcement as an ordinary text note and the kind 11112 wrapper as an unknown replaceable event. They ignore both. Nothing breaks.
Legacy clients see the kind 1 announcement as an ordinary text note and the kind 9999 proof carrier as an unknown event. They ignore both. Nothing breaks.
## What this NIP proves and does not prove
**It proves:**
- Each PQ signature scheme listed in the tags signed the attestation text. Verified by checking each PQ signature against its tag's pubkey.
- The attesting identity authorized the link. Verified by the secp256k1 Schnorr signature on the kind 1 and kind 11112 events, valid pre-quantum.
- The attesting identity authorized the link. Verified by the secp256k1 Schnorr signature on the kind 1 and kind 9999 events, valid pre-quantum.
- The link existed at a specific pre-quantum Bitcoin block. Verified by the OTS proof.
**It does not prove:**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.13",
"version": "0.0.17",
"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 1000019999 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 09999 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 (09999 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 (09999 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 09999 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
+229 -4
View File
@@ -31,18 +31,41 @@ const m = await import('../www/js/pq-crypto.mjs');
// ============================================================================
describe('BIP39 seed phrase', () => {
test('generateSeedPhrase produces a valid 12-word mnemonic', () => {
test('generateSeedPhrase defaults to a valid 24-word mnemonic', () => {
const mnemonic = m.generateSeedPhrase();
assert.ok(m.isValidMnemonic(mnemonic), 'generated mnemonic should be valid');
const words = mnemonic.split(' ');
assert.equal(words.length, 12, 'should be 12 words');
assert.equal(words.length, 24, 'default should be 24 words');
});
test('generateSeedPhraseWithEntropy produces a valid mnemonic', () => {
test('generateSeedPhrase(128) produces a valid 12-word mnemonic', () => {
const mnemonic = m.generateSeedPhrase(128);
assert.ok(m.isValidMnemonic(mnemonic), 'generated mnemonic should be valid');
assert.equal(mnemonic.split(' ').length, 12, 'should be 12 words');
});
test('generateSeedPhrase rejects invalid entropyBits', () => {
assert.throws(() => m.generateSeedPhrase(64), /entropyBits must be 128/);
assert.throws(() => m.generateSeedPhrase(192), /entropyBits must be 128/);
});
test('generateSeedPhraseWithEntropy defaults to a 24-word mnemonic', () => {
const userEntropy = new TextEncoder().encode('test entropy from user');
const mnemonic = m.generateSeedPhraseWithEntropy(userEntropy);
assert.ok(m.isValidMnemonic(mnemonic), 'mnemonic with entropy should be valid');
assert.equal(mnemonic.split(' ').length, 12);
assert.equal(mnemonic.split(' ').length, 24, 'default should be 24 words');
});
test('generateSeedPhraseWithEntropy(128) produces a 12-word mnemonic', () => {
const userEntropy = new TextEncoder().encode('test entropy from user');
const mnemonic = m.generateSeedPhraseWithEntropy(userEntropy, 128);
assert.ok(m.isValidMnemonic(mnemonic), 'mnemonic with entropy should be valid');
assert.equal(mnemonic.split(' ').length, 12, 'should be 12 words');
});
test('generateSeedPhraseWithEntropy rejects invalid entropyBits', () => {
const userEntropy = new TextEncoder().encode('test entropy');
assert.throws(() => m.generateSeedPhraseWithEntropy(userEntropy, 64), /entropyBits must be 128/);
});
test('generateSeedPhraseWithEntropy produces different mnemonics for different CSPRNG draws', () => {
@@ -494,3 +517,205 @@ describe('G56-01: strict PQ algorithm policy (verifyNIPQRContent)', () => {
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');
});
});
+9 -9
View File
@@ -57,7 +57,7 @@ A migration strategy that brings post-quantum security to Nostr:
1. **Seed phrase as algorithm-agnostic root of trust** — A BIP39 mnemonic is pure entropy (128/256 bits). It's not tied to any cryptographic algorithm. PQ keys are derived from it deterministically using BIP32 hierarchical deterministic derivation (the same standard used by NIP-06 for secp256k1 keys), with each PQ algorithm at a different child index under the NIP-06 base path. Same seed always produces the same PQ keys. The seed is quantum-safe (symmetric KDF, Grover's quadratic speedup is negligible).
2. **Multi-scheme cross-signed key-link events** — Two Nostr events (a kind 1 announcement and a kind 11112 wrapper) that link a user's secp256k1 identity to multiple PQ keys simultaneously: ML-DSA-44 and ML-DSA-65 (lattice-based signatures), SLH-DSA-128s (hash-based signatures), Falcon-512 (lattice-based signatures), and ML-KEM-768 (lattice-based KEM for encryption). Each PQ signature scheme independently signs the attestation text. If one scheme is later broken, the others remain valid.
2. **Multi-scheme cross-signed key-link events** — Two Nostr events (a kind 1 announcement and a kind 9999 proof carrier) that link a user's secp256k1 identity to multiple PQ keys simultaneously: ML-DSA-44 and ML-DSA-65 (lattice-based signatures), SLH-DSA-128s (hash-based signatures), Falcon-512 (lattice-based signatures), and ML-KEM-768 (lattice-based KEM for encryption). Each PQ signature scheme independently signs the attestation text. If one scheme is later broken, the others remain valid.
3. **OpenTimestamps for pre-quantum anchoring** — The key-link event is timestamped via NIP-03 (OpenTimestamps), anchoring it to a Bitcoin block. This prevents a future quantum attacker from backdating a fraudulent key-link event, because they cannot produce a valid OTS proof for a past Bitcoin block (re-mining historical blocks is infeasible even with a quantum computer).
@@ -84,7 +84,7 @@ A migration strategy that brings post-quantum security to Nostr:
Account #1 (existing identity, social graph knows this npub)
|
| signs: "I authorize this migration to these PQ keys"
| (secp256k1 Schnorr signature on the kind 1 and kind 11112 events,
| (secp256k1 Schnorr signature on the kind 1 and kind 9999 events,
| valid pre-quantum)
v
Kind 1 announcement event (published under Account #1's pubkey)
@@ -97,7 +97,7 @@ Kind 1 announcement event (published under Account #1's pubkey)
| OpenTimestamps anchor of the kind 1 event hash to Bitcoin block
| (proves the event existed at this time, pre-quantum)
v
Kind 11112 wrapper event (carries the OTS proof, embeds the kind 1 event)
Kind 9999 proof carrier event (carries the OTS proof, embeds the kind 1 event)
|
v
Published to Nostr relays
@@ -130,11 +130,11 @@ The migration uses **two Nostr events**:
The content is a human-readable attestation statement. Each PQ signature scheme signs `TextEncoder.encode(content)`. The PQ public keys and signatures go in the `algorithm` tags. ML-KEM-768 has only a pubkey tag (no signature — it's a KEM).
#### Kind 11112 wrapper (machine-verifiable, carries the OTS proof)
#### Kind 9999 proof carrier (machine-verifiable, carries the OTS proof)
```json
{
"kind": 11112,
"kind": 9999,
"pubkey": "<Account #1 secp256k1 pubkey>",
"content": "<JSON string of the full signed kind 1 event>",
"tags": [
@@ -174,10 +174,10 @@ A static web page (no backend with secrets) that:
1. **Signs in** — uses nostr-login-lite (NIP-46 bunker, seed phrase, or browser extension) to authenticate with the user's existing Nostr identity
2. **Generates a seed phrase** — 12-word BIP39 mnemonic, generated client-side, never sent to any server
3. **Derives PQ keys** — uses `@noble/post-quantum` (pure JavaScript, no WASM) to generate ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, and ML-KEM-768 keypairs from the seed via BIP32 derivation paths
4. **Signs the events** — PQ signatures are created in the browser; the secp256k1 signature is requested from the user's signer (via `window.nostr.signEvent()`) for both the kind 1 announcement and the kind 11112 wrapper
5. **Timestamps the kind 1 event** — submits the SHA-256 of the full signed kind 1 event JSON to OpenTimestamps and embeds the proof in the kind 11112 wrapper
4. **Signs the events** — PQ signatures are created in the browser; the secp256k1 signature is requested from the user's signer (via `window.nostr.signEvent()`) for both the kind 1 announcement and the kind 9999 proof carrier
5. **Timestamps the kind 1 event** — submits the SHA-256 of the full signed kind 1 event JSON to OpenTimestamps and embeds the proof in the kind 9999 proof carrier
6. **Publishes to relays** — sends both events to Nostr relays via direct WebSocket
7. **Polls for Bitcoin confirmation** — upgrades the OTS proof and republishes the kind 11112 wrapper with the confirmed proof
7. **Polls for Bitcoin confirmation** — upgrades the OTS proof and republishes the kind 9999 proof carrier with the confirmed proof
8. **Shows the result** — displays the complete event JSON and event ID
All cryptographic operations happen either in the browser (JavaScript/WASM) or in the user's signer (Amber, browser extension, or bunker). No private key ever touches a server.
@@ -213,7 +213,7 @@ We use multiple schemes simultaneously because:
### The demo
A working demo is deployed at **https://laantungir.net/post-quantum** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 11112 events, publish to relays, and timestamp via OpenTimestamps. It uses:
A working demo is deployed at **https://laantungir.net/post-quantum** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 9999 events, publish to relays, and timestamp via OpenTimestamps. It uses:
- `@noble/post-quantum` for PQ cryptography (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)
- `@scure/bip39` for seed phrase generation
+133 -78
View File
@@ -292,7 +292,7 @@
</div>
<div class="pq-checklist-item" id="step-2">
<span class="pq-checklist-box"></span>
<span>2. Generate a quantum-safe seed phrase</span>
<span>2. Generate a seed phrase</span>
</div>
<div class="pq-checklist-item" id="step-3">
<span class="pq-checklist-box"></span>
@@ -324,13 +324,18 @@
<div id="pqNotAuthed" class="pq-card">
<div class="pq-card-title">Post-Quantum Nostr</div>
<div class="pq-info-text" style="font-size: 16px; color: var(--primary-color);">
Make your Nostr identity quantum-resistant
Create an experimental pre-quantum PQ key commitment
</div>
<div class="pq-info-text">
Sign in with your Nostr signer to begin. Your npub won't change, your followers stay,
and your social graph is preserved. We'll generate a quantum-safe seed phrase and link
and your social graph is preserved. We'll generate a seed phrase and link
it to your current identity.
</div>
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
<strong>Research prototype.</strong> Do not enter a valuable existing mnemonic. This tool
does not make your Nostr identity post-quantum secure on its own; it creates a commitment
that future PQ protocols can build on.
</div>
<div class="pq-info-text" id="noSignerWarning" style="color: var(--accent-color); display: none;">
No Nostr signer detected. Install a NIP-07 browser extension (like nos2x or Alby) and reload.
</div>
@@ -352,7 +357,7 @@
<div class="pq-info-text">
This tool will:
<ol>
<li>Generate a new <strong>seed phrase</strong> (your quantum-safe backup)</li>
<li>Generate a new <strong>seed phrase</strong> (your recovery backup)</li>
<li>Derive <strong>post-quantum keys</strong> from that seed via BIP32 paths (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)</li>
<li>Sign a <strong>NIP-QR migration event</strong> linking your identity to the PQ keys</li>
<li>Publish the event to relays</li>
@@ -363,12 +368,17 @@
<!-- STEP 2: SEED PHRASE GENERATION -->
<div id="pqSeedStep" class="pq-card pq-hidden">
<div class="pq-card-title">Your Quantum-Safe Seed Phrase</div>
<div class="pq-card-title">Your Seed Phrase</div>
<div class="pq-info-text">
Your seed phrase is your <strong>quantum-safe root of trust</strong>. All post-quantum keys
Your seed phrase is your <strong>recovery root</strong>. All post-quantum keys
are derived from it. It is processed entirely in your browser and never sent to any server.
The code is open source and auditable.
</div>
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
<strong>Back this up offline.</strong> Anyone with this phrase can derive your PQ private keys.
24 words (256 bits) is recommended; 12 words (128 bits) is offered for testing only and is
weaker under a quantum brute-force model.
</div>
<!-- Mode toggle + copy row (always visible) -->
<div class="pq-button-row" style="margin-bottom: 15px;">
@@ -379,6 +389,11 @@
<!-- Generate mode -->
<div id="pqSeedGeneratePanel">
<!-- Word-count selector -->
<div class="pq-info-text" style="margin-bottom: 10px; font-size: 13px;">
<label style="margin-right: 12px;"><input type="radio" name="pqWordCount" value="256" id="pqWordCount24" checked> 24 words (recommended)</label>
<label><input type="radio" name="pqWordCount" value="128" id="pqWordCount12"> 12 words (testing only)</label>
</div>
<div class="pq-seed-display" id="pqSeedDisplay"></div>
<div class="pq-info-text" style="margin-top: 15px; font-size: 13px;">
<strong>Optional:</strong> Add extra entropy by moving your mouse and typing in the box below.
@@ -465,7 +480,7 @@
<div class="pq-card-title">Sign Migration Event</div>
<div class="pq-info-text">
We'll now create two events: a kind 1 announcement (public, visible in Nostr feeds) and
a kind 11112 wrapper (carries the OpenTimestamps proof). Each PQ key signs the attestation
a kind 9999 proof carrier (carries the OpenTimestamps proof). Each PQ key signs the attestation
statement, and your signer signs both events with your secp256k1 key. The SHA-256 of the
full kind 1 event (including its secp256k1 signature) is submitted to OpenTimestamps.
</div>
@@ -482,7 +497,7 @@
<div class="pq-card-title">Review & Publish Events</div>
<div class="pq-info-text">
Two events have been signed: a kind 1 announcement (public, visible in Nostr feeds) and
a kind 11112 wrapper (carries the OpenTimestamps proof). Review them below, then publish
a kind 9999 proof carrier (carries the OpenTimestamps proof). Review them below, then publish
both to relays when ready.
</div>
@@ -490,11 +505,11 @@
<div id="pqKind1IdDisplay" style="margin: 5px 0; font-size: 11px; color: var(--primary-color); word-break: break-all;"></div>
<div class="pq-event-preview" id="pqKind1Preview" style="max-height: 200px;"></div>
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;"><strong>Kind 11112 wrapper event:</strong></div>
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;"><strong>Kind 9999 proof carrier event:</strong></div>
<div id="pqEventIdDisplay" style="margin: 5px 0; font-size: 11px; color: var(--primary-color); word-break: break-all;"></div>
<div class="pq-event-preview" id="pqSuccessEventPreview"></div>
<div class="pq-button-row" style="margin-top: 10px;">
<button class="pq-button pq-button-secondary" id="pqCopyKind11112Btn">Copy Kind 11112 Event</button>
<button class="pq-button pq-button-secondary" id="pqCopyProofCarrierBtn">Copy Proof Carrier Event</button>
</div>
<div style="margin-top: 15px;">
@@ -521,7 +536,7 @@
<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.
<strong>pending</strong> timestamp proof is embedded in the kind 9999 event you just published.
OpenTimestamps anchors that hash into the Bitcoin blockchain, proving your event existed at this
point in time — preventing a future quantum attacker from backdating a fraudulent event.
</div>
@@ -529,8 +544,9 @@
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.
confirmed, this page will automatically sign and publish a <strong>new</strong> kind 9999 event carrying
the upgraded (confirmed) proof, referencing the original via an <code>upgrade_of</code> tag. The original
event remains on relays permanently. You're free to close the page; the workflow resumes on your next visit.
</div>
<div id="pqOtsStatus"></div>
@@ -545,7 +561,7 @@
</div>
<div class="pq-checklist-item pq-done" id="pqOtsPendingPublish">
<span class="pq-checklist-box"></span>
<span>Pending proof embedded in kind 11112 event</span>
<span>Pending proof embedded in kind 9999 event</span>
</div>
<div class="pq-checklist-item pq-active" id="pqOtsConfirm">
<span class="pq-checklist-box"></span>
@@ -555,7 +571,7 @@
</div>
<div class="pq-checklist-item" id="pqOtsConfirmedPublish">
<span class="pq-checklist-box"></span>
<span>Republish kind 11112 with confirmed proof — automatic on confirmation</span>
<span>Publish new kind 9999 with confirmed proof — automatic on confirmation</span>
</div>
</div>
<div id="pqOtsProofWrap" class="pq-hidden" style="margin-top: 15px;">
@@ -602,7 +618,7 @@
deriveSecp256k1FromSeed,
derivePQKeysFromSeed,
buildKind1Announcement,
buildKind11112Wrapper,
buildProofCarrier,
computeEventId,
hashFullEvent,
verifyNIPQRContent,
@@ -613,6 +629,7 @@
PQ_KEY_INFO,
NIP_QR_KIND,
buildUpgradedEvent,
selectCanonicalProofCarrier,
timestampEvent,
upgradeOts,
isOtsConfirmed,
@@ -632,7 +649,7 @@
let pqSeed = null;
let pqKeys = null;
let pqSecpKeys = null;
let pqEvent = null; // kind 11112 wrapper event
let pqEvent = null; // kind 9999 proof carrier event
let kind1Event = null; // kind 1 announcement event
/* ================================================================
@@ -749,24 +766,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();
@@ -925,14 +956,21 @@
let userEntropyChunks = [];
let entropyCollecting = false;
function getSelectedEntropyBits() {
const radio = document.querySelector('input[name="pqWordCount"]:checked');
return radio ? parseInt(radio.value, 10) : 256;
}
function generateAndShowSeed() {
const entropyBits = getSelectedEntropyBits();
// Use user entropy if available, otherwise pure CSPRNG
if (userEntropyChunks.length > 0) {
const userEntropy = new TextEncoder().encode(userEntropyChunks.join(''));
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy);
otsLog(`Seed generated with ${userEntropyChunks.length} entropy chunks from user input.`);
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy, entropyBits);
otsLog(`Seed generated with ${userEntropyChunks.length} entropy chunks from user input (${entropyBits}-bit).`);
} else {
pqMnemonic = generateSeedPhrase();
pqMnemonic = generateSeedPhrase(entropyBits);
otsLog(`Seed generated (${entropyBits}-bit).`);
}
const words = pqMnemonic.split(' ');
pqSeedDisplay.innerHTML = words.map((word, i) =>
@@ -1102,34 +1140,34 @@
otsLog(`Pending OTS proof created (${otsProof.length} bytes) for kind 1 event hash.`);
} catch (otsError) {
console.warn('[post-quantum] OTS submission failed, continuing without proof:', otsError.message);
otsLog(`WARNING: OTS submission failed: ${otsError.message}. Kind 11112 event will not have OTS proof yet.`);
otsLog(`WARNING: OTS submission failed: ${otsError.message}. Kind 9999 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) {
@@ -1190,27 +1228,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) {}
};
@@ -1230,7 +1270,7 @@
];
let pqRelays = DEFAULT_RELAYS.slice();
// Per-relay publish status: { url: { kind1: bool, kind11112: bool } }
// Per-relay publish status: { url: { kind1: bool, kind9999: bool } }
const pqRelayStatus = {};
function makeStatusCheckbox(label) {
@@ -1248,7 +1288,7 @@
}
function setRelayStatus(url, kind, ok) {
if (!pqRelayStatus[url]) pqRelayStatus[url] = { kind1: false, kind11112: false };
if (!pqRelayStatus[url]) pqRelayStatus[url] = { kind1: false, kind9999: false };
pqRelayStatus[url][kind] = ok;
const row = document.querySelector(`.pq-relay-row[data-relay="${CSS.escape(url)}"]`);
if (!row) return;
@@ -1279,9 +1319,9 @@
k1.box.classList.add('pq-relay-status-kind1');
row.appendChild(k1.wrap);
const k11112 = makeStatusCheckbox('k11112');
k11112.box.classList.add('pq-relay-status-kind11112');
row.appendChild(k11112.wrap);
const k9999 = makeStatusCheckbox('k9999');
k9999.box.classList.add('pq-relay-status-kind9999');
row.appendChild(k9999.wrap);
const removeBtn = document.createElement('button');
removeBtn.className = 'pq-button pq-button-secondary';
@@ -1300,7 +1340,7 @@
const st = pqRelayStatus[url];
if (st) {
if (st.kind1) setRelayStatus(url, 'kind1', true);
if (st.kind11112) setRelayStatus(url, 'kind11112', true);
if (st.kind9999) setRelayStatus(url, 'kind9999', true);
}
});
}
@@ -1345,6 +1385,21 @@
document.getElementById('pqSeedToggleGenerate').addEventListener('click', () => showSeedGeneratePanel());
document.getElementById('pqSeedToggleOwn').addEventListener('click', () => showSeedOwnPanel());
// Word-count selector: regenerate when the user switches between 12/24 words
document.querySelectorAll('input[name="pqWordCount"]').forEach((radio) => {
radio.addEventListener('change', () => {
userEntropyChunks = [];
generateAndShowSeed();
// Reset entropy UI
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
if (entropyBar) entropyBar.style.width = '0%';
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
if (regenWithEntropyBtn) { regenWithEntropyBtn.classList.add('pq-hidden'); regenWithEntropyBtn.disabled = true; }
});
});
// Bring your own seed: real-time validation
const pqSeedInput = document.getElementById('pqSeedInput');
const pqSeedValidation = document.getElementById('pqSeedValidation');
@@ -1407,15 +1462,15 @@
const fail1 = results1.filter(r => !r.success).length;
otsLog(`Kind 1 announcement published: ${success1} ok, ${fail1} failed.`);
// Publish kind 11112 wrapper
publishStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing kind 11112 wrapper to relays...</div>';
// Publish kind 9999 proof carrier
publishStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing kind 9999 proof carrier to relays...</div>';
const results2 = await publishToRelays(pqEvent, relayUrls);
results2.forEach(r => setRelayStatus(r.relay, 'kind11112', r.success));
results2.forEach(r => setRelayStatus(r.relay, 'kind9999', r.success));
const success2 = results2.filter(r => r.success).length;
const fail2 = results2.filter(r => !r.success).length;
otsLog(`Kind 11112 wrapper published: ${success2} ok, ${fail2} failed.`);
otsLog(`Kind 9999 proof carrier published: ${success2} ok, ${fail2} failed.`);
setStatus(publishStatus, 'success', `Published! Kind 1: ${success1}/${relayUrls.length} relays. Kind 11112: ${success2}/${relayUrls.length} relays.`);
setStatus(publishStatus, 'success', `Published! Kind 1: ${success1}/${relayUrls.length} relays. Kind 9999: ${success2}/${relayUrls.length} relays.`);
publishBtn.textContent = 'Published';
publishBtn.disabled = true;
setStepDone(5);
@@ -1455,13 +1510,13 @@
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('pqRelayAddBtn').click(); }
});
// Copy Kind 11112 Event button
document.getElementById('pqCopyKind11112Btn').addEventListener('click', () => {
// Copy Proof Carrier Event button
document.getElementById('pqCopyProofCarrierBtn').addEventListener('click', () => {
if (!pqEvent) return;
navigator.clipboard.writeText(JSON.stringify(pqEvent, null, 2)).then(() => {
const btn = document.getElementById('pqCopyKind11112Btn');
const btn = document.getElementById('pqCopyProofCarrierBtn');
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy Kind 11112 Event'; }, 2000);
setTimeout(() => { btn.textContent = 'Copy Proof Carrier Event'; }, 2000);
});
});
@@ -1560,7 +1615,7 @@
}
showOtsProof(pendingOtsBytes);
setKeyIcon('pqOtsPendingPublish', 'Done');
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof is embedded in the kind 11112 event. Waiting for Bitcoin confirmation...</div>';
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof is embedded in the kind 9999 event. Waiting for Bitcoin confirmation...</div>';
startOtsPolling();
return;
}
@@ -1582,7 +1637,7 @@
confirmedPublished: false
});
setKeyIcon('pqOtsPendingPublish', 'Done');
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof is embedded in the kind 11112 event. Waiting for Bitcoin confirmation (polling every 60 seconds)...</div>';
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof is embedded in the kind 9999 event. Waiting for Bitcoin confirmation (polling every 60 seconds)...</div>';
startOtsPolling();
return;
}
@@ -1592,7 +1647,7 @@
}
// No proof in the event — OTS submission failed during step 4.
// Submit the kind 1 event hash to OpenTimestamps now, then republish the event with the proof.
// Submit the kind 1 event hash to OpenTimestamps now, then publish a new kind 9999 event with the proof.
const sha256Tag = pqEvent.tags && pqEvent.tags.find(t => t[0] === 'sha256');
if (!sha256Tag || !sha256Tag[1]) {
otsLog('ERROR: No sha256 tag found in event. Cannot timestamp.');
@@ -1610,7 +1665,7 @@
showOtsProof(otsBytes);
// Republish the event with the OTS proof embedded
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Republishing kind 11112 event with embedded OTS proof...</div>';
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing new kind 9999 event with embedded OTS proof...</div>';
await publishUpgradedEvent(otsBytes);
setKeyIcon('pqOtsPendingPublish', 'Done');
savePendingOts(pqEvent.id, otsBytes, {
@@ -1670,7 +1725,7 @@
const saved = loadPendingOts();
if (!saved || !saved.confirmedPublished) {
setStatus(document.getElementById('pqOtsStatus'), 'success', 'Bitcoin attestation verified. Republishing kind 11112 with confirmed proof...');
setStatus(document.getElementById('pqOtsStatus'), 'success', 'Bitcoin attestation verified. Publishing new kind 9999 with confirmed proof...');
await publishUpgradedEvent(pendingOtsBytes);
}
} else if (isOtsConfirmed(pendingOtsBytes)) {
@@ -1706,7 +1761,7 @@
async function publishUpgradedEvent(upgradedOtsBytes) {
const otsStatus = document.getElementById('pqOtsStatus');
try {
otsLog('Building upgraded kind 11112 event with confirmed OTS proof...');
otsLog('Building upgraded proof carrier with confirmed OTS proof...');
const savedWorkflow = loadPendingOts();
const configuredRelays = getRelayUrlsString() || (savedWorkflow && savedWorkflow.relayUrls) || 'wss://laantungir.net/relay';
@@ -1714,16 +1769,16 @@
const upgradedTemplate = buildUpgradedEvent(pqEvent, upgradedOtsBytes);
if (!window.nostr || !window.nostr.signEvent) throw new Error('Nostr signer not available');
otsLog('Requesting secp256k1 signature for upgraded kind 11112 event...');
otsLog('Requesting secp256k1 signature for upgraded proof carrier...');
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
const relayUrls = configuredRelays.split(',').map(s => s.trim()).filter(Boolean);
const results = await publishToRelays(signedEvent, relayUrls);
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
if (successCount === 0) throw new Error('No relay accepted the upgraded kind 11112 event');
if (successCount === 0) throw new Error('No relay accepted the upgraded proof carrier');
setKeyIcon('pqOtsConfirmedPublish', 'Done');
otsLog(`Upgraded kind 11112 event published. Successful: ${successCount}; failed: ${failCount}.`);
otsLog(`Upgraded proof carrier published. Successful: ${successCount}; failed: ${failCount}.`);
// Update pqEvent to the new event so future operations use it
pqEvent = signedEvent;
savePendingOts(pqEvent.id, upgradedOtsBytes, {
@@ -1734,7 +1789,7 @@
confirmedPublished: true
});
setStatus(otsStatus, 'success', `Upgraded kind 11112 event published to ${successCount} relays. Timestamping complete.`);
setStatus(otsStatus, 'success', `Upgraded proof carrier published to ${successCount} relays. Timestamping complete.`);
setStepDone(6);
setStepActive(7);
return signedEvent;
+326 -44
View File
@@ -70,11 +70,20 @@ const PQ_SEED_LENGTHS = {
// ============================================================================
/**
* Generate a new 12-word BIP39 mnemonic.
* @returns {string} 12-word seed phrase
* Generate a new BIP39 mnemonic.
*
* Defaults to 24 words (256 bits of entropy) so the recovery root is not the
* weakest link in a system whose PQ algorithms target NIST Category 1+.
* Pass `128` for `entropyBits` to generate a 12-word phrase (e.g. for testing).
*
* @param {number} [entropyBits=256] - 128 (12 words) or 256 (24 words)
* @returns {string} BIP39 seed phrase (24 words by default)
*/
export function generateSeedPhrase() {
return generateMnemonic(wordlist, 128); // 128 bits = 12 words
export function generateSeedPhrase(entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error('entropyBits must be 128 (12 words) or 256 (24 words)');
}
return generateMnemonic(wordlist, entropyBits);
}
/**
@@ -85,20 +94,29 @@ export function generateSeedPhrase() {
* compromised, the seed remains unpredictable to an attacker who doesn't have
* the user's entropy.
*
* Defaults to 24 words (256 bits). Pass `128` for `entropyBits` to generate a
* 12-word phrase.
*
* @param {Uint8Array} userEntropy - Additional entropy from the user (any length)
* @returns {string} A valid 12-word BIP39 mnemonic
* @param {number} [entropyBits=256] - 128 (12 words) or 256 (24 words)
* @returns {string} A valid BIP39 mnemonic (24 words by default)
*/
export function generateSeedPhraseWithEntropy(userEntropy) {
// Get 16 bytes (128 bits) of CSPRNG entropy
const csprngBytes = new Uint8Array(16);
export function generateSeedPhraseWithEntropy(userEntropy, entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error('entropyBits must be 128 (12 words) or 256 (24 words)');
}
const entropyBytes = entropyBits / 8; // 16 or 32
// Get CSPRNG entropy of the requested size
const csprngBytes = new Uint8Array(entropyBytes);
crypto.getRandomValues(csprngBytes);
// Combine CSPRNG entropy with user entropy via SHA-256
const combined = concatBytes(csprngBytes, userEntropy);
const hash = sha256(combined);
// Use the first 16 bytes (128 bits) of the hash as the entropy
const finalEntropy = hash.slice(0, 16);
// Use the first `entropyBytes` bytes of the hash as the entropy
const finalEntropy = hash.slice(0, entropyBytes);
return entropyToMnemonic(finalEntropy, wordlist);
}
@@ -423,11 +441,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
* (09999 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,74 +554,122 @@ 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
* @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,
@@ -611,17 +680,20 @@ export function buildUpgradedEvent(originalEvent, upgradedOtsProof) {
* 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, AND the mandatory algorithm policy are all satisfied.
* 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) {
@@ -652,6 +724,7 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) {
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
identityBound: false,
errors: ['Content is not valid JSON']
};
}
@@ -669,6 +742,7 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) {
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
identityBound: false,
errors: ['Embedded event is not a valid kind 1 event']
};
}
@@ -683,6 +757,31 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) {
}
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 eTags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter(t => Array.isArray(t) && t[0] === 'e');
@@ -879,7 +978,7 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) {
// 7. Compute final results
const allChecksPassed = results.every(r => r.valid);
const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient;
const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient && identityBound;
return {
valid: allChecksPassed,
@@ -891,6 +990,189 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) {
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
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.13",
"VERSION_NUMBER": "0.0.13",
"BUILD_DATE": "2026-07-23T17:35:37.091Z"
"VERSION": "v0.0.17",
"VERSION_NUMBER": "0.0.17",
"BUILD_DATE": "2026-07-24T12:44:54.874Z"
}
+197 -24
View File
@@ -9609,15 +9609,22 @@ var PQ_SEED_LENGTHS = {
falcon512: 48,
mlKem: 64
};
function generateSeedPhrase() {
return generateMnemonic(wordlist, 128);
function generateSeedPhrase(entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error("entropyBits must be 128 (12 words) or 256 (24 words)");
}
return generateMnemonic(wordlist, entropyBits);
}
function generateSeedPhraseWithEntropy(userEntropy) {
const csprngBytes = new Uint8Array(16);
function generateSeedPhraseWithEntropy(userEntropy, entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error("entropyBits must be 128 (12 words) or 256 (24 words)");
}
const entropyBytes = entropyBits / 8;
const csprngBytes = new Uint8Array(entropyBytes);
crypto.getRandomValues(csprngBytes);
const combined = concatBytes4(csprngBytes, userEntropy);
const hash = sha256(combined);
const finalEntropy = hash.slice(0, 16);
const finalEntropy = hash.slice(0, entropyBytes);
return entropyToMnemonic(finalEntropy, wordlist);
}
function mnemonicToSeed(mnemonic, passphrase = "") {
@@ -9771,7 +9778,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,34 +9837,55 @@ 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"}`);
@@ -9882,6 +9910,7 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) {
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
identityBound: false,
errors: ["Content is not valid JSON"]
};
}
@@ -9896,6 +9925,7 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) {
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
identityBound: false,
errors: ["Embedded event is not a valid kind 1 event"]
};
}
@@ -9907,6 +9937,21 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) {
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 eTags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter((t) => Array.isArray(t) && t[0] === "e");
let eTagValid = false;
@@ -10056,7 +10101,7 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) {
for (const e of policyErrors) errors.push(e);
}
const allChecksPassed = results.every((r) => r.valid);
const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient;
const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient && identityBound;
return {
valid: allChecksPassed,
results,
@@ -10067,6 +10112,132 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) {
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
};
}
@@ -10493,6 +10664,7 @@ export {
base64ToBytes,
buildKind11112Wrapper,
buildKind1Announcement,
buildProofCarrier,
buildUpgradedEvent,
bytesToBase64,
bytesToHex3 as bytesToHex,
@@ -10512,6 +10684,7 @@ export {
mnemonicToSeed,
parseOtsFile,
savePendingOts,
selectCanonicalProofCarrier,
signWithFalcon,
signWithMLDSA44,
signWithMLDSA65,
+133 -83
View File
@@ -254,10 +254,10 @@
<div class="pq-card">
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
<div class="pq-info-text">
Verify post-quantum migration events (kind 11112) by querying relays for a user's pubkey,
or by pasting event JSON directly. The kind 11112 event wraps a kind 1 announcement
Verify post-quantum migration events (kind 9999) by querying relays for a user's pubkey,
or by pasting event JSON directly. The kind 9999 event wraps a kind 1 announcement
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks:
the kind 11112 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
the kind 9999 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), all PQ signatures
(ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768), and the OpenTimestamps proof.
</div>
@@ -274,7 +274,7 @@
<div class="pq-tab-panel pq-tab-panel-active" id="pqPanelRelay">
<div class="pq-info-text">
Enter a Nostr pubkey (hex or npub) and one or more relay URLs. The page will query the
relay(s) for the latest kind 11112 event from that pubkey and verify it.
relay(s) for the latest kind 9999 event from that pubkey and verify it.
</div>
<input type="text" class="pq-input" id="pqPubkeyInput"
placeholder="Pubkey (hex or npub), e.g. 3bf0c63f869a8972... or npub1..." />
@@ -288,13 +288,13 @@
<!-- TAB 2: Paste Event JSON -->
<div class="pq-tab-panel" id="pqPanelPaste">
<div class="pq-info-text">
Paste a kind 11112 event JSON below to verify all signatures.
Paste a kind 9999 event JSON below to verify all signatures.
</div>
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste kind 11112 event JSON here, e.g.:
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste kind 9999 event JSON here, e.g.:
{
"id": "...",
"pubkey": "...",
"kind": 11112,
"kind": 9999,
"content": "{\"id\":\"...\",\"pubkey\":\"...\",\"kind\":1,\"content\":\"I am signaling...\",\"tags\":[...],\"sig\":\"...\"}",
"tags": [
["e", "<kind 1 event id>"],
@@ -330,7 +330,7 @@
<div id="pqOtsInfo" class="pq-status pq-status-info"></div>
<div class="pq-button-row">
<button class="pq-button" id="pqOtsUpgradeBtn">Upgrade OTS Proof</button>
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Republish with Confirmed Proof</button>
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Publish Upgraded Event</button>
</div>
<div id="pqOtsUpgradeStatus"></div>
</div>
@@ -369,6 +369,8 @@
verifyNostrEvent,
NIP_QR_KIND,
buildUpgradedEvent,
buildProofCarrier,
selectCanonicalProofCarrier,
bytesToBase64,
base64ToBytes,
hexToBytes,
@@ -415,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) {
@@ -469,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)
@@ -535,31 +534,35 @@
otsUpgradeWrap.style.display = 'none';
otsBadge.innerHTML = '';
// Display full kind 11112 JSON
// 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
// 1. Verify proof carrier secp256k1 signature
let secpValid = false;
try {
secpValid = verifyNostrEvent(event);
} catch (e) {
secpValid = false;
addResult('secp256k1 (kind 11112 wrapper)', false, `verification threw: ${e.message}`);
addResult('secp256k1 (proof carrier)', false, `verification threw: ${e.message}`);
}
if (secpValid) {
addResult('secp256k1 (kind 11112 wrapper)', true, 'valid');
addResult('secp256k1 (proof carrier)', true, 'valid');
} else if (!resultsWrap.querySelector('.pq-result-item')) {
addResult('secp256k1 (kind 11112 wrapper)', false, 'INVALID');
addResult('secp256k1 (proof carrier)', false, 'INVALID');
}
if (!secpValid) allValid = false;
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag (strict policy)
// G56-03: pass outer pubkey and expected author for identity binding
let pqResults;
try {
pqResults = verifyNIPQRContent(event.content, event.tags);
pqResults = verifyNIPQRContent(event.content, event.tags, {
outerPubkey: event.pubkey,
expectedAuthor: currentExpectedAuthor || undefined
});
} catch (e) {
pqResults = {
valid: false,
@@ -691,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 };
@@ -714,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;
@@ -794,7 +844,7 @@
if (confirmed) {
const att = verifyResult.bitcoinAttestations[0];
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can republish the event with the confirmed proof below.`);
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'inline-block';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
@@ -816,7 +866,7 @@
try {
if (!window.nostr || !window.nostr.signEvent) {
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to republish.');
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to publish the upgraded event.');
}
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
@@ -859,11 +909,11 @@
}
currentEvent = signedEvent;
setStatus(otsUpgradeStatus, 'success', `Upgraded kind 11112 event published to ${ok} relay(s) (${fail} failed).`);
setStatus(otsUpgradeStatus, 'success', `Upgraded proof carrier published to ${ok} relay(s) (${fail} failed).`);
// Re-verify the new event
await verifyEvent(signedEvent);
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Republish failed: ${error.message}`);
setStatus(otsUpgradeStatus, 'error', `Publish failed: ${error.message}`);
} finally {
otsRepublishBtn.disabled = false;
}
@@ -873,7 +923,7 @@
AUTO-DETECT SIGNED-IN USER
On page load, check for a NIP-07 signer / nostr-login-lite. If the
user is already signed in, pre-fill the pubkey field and auto-query
relays for their kind 11112 event.
relays for their kind 9999 event.
================================================================ */
async function initAutoDetect() {
// Initialize nostr-login-lite if available (shares session with main page)