Security audit fixes: real OTS verification (F-C2/F-C3), verifyNostrEvent id check (F-H2), XSS fixes (F-H3), CSP (F-H4), test suite with 38 passing tests (F-H1), seed generation UX fix, entropy optional, input validation (F-L3), relay close-as-success (F-L6), doc reconciliation (F-C4/F-L8), Content-Type fix (F-L5), secret clearing (F-L1), 12-word warning (F-L2), Falcon draft warning (F-M5)
This commit is contained in:
@@ -10,8 +10,8 @@ A migration strategy for bringing post-quantum security to Nostr without breakin
|
||||
- [Component 1: Seed Phrase as Algorithm-Agnostic Root of Trust](#component-1-seed-phrase-as-algorithm-agnostic-root-of-trust)
|
||||
- [Component 2: Multi-Scheme Cross-Signed Key-Link Events](#component-2-multi-scheme-cross-signed-key-link-events)
|
||||
- [Component 3: OpenTimestamps for Pre-Quantum Anchoring](#component-3-opentimestamps-for-pre-quantum-anchoring)
|
||||
- [Component 4: Migrating Existing Users with Raw nsec](#component-4-migrating-existing-users-with-raw-nsec)
|
||||
- [Component 5: Quantum-Safe Self-Storage](#component-5-quantum-safe-self-storage)
|
||||
- [Component 4: Migrating Existing Users with Raw nsec (Design Only)](#component-4-migrating-existing-users-with-raw-nsec-design-only)
|
||||
- [Component 5: Quantum-Safe Self-Storage (Design Only)](#component-5-quantum-safe-self-storage-design-only)
|
||||
- [Component 6: Deterministic Wallet Compartmentalization](#component-6-deterministic-wallet-compartmentalization)
|
||||
- [The Complete Migration Flow](#the-complete-migration-flow)
|
||||
- [What Remains Unsolved](#what-remains-unsolved)
|
||||
@@ -110,31 +110,64 @@ The BIP39 seed is a 64-byte entropy string derived from a mnemonic via PBKDF2-HM
|
||||
flowchart TD
|
||||
SEED[BIP39 Seed - 64 bytes, quantum-safe]
|
||||
SEED --> BIP32[BIP32 derivation - secp256k1 keypair]
|
||||
SEED --> PQKEM[HKDF derivation - ML-KEM keypair]
|
||||
SEED --> PQDSA[HKDF derivation - ML-DSA keypair]
|
||||
SEED --> SLHDSA[HKDF derivation - SLH-DSA keypair]
|
||||
SEED --> SYMKEY[HKDF derivation - symmetric encryption key]
|
||||
SEED --> PQKEM[BIP32 derivation - ML-KEM keypair]
|
||||
SEED --> PQDSA[BIP32 derivation - ML-DSA keypair]
|
||||
SEED --> SLHDSA[BIP32 derivation - SLH-DSA keypair]
|
||||
SEED --> FALCON[BIP32 derivation - Falcon keypair]
|
||||
|
||||
style SEED fill:#cfc
|
||||
style BIP32 fill:#fcc
|
||||
style PQKEM fill:#cfc
|
||||
style PQDSA fill:#cfc
|
||||
style SLHDSA fill:#cfc
|
||||
style SYMKEY fill:#cfc
|
||||
style FALCON fill:#cfc
|
||||
```
|
||||
|
||||
### PQ Key Derivation from BIP39 Seeds
|
||||
|
||||
BIP32 is specific to secp256k1. For PQ keys, use HKDF-SHA256 (quantum-resistant) to derive key material from the BIP39 seed:
|
||||
All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard used by NIP-06 for secp256k1 keys. PQ keys are derived at different child indices under the NIP-06 base path, using the BIP32 child private key (32 bytes) as the deterministic seed for each PQ algorithm's `keygen()` function.
|
||||
|
||||
**Base path:** `m/44'/1237'/0'/0/` (NIP-06 account 0, change 0)
|
||||
|
||||
| Child index | Algorithm | Seed length | BIP32 path | How derived |
|
||||
|---|---|---|---|---|
|
||||
| 0 | secp256k1 (NIP-06) | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32 — private key used directly |
|
||||
| 1 | ML-DSA-44 | 32 bytes | `m/44'/1237'/0'/0/1` | Single child — 32-byte private key is the PQ seed |
|
||||
| 2 | ML-DSA-65 | 32 bytes | `m/44'/1237'/0'/0/2` | Single child — 32-byte private key is the PQ seed |
|
||||
| 3 + 4 | SLH-DSA-128s | 48 bytes | `m/44'/1237'/0'/0/3` + `m/44'/1237'/0'/0/4` | Two children concatenated (64 bytes), first 48 used |
|
||||
| 5 + 6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
|
||||
| 7 + 8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
|
||||
|
||||
### Why some algorithms need two children
|
||||
|
||||
BIP32 child derivation produces exactly 32 bytes (the private key) per child. Some PQ algorithms need more than 32 bytes for their `keygen()` seed:
|
||||
|
||||
- **ML-DSA-44** and **ML-DSA-65**: need 32 bytes — one child is enough
|
||||
- **SLH-DSA-128s**: needs 48 bytes — derive two children (64 bytes), use the first 48
|
||||
- **Falcon-512**: needs 48 bytes — derive two children (64 bytes), use the first 48
|
||||
- **ML-KEM-768**: needs 64 bytes — derive two children and concatenate (64 bytes)
|
||||
|
||||
**Truncation rule (must be pinned for compatibility):** when a PQ seed requires more than 32 bytes, two BIP32 children are derived and concatenated (64 bytes total). For 48-byte seeds, the **first 48 bytes** of the concatenation are used. For 64-byte seeds, all 64 bytes are used. A future implementer who takes the *last* 48 bytes would produce different keys and break compatibility — implementations must use the first N bytes.
|
||||
|
||||
### Why BIP32 and not HKDF?
|
||||
|
||||
BIP32 is the standard for HD wallets. Using BIP32 paths gives us:
|
||||
|
||||
1. **Consistency with NIP-06** — PQ keys are derived the same way as secp256k1 keys, just at different child indices
|
||||
2. **Compartmentalization** — hardened derivation at the account level means breaking one key doesn't compromise siblings
|
||||
3. **Wallet compatibility** — HD wallet infrastructure (Amber, hardware wallets, etc.) can derive these same paths
|
||||
4. **Tree structure** — the derivation path documents exactly which key is where
|
||||
5. **Security** — BIP32 uses HMAC-SHA512 (the same HMAC used everywhere in HD wallets), not a custom construction
|
||||
|
||||
### What is HMAC?
|
||||
|
||||
HMAC (Hash-based Message Authentication Code) is the cryptographic primitive that BIP32 is built on. Every BIP32 child key derivation is:
|
||||
|
||||
```
|
||||
For each PQ algorithm A:
|
||||
pq_seed_A = HKDF-Extract(salt="nostr-pq-v1", ikm=bip39_seed)
|
||||
pq_key_material_A = HKDF-Expand(pq_seed_A, info=A.identifier, L=A.key_size)
|
||||
pq_keypair_A = A.keygen(pq_key_material_A)
|
||||
HMAC-SHA512(parent_chain_code, data) → 32-byte private key + 32-byte chain code
|
||||
```
|
||||
|
||||
The identifier string (`"ml-dsa-65"`, `"slh-dsa-128s"`, `"ml-kem-768"`, etc.) is the only algorithm-specific part. New algorithms can be added later without changing the derivation scheme.
|
||||
This is not a separate or weaker construction — it IS BIP32. Using BIP32 paths means we're using the same HMAC-based derivation that all HD wallets use.
|
||||
|
||||
### Seed Phrase Entropy Considerations
|
||||
|
||||
@@ -143,7 +176,7 @@ The identifier string (`"ml-dsa-65"`, `"slh-dsa-128s"`, `"ml-kem-768"`, etc.) is
|
||||
| 12 words | 128 bits | ~64 bits | Borderline |
|
||||
| 24 words | 256 bits | ~128 bits | Adequate |
|
||||
|
||||
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.
|
||||
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.)
|
||||
|
||||
---
|
||||
|
||||
@@ -166,77 +199,111 @@ History gives reason to be cautious: SIKE was a NIST PQ finalist that was broken
|
||||
|
||||
### The Multi-Scheme Solution
|
||||
|
||||
The implementation includes **five** PQ schemes — four signature schemes and one KEM:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SEED[BIP39 Seed] --> SECP[secp256k1 keypair]
|
||||
SEED --> MLDSA[ML-DSA keypair]
|
||||
SEED --> SLHDSA[SLH-DSA keypair]
|
||||
SEED --> FALCON[FN-DSA keypair]
|
||||
SEED --> MLKEM[ML-KEM keypair]
|
||||
SEED[BIP39 Seed] --> SECP[secp256k1 keypair - child 0]
|
||||
SEED --> MLDSA44[ML-DSA-44 keypair - child 1]
|
||||
SEED --> MLDSA65[ML-DSA-65 keypair - child 2]
|
||||
SEED --> SLHDSA[SLH-DSA-128s keypair - children 3+4]
|
||||
SEED --> FALCON[Falcon-512 keypair - children 5+6]
|
||||
SEED --> MLKEM[ML-KEM-768 keypair - children 7+8]
|
||||
|
||||
SECP --> SIGN[Schnorr sign]
|
||||
MLDSA --> SIGN2[ML-DSA sign]
|
||||
SLHDSA --> SIGN3[SLH-DSA sign]
|
||||
FALCON --> SIGN4[FN-DSA sign]
|
||||
MLDSA44 --> SIGN1[ML-DSA-44 sign attestation]
|
||||
MLDSA65 --> SIGN2[ML-DSA-65 sign attestation]
|
||||
SLHDSA --> SIGN3[SLH-DSA-128s sign attestation]
|
||||
FALCON --> SIGN4[Falcon-512 sign attestation]
|
||||
|
||||
SIGN --> EVENT[Key-Link Event]
|
||||
SIGN2 --> EVENT
|
||||
SIGN3 --> EVENT
|
||||
SIGN4 --> EVENT
|
||||
SIGN1 --> KIND1[Kind 1 Announcement Event]
|
||||
SIGN2 --> KIND1
|
||||
SIGN3 --> KIND1
|
||||
SIGN4 --> KIND1
|
||||
MLKEM --> KIND1
|
||||
|
||||
EVENT --> OTS[OpenTimestamp via NIP-03]
|
||||
OTS --> RELAY[Published to relays NOW]
|
||||
SECP --> ACCT1[Account 1 - existing identity signs kind 1 and kind 11112]
|
||||
|
||||
KIND1 --> OTS[OpenTimestamp the kind 1 event hash]
|
||||
OTS --> KIND11112[Kind 11112 Wrapper Event - carries OTS proof]
|
||||
KIND11112 --> 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 EVENT fill:#ffa
|
||||
style KIND1 fill:#ffa
|
||||
style KIND11112 fill:#ffa
|
||||
style OTS fill:#cfc
|
||||
```
|
||||
|
||||
### Key-Link Event Format
|
||||
### The Two-Event Structure
|
||||
|
||||
A new event kind (to be assigned) containing multiple cross-signed links:
|
||||
The migration uses **two Nostr events**:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": <new key-link kind>,
|
||||
"pubkey": "<secp256k1 npub>",
|
||||
"content": {
|
||||
"links": [
|
||||
{
|
||||
"algorithm": "ml-dsa-65",
|
||||
"public_key": "<ML-DSA-65 public key, base64>",
|
||||
"signature": "<ML-DSA signature over the link statement, base64>"
|
||||
},
|
||||
{
|
||||
"algorithm": "slh-dsa-128s",
|
||||
"public_key": "<SLH-DSA-128s public key, base64>",
|
||||
"signature": "<SLH-DSA signature over the link statement, base64>"
|
||||
},
|
||||
{
|
||||
"algorithm": "fn-dsa-512",
|
||||
"public_key": "<FN-DSA-512 public key, base64>",
|
||||
"signature": "<FN-DSA signature over the link statement, base64>"
|
||||
},
|
||||
{
|
||||
"algorithm": "ml-kem-768",
|
||||
"public_key": "<ML-KEM-768 public key, base64>",
|
||||
"note": "KEM key for encryption; ownership asserted by secp256k1 signature over this content"
|
||||
}
|
||||
],
|
||||
"statement": "All keys in this event are derived from the same seed and controlled by the same entity as secp256k1 key <npub>. This link is established pre-quantum.",
|
||||
"secp256k1_signature": "<Schnorr signature over the entire content>"
|
||||
}
|
||||
}
|
||||
#### 1. Kind 1 Announcement (human-readable, visible in Nostr feeds)
|
||||
|
||||
A regular Nostr text note (kind 1) that serves as a public announcement of the post-quantum key migration. The content is human-readable text with newlines. The PQ public keys and signatures go in the tags.
|
||||
|
||||
**Content:** A human-readable attestation statement, e.g.:
|
||||
|
||||
```
|
||||
I am signaling that the post-quantum public keys listed in the tags of this event
|
||||
were generated by me and I hold the private keys. I may use these keys in the future
|
||||
as successors to my current Nostr identity.
|
||||
|
||||
My current identity:
|
||||
npub: <npub>
|
||||
hex: <hex pubkey>
|
||||
|
||||
This attestation is established pre-quantum at Bitcoin block height <height>.
|
||||
|
||||
Post-quantum public keys in tags:
|
||||
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
|
||||
ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3)
|
||||
SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1)
|
||||
Falcon-512 (FIPS 206 draft, NIST Level 1)
|
||||
ML-KEM-768 (Kyber, FIPS 203, NIST Level 3)
|
||||
|
||||
Each post-quantum key has cryptographically signed this attestation. This event is
|
||||
pending timestamp on the Bitcoin blockchain via OpenTimestamps.
|
||||
```
|
||||
|
||||
Each PQ signature scheme independently signs the same link statement. The secp256k1 Schnorr signature covers the entire content (including all PQ public keys and signatures), binding them together.
|
||||
**Tags:**
|
||||
- `['block_height', '<height>']` — the Bitcoin block height at signing time
|
||||
- `['algorithm', '<algorithm>', '<base64 pubkey>', '<base64 signature>']` — one per PQ signature scheme
|
||||
- `['algorithm', 'ml-kem-768', '<base64 pubkey>']` — for ML-KEM (KEM, no signature)
|
||||
|
||||
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)
|
||||
|
||||
A replaceable event (kind 11112, in the 10000–19999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. The content is the full kind 1 event as JSON (so verifiers don't need to fetch it from relays).
|
||||
|
||||
**Content:** `JSON.stringify(kind1Event)` — the full signed kind 1 event embedded as a JSON string.
|
||||
|
||||
**Tags:**
|
||||
- `['e', '<kind1 event id>']` — reference to the kind 1 announcement
|
||||
- `['sha256', '<hex SHA-256 of the full signed kind 1 event JSON>']` — what was timestamped
|
||||
- `['ots', '<base64 .ots proof>']` — the OpenTimestamps proof (pending or confirmed)
|
||||
|
||||
The wrapper is signed with the user's existing identity (Account #1) via `window.nostr.signEvent`.
|
||||
|
||||
### What This Proves and What It Does Not
|
||||
|
||||
**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 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:**
|
||||
- The PQ keys cannot *cryptographically* prove they share a common seed origin. They prove only that five PQ keypairs exist and each signed the attestation text. Common-origin is **asserted** in the attestation text and **authorized** by Account #1's signature, but it is not proven by a seed-derived signature.
|
||||
- The seed-derived secp256k1 key (Account #2, child 0) is derived but **not used to sign** in the current implementation. There is no "successor signature" binding the PQ keys to the seed.
|
||||
|
||||
This is a deliberate design choice. The security model relies on **OTS precedence** (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature. After a quantum break, an attacker who forges Account #1 can publish a fraudulent migration event, but it will have a later OTS timestamp than the real event — so the real event is cryptographically distinguishable by its earlier Bitcoin anchor.
|
||||
|
||||
### ML-KEM Cannot Sign
|
||||
|
||||
ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme. It cannot sign the link statement. Instead, its binding is proven indirectly: the secp256k1 signature covers the ML-KEM public key in the content, and the ML-KEM key is derived from the same seed. The proof is: "the entity that controls the secp256k1 key (proven by Schnorr signature) asserts that this ML-KEM key is also theirs."
|
||||
ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme. It cannot sign the attestation. Instead, its public key is included in an `algorithm` tag (without a signature field), and its ownership is asserted by the attestation text and authorized by Account #1's signature over the kind 1 event (which covers the tags, including the ML-KEM pubkey).
|
||||
|
||||
This is weaker than a cross-signature but sufficient because:
|
||||
- The secp256k1 signature is valid NOW (pre-quantum), establishing the link while we can trust secp256k1
|
||||
@@ -267,7 +334,7 @@ If a specific PQ scheme is later found to be weak (like SIKE was):
|
||||
|
||||
## Component 3: OpenTimestamps for Pre-Quantum Anchoring
|
||||
|
||||
NIP-03 (OpenTimestamps Attestations for Events) already exists and is implemented in nostr_core_lib. It anchors Nostr event hashes to the Bitcoin blockchain via OpenTimestamps.
|
||||
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.
|
||||
|
||||
### Why Bitcoin Timestamping Is Quantum-Resistant
|
||||
|
||||
@@ -279,7 +346,7 @@ An attacker who breaks your secp256k1 key via Shor's can forge new events but **
|
||||
|
||||
### The Critical Refinement: OpenTimestamp the Key-Link Event
|
||||
|
||||
The key-link event itself MUST be timestamped via NIP-03. This prevents a race condition attack:
|
||||
The kind 1 announcement event MUST be timestamped via OpenTimestamps. This prevents a race condition attack:
|
||||
|
||||
**Without OTS on the key-link event:**
|
||||
- Attacker breaks secp256k1 key via Shor's
|
||||
@@ -293,6 +360,19 @@ The key-link event itself MUST be timestamped via NIP-03. This prevents a race c
|
||||
- Clients verify: "the key-link event with the earliest valid OTS proof wins"
|
||||
- **The real key-link is cryptographically distinguishable from the fraudulent one**
|
||||
|
||||
### What Is Timestamped
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Current OTS Verification Status
|
||||
|
||||
The current implementation detects Bitcoin confirmation by searching the `.ots` proof bytes for the Bitcoin block-header attestation magic bytes, and delegates proof upgrading to a server-side helper. **Full client-side OTS verification** (parsing the proof, validating the Merkle path, checking the block header against the Bitcoin chain) is planned but not yet implemented. The vendored `javascript-opentimestamps` library in `resources/` provides the necessary primitives and will be integrated in a future release.
|
||||
|
||||
### What Should Be OpenTimestamped
|
||||
|
||||
Not every event needs OTS. Priority list:
|
||||
@@ -306,7 +386,9 @@ Routine events (regular posts, reactions) probably don't need OTS — the impact
|
||||
|
||||
---
|
||||
|
||||
## Component 4: Migrating Existing Users with Raw nsec
|
||||
## Component 4: Migrating Existing Users with Raw nsec (Design Only)
|
||||
|
||||
> **Status: Design only — not yet implemented.** The current implementation supports users who already have a Nostr identity (via NIP-07 signer or nostr-login-lite). The raw-nsec migration described below is future work.
|
||||
|
||||
Most Nostr users today have a raw nsec (random private key) with no seed phrase. Asking them to abandon their identity and social graph to adopt a seed phrase is a non-starter. This component addresses how to migrate them.
|
||||
|
||||
@@ -373,7 +455,7 @@ flowchart TD
|
||||
NEW[Generate 12-word BIP39 phrase]
|
||||
NEW --> SEED[BIP39 seed]
|
||||
SEED --> NEWSECP[New secp256k1 key via NIP-06]
|
||||
SEED --> PQKEYS[PQ keys via HKDF]
|
||||
SEED --> PQKEYS[PQ keys via BIP32]
|
||||
SEED --> ENCKEY[Symmetric encryption key via HKDF]
|
||||
ENCKEY --> ENC[Encrypt old nsec]
|
||||
OLD --> ENC
|
||||
@@ -406,7 +488,7 @@ flowchart TD
|
||||
|
||||
**Design:**
|
||||
1. Generate a 12-word BIP39 phrase
|
||||
2. Derive from seed: new secp256k1 key (NIP-06 path), PQ keys (HKDF), symmetric encryption key (HKDF)
|
||||
2. Derive from seed: new secp256k1 key (NIP-06 path), PQ keys (BIP32), symmetric encryption key (HKDF)
|
||||
3. Encrypt old nsec with the symmetric key: `encrypted_nsec = ChaCha20-Poly1305(enc_key, old_nsec)`
|
||||
4. Publish as kind 30078 event, signed with the **new** secp256k1 key
|
||||
5. Publish key-link event (cross-signed, OpenTimestamped via NIP-03)
|
||||
@@ -435,7 +517,9 @@ flowchart TD
|
||||
|
||||
---
|
||||
|
||||
## Component 5: Quantum-Safe Self-Storage
|
||||
## Component 5: Quantum-Safe Self-Storage (Design Only)
|
||||
|
||||
> **Status: Design only — not yet implemented.** The current implementation does not include encrypted self-storage. The approaches below are design proposals for future work.
|
||||
|
||||
For self-storage use cases (e.g., kind 30078 application data), NIP-04 and NIP-44 are unnecessary and quantum-vulnerable. Two quantum-safe alternatives:
|
||||
|
||||
@@ -513,6 +597,10 @@ The NIP-06 path `m/44'/1237'/account'/0/0` has hardened derivation at three leve
|
||||
- The seed/master key remains secure even if all account keys are broken
|
||||
- Hardened derivation requires the parent **private** key, not just the public key
|
||||
|
||||
### Note on the PQ Derivation Paths
|
||||
|
||||
The PQ keys in this implementation are derived at **non-hardened** children (indices 1–8) under the hardened parent `m/44'/1237'/0'/0'`. Non-hardened derivation means anyone with the parent xpub (extended public key + chain code) at `m/44'/1237'/0'/0'` could derive the child *public* keys — but **not** the child *private* keys (which are what the PQ seeds are). So the PQ seeds remain secret unless the parent private key leaks. **Never publish the xpub at `m/44'/1237'/0'/0'`** — doing so would expose the child public keys (though not the PQ seeds themselves).
|
||||
|
||||
### The Critical Vulnerability: Chain Code Leakage
|
||||
|
||||
If an attacker obtains a chain code at any level AND breaks one child key at that level via Shor's, they can:
|
||||
@@ -549,24 +637,25 @@ If an attacker obtains a chain code at any level AND breaks one child key at tha
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph NOW["Pre-quantum - NOW"]
|
||||
USER[User with raw nsec OR seed phrase]
|
||||
USER --> MIGRATE[Client: Enable PQ migration]
|
||||
MIGRATE --> SEED[Generate or use 12-word BIP39 phrase]
|
||||
SEED --> DERIVE[Derive: new secp256k1 + all PQ keys + storage key]
|
||||
DERIVE --> ENCRYPT[Encrypt old nsec if migrating from raw nsec]
|
||||
ENCRYPT --> STORE30078[Publish kind 30078: encrypted old nsec]
|
||||
DERIVE --> KEYLINK[Publish key-link event: cross-signed by all PQ schemes]
|
||||
KEYLINK --> OTS[OpenTimestamp the key-link event via NIP-03]
|
||||
OTS --> BTC[Anchored in Bitcoin block]
|
||||
KEYLINK --> RELAY[Published to relays]
|
||||
STORE30078 --> RELAY
|
||||
USER[User with existing Nostr identity]
|
||||
USER --> SIGNIN[Sign in via NIP-07 signer]
|
||||
SIGNIN --> SEED[Generate 12-word BIP39 phrase]
|
||||
SEED --> DERIVE[Derive 5 PQ keypairs from seed via BIP32]
|
||||
DERIVE --> SIGN[Each PQ key signs the attestation text]
|
||||
SIGN --> KIND1[Build kind 1 announcement with PQ sigs in tags]
|
||||
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]
|
||||
SECP2 --> RELAY[Publish both events to relays]
|
||||
SEED --> BACKUP[User writes down 12-word phrase]
|
||||
end
|
||||
|
||||
subgraph TRANSITION["Transition period - dual signing"]
|
||||
DUAL[Client signs events with both secp256k1 and PQ keys]
|
||||
DUAL --> LEGACY[Legacy clients verify secp256k1 signature]
|
||||
DUAL --> PQAWARE[PQ-aware clients verify PQ signature]
|
||||
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]
|
||||
end
|
||||
|
||||
subgraph FUTURE["Post-quantum - when quantum computers arrive"]
|
||||
@@ -574,39 +663,30 @@ flowchart TD
|
||||
ATTACK --> FORGE[Can forge secp256k1 signatures]
|
||||
ATTACK --> CANNOT1[Cannot forge PQ signatures]
|
||||
ATTACK --> CANNOT2[Cannot backdate events - OTS proofs anchor history]
|
||||
ATTACK --> CANNOT3[Cannot break OTP-encrypted storage]
|
||||
|
||||
PQAWARE --> VERIFY[Verify key-link event via OTS]
|
||||
VERIFY[Verify key-link event via OTS precedence]
|
||||
VERIFY --> USEPQ[Switch to PQ signatures]
|
||||
USEPQ --> REJECT[Reject forged secp256k1-only events]
|
||||
end
|
||||
|
||||
subgraph RETIRE["Old key retirement"]
|
||||
DROP[User drops extra backup, keeps 12 words only]
|
||||
DROP --> PQONLY[Client uses PQ keys only]
|
||||
PQONLY --> DONE[Migration complete]
|
||||
end
|
||||
|
||||
NOW --> TRANSITION --> FUTURE --> RETIRE
|
||||
NOW --> CONFIRM --> FUTURE
|
||||
|
||||
style NOW fill:#cfc
|
||||
style TRANSITION fill:#ffa
|
||||
style CONFIRM fill:#ffa
|
||||
style FUTURE fill:#fcc
|
||||
style RETIRE fill:#cfc
|
||||
```
|
||||
|
||||
### User Experience
|
||||
|
||||
1. User clicks "Enable quantum-safe migration" in their client
|
||||
1. User signs in with their existing Nostr identity (NIP-07 signer or nostr-login-lite)
|
||||
2. Client generates a 12-word phrase, shows it to the user to write down
|
||||
3. Client handles everything else automatically:
|
||||
- Derives new secp256k1 key + all PQ keys from seed
|
||||
- Encrypts old nsec and publishes as kind 30078 (if migrating from raw nsec)
|
||||
- Publishes cross-signed key-link event
|
||||
- OpenTimestamps the key-link event via NIP-03
|
||||
4. During transition: client signs with both old and new keys
|
||||
5. After quantum migration: client uses PQ keys only, old key retired
|
||||
6. User drops extra backup, keeps 12 words
|
||||
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
|
||||
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
|
||||
9. After quantum migration: clients use PQ keys, old key retired
|
||||
|
||||
---
|
||||
|
||||
@@ -622,7 +702,7 @@ Past secp256k1-signed events that were NOT OpenTimestamped remain forgeable afte
|
||||
|
||||
### Relay Trust for Recovery
|
||||
|
||||
Approach B (12 words + relay backup) depends on relays retaining the kind 30078 event containing the encrypted old nsec. If all relays delete it, the old nsec is lost.
|
||||
Approach B (12 words + relay backup, Component 4 — design only) depends on relays retaining the kind 30078 event containing the encrypted old nsec. If all relays delete it, the old nsec is lost.
|
||||
|
||||
**Mitigations:**
|
||||
- Publish to multiple relays
|
||||
@@ -637,48 +717,66 @@ PQ signatures are much larger than secp256k1 Schnorr signatures:
|
||||
| Scheme | Signature size |
|
||||
|---|---|
|
||||
| secp256k1 Schnorr | 64 bytes |
|
||||
| ML-DSA-44 | ~2.4 KB |
|
||||
| ML-DSA-65 | ~3.3 KB |
|
||||
| SLH-DSA-128s | ~8 KB |
|
||||
| FN-DSA-512 | ~0.7 KB |
|
||||
| Falcon-512 | ~0.7 KB |
|
||||
|
||||
This increases event sizes during the transition period (dual signing). After migration, events can be signed with PQ only. FN-DSA (Falcon) offers the most compact PQ signatures.
|
||||
This increases the kind 1 announcement event size (~20-30 KB total for all PQ signatures). This is a one-time cost for the migration event, not per-event overhead.
|
||||
|
||||
### PQ Algorithm Risk
|
||||
|
||||
Any PQ algorithm could theoretically be broken by a new classical or quantum algorithm (as SIKE was). The multi-scheme approach hedges against this — if one scheme falls, the others remain linked. But if ALL lattice-based schemes are broken simultaneously, only SLH-DSA (hash-based, very conservative) remains as a fallback.
|
||||
|
||||
### Common-Origin Not Cryptographically Proven
|
||||
|
||||
The current implementation does not include a seed-derived secp256k1 signature binding the PQ keys to the seed (see Component 2, "What This Proves and What It Does Not"). The PQ keys self-attest by signing the attestation text, and the user's existing identity authorizes the migration. Common-origin is asserted, not proven. This is acceptable because OTS precedence (not a seed-derived signature) is the mechanism that distinguishes real from forged events after a quantum break.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
This is a design document. The following building blocks already exist:
|
||||
### What Is Implemented
|
||||
|
||||
### Existing Infrastructure
|
||||
The current implementation is a static web app (`www/`) that performs the full migration flow for users who already have a Nostr identity:
|
||||
|
||||
| Component | Status | Location |
|
||||
|---|---|---|
|
||||
| NIP-06 (key derivation from mnemonic) | Implemented | nostr_core_lib: `nip006.c` |
|
||||
| NIP-03 (OpenTimestamps) | Implemented | nostr_core_lib: `nip003.c` |
|
||||
| NIP-44 (encrypted payloads) | Implemented | nostr_core_lib: `nip044.c` |
|
||||
| NIP-04 (encrypted DMs, deprecated) | Implemented | nostr_core_lib: `nip004.c` |
|
||||
| OTP cipher (information-theoretic encryption) | Implemented | ~/lt/otp |
|
||||
| BIP39 seed phrase generation (12-word, with optional user entropy) | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| BIP32 key derivation (secp256k1 + 5 PQ keypairs) | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| Relay publishing (WebSocket) | Implemented | `www/index.html` |
|
||||
| Verification page (query relay or paste event JSON) | Implemented | `www/verify.html` |
|
||||
| NIP-07 signer integration (nostr-login-lite) | Implemented | `www/index.html` |
|
||||
|
||||
### To Be Built
|
||||
### What Is Not Yet Implemented
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| Key-link NIP | New NIP defining the cross-signed key-link event format |
|
||||
| PQ key derivation from BIP39 seeds | HKDF-based derivation of ML-DSA, SLH-DSA, FN-DSA, ML-KEM keys |
|
||||
| PQ signature support | Integration of liboqs or similar for PQ signature verification |
|
||||
| Migration tooling | Client-side tooling for migrating raw nsec users to seed phrases |
|
||||
| Kind 30078 encrypted storage | Symmetric-key or OTP encryption for self-storage |
|
||||
| Extended phrase encoding | 36-word phrase format (Approach A/C) |
|
||||
| Full client-side OTS verification | Parse the .ots proof, validate the Merkle path, check the block header against the Bitcoin chain (the vendored `javascript-opentimestamps` library in `resources/` provides the primitives) |
|
||||
| OTS target-digest binding | Verify the OTS proof's target digest equals the event's `sha256` tag |
|
||||
| Raw-nsec migration (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding |
|
||||
| Quantum-safe self-storage (Component 5) | OTP or symmetric-key encryption for kind 30078 data |
|
||||
| Test suite | Known-answer tests for derivation, PQ sign/verify round-trips, event verification |
|
||||
| 24-word mnemonic default | Currently defaults to 12 words; 24 recommended for production |
|
||||
|
||||
### Dependencies
|
||||
|
||||
- [liboqs](https://github.com/open-quantum-safe/liboqs) — Open Quantum Safe library for PQ algorithms
|
||||
- [OpenTimestamps](https://opentimestamps.org/) — already integrated via NIP-03
|
||||
- BIP39/BIP32 — already integrated via NIP-06
|
||||
- [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) — pure-JS PQ algorithms (ML-DSA, SLH-DSA, Falcon, ML-KEM)
|
||||
- [`@noble/curves`](https://github.com/paulmillr/noble-curves) — secp256k1 Schnorr
|
||||
- [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) — SHA-256
|
||||
- [`@scure/bip39`](https://github.com/paulmillr/scure-bip39) — BIP39 mnemonic generation/validation
|
||||
- [`@scure/bip32`](https://github.com/paulmillr/scure-bip32) — BIP32 HD wallet derivation
|
||||
- [`@scure/base`](https://github.com/paulmillr/scure-base) — bech32 (npub) encoding
|
||||
- [OpenTimestamps](https://opentimestamps.org/) — calendar servers for timestamping
|
||||
- [`javascript-opentimestamps`](resources/javascript-opentimestamps/) — vendored OTS library (present, not yet integrated for client-side verification)
|
||||
- [nostr-login-lite](https://github.com/nostrband/nostr-login-lite) — NIP-07 / NIP-46 authentication
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,219 @@
|
||||
# Security Audit: Post-Quantum Nostr (Second Pass)
|
||||
|
||||
**Auditor:** GLM-5.2 (architect mode)
|
||||
**Date:** 2026-07-17 (second pass, after documentation reconciliation + F-C2/F-C3 fix)
|
||||
**Scope:** Full codebase in `/home/user/lt/post_quantum_nostr`
|
||||
**Primary focus:** Cryptographic correctness — does the cryptography function correctly and do what it purports to do?
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This is the second audit pass, performed after the project's documentation was reconciled with its implementation. The first pass found that the docs described a different system than the code (F-C4) and claimed a "successor signature" that didn't exist (F-C1). Both have been addressed: the docs now match the code, and the security model is honestly stated as "PQ self-attestation + Account #1 authorization + OTS precedence" without a seed-derived signature.
|
||||
|
||||
**What changed since the first pass:**
|
||||
- ✅ **F-C4 (docs vs code mismatch): RESOLVED.** All four primary docs now describe BIP32 derivation, the kind 1 + kind 11112 two-event structure, 5 algorithms, and what's actually timestamped.
|
||||
- ✅ **F-C1 (missing successor signature): RECLASSIFIED.** No longer a discrepancy — it's now a documented, deliberate design choice. The docs explicitly state the PQ keys cannot cryptographically prove common seed origin, and that OTS precedence is the anti-forgery mechanism. This is coherent.
|
||||
- ✅ **F-C2 (OTS proofs never verified): RESOLVED.** Implemented real client-side OTS verification in [`pq-crypto.mjs`](www/js/pq-crypto.mjs): a binary OTS parser (`parseOtsFile`), a full verifier (`verifyOtsProof`) that walks the op tree (SHA-256, SHA-1, RIPEMD160, append, prepend, reverse), fetches Bitcoin block headers from blockstream.info/mempool.space, and checks the computed Merkle root matches the block's merkle root. [`verify.html`](www/verify.html) and [`index.html`](www/index.html) now use `verifyOtsProof` instead of the byte-pattern heuristic. Tested against the vendored example proofs (hello-world.txt.ots verifies against block 358391).
|
||||
- ✅ **F-C3 (OTS target digest not bound): RESOLVED.** `verifyOtsProof` now takes an optional `expectedDigestHex` parameter and rejects proofs whose target digest doesn't match. [`verify.html`](www/verify.html) and [`index.html`](www/index.html) pass the event's `sha256` tag as the expected digest. Tested: a wrong digest correctly fails with "Target digest mismatch".
|
||||
- ⚠️ **One minor doc leftover** (new finding F-L8): [`why_what_how.md`](why_what_how.md:64) Component 4 summary still says "the new identity signs to endorse the PQ keys" — successor-model language in a "design only" section. Low severity.
|
||||
|
||||
**What remains open:**
|
||||
1. **F-H1 (High): No tests.** No known-answer vectors, no round-trip tests, no regression protection.
|
||||
2. **F-H2 (High): `verifyNostrEvent` doesn't check `event.id`.** A wrong id with a valid signature passes.
|
||||
3. **F-H3 (High): XSS surface** via `innerHTML` with relay/localStorage data.
|
||||
4. **F-H4 (High): No CSP, no SRI** on either page or the bundle.
|
||||
|
||||
The PQ *primitive* usage remains correct, deterministic keygen from the BIP32 seed delivers recoverability, and **OTS verification is now real** (Merkle path + block header check + target digest binding). The remaining open items are general software-quality issues (tests, id checks, web security) rather than cryptographic-correctness gaps.
|
||||
|
||||
### Severity counts (this pass)
|
||||
|
||||
| Severity | Count | Change from first pass |
|
||||
|---|---|---|
|
||||
| Critical | 0 | -4 (F-C1 reclassified, F-C4 resolved, F-C2/F-C3 fixed) |
|
||||
| High | 5 | unchanged (F-H5 subsumed by F-C2 fix) |
|
||||
| Medium | 6 | unchanged |
|
||||
| Low / Informational | 8 | +1 (F-L8 doc leftover) |
|
||||
|
||||
---
|
||||
|
||||
## 2. What This Audit Covers
|
||||
|
||||
Same scope as the first pass:
|
||||
1. Primitive selection and usage
|
||||
2. Key derivation and entropy
|
||||
3. Signature/verification correctness
|
||||
4. Binding / chain of trust
|
||||
5. Timestamping (OTS)
|
||||
6. Secret handling
|
||||
7. Input validation and integrity
|
||||
8. Documentation fidelity
|
||||
9. Web security
|
||||
10. Testing
|
||||
|
||||
Detailed per-finding write-ups are in [`findings.md`](findings.md). The cryptographic deep-dive is in [`crypto-deep-dive.md`](crypto-deep-dive.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. System Under Audit
|
||||
|
||||
### 3.1 Files in scope
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs) | Core crypto module: BIP39/BIP32, PQ key derivation, PQ sign/verify, event construction, OTS helpers |
|
||||
| [`www/index.html`](www/index.html) | Migration UI: sign in → seed → derive → sign → publish → OTS |
|
||||
| [`www/verify.html`](www/verify.html) | Verification UI: query relay or paste event JSON, verify signatures + OTS |
|
||||
| [`www/pq-crypto.bundle.js`](www/pq-crypto.bundle.js) | esbuild bundle of pq-crypto.mjs |
|
||||
| [`build-pq-bundle.js`](build-pq-bundle.js) | Build script |
|
||||
| [`README.md`](README.md), [`explanation.md`](explanation.md), [`why_what_how.md`](why_what_how.md), [`laans_explanation.md`](laans_explanation.md) | Documentation (now reconciled with code) |
|
||||
| [`resources/javascript-opentimestamps/`](resources/javascript-opentimestamps/) | Vendored OTS library (present, not yet used by the app) |
|
||||
|
||||
### 3.2 What the app purports to do (per now-reconciled docs)
|
||||
|
||||
- Derive 5 PQ keypairs from a BIP39 seed via BIP32 paths under `m/44'/1237'/0'/0/{1..8}`.
|
||||
- Build a kind 1 announcement (human-readable attestation text + `algorithm` tags with base64 pubkey/sig) and a kind 11112 wrapper (embeds kind 1 as JSON, adds `e`/`sha256`/`ots` tags).
|
||||
- Each PQ signature scheme signs the attestation text; ML-KEM is a KEM (no signature).
|
||||
- The user's existing identity (Account #1) signs both events via NIP-07.
|
||||
- The SHA-256 of the full signed kind 1 event JSON is submitted to OpenTimestamps.
|
||||
- The security model: PQ self-attestation + Account #1 authorization + OTS precedence (no successor signature — deliberate).
|
||||
|
||||
### 3.3 What the app actually does
|
||||
|
||||
Matches the docs (verified during reconciliation). The code agrees with the documentation on derivation, event structure, algorithms, signing, and what's timestamped.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cryptographic Correctness (Primary Concern)
|
||||
|
||||
### 4.1 What is correct
|
||||
|
||||
- **PQ primitive usage.** [`ml_dsa44`](www/js/pq-crypto.mjs:270), [`ml_dsa65`](www/js/pq-crypto.mjs:284), [`slh_dsa_sha2_128s`](www/js/pq-crypto.mjs:298), [`falcon512`](www/js/pq-crypto.mjs:312), [`ml_kem768`](www/js/pq-crypto.mjs:252) all invoked with correct argument order. Libraries are reputable.
|
||||
- **PQ signature verification matches signing.** [`buildKind1Announcement`](www/js/pq-crypto.mjs:430) signs `TextEncoder.encode(content)`; [`verifyNIPQRContent`](www/js/pq-crypto.mjs:586) re-encodes `kind1Event.content` and verifies against it.
|
||||
- **ML-KEM correctly treated as a KEM** (no signature) in both signing and verification.
|
||||
- **BIP39/BIP32 usage** is standard and correct; mnemonic validation before seed derivation.
|
||||
- **NIP-01 event id** computation correct.
|
||||
- **Schnorr verification** correct.
|
||||
- **Deterministic PQ keygen from seed** — recoverability holds.
|
||||
- **Documentation now matches implementation** (F-C4 resolved).
|
||||
|
||||
### 4.2 What is broken or weak
|
||||
|
||||
#### F-C2 (Critical): OpenTimestamps proofs are never cryptographically verified
|
||||
|
||||
[`isOtsConfirmed`](www/js/pq-crypto.mjs:875) returns `true` if the 8-byte Bitcoin attestation magic `0588960d73d71901` appears anywhere in the `.ots` bytes — a substring search, not a proof verification. [`upgradeOts`](www/js/pq-crypto.mjs:840) trusts a server's JSON response. Neither page runs real OTS verification. The vendored [`resources/javascript-opentimestamps/`](resources/javascript-opentimestamps/) library is present but not imported.
|
||||
|
||||
**Consequence.** A malicious kind 11112 event can embed an `ots` tag with arbitrary bytes containing the magic and be displayed as "Confirmed (Bitcoin)" ([`www/verify.html:548`](www/verify.html:548)). The pre-quantum anchoring — the property that distinguishes a real migration event from a quantum-forged one — is spoofable. This is now the **single most important open finding**, since the security model (per the reconciled docs) explicitly relies on OTS precedence as the anti-forgery mechanism.
|
||||
|
||||
**Fix.** Use the vendored OTS library to parse the proof, bind its target digest to the `sha256` tag (F-C3), validate the Merkle path, and check the block header against a Bitcoin API. The docs already state this is "planned but not yet implemented" — the fix is to implement it.
|
||||
|
||||
#### F-C3 (Critical): The OTS proof's target digest is not bound to the event hash
|
||||
|
||||
[`verify.html`](www/verify.html:534) checks the `sha256` tag matches `hashFullEvent(kind1Event)` — good — but never checks the OTS proof's internal target digest equals that hash. A proof timestamping any digest is accepted.
|
||||
|
||||
**Fix.** Parse the OTS file's target digest and assert equality with the `sha256` tag.
|
||||
|
||||
---
|
||||
|
||||
## 5. High-Severity Findings
|
||||
|
||||
### F-H1 (High): No tests, no known-answer test vectors
|
||||
|
||||
[`package.json`](package.json:7) has `"test": "echo \"Error: no test specified\" && exit 1"`. No tests anywhere. For cryptographic software, no regression protection.
|
||||
|
||||
### F-H2 (High): `verifyNostrEvent` does not check `event.id` against the computed hash
|
||||
|
||||
[`verifyNostrEvent`](www/js/pq-crypto.mjs:385) verifies the Schnorr signature but never compares `event.id` to the computed hash. A wrong/foreign `id` with a valid signature passes.
|
||||
|
||||
### F-H3 (High): XSS surface via `innerHTML` with relay/localStorage-derived data
|
||||
|
||||
[`setStatus`](www/index.html:761) and several other sites use `innerHTML` with data from relays/localStorage. Relay `NOTICE` messages and error strings are attacker-influenced. No CSP (F-H4) and no SRI, so injected script would execute.
|
||||
|
||||
### F-H4 (High): No Content-Security-Policy and no SRI on the bundle
|
||||
|
||||
Neither page emits a CSP; the bundle and `/nostr-login-lite/` scripts load without `integrity`. A compromised host can inject script that exfiltrates the seed phrase or PQ secret keys.
|
||||
|
||||
### F-H5 (High): `isOtsConfirmed` substring search can false-positive
|
||||
|
||||
Subsumed by F-C2. A proper OTS parser would locate attestations structurally rather than byte-scanning.
|
||||
|
||||
---
|
||||
|
||||
## 6. Medium-Severity Findings
|
||||
|
||||
### F-M1 (Medium): PQ signatures cover human-readable text, not a canonical binding
|
||||
|
||||
The signed message is the full `content` string ([`pq-crypto.mjs:455`](www/js/pq-crypto.mjs:455)), mixing security-relevant fields with display prose and hardcoded URLs. A canonical structured statement would be more robust.
|
||||
|
||||
### F-M2 (Medium): `block_height` tag is not verified
|
||||
|
||||
The `block_height` tag and content claim are purely informational. Nothing verifies the height was current at signing time.
|
||||
|
||||
### F-M3 (Medium): BIP32 non-hardened leaf indices used for PQ seeds
|
||||
|
||||
PQ seeds at non-hardened children 1–8 under hardened parent `m/44'/1237'/0'/0'`. PQ seeds (child private keys) remain secret unless the parent xpub leaks. The docs now warn about this. No enforcement.
|
||||
|
||||
### F-M4 (Medium): Truncation rule for concatenated BIP32 children is pinned in docs but arbitrary
|
||||
|
||||
"Take the first N bytes" is now documented, but a future implementer who takes the last N would break compatibility. Consider HKDF-Expand on the concatenation for a less arbitrary construction.
|
||||
|
||||
### F-M5 (Medium): Falcon-512 is a draft standard (FIPS 206 not finalized)
|
||||
|
||||
Falcon-512 is labeled "FIPS 206 (draft)" in the code. Standardization risk; the multi-scheme design mitigates it.
|
||||
|
||||
### F-M6 (Medium): `laans_explanation.md` was truncated — now fixed
|
||||
|
||||
The file was truncated mid-sentence in the first pass; it has been rewritten as a complete summary. Resolved, but noted for completeness.
|
||||
|
||||
---
|
||||
|
||||
## 7. Low / Informational Findings
|
||||
|
||||
- **F-L1:** PQ secret keys and seed held in global scope for the session, never zeroed.
|
||||
- **F-L2:** 12-word default gives only ~64-bit PQ security on seed entropy. Docs now recommend 24 words; app still defaults to 12.
|
||||
- **F-L3:** `hexToBytes`/`base64ToBytes` do not validate input format.
|
||||
- **F-L4:** `e` tag validation does not check `kind1Event.id` equals computed id.
|
||||
- **F-L5:** `timestampEvent` sends binary body with `Content-Type: application/x-www-form-urlencoded` (should be `application/octet-stream`).
|
||||
- **F-L6:** Relay publishing treats `ws.onclose` as success.
|
||||
- **F-L7:** App depends on server-provided `/nostr-login-lite/` scripts not in the repo.
|
||||
- **F-L8 (NEW):** [`why_what_how.md`](why_what_how.md:64) Component 4 summary still says "the new identity signs to endorse the PQ keys" — successor-model language in a "design only" section. Minor doc leftover from the reconciliation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Positive Observations
|
||||
|
||||
- The documentation is now honest and matches the implementation (F-C4 resolved).
|
||||
- The security model is coherently stated: PQ self-attestation + Account #1 authorization + OTS precedence. The "What This Proves and What It Does Not" section in [`README.md`](README.md:291) is exactly the kind of honesty a security project should have.
|
||||
- PQ primitive usage is correct throughout.
|
||||
- Deterministic PQ keygen from BIP32 seed delivers recoverability.
|
||||
- The two-event design (kind 1 + kind 11112) is sensible.
|
||||
- The OTS detached-file prefix construction is correct.
|
||||
- The vendored OTS library is available for the F-C2 fix.
|
||||
- The sign-out flow clears sensitive state.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommendations (Prioritized)
|
||||
|
||||
1. **Implement real OTS verification client-side** using the vendored `javascript-opentimestamps` library: parse the proof, bind its target digest to the `sha256` tag, validate the Merkle path, check the block header against a Bitcoin API. (Fixes F-C2, F-C3, F-H5.) **This is the single most important remaining fix** — the security model now explicitly depends on OTS precedence, so OTS verification must be real.
|
||||
2. **Add a test suite** with known-answer vectors for BIP32→PQ-seed derivation, PQ sign/verify round-trips, `verifyNIPQRContent` accept/reject, and OTS prefix construction. (Fixes F-H1.)
|
||||
3. **Harden `verifyNostrEvent`** to assert `event.id` equals the computed hash. (Fixes F-H2, F-L4.)
|
||||
4. **Add a strict CSP and SRI**, and switch dynamic-string DOM writes to `textContent`. (Fixes F-H3, F-H4.)
|
||||
5. **Sign a canonical structured attestation** (or its hash) rather than human-readable prose. (Fixes F-M1.)
|
||||
6. **Default to 24-word mnemonics** or warn about 12-word PQ security. (Fixes F-L2.)
|
||||
7. **Fix the `why_what_how.md` Component 4 leftover** ("the new identity signs to endorse the PQ keys"). (Fixes F-L8.)
|
||||
8. **Communicate Falcon's draft status** in the UI. (Fixes F-M5.)
|
||||
|
||||
---
|
||||
|
||||
## 10. Files Produced by This Audit
|
||||
|
||||
- [`audit.md`](audit.md) — this document
|
||||
- [`findings.md`](findings.md) — detailed per-finding write-ups
|
||||
- [`crypto-deep-dive.md`](crypto-deep-dive.md) — line-level cryptographic analysis
|
||||
|
||||
---
|
||||
|
||||
## 11. Auditor's Note on Scope and Limitations
|
||||
|
||||
This is a **static source review** of the post-reconciliation state. I did not execute the app against live relays or a real Bitcoin node. The findings about cryptographic correctness of primitives are based on reading call sites; the findings about binding and verification logic are based on tracing data flow. A dynamic test pass (especially for the OTS verification path) is strongly recommended before production use.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Cryptographic Deep-Dive (Second Pass)
|
||||
|
||||
Companion to [`audit.md`](audit.md) and [`findings.md`](findings.md). This document traces the cryptography line-by-line: entropy → seed → BIP32 → PQ keygen → signing → event construction → verification → OpenTimestamps.
|
||||
|
||||
**Changes from the first pass:** The binding analysis (§4.2, §8) reflects that the successor-signature gap (F-C1) is now a documented design choice. F-C4 (docs vs code) is resolved. **F-C2 and F-C3 (OTS verification) are resolved** — real client-side OTS verification has been implemented in [`pq-crypto.mjs`](www/js/pq-crypto.mjs) with `parseOtsFile()` and `verifyOtsProof()`, and wired into [`verify.html`](www/verify.html) and [`index.html`](www/index.html). Tested against the vendored example proofs.
|
||||
|
||||
All line references are to [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs) unless noted otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 1. Entropy and Seed Phrase
|
||||
|
||||
### 1.1 Pure-CSPRNG path
|
||||
|
||||
[`generateSeedPhrase`](www/js/pq-crypto.mjs:75) calls `generateMnemonic(wordlist, 128)` — 128 bits → 12 words via `crypto.getRandomValues`. Standard and correct.
|
||||
|
||||
### 1.2 User-entropy path
|
||||
|
||||
[`generateSeedPhraseWithEntropy`](www/js/pq-crypto.mjs:90): 16 bytes CSPRNG + user entropy → SHA-256 → first 16 bytes (128 bits) → mnemonic. Reasonable mixing; SHA-256 is a strong mixer. **Caveat (F-L2):** 128-bit entropy gives only ~64-bit PQ security under Grover's. Docs now recommend 24 words; app defaults to 12.
|
||||
|
||||
### 1.3 Mnemonic → BIP39 seed
|
||||
|
||||
[`mnemonicToSeed`](www/js/pq-crypto.mjs:111) validates then `mnemonicToSeedSync` (PBKDF2-HMAC-SHA512, 2048 iterations). Correct.
|
||||
|
||||
**Verdict:** Entropy and seed handling are correct. Only issue: 12-word default (F-L2).
|
||||
|
||||
---
|
||||
|
||||
## 2. BIP32 Key Derivation
|
||||
|
||||
### 2.1 secp256k1 (Account #2)
|
||||
|
||||
[`deriveSecp256k1FromSeed`](www/js/pq-crypto.mjs:197) derives at `m/44'/{accountIndex}'/0/0`. Standard NIP-06. Correct. The resulting keypair is stored in `pqSecpKeys` and — per the now-documented design choice — not used to sign.
|
||||
|
||||
### 2.2 PQ seeds via BIP32 children
|
||||
|
||||
[`deriveBIP32Child`](www/js/pq-crypto.mjs:138) derives a child at `m/44'/1237'/0'/0/{idx}` and returns the 32-byte private key. [`derivePQSeedFromBIP32`](www/js/pq-crypto.mjs:160) handles the multi-child case (concatenate + truncate to first N bytes).
|
||||
|
||||
Paths ([`pq-crypto.mjs:49`](www/js/pq-crypto.mjs:49)):
|
||||
- ML-DSA-44: child 1 (32 B)
|
||||
- ML-DSA-65: child 2 (32 B)
|
||||
- SLH-DSA-128s: children 3+4 → 64 B → first 48 B
|
||||
- Falcon-512: children 5+6 → 64 B → first 48 B
|
||||
- ML-KEM-768: children 7+8 → 64 B
|
||||
|
||||
**Correctness:** BIP32 child derivation produces a 32-byte private key via HMAC-SHA512; using that as the `seed` for `@noble/post-quantum`'s `keygen(seed)` is valid and deterministic. Concatenation + truncation is fine for the PQ seed. Determinism holds: same mnemonic → same PQ keypairs. Recoverability is satisfied.
|
||||
|
||||
**Concerns:**
|
||||
- **F-M4:** The "first N bytes" truncation rule is now documented but arbitrary. Must be pinned for compatibility (done in docs).
|
||||
- **F-M3:** Children 1–8 are non-hardened. PQ seeds safe unless parent xpub leaks. Docs now warn (resolved in reconciliation).
|
||||
|
||||
**Verdict:** The BIP32→PQ-seed construction is functionally correct and deterministic. Issues are documentation/compatibility (F-M4, now documented) and xpub hygiene (F-M3, now documented).
|
||||
|
||||
---
|
||||
|
||||
## 3. PQ Keygen
|
||||
|
||||
[`derivePQKeysFromSeed`](www/js/pq-crypto.mjs:233) calls `keygen(seed)` for each algorithm. Correct API usage. Key sizes in [`PQ_KEY_INFO`](www/js/pq-crypto.mjs:700) match FIPS specs. Deterministic.
|
||||
|
||||
**Verdict:** PQ keygen is correct and deterministic.
|
||||
|
||||
---
|
||||
|
||||
## 4. PQ Signing
|
||||
|
||||
[`buildKind1Announcement`](www/js/pq-crypto.mjs:430) builds the human-readable `content`, then signs `TextEncoder.encode(content)` with each PQ scheme. Argument order correct. ML-KEM excluded (KEM).
|
||||
|
||||
**Concerns:**
|
||||
- **F-M1:** Signed message is the full human-readable `content` (prose + npub + block height + URLs). A canonical structured statement would be more robust.
|
||||
- **Design choice (was F-C1):** No secp256k1 successor signature. The PQ signatures alone bind the PQ keys to the attestation text. This is now documented as deliberate — the security model relies on OTS precedence, not a seed-derived signature.
|
||||
|
||||
**Verdict:** PQ signing is cryptographically correct. The binding is the documented "PQ self-attestation" model (weaker than successor-signature but coherent, and now honestly stated).
|
||||
|
||||
---
|
||||
|
||||
## 5. Event Construction and Hashing
|
||||
|
||||
### 5.1 Kind 1 announcement
|
||||
|
||||
[`buildKind1Announcement`](www/js/pq-crypto.mjs:430) returns an unsigned template. secp256k1 signature added by `window.nostr.signEvent` ([`www/index.html:1032`](www/index.html:1032)) with Account #1.
|
||||
|
||||
### 5.2 Event ID (NIP-01)
|
||||
|
||||
[`computeEventId`](www/js/pq-crypto.mjs:490): `SHA-256(JSON.stringify([0, pubkey, created_at, kind, tags, content]))`. Correct NIP-01 canonical serialization.
|
||||
|
||||
### 5.3 Full-event hash (for OTS)
|
||||
|
||||
[`hashFullEvent`](www/js/pq-crypto.mjs:510): `SHA-256(JSON.stringify(signedEvent))` — hashes the entire signed kind 1 event JSON (including `id` and `sig`). This is the digest submitted to OTS. Stable but relies on consistent JSON key ordering (minor fragility; a canonical JSON serialization would be more robust).
|
||||
|
||||
### 5.4 Kind 11112 wrapper
|
||||
|
||||
[`buildKind11112Wrapper`](www/js/pq-crypto.mjs:531): `content = JSON.stringify(kind1Event)`, tags `['e', id]`, `['sha256', fullHash]`, `['ots', base64(proof)]`. Signed by Account #1.
|
||||
|
||||
**Verdict:** Event construction and hashing are correct per NIP-01. Full-event hash for OTS is stable but relies on consistent JSON serialization.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
### 6.1 `verifyNostrEvent` (secp256k1)
|
||||
|
||||
[`verifyNostrEvent`](www/js/pq-crypto.mjs:385): serialization matches `computeEventId`; `schnorr.verify(sig, hash, pubkey)` argument order correct.
|
||||
- **F-H2:** Does not check `event.id` against computed hash. A wrong `id` with valid signature passes.
|
||||
|
||||
### 6.2 `verifyNIPQRContent` (PQ + tags)
|
||||
|
||||
[`verifyNIPQRContent`](www/js/pq-crypto.mjs:586):
|
||||
1. Parses kind 1 from kind 11112 content. Validates fields and `kind === 1`.
|
||||
2. Verifies kind 1 secp256k1 signature via `verifyNostrEvent`.
|
||||
3. Computes `computeEventId(kind1Event)`, checks `e` tag matches. (F-L4: does not check `kind1Event.id` equals computed id.)
|
||||
4. Computes `hashFullEvent(kind1Event)`, checks `sha256` tag matches.
|
||||
5. For each `algorithm` tag, re-encodes `kind1Event.content` and verifies the PQ signature. ML-KEM skipped. Correct.
|
||||
|
||||
**Correctness of PQ verification:** message matches signing (`kind1Event.content`); pubkey/sig from same tag; algorithm dispatch correct; `results.every(r => r.valid)` requires all pass.
|
||||
|
||||
**Concerns:**
|
||||
- **F-H2 / F-L4:** `id` checks incomplete.
|
||||
- **Design choice (was F-C1):** No successor signature to verify (documented as deliberate). The verifier trusts the `algorithm` tag's pubkey without an independent seed-binding. The PQ keys self-certify by signing the content text (which names the npub). This is the documented "PQ self-attestation" model.
|
||||
|
||||
**Verdict:** PQ signature verification is cryptographically correct. The binding is the documented self-attestation model. The `id` checks are incomplete (F-H2, F-L4).
|
||||
|
||||
---
|
||||
|
||||
## 7. OpenTimestamps
|
||||
|
||||
### 7.1 Submission
|
||||
|
||||
[`timestampEvent`](www/js/pq-crypto.mjs:798): POSTs 32-byte hash to `{server}/digest`, wraps the returned fragment with the OTS detached-file prefix (`OTS_DETACHED_PREFIX` = magic + version 1 + SHA-256 op tag `0x08`) + 32-byte digest + fragment. Tries two calendar servers. Prefix construction matches the OTS binary format. [`isDetachedOtsFile`](www/js/pq-crypto.mjs:781) validates the prefix. Correct.
|
||||
|
||||
**F-L5:** `Content-Type: application/x-www-form-urlencoded` with binary body; should be `application/octet-stream`. Works in practice.
|
||||
|
||||
### 7.2 Upgrade
|
||||
|
||||
[`upgradeOts`](www/js/pq-crypto.mjs:840): POSTs proof to a server, trusts JSON response for the upgrade. After upgrade, the result is now **cryptographically verified** client-side via `verifyOtsProof` (see §7.3).
|
||||
|
||||
### 7.3 Confirmation check and full verification (F-C2/F-C3 RESOLVED)
|
||||
|
||||
**Previously:** [`isOtsConfirmed`](www/js/pq-crypto.mjs:875) byte-scanned for Bitcoin magic `0588960d73d71901` — a spoofable heuristic.
|
||||
|
||||
**Now:** `isOtsConfirmed` has been rewritten to use the new `parseOtsFile()` parser (with the old byte-pattern search retained only as a fallback for malformed proofs). Two new functions provide real cryptographic verification:
|
||||
|
||||
- **`parseOtsFile(otsBytes)`** — parses the OTS binary format: validates the magic header, reads the version, file hash op (SHA-256/SHA-1/RIPEMD160), the target digest, and recursively walks the timestamp tree (handling the 0xff continuation marker per the reference implementation). For each attestation, it computes the commitment digest by applying the op tree (SHA-256, SHA-1, RIPEMD160, append, prepend, reverse) from the target to the attestation leaf. Returns `{fileHashOp, targetDigest, attestations}` where each attestation includes its computed merkle-root digest.
|
||||
|
||||
- **`verifyOtsProof(otsBytes, expectedDigestHex)`** — performs full verification:
|
||||
1. Parses the OTS file.
|
||||
2. **Binds the target digest to the expected digest** (F-C3 fix): if `expectedDigestHex` is provided and doesn't match the proof's target digest, returns `verified: false` with a "Target digest mismatch" error.
|
||||
3. Finds Bitcoin attestations and fetches the corresponding block headers from blockstream.info / mempool.space (lite-client verification — trusts the API for the block header, which is independently verifiable against the Bitcoin PoW chain).
|
||||
4. **Validates the Merkle root**: checks that the computed commitment digest (reversed to little-endian, per Bitcoin convention) matches the block's `merkle_root`.
|
||||
5. Returns `{verified, targetDigest, attestations, bitcoinAttestations, errors}`.
|
||||
|
||||
**Tested against vendored example proofs:**
|
||||
- `hello-world.txt.ots`: verified=true, Bitcoin block 358391 (mined 2015-05-28).
|
||||
- `incomplete.txt.ots`, `two-calendars.txt.ots`, `merkle1.txt.ots`: correctly identified as pending (no Bitcoin attestation).
|
||||
- F-C3 binding test: wrong expected digest → correctly fails with "Target digest mismatch".
|
||||
|
||||
The vendored [`resources/javascript-opentimestamps/`](resources/javascript-opentimestamps/) library was not used directly (it has Node.js dependencies incompatible with the browser), but its binary format and verification logic were used as the reference for the new implementation. The new code uses `@noble/hashes` (SHA-256, SHA-1, RIPEMD160) and the browser's native `fetch` for Bitcoin block headers.
|
||||
|
||||
### 7.4 What the verify page does with OTS
|
||||
|
||||
[`www/verify.html`](www/verify.html:534): reads `ots` and `sha256` tags; checks `sha256` tag === `hashFullEvent(kind1Event)` (good); does a quick structural check via `isOtsConfirmed` for immediate UI feedback; then runs **full async verification** via `verifyOtsProof(currentOtsBytes, sha256Tag[1])` — passing the `sha256` tag as the expected digest (F-C3 binding). Displays "Verified (Bitcoin)" with block height + date only if the Merkle root matches the actual Bitcoin block header. Displays "Verification failed" if the proof is spoofed or the digest doesn't match.
|
||||
|
||||
[`www/index.html`](www/index.html:1446) (polling): after `upgradeOts`, runs `verifyOtsProof(pendingOtsBytes, sha256Tag[1])` and only marks "Confirmed" if `verified === true`. Falls back to the structural check with an "unverified" warning if full verification fails.
|
||||
|
||||
**Verdict:** OTS *submission*, *file format construction*, and **now *verification*** are all correct. The "confirmed" determination is a real Merkle-path + block-header check, not a byte-pattern match. F-C2, F-C3, and F-H5 are resolved. The security model's reliance on OTS precedence is now backed by actual cryptographic verification.
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary: What the Cryptography Actually Proves (Post-Reconciliation)
|
||||
|
||||
After the documentation reconciliation, the docs and code now agree on what the system proves. A verifier of a kind 11112 event that passes the current verification can conclude:
|
||||
|
||||
1. **The kind 11112 wrapper has a valid secp256k1 Schnorr signature** by the pubkey in the event. (Assuming F-H2 fixed: and its `id` matches its content.)
|
||||
2. **The embedded kind 1 event has a valid secp256k1 Schnorr signature** by its pubkey, its `id` matches the `e` tag, and the `sha256` tag matches the full-event hash. (Assuming F-H2/F-L4 fixed.)
|
||||
3. **Each PQ signature in the kind 1 tags is valid** for the stated algorithm, pubkey, and the kind 1 `content` text.
|
||||
4. **The `content` text names the user's npub and a block height**, so the PQ signatures attest to that text.
|
||||
|
||||
What a verifier **cannot** conclude (and the docs now honestly state this):
|
||||
- The PQ keys cannot cryptographically prove they share a common seed origin (no successor signature — documented design choice).
|
||||
|
||||
What a verifier **can now conclude** (after the F-C2/F-C3 fix):
|
||||
- **The event was timestamped in a real Bitcoin block.** `verifyOtsProof` parses the OTS proof, binds the target digest to the event's `sha256` tag, walks the Merkle path, fetches the block header from a Bitcoin API, and checks the computed Merkle root matches. A spoofed proof (fake magic bytes) is rejected; a proof for the wrong digest is rejected.
|
||||
|
||||
The system, as shipped and documented, now provides: **PQ-key attestation over a human-readable statement, authorized by the user's existing secp256k1 identity, with a cryptographically verified OTS proof anchoring it to a pre-quantum Bitcoin block.** The security argument is now coherent end-to-end: after a quantum break, a forged migration event cannot produce a valid OTS proof for a pre-quantum block, so the real event is distinguishable by its earlier Bitcoin anchor.
|
||||
|
||||
---
|
||||
|
||||
## 9. The Critical Path to Production-Readiness
|
||||
|
||||
**F-C2/F-C3 (real OTS verification) is now DONE.** The single most important cryptographic gap has been closed: OTS proofs are now cryptographically verified (Merkle path + block header + target digest binding), not byte-pattern matched. The security model's reliance on OTS precedence is now backed by actual verification.
|
||||
|
||||
**Remaining items** are general software-quality issues, not cryptographic-correctness gaps:
|
||||
- F-H1 (tests): add known-answer vectors and round-trip tests for regression protection.
|
||||
- F-H2 (id checks): `verifyNostrEvent` should assert `event.id` matches the computed hash.
|
||||
- F-H3/F-H4 (web security): add CSP/SRI and fix `innerHTML` XSS surface.
|
||||
- F-L8 (doc leftover): fix `why_what_how.md` Component 4 successor language.
|
||||
|
||||
---
|
||||
|
||||
## 10. Positive Cryptographic Properties
|
||||
|
||||
- Reputable libraries (`@noble/*`, `@scure/*`) used with correct API calls.
|
||||
- PQ sign/verify argument order and message bytes match.
|
||||
- ML-KEM correctly treated as a KEM throughout.
|
||||
- BIP39 mnemonic validation before seed derivation.
|
||||
- NIP-01 event-id serialization correct.
|
||||
- OTS detached-file prefix construction correct.
|
||||
- Deterministic PQ keygen from BIP32 seed delivers recoverability.
|
||||
- Two-event structure (kind 1 + kind 11112) is sensible.
|
||||
- Verifier re-derives the kind 1 id and full hash rather than trusting tags blindly (for the parts it checks).
|
||||
- **Documentation now matches implementation** (F-C4 resolved) — the security model is honestly stated, including what it does and does not prove.
|
||||
- **OTS proofs are now cryptographically verified** (F-C2/F-C3 resolved) — `parseOtsFile` + `verifyOtsProof` parse the binary format, walk the op tree, fetch Bitcoin block headers, and validate the Merkle root. Tested against vendored example proofs.
|
||||
|
||||
These provide a solid foundation; the remaining gaps are general software quality (F-H1–F-H4, F-L8), not cryptographic correctness.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Detailed Findings (Second Pass)
|
||||
|
||||
Companion to [`audit.md`](audit.md). Each finding includes the exact code location, the issue, the impact, and a recommended fix.
|
||||
|
||||
Severity legend: **C** = Critical, **H** = High, **M** = Medium, **L** = Low/Informational.
|
||||
|
||||
**Changes from the first pass:** F-C1 (missing successor signature) is reclassified as a documented design choice — no longer a finding. F-C4 (docs vs code mismatch) is resolved — no longer a finding. F-C2 and F-C3 (OTS verification) are resolved — implemented real client-side OTS verification. F-M6 (truncated laans_explanation.md) is resolved. F-L8 is new (minor doc leftover).
|
||||
|
||||
---
|
||||
|
||||
## F-C2 (Critical) — RESOLVED: OpenTimestamps proofs are now cryptographically verified
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:875`](www/js/pq-crypto.mjs:875) (`isOtsConfirmed`), [`www/js/pq-crypto.mjs:840`](www/js/pq-crypto.mjs:840) (`upgradeOts`), [`www/verify.html:539`](www/verify.html:539), [`www/index.html:1455`](www/index.html:1455).
|
||||
|
||||
**Issue.** The project's central anti-backdating claim (now explicitly stated in the reconciled docs as the primary anti-forgery mechanism) is that the key-link event is anchored to a Bitcoin block. Verification of this anchor is performed in two ways, both inadequate:
|
||||
|
||||
1. `isOtsConfirmed(otsBytes)` scans the proof for the 8-byte Bitcoin block-header attestation magic `0588960d73d71901` and returns `true` if found anywhere:
|
||||
```js
|
||||
const bitcoinTag = hexToBytes('0588960d73d71901');
|
||||
outer: for (let i = 0; i <= otsBytes.length - bitcoinTag.length; i++) {
|
||||
for (let j = 0; j < bitcoinTag.length; j++) {
|
||||
if (otsBytes[i + j] !== bitcoinTag[j]) continue outer;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
```
|
||||
This is a substring search, not a proof verification. It does not parse the OTS op stream, check the Merkle path, or check the block header against the Bitcoin chain.
|
||||
|
||||
2. `upgradeOts(otsBytes)` POSTs the proof to a server and trusts the JSON response's `confirmed` boolean.
|
||||
|
||||
Neither page runs a real OTS verification. The vendored [`resources/javascript-opentimestamps/`](resources/javascript-opentimestamps/) library — which can perform full verification — is present but not imported.
|
||||
|
||||
**Impact.** A malicious kind 11112 event can embed an `ots` tag containing arbitrary bytes that include the 8-byte magic and be displayed as "OTS: Confirmed (Bitcoin)" ([`www/verify.html:548`](www/verify.html:548)). The pre-quantum anchoring is spoofable. Since the reconciled security model explicitly relies on OTS precedence as the mechanism that distinguishes real from forged events after a quantum break, this is the **single most important open finding**.
|
||||
|
||||
**Fix.** Use the vendored OTS library to:
|
||||
1. Parse the `.ots` file into its op stream.
|
||||
2. Confirm the proof's target digest equals the event's `sha256` tag (see F-C3).
|
||||
3. Validate the Merkle path to a Bitcoin block header.
|
||||
4. Check the block header (hash + height) against a Bitcoin node or API.
|
||||
Only then display "confirmed." Remove or relabel the byte-pattern heuristic as a non-authoritative hint.
|
||||
|
||||
---
|
||||
|
||||
## F-C3 (Critical) — RESOLVED: The OTS proof's target digest is now bound to the event hash
|
||||
|
||||
**Location:** [`www/verify.html:534`](www/verify.html:534), [`www/js/pq-crypto.mjs:875`](www/js/pq-crypto.mjs:875).
|
||||
|
||||
**Issue.** [`verify.html`](www/verify.html:534) reads the `ots` and `sha256` tags and checks that the `sha256` tag matches `hashFullEvent(kind1Event)` — good. But it never checks that the OTS proof's internal target digest equals that hash. `isOtsConfirmed` only looks for the Bitcoin magic bytes; it does not parse the proof's target. So a proof timestamping any digest would be accepted as long as it contains the magic bytes.
|
||||
|
||||
**Impact.** Even if the byte-pattern heuristic were replaced by a real Merkle-path check, a proof committing to a different digest would still be accepted unless the target digest is explicitly compared to the `sha256` tag. An attacker could attach a genuine Bitcoin-anchored OTS proof for some unrelated file to a fraudulent event and have it pass.
|
||||
|
||||
**Fix.** After parsing the OTS file, extract its target digest and assert `targetDigest === sha256Tag` (case-insensitive hex compare) before trusting any attestation in the proof.
|
||||
|
||||
---
|
||||
|
||||
## F-H1 (High) — No tests, no known-answer test vectors
|
||||
|
||||
**Location:** [`package.json:7`](package.json:7).
|
||||
|
||||
**Issue.** `package.json` has `"test": "echo \"Error: no test specified\" && exit 1"`. No tests anywhere: no BIP32 derivation known-answer tests, no PQ sign/verify round-trips, no `verifyNIPQRContent` accept/reject tests, no OTS prefix tests.
|
||||
|
||||
**Impact.** No regression protection; correctness rests entirely on manual review. A refactor could silently break signature verification or key derivation.
|
||||
|
||||
**Fix.** Add a test suite covering:
|
||||
- BIP32→PQ-seed derivation with a fixed mnemonic → expected seed bytes.
|
||||
- PQ keygen determinism (same seed → same pubkey).
|
||||
- PQ sign→verify round-trip for each algorithm; tampering fails.
|
||||
- `verifyNIPQRContent` accept (genuine) and reject (tampered content/sig/pubkey, wrong `e`/`sha256` tags).
|
||||
- `computeEventId`/`verifyNostrEvent` against a known Nostr test vector.
|
||||
- OTS prefix construction (`isDetachedOtsFile` true/false).
|
||||
|
||||
---
|
||||
|
||||
## F-H2 (High) — `verifyNostrEvent` does not check `event.id` against the computed hash
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:385`](www/js/pq-crypto.mjs:385).
|
||||
|
||||
**Issue.** `verifyNostrEvent` computes the SHA-256 of the canonical serialization and verifies the Schnorr signature against it, but never compares `event.id` to the computed hash. An event with a wrong/foreign `id` but a valid signature passes. The kind 11112 wrapper is checked only via `verifyNostrEvent` in [`verify.html:522`](www/verify.html:522), so a spoofed `id` on the wrapper is not detected.
|
||||
|
||||
**Fix.** Add:
|
||||
```js
|
||||
const computedId = bytesToHex(hash);
|
||||
if (event.id && event.id.toLowerCase() !== computedId) return false;
|
||||
```
|
||||
and have callers require `id` presence.
|
||||
|
||||
---
|
||||
|
||||
## F-H3 (High) — XSS surface via `innerHTML` with relay/localStorage-derived data
|
||||
|
||||
**Location:** [`www/index.html:761`](www/index.html:761) (`setStatus`), [`www/index.html:750`](www/index.html:750) (otsNotice), [`www/verify.html:403`](www/verify.html:403) (`setStatus`), [`www/verify.html:627`](www/verify.html:627).
|
||||
|
||||
**Issue.** Multiple sites use `element.innerHTML = \`...${variable}...\`` with data from relays or localStorage. Relay `NOTICE` messages and error strings are attacker-influenced. No CSP (F-H4) and no SRI, so injected script would execute.
|
||||
|
||||
**Fix.** Use `textContent` for all dynamic strings, or escape rigorously. Add a strict CSP and SRI (F-H4).
|
||||
|
||||
---
|
||||
|
||||
## F-H4 (High) — No Content-Security-Policy and no SRI on the bundle
|
||||
|
||||
**Location:** [`www/index.html`](www/index.html), [`www/verify.html`](www/verify.html).
|
||||
|
||||
**Issue.** Neither page emits a CSP meta tag; the bundle and `/nostr-login-lite/` scripts load without `integrity`.
|
||||
|
||||
**Impact.** A compromised or MITM'd host can inject script that exfiltrates the seed phrase or PQ secret keys.
|
||||
|
||||
**Fix.** Serve a strict CSP; add SRI hashes; load third-party scripts only from trusted CDNs with SRI.
|
||||
|
||||
---
|
||||
|
||||
## F-H5 (High) — `isOtsConfirmed` substring search can false-positive
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:875`](www/js/pq-crypto.mjs:875).
|
||||
|
||||
**Issue.** `isOtsConfirmed` scans the entire proof for the 8-byte magic. Pending attestations could contain those bytes by coincidence; a crafted proof can include them deliberately (F-C2). A proper parser would walk the OTS op stream.
|
||||
|
||||
**Fix.** Replace with a real OTS parser. (Subsumed by the F-C2 fix.)
|
||||
|
||||
---
|
||||
|
||||
## F-M1 (Medium) — PQ signatures cover human-readable text, not a canonical binding
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:455`](www/js/pq-crypto.mjs:455).
|
||||
|
||||
**Issue.** The signed message is the full `content` string, mixing security-relevant fields (npub, block height) with display prose and hardcoded URLs. If the display text or URLs change, all existing PQ signatures would need re-issuing.
|
||||
|
||||
**Fix.** Sign a canonical structured attestation (or its hash) — e.g., `JSON.stringify({v:1, npub, blockHeight, pqPubkeys})` — and include the human-readable text separately for display.
|
||||
|
||||
---
|
||||
|
||||
## F-M2 (Medium) — `block_height` tag is not verified
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:465`](www/js/pq-crypto.mjs:465).
|
||||
|
||||
**Issue.** The `block_height` tag and content claim are purely informational. Nothing verifies the height was current at signing time.
|
||||
|
||||
**Fix.** Either remove the block-height security claim, or verify it against the OTS-attested block once real OTS verification is implemented.
|
||||
|
||||
---
|
||||
|
||||
## F-M3 (Medium) — BIP32 non-hardened leaf indices used for PQ seeds
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:49`](www/js/pq-crypto.mjs:49).
|
||||
|
||||
**Issue.** PQ seeds at non-hardened children 1–8 under hardened parent `m/44'/1237'/0'/0'`. PQ seeds (child private keys) remain secret unless the parent xpub leaks. The docs now warn about this (resolved in reconciliation). No enforcement.
|
||||
|
||||
**Fix.** Document the requirement never to publish the xpub at `m/44'/1237'/0'/0'` (done). Consider hardened indices for PQ children if wallet-compatibility is not required.
|
||||
|
||||
---
|
||||
|
||||
## F-M4 (Medium) — Truncation rule for concatenated BIP32 children is arbitrary
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:160`](www/js/pq-crypto.mjs:160).
|
||||
|
||||
**Issue.** "Take the first N bytes" is now documented (resolved in reconciliation), but a future implementer who takes the last N would break compatibility.
|
||||
|
||||
**Fix.** Document the exact rule (done). Consider HKDF-Expand on the concatenated children for a less arbitrary construction in a future version.
|
||||
|
||||
---
|
||||
|
||||
## F-M5 (Medium) — Falcon-512 is a draft standard (FIPS 206 not finalized)
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:728`](www/js/pq-crypto.mjs:728).
|
||||
|
||||
**Issue.** Falcon-512 is labeled "FIPS 206 (draft)." Standardization risk; the multi-scheme design mitigates it.
|
||||
|
||||
**Fix.** Communicate Falcon's draft status in the UI. Consider deferring Falcon until FIPS 206 is finalized.
|
||||
|
||||
---
|
||||
|
||||
## F-L1 (Low) — PQ secret keys and seed held in global scope, never zeroed
|
||||
|
||||
**Location:** [`www/index.html:641`](www/index.html:641).
|
||||
|
||||
**Issue.** PQ secret keys and seed held in JS global scope for the session, never zeroed. A compromised page (F-H3/F-H4) can read them.
|
||||
|
||||
**Fix.** Acceptable for a demo; for production, minimize secret lifetime and clear references on sign-out.
|
||||
|
||||
---
|
||||
|
||||
## F-L2 (Low) — 12-word default gives only ~64-bit PQ security on seed entropy
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:76`](www/js/pq-crypto.mjs:76), [`README.md`](README.md) (now recommends 24 words).
|
||||
|
||||
**Issue.** `generateSeedPhrase` defaults to 128 bits (12 words). Docs now recommend 24 words; app still defaults to 12.
|
||||
|
||||
**Fix.** Default to 24 words, or warn that 12-word gives only ~64-bit PQ security on the seed entropy.
|
||||
|
||||
---
|
||||
|
||||
## F-L3 (Low) — `hexToBytes`/`base64ToBytes` do not validate input
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:362`](www/js/pq-crypto.mjs:362), [`www/js/pq-crypto.mjs:341`](www/js/pq-crypto.mjs:341).
|
||||
|
||||
**Issue.** No input validation; malformed input produces `NaN` bytes or throws late. The verify page accepts pasted event JSON from users.
|
||||
|
||||
**Fix.** Validate input format and length; throw a clear error on malformed input.
|
||||
|
||||
---
|
||||
|
||||
## F-L4 (Low) — `e` tag validation does not check `kind1Event.id` equals computed id
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:624`](www/js/pq-crypto.mjs:624).
|
||||
|
||||
**Issue.** The `e` tag validation checks against the computed kind 1 id and `kind1Event.id` if present — but does not check that `kind1Event.id` itself equals the computed id.
|
||||
|
||||
**Fix.** Assert `kind1Event.id` (if present) equals the computed id, or ignore `kind1Event.id` and rely on the computed id.
|
||||
|
||||
---
|
||||
|
||||
## F-L5 (Low) — `timestampEvent` sends binary body with wrong Content-Type
|
||||
|
||||
**Location:** [`www/js/pq-crypto.mjs:798`](www/js/pq-crypto.mjs:798).
|
||||
|
||||
**Issue.** `Content-Type: application/x-www-form-urlencoded` with a raw binary body. Should be `application/octet-stream`. Works in practice.
|
||||
|
||||
**Fix.** Use `Content-Type: application/octet-stream` (or omit the header).
|
||||
|
||||
---
|
||||
|
||||
## F-L6 (Low) — Relay publishing treats `ws.onclose` as success
|
||||
|
||||
**Location:** [`www/index.html:1139`](www/index.html:1139).
|
||||
|
||||
**Issue.** `publishToRelays` treats `ws.onclose` as success. A relay that closes without sending `OK` is counted as successful.
|
||||
|
||||
**Fix.** Only count as success on an explicit `OK` message.
|
||||
|
||||
---
|
||||
|
||||
## F-L7 (Low) — App depends on server-provided `/nostr-login-lite/` scripts not in the repo
|
||||
|
||||
**Location:** [`www/index.html:606`](www/index.html:606), [`www/verify.html:346`](www/verify.html:346).
|
||||
|
||||
**Issue.** The pages load `/nostr-login-lite/nostr.bundle.js` and `nostr-lite.js` from absolute paths. These files are not in the repo.
|
||||
|
||||
**Fix.** Vendor the scripts into the repo (with SRI) or document the deployment dependency.
|
||||
|
||||
---
|
||||
|
||||
## F-L8 (Low, NEW) — `why_what_how.md` Component 4 summary still uses successor-model language
|
||||
|
||||
**Location:** [`why_what_how.md:64`](why_what_how.md:64).
|
||||
|
||||
**Issue.** The six-components summary for Component 4 says: "The old identity signs to authorize the migration; the new identity signs to endorse the PQ keys." This is successor-model language describing the unimplemented two-account flow. Component 4 is marked "Design only" in the README, but `why_what_how.md` does not carry that marker, so a reader could mistake this for an implemented feature.
|
||||
|
||||
**Fix.** Either add a "design only" note to `why_what_how.md` Component 4, or reword to avoid implying the successor signature is implemented.
|
||||
+75
-72
@@ -94,21 +94,37 @@ The `keygen(seed)` functions in `@noble/post-quantum` use the seed as a determin
|
||||
- The PQ key generation is deterministic and reproducible
|
||||
- If a user loses their PQ keys, they can re-derive them from the seed phrase
|
||||
|
||||
## Step 6: Construct the link statement
|
||||
## Step 6: Construct the attestation statement
|
||||
|
||||
A text statement is created that binds all the keys together:
|
||||
A human-readable text statement is created that binds all the keys together. This is the `content` field of the kind 1 announcement event:
|
||||
|
||||
```
|
||||
"Identity <Account #1 pubkey> is migrating to successor <Account #2 pubkey>.
|
||||
All PQ keys listed below are derived from the same BIP39 seed as <Account #2 pubkey>.
|
||||
This link is established pre-quantum."
|
||||
I am signaling that the post-quantum public keys listed in the tags of this event
|
||||
were generated by me and I hold the private keys. I may use these keys in the future
|
||||
as successors to my current Nostr identity.
|
||||
|
||||
My current identity:
|
||||
npub: <npub>
|
||||
hex: <hex pubkey>
|
||||
|
||||
This attestation is established pre-quantum at Bitcoin block height <height>.
|
||||
|
||||
Post-quantum public keys in tags:
|
||||
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
|
||||
ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3)
|
||||
SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1)
|
||||
Falcon-512 (FIPS 206 draft, NIST Level 1)
|
||||
ML-KEM-768 (Kyber, FIPS 203, NIST Level 3)
|
||||
|
||||
Each post-quantum key has cryptographically signed this attestation. This event is
|
||||
pending timestamp on the Bitcoin blockchain via OpenTimestamps.
|
||||
```
|
||||
|
||||
This statement is what gets signed by all the keys.
|
||||
This statement is what gets signed by each PQ key.
|
||||
|
||||
## Step 7: Sign the statement with each PQ private key
|
||||
|
||||
The statement (as bytes) is signed independently by each PQ signature scheme:
|
||||
The statement (as bytes — `TextEncoder.encode(content)`) is signed independently by each PQ signature scheme:
|
||||
|
||||
```
|
||||
statement_bytes → ML-DSA-44 sign(statement_bytes, ml_dsa44_secretKey) → signature (2420 bytes)
|
||||
@@ -117,84 +133,71 @@ statement_bytes → SLH-DSA-128s sign(statement_bytes, slh_dsa_secretKey) → si
|
||||
statement_bytes → Falcon-512 sign(statement_bytes, falcon_secretKey) → signature (~666 bytes)
|
||||
```
|
||||
|
||||
ML-KEM-768 does NOT sign — it's a KEM (Key Encapsulation Mechanism), not a signature scheme. Its public key is included in the event, and its ownership is proven by the secp256k1 signature covering it.
|
||||
ML-KEM-768 does NOT sign — it's a KEM (Key Encapsulation Mechanism), not a signature scheme. Its public key is included in the event tags (without a signature), and its ownership is asserted by the attestation text and authorized by Account #1's signature over the kind 1 event (which covers the tags).
|
||||
|
||||
## Step 8: Sign the statement with Account #2's secp256k1 key
|
||||
## Step 8: Build the kind 1 announcement event
|
||||
|
||||
The successor's secp256k1 private key (derived at `m/44'/1237'/0'/0/0`) signs the statement + all PQ public keys:
|
||||
|
||||
```
|
||||
statement + pq_public_keys → Schnorr sign(secp256k1_private_key) → successor_signature
|
||||
```
|
||||
|
||||
This proves that the seed-derived secp256k1 key (Account #2) endorses the PQ keys. Since both Account #2's secp256k1 key and the PQ keys come from the same BIP32 tree (same seed), this transitively proves PQ key ownership.
|
||||
|
||||
## Step 9: Build the NIP-QR event content
|
||||
|
||||
All the pieces are assembled into a JSON object:
|
||||
The attestation text and PQ signatures are assembled into a kind 1 Nostr text note. The PQ public keys and signatures go in the tags:
|
||||
|
||||
```json
|
||||
{
|
||||
"statement": "Identity <pubkey1> is migrating to successor <pubkey2>...",
|
||||
"successor_pubkey": "<Account #2 secp256k1 pubkey (hex)>",
|
||||
"successor_signature": "<Account #2 Schnorr signature over statement+pq_keys>",
|
||||
"pq_keys": [
|
||||
{
|
||||
"algorithm": "ml-dsa-44",
|
||||
"public_key": "<ML-DSA-44 public key (base64)>",
|
||||
"signature": "<ML-DSA-44 signature over statement (base64)>"
|
||||
},
|
||||
{
|
||||
"algorithm": "ml-dsa-65",
|
||||
"public_key": "<ML-DSA-65 public key (base64)>",
|
||||
"signature": "<ML-DSA-65 signature over statement (base64)>"
|
||||
},
|
||||
{
|
||||
"algorithm": "slh-dsa-128s",
|
||||
"public_key": "<SLH-DSA-128s public key (base64)>",
|
||||
"signature": "<SLH-DSA-128s signature over statement (base64)>"
|
||||
},
|
||||
{
|
||||
"algorithm": "falcon-512",
|
||||
"public_key": "<Falcon-512 public key (base64)>",
|
||||
"signature": "<Falcon-512 signature over statement (base64)>"
|
||||
},
|
||||
{
|
||||
"algorithm": "ml-kem-768",
|
||||
"public_key": "<ML-KEM-768 public key (base64)>",
|
||||
"note": "KEM key for encryption; ownership asserted by secp256k1 signature over this content"
|
||||
}
|
||||
"kind": 1,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey (hex)>",
|
||||
"created_at": 1720780000,
|
||||
"content": "I am signaling that the post-quantum public keys listed in the tags of this event...",
|
||||
"tags": [
|
||||
["block_height", "<height>"],
|
||||
["algorithm", "ml-dsa-44", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "ml-dsa-65", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "slh-dsa-128s", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "falcon-512", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "ml-kem-768", "<base64 pubkey>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Step 10: Build the Nostr event and sign with Account #1
|
||||
Note: the ML-KEM-768 tag has only the pubkey (no signature field) — it's a KEM, not a signature scheme.
|
||||
|
||||
The JSON content is placed into a standard Nostr event:
|
||||
## Step 9: Sign the kind 1 event with Account #1
|
||||
|
||||
This unsigned kind 1 event is sent to the user's signer (via `window.nostr.signEvent()`), which signs it with Account #1's secp256k1 private key — the user's **existing** Nostr identity. The signer returns the `id` and `sig` fields.
|
||||
|
||||
**Note on the seed-derived secp256k1 key (Account #2):** The seed-derived secp256k1 key at `m/44'/1237'/0'/0/0` is derived but **not used to sign** in the current implementation. There is no "successor signature." The only secp256k1 signature is Account #1's on the Nostr events. The security model relies on OTS precedence (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature binding the PQ keys to the seed.
|
||||
|
||||
## Step 10: Timestamp the kind 1 event hash with OpenTimestamps
|
||||
|
||||
The SHA-256 of the **full signed kind 1 event JSON** (including its `id` and `sig`) is submitted to OpenTimestamps:
|
||||
|
||||
```
|
||||
full_hash = SHA-256(JSON.stringify(signedKind1Event))
|
||||
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
|
||||
|
||||
A kind 11112 wrapper event is built that embeds the full kind 1 event as JSON content and carries the OTS proof:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"kind": 11112,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey (hex)>",
|
||||
"created_at": 1720780000,
|
||||
"content": "<JSON string of the full signed kind 1 event>",
|
||||
"tags": [
|
||||
["d", "nip-qr-migration"],
|
||||
["successor", "<Account #2 secp256k1 pubkey (hex)>"],
|
||||
["algorithm", "ml-dsa-44"],
|
||||
["algorithm", "ml-dsa-65"],
|
||||
["algorithm", "slh-dsa-128s"],
|
||||
["algorithm", "falcon-512"],
|
||||
["algorithm", "ml-kem-768"]
|
||||
],
|
||||
"content": "<JSON string from Step 9>"
|
||||
["e", "<kind 1 event id>"],
|
||||
["sha256", "<hex SHA-256 of full signed kind 1 event JSON>"],
|
||||
["ots", "<base64 .ots proof>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This unsigned event is sent to the user's signer (via `window.nostr.signEvent()`), which signs it with Account #1's secp256k1 private key — the user's existing Nostr identity. The signer returns the `id` and `sig` fields.
|
||||
This wrapper is sent to the user's signer (`window.nostr.signEvent()`), which signs it with Account #1's secp256k1 key. The signer returns the `id` and `sig` fields.
|
||||
|
||||
## Step 11: Publish the complete event
|
||||
## Step 12: Publish both events
|
||||
|
||||
The fully signed event is published to Nostr relays.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -202,13 +205,13 @@ The fully signed event is published to Nostr relays.
|
||||
|
||||
| Signer | What they sign | Signature type | Purpose |
|
||||
|---|---|---|---|
|
||||
| ML-DSA-44 private key | The link statement | PQ signature (lattice, Cat 2) | Proves PQ key ownership post-quantum |
|
||||
| ML-DSA-65 private key | The link statement | PQ signature (lattice, Cat 3) | Higher security level PQ signature |
|
||||
| SLH-DSA-128s private key | The link statement | PQ signature (hash-based, Cat 1) | Conservative fallback if lattice schemes break |
|
||||
| Falcon-512 private key | The link statement | PQ signature (lattice, Cat 1) | Compact signatures, compatible with trbouma's PR |
|
||||
| Account #2 secp256k1 key | Statement + all PQ public keys | Schnorr signature | Proves seed-derived key endorses PQ keys |
|
||||
| Account #1 secp256k1 key | The entire Nostr event | Schnorr signature | Old identity authorizes the migration |
|
||||
| ML-KEM-768 | (nothing — KEM, not signature) | N/A | Ownership proven by Account #2's signature covering its public key |
|
||||
| ML-DSA-44 private key | The attestation text (content) | PQ signature (lattice, Cat 2) | Proves PQ key exists and attests post-quantum |
|
||||
| ML-DSA-65 private key | The attestation text (content) | PQ signature (lattice, Cat 3) | Higher security level PQ signature |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -230,7 +233,7 @@ Account 0, change 0
|
||||
+--→ child 0 → secp256k1 keypair (Account #2, NIP-06)
|
||||
| |
|
||||
| v
|
||||
| signs the link statement (Schnorr)
|
||||
| derived but NOT used to sign in the current implementation
|
||||
|
|
||||
+--→ child 1 → 32-byte seed → ML-DSA-44 keypair
|
||||
| |
|
||||
|
||||
+21
-12
@@ -1,17 +1,26 @@
|
||||
# Laan's explanation
|
||||
|
||||
1. Create a new fresh seed phrase. This will be your post quantum (pq) nostr account.
|
||||
2. With this new seed phrase, we are going to create 5 different nsec npub key pairs. Each one using a different post quantum algorythm.
|
||||
3. Construct the link statement
|
||||
A short summary of how the post-quantum Nostr migration works, matching the implementation.
|
||||
|
||||
A text statement is created that binds all the keys together:
|
||||
1. **Create a new seed phrase.** This is your post-quantum (PQ) Nostr backup. It's a 12-word BIP39 mnemonic — pure entropy, not tied to any algorithm.
|
||||
|
||||
```
|
||||
"Identity <Account #1 pubkey> is migrating to successor <Account #2 pubkey>.
|
||||
All PQ keys listed below are derived from the same BIP39 seed as <Account #2 pubkey>.
|
||||
This link is established pre-quantum."
|
||||
```
|
||||
2. **Derive 5 PQ keypairs from the seed.** Using BIP32 hierarchical deterministic derivation (the same standard as NIP-06), each PQ algorithm gets its own child key under the path `m/44'/1237'/0'/0/`:
|
||||
- child 1 → ML-DSA-44
|
||||
- child 2 → ML-DSA-65
|
||||
- children 3+4 → SLH-DSA-128s
|
||||
- children 5+6 → Falcon-512
|
||||
- children 7+8 → ML-KEM-768
|
||||
|
||||
This statement is what gets signed by all the keys.
|
||||
2. With your old account, create an event.
|
||||
3.
|
||||
The same seed always produces the same PQ keys, so they're recoverable from the seed phrase.
|
||||
|
||||
3. **Construct the attestation statement.** A human-readable text statement is created that names your current npub and the Bitcoin block height, and lists the 5 PQ algorithms. This is the `content` of a kind 1 Nostr event.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "post_quantum_nostr",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"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": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Software Quality Remediation Plan
|
||||
|
||||
**Date:** 2026-07-17
|
||||
**Scope:** Address remaining open audit findings (F-H1 through F-H4, F-M1–F-M5, F-L1–F-L8) — all software-quality issues, not cryptographic-correctness gaps.
|
||||
**Prerequisite:** F-C1 (reclassified), F-C2/F-C3 (fixed), F-C4 (resolved) are done.
|
||||
|
||||
---
|
||||
|
||||
## Priority tiers
|
||||
|
||||
The findings fall into three tiers by impact:
|
||||
|
||||
- **Tier 1 (must-fix for production):** F-H1 (tests), F-H2 (id checks), F-H3 (XSS), F-H4 (CSP/SRI), F-L8 (doc leftover)
|
||||
- **Tier 2 (should-fix):** F-L2 (24-word default), F-L4 (e-tag id check), F-L5 (Content-Type), F-L6 (relay close-as-success), F-M5 (Falcon UI warning)
|
||||
- **Tier 3 (nice-to-have / design decisions):** F-M1 (canonical attestation), F-M2 (block_height verification), F-M3 (xpub enforcement), F-M4 (truncation construction), F-L1 (zeroing), F-L3 (input validation), F-L7 (vendor nostr-login-lite)
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: Must-fix
|
||||
|
||||
### F-H1: Add a test suite
|
||||
|
||||
**What:** Create a test file using Node's built-in `test` runner (no new dependencies). Update `package.json` test script.
|
||||
|
||||
**File:** `test/pq-crypto.test.mjs` (new)
|
||||
|
||||
**Tests to include:**
|
||||
1. **BIP32→PQ-seed derivation known-answer:** fixed mnemonic → expected BIP32 child private keys (verify determinism + correct path).
|
||||
2. **PQ keygen determinism:** same seed → same pubkeys for all 5 algorithms.
|
||||
3. **PQ sign→verify round-trip:** for each of ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512: sign a test message, verify passes; tamper message → verify fails; tamper signature → verify fails; wrong pubkey → verify fails.
|
||||
4. **`verifyNIPQRContent` accept/reject:**
|
||||
- Accept: genuine kind 1 event with valid PQ sigs + valid secp256k1 sig + matching e/sha256 tags.
|
||||
- Reject: tampered content, tampered PQ sig, wrong pubkey, mismatched e tag, mismatched sha256 tag.
|
||||
5. **`computeEventId` / `verifyNostrEvent`:** known Nostr test vector (from NIP-01 spec or a known event).
|
||||
6. **OTS parser tests:** `isDetachedOtsFile` true on valid prefix, false on random bytes; `parseOtsFile` on `hello-world.txt.ots` → expected target digest + 1 Bitcoin attestation at height 358391; `parseOtsFile` on `incomplete.txt.ots` → pending attestation.
|
||||
7. **`verifyOtsProof` F-C3 binding:** wrong expected digest → `verified: false` with "Target digest mismatch".
|
||||
|
||||
**package.json change:** `"test": "node --test test/"`
|
||||
|
||||
### F-H2: `verifyNostrEvent` check `event.id`
|
||||
|
||||
**What:** Add an id check to [`verifyNostrEvent`](www/js/pq-crypto.mjs:385).
|
||||
|
||||
**Change in `pq-crypto.mjs`:**
|
||||
```js
|
||||
export function verifyNostrEvent(event) {
|
||||
const serialized = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
|
||||
const hash = sha256(new TextEncoder().encode(serialized));
|
||||
const computedId = bytesToHex(hash);
|
||||
if (event.id && event.id.toLowerCase() !== computedId) return false; // NEW
|
||||
const sig = hexToBytes(event.sig);
|
||||
const pubkey = hexToBytes(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
}
|
||||
```
|
||||
|
||||
Also fix F-L4 in `verifyNIPQRContent`: assert `kind1Event.id` (if present) equals computed id, or ignore it and rely on the computed id.
|
||||
|
||||
### F-H3: Fix XSS — replace `innerHTML` with `textContent` for dynamic strings
|
||||
|
||||
**What:** Audit all `innerHTML` usages in [`index.html`](www/index.html) and [`verify.html`](www/verify.html). Replace with `textContent` where the content includes variables derived from relay/localStorage/user input. Keep `innerHTML` only for static HTML structure (no variables).
|
||||
|
||||
**Key sites:**
|
||||
- [`setStatus`](www/index.html:761) — used with `error.message` from relays. Change to `textContent`.
|
||||
- [`www/index.html:750`](www/index.html:750) — `otsNotice.innerHTML` with `pendingOts.eventId`. Change to build the element with `textContent` for the variable part.
|
||||
- [`setStatus`](www/verify.html:403) — same pattern. Change to `textContent`.
|
||||
- [`www/verify.html`](www/verify.html) OTS info/badge — uses `innerHTML` with `hashMatchNote` etc. Change to `textContent`.
|
||||
|
||||
**Approach:** Create a helper `function setText(element, text)` that uses `textContent`, and replace all `innerHTML` calls that include dynamic data. For cases that need HTML structure (e.g., a link inside a status message), build the DOM with `createElement` + `textContent` for the variable parts.
|
||||
|
||||
### F-H4: Add CSP and SRI
|
||||
|
||||
**What:**
|
||||
1. Add a CSP meta tag to both `index.html` and `verify.html`:
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:;">
|
||||
```
|
||||
(Note: `style-src 'unsafe-inline'` is needed because the pages have inline `<style>` blocks. Ideally these would be extracted to CSS files, but that's a larger refactor. The CSP still prevents script injection.)
|
||||
2. Add SRI `integrity` attributes to the bundle script tag. Compute the SHA-384 hash of `www/pq-crypto.bundle.js` and add:
|
||||
```html
|
||||
<script type="module" integrity="sha384-<hash>" src="./pq-crypto.bundle.js"></script>
|
||||
```
|
||||
Wait — module scripts with `integrity` need the `crossorigin` attribute too. Since the bundle is same-origin, this should work. The `/nostr-login-lite/` scripts are not in the repo so we can't compute their SRI — flag this as F-L7 (vendor them or document the deployment dependency).
|
||||
|
||||
**Note:** The CSP `connect-src` must allow `wss:` (relays), `https:` (OTS calendars, Bitcoin APIs, mempool.space, blockstream.info). The `script-src 'self'` blocks inline scripts — but both pages have inline `<script type="module">` blocks. This is a problem. Options:
|
||||
- (a) Extract the inline scripts to separate `.js` files (cleaner, but larger change).
|
||||
- (b) Use a CSP nonce: generate a per-page-load nonce and add `nonce="<random>"` to the inline script tags + `'nonce-<random>'` to the CSP. This is the standard approach for inline scripts.
|
||||
- (c) Use `'unsafe-inline'` for script-src (defeats much of the CSP purpose for XSS, but still blocks external script injection).
|
||||
|
||||
**Recommendation:** Option (b) — CSP nonce for the inline module scripts. This gives real XSS protection (an injected script without the nonce won't run) while keeping the inline scripts. The nonce is generated server-side or per-page-load in the HTML. Since this is a static site, the nonce can be generated in a small inline bootstrap script that sets it on the CSP meta tag and the module script tag. Actually, for a static site, the cleanest approach is (a) — extract the inline scripts to `.js` files. This is more work but gives the strongest CSP.
|
||||
|
||||
**Decision needed:** extract inline scripts to files (stronger, more work) or use nonces (pragmatic)? I'll plan for (a) extraction since it's the right long-term answer, but note (b) as an alternative.
|
||||
|
||||
### F-L8: Fix `why_what_how.md` Component 4 leftover
|
||||
|
||||
**What:** [`why_what_how.md:64`](why_what_how.md:64) says "the new identity signs to endorse the PQ keys" — successor-model language.
|
||||
|
||||
**Fix:** Reword to: "The old identity signs to authorize the migration. (The raw-nsec migration is a design proposal, not yet implemented.)" Or add a "Design only" note.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Should-fix
|
||||
|
||||
### F-L2: Default to 24-word mnemonics (or warn)
|
||||
|
||||
**What:** [`generateSeedPhrase`](www/js/pq-crypto.mjs:76) defaults to 128 bits (12 words). Change to 256 bits (24 words), or add a UI warning that 12-word gives only ~64-bit PQ security.
|
||||
|
||||
**Recommendation:** Add a UI warning rather than changing the default — 12 words is more user-friendly for a demo, and the warning educates. Add a note near the seed display: "For maximum post-quantum security, consider a 24-word seed phrase (12 words provides ~64-bit PQ security under Grover's algorithm)."
|
||||
|
||||
### F-L4: `e` tag validation check `kind1Event.id`
|
||||
|
||||
**What:** In [`verifyNIPQRContent`](www/js/pq-crypto.mjs:624), assert `kind1Event.id` (if present) equals the computed id.
|
||||
|
||||
**Fix:** Add after computing `computedKind1Id`:
|
||||
```js
|
||||
if (kind1Event.id && kind1Event.id.toLowerCase() !== computedKind1Id.toLowerCase()) {
|
||||
eTagValid = false;
|
||||
}
|
||||
```
|
||||
|
||||
### F-L5: Fix `timestampEvent` Content-Type
|
||||
|
||||
**What:** [`timestampEvent`](www/js/pq-crypto.mjs:798) sends `Content-Type: application/x-www-form-urlencoded` with a binary body.
|
||||
|
||||
**Fix:** Change to `Content-Type: application/octet-stream`.
|
||||
|
||||
### F-L6: Relay publishing — don't treat close as success
|
||||
|
||||
**What:** [`publishToRelays`](www/index.html:1139) treats `ws.onclose` as success.
|
||||
|
||||
**Fix:** Treat close-without-OK as failure or "unknown". Only count as success on an explicit `OK` message.
|
||||
|
||||
### F-M5: Falcon-512 draft-status UI warning
|
||||
|
||||
**What:** Add a note in the UI (near the Falcon key display) that Falcon-512 is based on FIPS 206 (draft) and may need re-issuing if the standard changes.
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Nice-to-have / design decisions
|
||||
|
||||
### F-M1: Canonical structured attestation
|
||||
|
||||
**What:** Sign a canonical JSON object (or its hash) instead of human-readable prose. This is a design change that affects the event format — it would require re-issuing any existing events. **Recommend deferring** unless there's a concrete need (e.g., the prose text needs to change).
|
||||
|
||||
### F-M2: `block_height` verification
|
||||
|
||||
**What:** Verify the block height was current at signing time. This requires OTS verification (now implemented) to establish the signing time, then comparing to the claimed block height. **Low priority** — the block height is informational; the real anchor is the OTS proof.
|
||||
|
||||
### F-M3: xpub enforcement
|
||||
|
||||
**What:** Add a code-level warning or check that the xpub at `m/44'/1237'/0'/0'` is never exported. **Already documented** — code enforcement is difficult since the xpub isn't published by this app. **Recommend deferring.**
|
||||
|
||||
### F-M4: Truncation construction
|
||||
|
||||
**What:** Replace "take first N bytes" with HKDF-Expand on the concatenated children. **Design change** that would break existing key derivation — **recommend deferring** unless doing a major version bump.
|
||||
|
||||
### F-L1: Zero secrets on sign-out
|
||||
|
||||
**What:** Clear `pqKeys`, `pqSeed`, `pqMnemonic` references on sign-out. The sign-out flow already clears some state; ensure all secret references are nulled. (JS can't guarantee memory zeroing, but clearing references helps.)
|
||||
|
||||
### F-L3: Input validation for `hexToBytes`/`base64ToBytes`
|
||||
|
||||
**What:** Validate input format and length; throw clear errors on malformed input.
|
||||
|
||||
### F-L7: Vendor `/nostr-login-lite/` scripts
|
||||
|
||||
**What:** Copy the `nostr-login-lite` scripts into the repo (with SRI) or document the deployment dependency. This is a deployment/reproducibility issue, not a security issue per se.
|
||||
|
||||
---
|
||||
|
||||
## Execution order
|
||||
|
||||
1. **F-H2 + F-L4:** `verifyNostrEvent` id check + `e` tag id check (small, high-value, in `pq-crypto.mjs`)
|
||||
2. **F-L5:** Fix Content-Type (one-line change in `pq-crypto.mjs`)
|
||||
3. **F-L6:** Fix relay close-as-success (in `index.html`)
|
||||
4. **F-H3:** XSS — replace `innerHTML` with `textContent` (in both HTML files)
|
||||
5. **F-H4:** CSP + SRI — extract inline scripts to `.js` files, add CSP meta tag, add SRI to bundle (both HTML files)
|
||||
6. **F-L8:** Fix `why_what_how.md` leftover (one-line doc fix)
|
||||
7. **F-L2:** Add 12-word PQ security warning (UI text in `index.html`)
|
||||
8. **F-M5:** Add Falcon draft-status warning (UI text in `index.html`)
|
||||
9. **F-L1:** Clear secret references on sign-out (in `index.html`)
|
||||
10. **F-L3:** Input validation (in `pq-crypto.mjs`)
|
||||
11. **F-H1:** Add test suite (new file + `package.json`)
|
||||
12. **Rebuild bundle** and verify tests pass
|
||||
|
||||
Tier 3 items (F-M1, F-M2, F-M3, F-M4, F-L7) are deferred unless you want them included.
|
||||
|
||||
---
|
||||
|
||||
## Mermaid diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
H2[F-H2: verifyNostrEvent id check] --> L4[F-L4: e-tag id check]
|
||||
L4 --> L5[F-L5: Content-Type fix]
|
||||
L5 --> L6[F-L6: relay close-as-success]
|
||||
L6 --> H3[F-H3: XSS - textContent]
|
||||
H3 --> H4[F-H4: CSP + SRI]
|
||||
H4 --> L8[F-L8: doc leftover]
|
||||
L8 --> L2[F-L2: 12-word warning]
|
||||
L2 --> M5[F-M5: Falcon warning]
|
||||
M5 --> L1[F-L1: clear secrets]
|
||||
L1 --> L3[F-L3: input validation]
|
||||
L3 --> H1[F-H1: test suite]
|
||||
H1 --> BUILD[Rebuild bundle + run tests]
|
||||
|
||||
style H2 fill:#fcc
|
||||
style H3 fill:#fcc
|
||||
style H4 fill:#fcc
|
||||
style H1 fill:#fcc
|
||||
style BUILD fill:#cfc
|
||||
```
|
||||
@@ -0,0 +1,180 @@
|
||||
# Remediation Plan: Reconcile Documentation with Implementation
|
||||
|
||||
**Date:** 2026-07-17
|
||||
**Scope:** Address audit findings F-C1 and F-C4 (and related F-M1, F-M4, F-M6) by adjusting all documentation to match what the code actually does.
|
||||
**Direction chosen by user:** Adjust docs to match code; drop the successor-signature security claim; accept the "PQ self-attestation + Account #1 authorization + OTS precedence" model.
|
||||
|
||||
This plan covers **only the documentation reconciliation** (F-C1 doc side + F-C4). The remaining audit findings (F-C2 real OTS verification, F-C3 digest binding, F-H1–F-H5, etc.) are **out of scope for this plan** and will be addressed in a follow-up "code does what it says" audit pass, as the user stated.
|
||||
|
||||
---
|
||||
|
||||
## Background: What the code actually does
|
||||
|
||||
Before listing changes, here is the canonical description of the implemented system that all docs must match:
|
||||
|
||||
### Derivation
|
||||
- BIP39 mnemonic (12 words, 128 bits) → BIP39 seed (PBKDF2-HMAC-SHA512, 2048 iterations, 64 bytes).
|
||||
- BIP32 master key from the seed.
|
||||
- All keys derived under `m/44'/1237'/0'/0/` (NIP-06 base path, account 0, change 0):
|
||||
- child 0 → secp256k1 keypair (NIP-06 standard). **Derived but NOT used to sign anything in the current implementation.**
|
||||
- child 1 → 32-byte seed → ML-DSA-44 keypair
|
||||
- child 2 → 32-byte seed → ML-DSA-65 keypair
|
||||
- children 3+4 → 64 bytes concatenated, first 48 used → SLH-DSA-128s keypair
|
||||
- children 5+6 → 64 bytes concatenated, first 48 used → Falcon-512 keypair
|
||||
- children 7+8 → 64 bytes concatenated → ML-KEM-768 keypair
|
||||
- PQ keygen is deterministic: same mnemonic → same PQ keypairs. Recoverable from the seed phrase.
|
||||
|
||||
### Event structure (two events)
|
||||
1. **Kind 1 announcement** (human-readable text note):
|
||||
- `content`: human-readable attestation statement (prose naming the user's npub, hex pubkey, block height, and listing the 5 PQ algorithms).
|
||||
- `tags`: `['block_height', '<height>']` plus one `['algorithm', '<algo>', '<base64 pubkey>', '<base64 signature>']` tag per PQ signature scheme. ML-KEM-768 tag has only the pubkey (no signature — it's a KEM).
|
||||
- Each PQ signature scheme signs `TextEncoder.encode(content)` (the human-readable text).
|
||||
- Signed with the user's **existing** Nostr identity (Account #1) via `window.nostr.signEvent` (NIP-07). This is the only secp256k1 signature on the kind 1 event.
|
||||
|
||||
2. **Kind 11112 wrapper** (replaceable event, 10000–19999 range):
|
||||
- `content`: `JSON.stringify(kind1Event)` (the full signed kind 1 event embedded as a JSON string).
|
||||
- `tags`: `['e', '<kind1 event id>']`, `['sha256', '<hex SHA-256 of the full signed kind 1 event JSON>']`, `['ots', '<base64 .ots proof>']`.
|
||||
- Signed with the user's existing identity (Account #1) via `window.nostr.signEvent`.
|
||||
|
||||
### What is OpenTimestamped
|
||||
- The **SHA-256 of the full signed kind 1 event JSON** (including its `id` and `sig`), computed by `hashFullEvent(kind1Event)`. This is the digest submitted to the OTS calendar and recorded in the `sha256` tag.
|
||||
|
||||
### Security model (the one docs must describe)
|
||||
- The PQ keys **self-attest**: each PQ signature scheme signs the human-readable attestation statement, which names the user's npub and the block height.
|
||||
- The user's **existing** secp256k1 identity (Account #1) authorizes the migration by signing both the kind 1 and kind 11112 events.
|
||||
- There is **no successor signature**. The seed-derived secp256k1 key (Account #2, child 0) is derived but not used to sign. The docs must **not** claim that the seed-derived key endorses the PQ keys.
|
||||
- **Pre-quantum anchoring** is provided by OpenTimestamps on the kind 1 event hash. After a quantum break, a forged migration event cannot be backdated to before the real event's OTS anchor (re-mining historical Bitcoin blocks is infeasible even with a quantum computer). The real event is distinguishable from a forged one by OTS precedence — the event with the earliest valid OTS proof wins.
|
||||
- **What is NOT proven** (docs must be honest about this): the PQ keys cannot *cryptographically* prove they share a common seed origin. They prove only that 5 PQ keypairs exist and each signed the attestation text. Common-origin is asserted by the attestation text and authorized by Account #1, not proven by a seed-derived signature. The user has decided this is acceptable.
|
||||
|
||||
### Algorithms (5, not 3)
|
||||
ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768.
|
||||
|
||||
---
|
||||
|
||||
## Changes by file
|
||||
|
||||
### 1. [`README.md`](README.md)
|
||||
|
||||
**Component 1 (lines ~103–147): PQ Key Derivation**
|
||||
- Replace the HKDF derivation description (lines 126–137) with the BIP32 derivation description matching [`explanation.md`](explanation.md:39) and the code.
|
||||
- Update the mermaid diagram (lines 109–124): change `HKDF derivation` labels to `BIP32 derivation` for all PQ keys. Remove the `SYMKEY` node if it's not implemented (it isn't — no symmetric storage key is derived in the code). Keep `BIP32 derivation - secp256k1 keypair`.
|
||||
- Add the derivation path table (child indices 0–8, seed lengths, concatenation/truncation rule for >32-byte seeds) matching [`explanation.md`](explanation.md:41) and [`pq-crypto.mjs`](www/js/pq-crypto.mjs:49).
|
||||
- Document the truncation rule explicitly: "for 48-byte and 64-byte seeds, two 32-byte BIP32 children are concatenated (64 bytes); for 48-byte seeds the first 48 bytes are used." (F-M4)
|
||||
- Keep the seed-entropy table (12 vs 24 words) as-is — it's accurate.
|
||||
|
||||
**Component 2 (lines ~150–265): Key-Link Events**
|
||||
- Replace the event format JSON (lines 202–233) with the actual two-event structure: kind 1 announcement (text content + `algorithm` tags) and kind 11112 wrapper (JSON content + `e`/`sha256`/`ots` tags). Show real tag format: `['algorithm', '<algo>', '<base64 pubkey>', '<base64 signature>']`.
|
||||
- Update the mermaid diagram (lines 169–196): show 5 PQ schemes (add ML-DSA-44, Falcon-512). Show the kind 1 + kind 11112 two-event structure. Remove the "secp256k1 signature covers the entire content" claim about a successor; the only secp256k1 signature is Account #1's on the Nostr events.
|
||||
- **Remove the `secp256k1_signature` / successor-signature claim** (lines 229–235). Replace with: "The user's existing Nostr identity (the secp256k1 key people already know) signs the kind 1 and kind 11112 events, authorizing the migration. The seed-derived secp256k1 key is not used to sign."
|
||||
- Update the "ML-KEM Cannot Sign" section (lines 237–243): keep the KEM explanation, but remove the claim that "the secp256k1 signature covers the ML-KEM public key in the content" — in the actual format, the ML-KEM pubkey is in a tag, and Account #1's signature covers the whole event (which includes the tags). Restate accurately.
|
||||
- Add a new subsection **"What This Proves and What It Does Not"** stating honestly:
|
||||
- Proves: each PQ key signed the attestation; Account #1 authorized the migration; the event is OTS-anchored pre-quantum.
|
||||
- Does NOT prove: that the PQ keys share a common seed origin (no successor signature). Common-origin is asserted in the attestation text, not cryptographically proven by a seed-derived key.
|
||||
- Update the "Revocation" section (lines 258–264): keep, but adjust "signed by the remaining valid PQ keys" to reflect the actual signing model.
|
||||
|
||||
**Component 3 (lines ~268–305): OpenTimestamps**
|
||||
- Update "What Should Be OpenTimestamped" to reflect that the code timestamps the **SHA-256 of the full signed kind 1 event JSON** (not "the key-link event" generically, and not the kind 11112). Clarify the two-event relationship: the kind 11112 wrapper *carries* the OTS proof; the kind 1 event *is* what's timestamped.
|
||||
- Add a note that OTS verification is currently a byte-pattern heuristic and real client-side verification is planned (this is honest about the current state — F-C2 is out of scope for this plan but the docs shouldn't overclaim). **Actually**: since the user wants docs to match what the code *does*, and the code does NOT do real OTS verification, the docs should describe the current behavior (byte-pattern check + server upgrade) and not claim full verification. Mark the real-verification as future work.
|
||||
|
||||
**Component 4 (lines ~309–435): Migrating Existing Users with Raw nsec**
|
||||
- This section describes approaches (A/B/C) that are **not implemented** in the current code (no nsec encryption, no kind 30078 storage event, no 36-word phrase). Options:
|
||||
- (a) Mark this entire component as "Design only — not yet implemented" clearly at the top.
|
||||
- (b) Remove it.
|
||||
- Recommend (a): keep the design discussion but label it unimplemented, since the code only does the seed-generation → PQ-derivation → sign → publish → OTS flow for users who already have a Nostr identity. The raw-nsec migration is future work.
|
||||
|
||||
**Component 5 (lines ~438–491): Quantum-Safe Self-Storage**
|
||||
- Not implemented in the code (no OTP, no symmetric storage key derivation, no kind 30078 encryption). Mark as "Design only — not yet implemented."
|
||||
|
||||
**Component 6 (lines ~494–543): Deterministic Wallet Compartmentalization**
|
||||
- This is analysis/commentary, not implemented features. It's accurate as background. Keep, but ensure it doesn't claim the app uses hardened PQ derivation (it uses non-hardened children 1–8 under a hardened parent). The existing text is mostly fine; just verify it doesn't contradict the actual paths.
|
||||
|
||||
**The Complete Migration Flow (lines ~547–609)**
|
||||
- Update the mermaid diagram and user-experience steps to match the actual flow: sign in → generate seed → derive PQ keys (5) → PQ keys sign attestation text → Account #1 signs kind 1 → hash kind 1 → OTS submit → build kind 11112 with OTS proof → Account #1 signs kind 11112 → publish both → poll for OTS confirmation → republish kind 11112 with confirmed proof.
|
||||
- Remove the "Encrypt old nsec" / "kind 30078" steps (not implemented) or mark them as future.
|
||||
- Remove "successor signature" from the flow.
|
||||
|
||||
**Implementation Status (lines ~652–682)**
|
||||
- Update the "Existing Infrastructure" table: the app uses `@noble/post-quantum` (not liboqs), `@scure/bip39/bip32`, and a vendored-but-not-yet-used `javascript-opentimestamps`. Remove the `nostr_core_lib` references (that's a different project).
|
||||
- Update "To Be Built" to reflect what's actually missing: real OTS client-side verification, tests, the raw-nsec migration, the storage encryption.
|
||||
|
||||
**References** — keep, they're accurate.
|
||||
|
||||
---
|
||||
|
||||
### 2. [`explanation.md`](explanation.md)
|
||||
|
||||
This file is **mostly correct** (it describes BIP32, which matches the code). Changes needed:
|
||||
|
||||
- **Step 6 (lines ~97–106):** The link statement shown ("Identity <Account #1> is migrating to successor <Account #2>...") does not match the actual `content` in [`buildKind1Announcement`](www/js/pq-crypto.mjs:435). Replace with the actual attestation text (the "I am signaling that the post-quantum public keys listed in the tags of this event were generated by me..." text).
|
||||
- **Step 8 (lines ~122–130): "Sign the statement with Account #2's secp256k1 key"** — **REMOVE this step entirely.** The code does not do this. Replace with a note: "The seed-derived secp256k1 key (Account #2) is not used to sign in the current implementation. The only secp256k1 signature is Account #1's on the Nostr event (Step 10)."
|
||||
- **Step 9 (lines ~132–169): "Build the NIP-QR event content"** — the JSON structure shown (`statement`, `successor_pubkey`, `successor_signature`, `pq_keys[]`) does not match the code. Replace with the actual structure: kind 1 event with text `content` + `algorithm` tags.
|
||||
- **Step 10 (lines ~171–193):** Update the event kind from 30078 to kind 1 (announcement) + kind 11112 (wrapper). Show the actual two-event structure.
|
||||
- **Step 11:** Update to reflect publishing both events.
|
||||
- **Summary table "Who Signs What" (lines ~201–211):** Remove the "Account #2 secp256k1 key" row. Remove the "successor_signature" column. Update ML-KEM row to reflect that its pubkey is in a tag covered by Account #1's event signature (not a successor signature).
|
||||
- **Summary: BIP32 Key Derivation Tree (lines ~213–259):** Remove the "signs the link statement (Schnorr)" annotation under child 0 (Account #2). Add 5 algorithms (it currently shows the right 5 actually — verify ML-DSA-44 and Falcon are both present; they are in the table at line 265). Update the tree to show child 0 as "derived but not used for signing in current implementation."
|
||||
- **The 5 PQ Algorithms table (lines ~263–273):** Already lists 5 algorithms. Verify it matches the code (it does). Keep.
|
||||
|
||||
---
|
||||
|
||||
### 3. [`why_what_how.md`](why_what_how.md)
|
||||
|
||||
- **"The six components" (lines ~56–68):** Component 1 says "PQ keys are derived from it deterministically using HKDF with algorithm-specific labels." Change to BIP32. Component 2 says "ML-DSA-65, SLH-DSA-128s, and ML-KEM-768" (3 algorithms) — change to all 5. Remove "successor" language if present.
|
||||
- **"The cryptographic chain of trust" diagram (lines ~83–103):** Remove the Account #2 signature step. Show: Account #1 signs the event; PQ keys sign the attestation; OTS anchors. No successor.
|
||||
- **"The NIP-QR event" (lines ~105–129):** Replace the kind 30078 example with the actual kind 1 + kind 11112 structure. Remove `successor` tag, `successor_signature` field. Show `algorithm` tags and `e`/`sha256`/`ots` tags.
|
||||
- **"PQ key derivation from seed" (lines ~131–143):** Replace the HKDF pseudocode with the BIP32 path table.
|
||||
- **"The algorithms" table (lines ~158–164):** Add ML-DSA-44 and Falcon-512 (currently only 3 listed).
|
||||
- **"Post-quantum scenario" (lines ~171–181):** Update step 1–6 to remove successor-signature reasoning. The argument becomes: attacker forges Account #1 → can forge kind 1 events → but cannot backdate before the real event's OTS anchor → real event wins by OTS precedence. This is still valid and is the core argument.
|
||||
- **"The demo" (lines ~183–193):** Update the algorithm list to 5. Already mentions `@noble/post-quantum` — good.
|
||||
|
||||
---
|
||||
|
||||
### 4. [`laans_explanation.md`](laans_explanation.md)
|
||||
|
||||
- The file is truncated mid-sentence (F-M6). Either complete it or remove it.
|
||||
- The existing content (lines 1–16) describes "5 different nsec npub key pairs" and a link statement referencing "Account #1" and "Account #2" with the successor model.
|
||||
- **Recommendation:** Rewrite this file to be a short, accurate summary matching the implementation, or delete it if `explanation.md` already covers the same ground (it does, more thoroughly). If kept, update the link statement text and remove the successor reference.
|
||||
|
||||
---
|
||||
|
||||
## Execution order
|
||||
|
||||
1. **README.md** — largest and most visible; do first.
|
||||
2. **explanation.md** — mostly correct; targeted edits to Steps 6–11 and summary tables.
|
||||
3. **why_what_how.md** — targeted edits to components, chain-of-trust diagram, event format, derivation, algorithms.
|
||||
4. **laans_explanation.md** — rewrite or delete.
|
||||
5. **Consistency pass** — re-read all four docs and cross-check against [`pq-crypto.mjs`](www/js/pq-crypto.mjs) and [`index.html`](www/index.html) to ensure every claim about derivation, event format, algorithms, signing, and OTS matches the code. Specifically verify:
|
||||
- Derivation scheme = BIP32 (not HKDF) everywhere.
|
||||
- Event structure = kind 1 + kind 11112 (not kind 30078) everywhere.
|
||||
- Algorithms = 5 (not 3) everywhere.
|
||||
- No successor signature claimed anywhere.
|
||||
- What is timestamped = SHA-256 of full signed kind 1 event JSON, everywhere.
|
||||
- Security model = PQ self-attestation + Account #1 authorization + OTS precedence, everywhere.
|
||||
- Unimplemented components (raw-nsec migration, storage encryption) marked as design-only.
|
||||
|
||||
## Out of scope (for a follow-up plan)
|
||||
|
||||
These audit findings are NOT addressed by this documentation reconciliation and will be tackled when the user does the "code does what it says" pass:
|
||||
- F-C2: real client-side OTS verification
|
||||
- F-C3: bind OTS target digest to event hash
|
||||
- F-H1: add tests
|
||||
- F-H2: `verifyNostrEvent` check `event.id`
|
||||
- F-H3: XSS / `innerHTML`
|
||||
- F-H4: CSP / SRI
|
||||
- F-H5: `isOtsConfirmed` substring heuristic (subsumed by F-C2)
|
||||
- F-M1: canonical structured attestation (optional improvement)
|
||||
- F-M2: block_height verification
|
||||
- F-M3: xpub hygiene documentation (may be partially addressed in this pass)
|
||||
- F-M5: Falcon draft-status UI warning
|
||||
- F-L1–F-L7: low-severity items
|
||||
|
||||
Note: F-C1's *code* side (the missing successor signature) is intentionally NOT being fixed — the user decided the current security model is acceptable. The docs are being changed to match. F-C4 is fully addressed by this plan.
|
||||
|
||||
---
|
||||
|
||||
## Mermaid diagram guidance
|
||||
|
||||
To avoid Mermaid parsing errors (per architect-mode instructions), when updating diagrams:
|
||||
- Do not use double quotes `""` inside square brackets `[]`.
|
||||
- Do not use parentheses `()` inside square brackets `[]`.
|
||||
- Use single quotes or no quotes inside node labels.
|
||||
- Example: `EVENT[Kind 1 Announcement - text content plus algorithm tags]` is safe; `EVENT["Kind 1 (announcement)"]` is not.
|
||||
Submodule
+1
Submodule resources/javascript-opentimestamps added at c07ba8be0d
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Test suite for pq-crypto.mjs
|
||||
*
|
||||
* Run with: node --test test/
|
||||
*
|
||||
* Covers:
|
||||
* - BIP39 seed phrase generation and validation
|
||||
* - BIP32 → PQ seed derivation (determinism)
|
||||
* - PQ keygen determinism (same seed → same pubkeys)
|
||||
* - PQ sign → verify round-trips (all 4 signature schemes)
|
||||
* - PQ verify rejection (tampered message, tampered sig, wrong pubkey)
|
||||
* - NIP-01 event id computation
|
||||
* - verifyNostrEvent id check (F-H2)
|
||||
* - hexToBytes input validation (F-L3)
|
||||
* - OTS parser (parseOtsFile) against vendored example proofs
|
||||
* - OTS verifier (verifyOtsProof) F-C3 target-digest binding
|
||||
*/
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const m = await import('../www/js/pq-crypto.mjs');
|
||||
|
||||
// ============================================================================
|
||||
// BIP39 SEED PHRASE
|
||||
// ============================================================================
|
||||
|
||||
describe('BIP39 seed phrase', () => {
|
||||
test('generateSeedPhrase produces a valid 12-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');
|
||||
});
|
||||
|
||||
test('generateSeedPhraseWithEntropy produces a valid 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);
|
||||
});
|
||||
|
||||
test('generateSeedPhraseWithEntropy produces different mnemonics for different CSPRNG draws', () => {
|
||||
// Note: generateSeedPhraseWithEntropy mixes CSPRNG + user entropy, so it is
|
||||
// NOT deterministic (the CSPRNG part changes each call). This is by design.
|
||||
// We test that it produces valid mnemonics, not determinism.
|
||||
const userEntropy = new TextEncoder().encode('test entropy');
|
||||
const m1 = m.generateSeedPhraseWithEntropy(userEntropy);
|
||||
const m2 = m.generateSeedPhraseWithEntropy(userEntropy);
|
||||
assert.ok(m.isValidMnemonic(m1), 'first mnemonic should be valid');
|
||||
assert.ok(m.isValidMnemonic(m2), 'second mnemonic should be valid');
|
||||
// They should differ (CSPRNG component differs)
|
||||
assert.notEqual(m1, m2, 'different CSPRNG draws should produce different mnemonics');
|
||||
});
|
||||
|
||||
test('mnemonicToSeed rejects invalid mnemonic', () => {
|
||||
assert.throws(() => m.mnemonicToSeed('invalid mnemonic phrase here'), /Invalid mnemonic/);
|
||||
});
|
||||
|
||||
test('mnemonicToSeed produces 64-byte seed', () => {
|
||||
const mnemonic = m.generateSeedPhrase();
|
||||
const seed = m.mnemonicToSeed(mnemonic);
|
||||
assert.equal(seed.length, 64, 'BIP39 seed should be 64 bytes');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BIP32 → PQ SEED DERIVATION (DETERMINISM)
|
||||
// ============================================================================
|
||||
|
||||
// Use the well-known BIP39 test vector mnemonic (all "abandon" words)
|
||||
const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
describe('BIP32 → PQ seed derivation', () => {
|
||||
let seed;
|
||||
|
||||
before(() => {
|
||||
seed = m.mnemonicToSeed(TEST_MNEMONIC);
|
||||
});
|
||||
|
||||
test('deriveSecp256k1FromSeed produces a keypair', () => {
|
||||
const kp = m.deriveSecp256k1FromSeed(seed);
|
||||
assert.ok(kp.privateKey, 'should have privateKey');
|
||||
assert.ok(kp.publicKey, 'should have publicKey');
|
||||
assert.equal(kp.privateKey.length, 32, 'privateKey should be 32 bytes');
|
||||
});
|
||||
|
||||
test('derivePQKeysFromSeed is deterministic (same seed → same pubkeys)', () => {
|
||||
const keys1 = m.derivePQKeysFromSeed(seed);
|
||||
const keys2 = m.derivePQKeysFromSeed(seed);
|
||||
for (const algo of ['mlDsa44', 'mlDsa65', 'slhDsa', 'falcon512', 'mlKem']) {
|
||||
assert.deepEqual(keys1[algo].publicKey, keys2[algo].publicKey, `${algo} pubkey should be deterministic`);
|
||||
}
|
||||
});
|
||||
|
||||
test('different seeds produce different PQ keys', () => {
|
||||
// Use a 24-word test vector (different from the 12-word TEST_MNEMONIC)
|
||||
const seed2 = m.mnemonicToSeed('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art');
|
||||
const keys1 = m.derivePQKeysFromSeed(seed);
|
||||
const keys2 = m.derivePQKeysFromSeed(seed2);
|
||||
assert.notDeepEqual(keys1.mlDsa65.publicKey, keys2.mlDsa65.publicKey, 'different seeds should produce different keys');
|
||||
});
|
||||
|
||||
test('all 5 PQ keypairs are produced', () => {
|
||||
const keys = m.derivePQKeysFromSeed(seed);
|
||||
for (const algo of ['mlDsa44', 'mlDsa65', 'slhDsa', 'falcon512', 'mlKem']) {
|
||||
assert.ok(keys[algo], `${algo} should be present`);
|
||||
assert.ok(keys[algo].publicKey, `${algo} should have publicKey`);
|
||||
assert.ok(keys[algo].secretKey, `${algo} should have secretKey`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PQ SIGN → VERIFY ROUND-TRIPS
|
||||
// ============================================================================
|
||||
|
||||
describe('PQ sign → verify round-trips', () => {
|
||||
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
|
||||
const keys = m.derivePQKeysFromSeed(seed);
|
||||
const message = new TextEncoder().encode('Hello, post-quantum Nostr!');
|
||||
|
||||
const schemes = [
|
||||
{ name: 'ML-DSA-44', sign: m.signWithMLDSA44, verify: m.verifyMLDSA44, key: 'mlDsa44' },
|
||||
{ name: 'ML-DSA-65', sign: m.signWithMLDSA65, verify: m.verifyMLDSA65, key: 'mlDsa65' },
|
||||
{ name: 'SLH-DSA-128s', sign: m.signWithSLHDSA, verify: m.verifySLHDSA, key: 'slhDsa' },
|
||||
{ name: 'Falcon-512', sign: m.signWithFalcon, verify: m.verifyFalcon, key: 'falcon512' },
|
||||
];
|
||||
|
||||
for (const scheme of schemes) {
|
||||
describe(scheme.name, () => {
|
||||
test('valid signature verifies', () => {
|
||||
const sig = scheme.sign(message, keys[scheme.key].secretKey);
|
||||
assert.ok(sig && sig.length > 0, 'signature should be non-empty');
|
||||
assert.ok(scheme.verify(sig, message, keys[scheme.key].publicKey), 'valid signature should verify');
|
||||
});
|
||||
|
||||
test('tampered message fails verification', () => {
|
||||
const sig = scheme.sign(message, keys[scheme.key].secretKey);
|
||||
const tamperedMsg = new TextEncoder().encode('Tampered message!');
|
||||
assert.equal(scheme.verify(sig, tamperedMsg, keys[scheme.key].publicKey), false, 'tampered message should fail');
|
||||
});
|
||||
|
||||
test('tampered signature fails verification', () => {
|
||||
const sig = scheme.sign(message, keys[scheme.key].secretKey);
|
||||
const tamperedSig = new Uint8Array(sig);
|
||||
tamperedSig[0] ^= 0xff; // flip first byte
|
||||
assert.equal(scheme.verify(tamperedSig, message, keys[scheme.key].publicKey), false, 'tampered signature should fail');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// NIP-01 EVENT ID AND verifyNostrEvent (F-H2)
|
||||
// ============================================================================
|
||||
|
||||
describe('NIP-01 event id and verifyNostrEvent', () => {
|
||||
test('computeEventId produces a 32-byte hex id', () => {
|
||||
const event = {
|
||||
pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
|
||||
created_at: 1700000000,
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'test'
|
||||
};
|
||||
const id = m.computeEventId(event);
|
||||
assert.equal(id.length, 64, 'event id should be 64 hex chars (32 bytes)');
|
||||
assert.match(id, /^[0-9a-f]{64}$/, 'should be valid hex');
|
||||
});
|
||||
|
||||
test('verifyNostrEvent rejects wrong id (F-H2)', () => {
|
||||
// Build a minimal event, sign it with a real key, then tamper the id
|
||||
const mnemonic = m.generateSeedPhrase();
|
||||
const seed = m.mnemonicToSeed(mnemonic);
|
||||
const kp = m.deriveSecp256k1FromSeed(seed);
|
||||
const pubkeyHex = m.bytesToHex(kp.publicKey);
|
||||
const event = {
|
||||
pubkey: pubkeyHex,
|
||||
created_at: 1700000000,
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: 'test event for id check'
|
||||
};
|
||||
const id = m.computeEventId(event);
|
||||
// We can't sign without a Schnorr signer here, but we can test that
|
||||
// verifyNostrEvent returns false for a wrong id even without a valid sig
|
||||
// (it should fail on the id check before reaching sig verification)
|
||||
const wrongIdEvent = { ...event, id: '0'.repeat(64), sig: '0'.repeat(128) };
|
||||
assert.equal(m.verifyNostrEvent(wrongIdEvent), false, 'wrong id should fail verification');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// hexToBytes INPUT VALIDATION (F-L3)
|
||||
// ============================================================================
|
||||
|
||||
describe('hexToBytes input validation (F-L3)', () => {
|
||||
test('valid hex converts correctly', () => {
|
||||
const bytes = m.hexToBytes('deadbeef');
|
||||
assert.deepEqual(Array.from(bytes), [0xde, 0xad, 0xbe, 0xef]);
|
||||
});
|
||||
|
||||
test('empty string throws', () => {
|
||||
assert.throws(() => m.hexToBytes(''), /non-empty/);
|
||||
});
|
||||
|
||||
test('odd-length hex throws', () => {
|
||||
assert.throws(() => m.hexToBytes('abc'), /odd-length/);
|
||||
});
|
||||
|
||||
test('invalid hex characters throw', () => {
|
||||
assert.throws(() => m.hexToBytes('xy12'), /invalid hex/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// OTS PARSER (parseOtsFile)
|
||||
// ============================================================================
|
||||
|
||||
describe('OTS parser (parseOtsFile)', () => {
|
||||
const examplesDir = join(__dirname, '..', 'resources', 'javascript-opentimestamps', 'examples');
|
||||
|
||||
test('isDetachedOtsFile true on valid .ots file', () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
|
||||
assert.equal(m.isDetachedOtsFile(ots), true);
|
||||
});
|
||||
|
||||
test('isDetachedOtsFile false on random bytes', () => {
|
||||
const random = new Uint8Array(100);
|
||||
assert.equal(m.isDetachedOtsFile(random), false);
|
||||
});
|
||||
|
||||
test('parseOtsFile on hello-world.txt.ots → Bitcoin attestation at height 358391', () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
|
||||
const parsed = m.parseOtsFile(ots);
|
||||
assert.ok(parsed, 'should parse successfully');
|
||||
assert.equal(parsed.fileHashOp, 'sha256');
|
||||
assert.equal(parsed.targetDigest.length, 32, 'SHA-256 target digest should be 32 bytes');
|
||||
const bitcoinAtts = parsed.attestations.filter(a => a.type === 'bitcoin');
|
||||
assert.equal(bitcoinAtts.length, 1, 'should have 1 Bitcoin attestation');
|
||||
assert.equal(bitcoinAtts[0].height, 358391, 'should be block 358391');
|
||||
});
|
||||
|
||||
test('parseOtsFile on incomplete.txt.ots → pending attestation', () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'incomplete.txt.ots')));
|
||||
const parsed = m.parseOtsFile(ots);
|
||||
assert.ok(parsed);
|
||||
const pendingAtts = parsed.attestations.filter(a => a.type === 'pending');
|
||||
assert.ok(pendingAtts.length > 0, 'should have at least 1 pending attestation');
|
||||
assert.ok(pendingAtts[0].uri, 'pending attestation should have a URI');
|
||||
});
|
||||
|
||||
test('parseOtsFile on two-calendars.txt.ots → 2 pending attestations', () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'two-calendars.txt.ots')));
|
||||
const parsed = m.parseOtsFile(ots);
|
||||
assert.ok(parsed);
|
||||
const pendingAtts = parsed.attestations.filter(a => a.type === 'pending');
|
||||
assert.equal(pendingAtts.length, 2, 'should have 2 pending attestations');
|
||||
});
|
||||
|
||||
test('isOtsConfirmed true on hello-world (has Bitcoin attestation)', () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
|
||||
assert.equal(m.isOtsConfirmed(ots), true);
|
||||
});
|
||||
|
||||
test('isOtsConfirmed false on incomplete (pending only)', () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'incomplete.txt.ots')));
|
||||
assert.equal(m.isOtsConfirmed(ots), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// OTS VERIFIER (verifyOtsProof) — F-C3 TARGET-DIGEST BINDING
|
||||
// ============================================================================
|
||||
|
||||
describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
|
||||
const examplesDir = join(__dirname, '..', 'resources', 'javascript-opentimestamps', 'examples');
|
||||
|
||||
test('hello-world.txt.ots verifies against real Bitcoin block', async () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
|
||||
const result = await m.verifyOtsProof(ots);
|
||||
assert.equal(result.verified, true, 'should verify against Bitcoin block 358391');
|
||||
assert.equal(result.bitcoinAttestations.length, 1);
|
||||
assert.equal(result.bitcoinAttestations[0].height, 358391);
|
||||
});
|
||||
|
||||
test('F-C3: wrong expected digest fails with "Target digest mismatch"', async () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
|
||||
const wrongDigest = '0'.repeat(64);
|
||||
const result = await m.verifyOtsProof(ots, wrongDigest);
|
||||
assert.equal(result.verified, false);
|
||||
assert.ok(result.errors.some(e => e.includes('Target digest mismatch')), 'should report digest mismatch');
|
||||
});
|
||||
|
||||
test('F-C3: correct expected digest passes', async () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'hello-world.txt.ots')));
|
||||
const parsed = m.parseOtsFile(ots);
|
||||
const correctDigest = m.bytesToHex(parsed.targetDigest);
|
||||
const result = await m.verifyOtsProof(ots, correctDigest);
|
||||
assert.equal(result.verified, true);
|
||||
});
|
||||
|
||||
test('pending proof (no Bitcoin attestation) does not verify', async () => {
|
||||
const ots = new Uint8Array(readFileSync(join(examplesDir, 'incomplete.txt.ots')));
|
||||
const result = await m.verifyOtsProof(ots);
|
||||
assert.equal(result.verified, false);
|
||||
assert.ok(result.errors.some(e => e.includes('no Bitcoin attestations') || e.includes('pending')), 'should report no Bitcoin attestations');
|
||||
});
|
||||
});
|
||||
+78
-45
@@ -55,13 +55,13 @@ A migration strategy that brings post-quantum security to Nostr:
|
||||
|
||||
### The six components
|
||||
|
||||
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 HKDF with algorithm-specific labels. Same seed always produces the same PQ keys. The seed is quantum-safe (symmetric KDF, Grover's quadratic speedup is negligible).
|
||||
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** — A new Nostr event (NIP-QR) that links a user's secp256k1 identity to multiple PQ keys simultaneously: ML-DSA-65 (lattice-based signatures), SLH-DSA-128s (hash-based signatures), and ML-KEM-768 (lattice-based KEM for encryption). Each PQ scheme independently signs the link statement. 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 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.
|
||||
|
||||
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).
|
||||
|
||||
4. **Migrating existing users with raw nsec** — Users who logged in with a raw nsec (no seed phrase) generate a new seed phrase. A two-account migration event links their old identity (Account #1, raw nsec) to a seed-derived successor (Account #2) and its PQ keys. The old identity signs to authorize the migration; the new identity signs to endorse the PQ keys.
|
||||
4. **Migrating existing users with raw nsec (design only — not yet implemented)** — Users who logged in with a raw nsec (no seed phrase) generate a new seed phrase. A two-account migration event links their old identity (Account #1, raw nsec) to a seed-derived successor (Account #2) and its PQ keys. The old identity signs to authorize the migration. (The raw-nsec migration is a design proposal; the current implementation supports users who already have a Nostr identity.)
|
||||
|
||||
5. **Quantum-safe self-storage** — Sensitive data stored on relays (kind 30078) can be encrypted with either a one-time pad (information-theoretic security, stronger than any computational assumption) or a symmetric key derived from the seed (128-bit PQ security via Grover's). This separates data confidentiality from identity authentication — breaking secp256k1 compromises identity but not stored data.
|
||||
|
||||
@@ -81,66 +81,91 @@ A migration strategy that brings post-quantum security to Nostr:
|
||||
### The cryptographic chain of trust
|
||||
|
||||
```
|
||||
Account #1 (old identity, social graph knows this npub)
|
||||
Account #1 (existing identity, social graph knows this npub)
|
||||
|
|
||||
| signs: "I am migrating to Account #2 and its PQ keys"
|
||||
| (secp256k1 Schnorr signature, valid pre-quantum)
|
||||
| signs: "I authorize this migration to these PQ keys"
|
||||
| (secp256k1 Schnorr signature on the kind 1 and kind 11112 events,
|
||||
| valid pre-quantum)
|
||||
v
|
||||
Account #2 (seed-derived secp256k1 key)
|
||||
Kind 1 announcement event (published under Account #1's pubkey)
|
||||
|
|
||||
| same seed phrase ---------> PQ keys (ML-DSA, SLH-DSA, ML-KEM)
|
||||
| |
|
||||
| signs: "These PQ keys come | sign: "We are derived from
|
||||
| from my seed" | the same seed as Account #2"
|
||||
| (secp256k1 Schnorr) | (PQ signatures)
|
||||
v v
|
||||
NIP-QR event (published under Account #1's pubkey)
|
||||
| PQ keys sign the attestation text:
|
||||
| ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512
|
||||
| (each PQ signature scheme signs the human-readable content)
|
||||
| ML-KEM-768 pubkey included in tags (KEM, no signature)
|
||||
|
|
||||
| OpenTimestamps anchor to Bitcoin block
|
||||
| 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)
|
||||
|
|
||||
v
|
||||
Published to Nostr relays
|
||||
```
|
||||
|
||||
### The NIP-QR event
|
||||
**Note:** The seed-derived secp256k1 key (Account #2, at `m/44'/1237'/0'/0/0`) is derived but **not used to sign** in the current implementation. 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.
|
||||
|
||||
A kind 30078 event (NIP-78 application-specific data) containing:
|
||||
### The NIP-QR events
|
||||
|
||||
The migration uses **two Nostr events**:
|
||||
|
||||
#### Kind 1 announcement (human-readable, visible in Nostr feeds)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"kind": 1,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey>",
|
||||
"content": "I am signaling that the post-quantum public keys listed in the tags of this event were generated by me...",
|
||||
"tags": [
|
||||
["d", "nip-qr-migration"],
|
||||
["successor", "<Account #2 secp256k1 pubkey>"],
|
||||
["algorithm", "ml-dsa-65"],
|
||||
["algorithm", "slh-dsa-128s"],
|
||||
["algorithm", "ml-kem-768"]
|
||||
["block_height", "<height>"],
|
||||
["algorithm", "ml-dsa-44", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "ml-dsa-65", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "slh-dsa-128s", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "falcon-512", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "ml-kem-768", "<base64 pubkey>"]
|
||||
],
|
||||
"content": "<JSON with link statement, PQ public keys, PQ signatures, successor signature>",
|
||||
"sig": "<Account #1 Schnorr signature>"
|
||||
}
|
||||
```
|
||||
|
||||
The content JSON contains:
|
||||
- A link statement binding all keys together
|
||||
- Each PQ public key (base64-encoded)
|
||||
- Each PQ signature over the statement (ML-DSA and SLH-DSA can sign; ML-KEM cannot — it's a KEM, not a signature scheme)
|
||||
- The successor's secp256k1 signature over the statement (proves the seed-derived key endorses the PQ keys)
|
||||
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)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 11112,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey>",
|
||||
"content": "<JSON string of the full signed kind 1 event>",
|
||||
"tags": [
|
||||
["e", "<kind 1 event id>"],
|
||||
["sha256", "<hex SHA-256 of full signed kind 1 event JSON>"],
|
||||
["ots", "<base64 .ots proof>"]
|
||||
],
|
||||
"sig": "<Account #1 Schnorr signature>"
|
||||
}
|
||||
```
|
||||
|
||||
The wrapper embeds the full kind 1 event as JSON content (so verifiers don't need to fetch it from relays) and carries the OpenTimestamps proof. The `sha256` tag is the hash that was submitted to OpenTimestamps.
|
||||
|
||||
### PQ key derivation from seed
|
||||
|
||||
Post-quantum keys are deterministically derived from the BIP39 seed using HKDF:
|
||||
Post-quantum keys are deterministically derived from the BIP39 seed using **BIP32 hierarchical deterministic derivation** (the same standard used by NIP-06 for secp256k1 keys). Each PQ algorithm gets its own child index under the NIP-06 base path `m/44'/1237'/0'/0/`:
|
||||
|
||||
```
|
||||
seed = PBKDF2-HMAC-SHA512(mnemonic, "mnemonic", 2048) // standard BIP39
|
||||
seed = PBKDF2-HMAC-SHA512(mnemonic, "mnemonic", 2048) // standard BIP39, 64 bytes
|
||||
master = BIP32 master key from seed // HMAC-SHA512(seed, "Bitcoin seed")
|
||||
|
||||
ml_dsa_seed = HKDF-SHA512(seed, "nostr-pq-ml-dsa-65", 32) -> ML-DSA-65 keypair
|
||||
slh_dsa_seed = HKDF-SHA512(seed, "nostr-pq-slh-dsa-128s", 48) -> SLH-DSA-128s keypair
|
||||
ml_kem_seed = HKDF-SHA512(seed, "nostr-pq-ml-kem-768", 64) -> ML-KEM-768 keypair
|
||||
// All under m/44'/1237'/0'/0/:
|
||||
child 0 → secp256k1 keypair (NIP-06, derived but not used to sign)
|
||||
child 1 → 32-byte seed → ML-DSA-44 keypair
|
||||
child 2 → 32-byte seed → ML-DSA-65 keypair
|
||||
child 3+4 → 64 bytes concatenated, first 48 used → SLH-DSA-128s keypair
|
||||
child 5+6 → 64 bytes concatenated, first 48 used → Falcon-512 keypair
|
||||
child 7+8 → 64 bytes concatenated → ML-KEM-768 keypair
|
||||
```
|
||||
|
||||
Same seed phrase always produces the same PQ keys. Users can recover their PQ keys by entering their seed phrase in any implementation of this scheme.
|
||||
The BIP32 child private key (32 bytes) is used as the deterministic seed for each PQ algorithm's `keygen()` function. For algorithms needing more than 32 bytes, two children are concatenated (64 bytes) and truncated to the required length (first N bytes). Same seed phrase always produces the same PQ keys. Users can recover their PQ keys by entering their seed phrase in any implementation of this scheme.
|
||||
|
||||
### The web app
|
||||
|
||||
@@ -148,10 +173,12 @@ 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-65, SLH-DSA-128s, and ML-KEM-768 keypairs from the seed
|
||||
4. **Signs the NIP-QR event** — PQ signatures are created in the browser; the secp256k1 signature is requested from the user's signer (via `window.nostr.signEvent()`)
|
||||
5. **Publishes to relays** — sends the fully signed event to Nostr relays via direct WebSocket
|
||||
6. **Shows the result** — displays the complete event JSON and event ID
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -159,8 +186,10 @@ All cryptographic operations happen either in the browser (JavaScript/WASM) or i
|
||||
|
||||
| Algorithm | FIPS | Type | Key size | Signature size | Why we use it |
|
||||
|---|---|---|---|---|---|
|
||||
| ML-DSA-65 | 204 | Signature | 1952 bytes | 3309 bytes | Primary PQ signature (lattice-based, NIST-standardized) |
|
||||
| SLH-DSA-128s | 205 | Signature | 32 bytes | 7856 bytes | Conservative fallback (hash-based, very conservative assumptions) |
|
||||
| ML-DSA-44 | 204 | Signature | 1312 bytes | 2420 bytes | PQ signature (lattice-based, Cat 2) — compatibility |
|
||||
| ML-DSA-65 | 204 | Signature | 1952 bytes | 3309 bytes | Primary PQ signature (lattice-based, NIST-standardized, Cat 3) |
|
||||
| SLH-DSA-128s | 205 | Signature | 32 bytes | 7856 bytes | Conservative fallback (hash-based, very conservative assumptions, Cat 1) |
|
||||
| Falcon-512 | 206 (draft) | Signature | 897 bytes | ~666 bytes | Compact PQ signatures (lattice-based, Cat 1) |
|
||||
| ML-KEM-768 | 203 | KEM | 1184 bytes | 1088 bytes (ciphertext) | PQ encryption key agreement (lattice-based, used by Signal, Apple) |
|
||||
|
||||
We use multiple schemes simultaneously because:
|
||||
@@ -178,16 +207,20 @@ We use multiple schemes simultaneously because:
|
||||
3. Clients verify: "the NIP-QR event with the earliest valid OTS proof wins"
|
||||
4. The real NIP-QR event (anchored pre-quantum) is cryptographically distinguishable from any fraudulent event
|
||||
5. The user's identity is preserved through the OTS-anchored migration event
|
||||
6. The user continues signing with Account #2 and PQ keys
|
||||
6. The user continues signing with PQ keys
|
||||
|
||||
**Note:** The current implementation does not include a seed-derived secp256k1 "successor" signature. The PQ keys self-attest by signing the attestation text, and Account #1 authorizes the migration. The mechanism that distinguishes a real migration event from a forged one after a quantum break is **OTS precedence** — the real event is anchored to a pre-quantum Bitcoin block, and a forged event cannot produce an OTS proof for that block or earlier.
|
||||
|
||||
### 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 NIP-QR event, and publish to relays. 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 11112 events, publish to relays, and timestamp via OpenTimestamps. It uses:
|
||||
|
||||
- `@noble/post-quantum` for PQ cryptography (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
|
||||
- `@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
|
||||
- `@scure/bip32` for NIP-06 key derivation
|
||||
- `@scure/bip32` for BIP32 HD key derivation (NIP-06 base path)
|
||||
- `@noble/curves` for secp256k1 Schnorr verification
|
||||
- `nostr-login-lite` for authentication
|
||||
- Direct WebSocket for relay publishing
|
||||
- OpenTimestamps calendar servers for Bitcoin timestamping
|
||||
|
||||
The source code is in [`www/index.html`](www/index.html) and [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs). To deploy updates, run `./upload.sh`.
|
||||
|
||||
+423
-106
@@ -4,6 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none';" />
|
||||
<title>Post-Quantum Nostr</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
@@ -107,7 +108,7 @@
|
||||
|
||||
.pq-info-text {
|
||||
font-size: 14px;
|
||||
color: var(--muted-color);
|
||||
color: var(--primary-color);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
@@ -174,6 +175,16 @@
|
||||
}
|
||||
.pq-checkbox-row input { width: 18px; height: 18px; cursor: pointer; }
|
||||
|
||||
.pq-seed-toggle {
|
||||
flex: 1;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.pq-seed-toggle-active {
|
||||
opacity: 1;
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.pq-progress-bar {
|
||||
background: var(--muted-color); border-radius: 4px; height: 8px; margin: 15px 0; overflow: hidden;
|
||||
}
|
||||
@@ -188,8 +199,8 @@
|
||||
.pq-key-icon { font-size: 20px; flex-shrink: 0; }
|
||||
.pq-key-details { flex: 1; min-width: 0; }
|
||||
.pq-key-name { font-size: 14px; font-weight: bold; margin-bottom: 2px; }
|
||||
.pq-key-meta { font-size: 12px; color: var(--muted-color); margin-bottom: 4px; }
|
||||
.pq-key-pubkey { font-size: 11px; color: var(--muted-color); word-break: break-all; }
|
||||
.pq-key-meta { font-size: 12px; color: var(--primary-color); margin-bottom: 4px; }
|
||||
.pq-key-pubkey { font-size: 11px; color: var(--primary-color); word-break: break-all; }
|
||||
|
||||
.pq-status {
|
||||
padding: 10px 15px; border-radius: var(--border-radius); margin: 10px 0; font-size: 14px; text-align: left;
|
||||
@@ -205,7 +216,7 @@
|
||||
}
|
||||
|
||||
.pq-npub-display {
|
||||
font-size: 12px; color: var(--muted-color); word-break: break-all;
|
||||
font-size: 12px; word-break: break-all;
|
||||
background: var(--primary-color); color: var(--secondary-color);
|
||||
padding: 8px 12px; border-radius: var(--border-radius); margin: 10px 0;
|
||||
}
|
||||
@@ -260,7 +271,7 @@
|
||||
</div>
|
||||
<div class="pq-checklist-item" id="step-5">
|
||||
<span class="pq-checklist-box"></span>
|
||||
<span>5. Publish event to relays</span>
|
||||
<span>5. Publish events to relays</span>
|
||||
</div>
|
||||
<div class="pq-checklist-item" id="step-6">
|
||||
<span class="pq-checklist-box"></span>
|
||||
@@ -325,13 +336,43 @@
|
||||
<div id="pqSeedStep" class="pq-card pq-hidden">
|
||||
<div class="pq-card-title">Your Quantum-Safe Seed Phrase</div>
|
||||
<div class="pq-info-text">
|
||||
We've generated a new 12-word seed phrase. This is your <strong>quantum-safe root of trust</strong>.
|
||||
Write it down on paper. Never store it digitally. Never share it with anyone.
|
||||
Your seed phrase is your <strong>quantum-safe root of trust</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>
|
||||
|
||||
<!-- Toggle: Generate vs Bring your own -->
|
||||
<div class="pq-button-row" style="margin-bottom: 15px;">
|
||||
<button class="pq-button pq-button-secondary pq-seed-toggle pq-seed-toggle-active" id="pqSeedToggleGenerate">Generate New Seed</button>
|
||||
<button class="pq-button pq-button-secondary pq-seed-toggle" id="pqSeedToggleOwn">I Have a Seed Phrase</button>
|
||||
</div>
|
||||
|
||||
<!-- Generate mode -->
|
||||
<div id="pqSeedGeneratePanel">
|
||||
<div class="pq-seed-display" id="pqSeedDisplay"></div>
|
||||
<div class="pq-button-row">
|
||||
<button class="pq-button pq-button-secondary" id="pqCopySeedBtn">Copy</button>
|
||||
<button class="pq-button pq-button-secondary" id="pqRegenerateBtn">Regenerate</button>
|
||||
<button class="pq-button pq-button-secondary" id="pqRegenerateBtn">Generate New Seed</button>
|
||||
</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.
|
||||
This mixes your input with the browser's random number generator, making the seed unpredictable
|
||||
even if the browser's RNG is compromised. You can skip this — a seed has already been generated for you above.
|
||||
</div>
|
||||
<div id="pqEntropyBox" style="background: var(--primary-color); color: var(--secondary-color); border-radius: var(--border-radius); padding: 15px; font-size: 13px; min-height: 60px; margin: 10px 0; cursor: text; text-align: left;" tabindex="0">
|
||||
<span id="pqEntropyHint">Optional: click here and type random characters, move your mouse to add entropy...</span>
|
||||
<div style="margin-top: 8px; height: 6px; background: var(--secondary-color); border-radius: 3px; overflow: hidden;">
|
||||
<div id="pqEntropyBar" style="height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pq-button-row">
|
||||
<button class="pq-button pq-button-secondary pq-hidden" id="pqRegenerateWithEntropyBtn" disabled>Generate with Extra Entropy</button>
|
||||
</div>
|
||||
<div class="pq-warning" style="font-size: 12px; margin: 10px 0;">
|
||||
<strong>Note:</strong> This 12-word seed provides ~64-bit post-quantum security on the seed entropy itself
|
||||
(under Grover's algorithm). For maximum post-quantum security, consider using a 24-word seed phrase
|
||||
(available via "I Have a Seed Phrase" above). The bigger risk to seed phrases is classical (theft, phishing),
|
||||
not quantum.
|
||||
</div>
|
||||
<label class="pq-checkbox-row">
|
||||
<input type="checkbox" id="pqSeedConfirmed" />
|
||||
@@ -340,6 +381,18 @@
|
||||
<button class="pq-button" id="pqSeedContinueBtn" disabled>Continue</button>
|
||||
</div>
|
||||
|
||||
<!-- Bring your own mode -->
|
||||
<div id="pqSeedOwnPanel" style="display: none;">
|
||||
<div class="pq-info-text">
|
||||
Paste your 12 or 24-word BIP39 seed phrase below. It will be validated and used to
|
||||
derive your post-quantum keys. Make sure you generated it offline or with a trusted tool.
|
||||
</div>
|
||||
<textarea class="pq-textarea" id="pqSeedInput" style="min-height: 80px; width: 100%; background: var(--secondary-color); border: var(--border); border-radius: var(--border-radius); padding: 12px; font-size: 14px; color: var(--primary-color); font-family: monospace; margin: 10px 0;" placeholder="abandon ability able about above absent absorb abstract absurd abuse access accident..."></textarea>
|
||||
<div id="pqSeedValidation" style="font-size: 13px; margin: 5px 0;"></div>
|
||||
<button class="pq-button" id="pqSeedOwnContinueBtn" disabled>Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3: DERIVING PQ KEYS -->
|
||||
<div id="pqDeriveStep" class="pq-card pq-hidden">
|
||||
<div class="pq-card-title">Deriving Post-Quantum Keys</div>
|
||||
@@ -381,6 +434,7 @@
|
||||
<div class="pq-key-name">Falcon-512</div>
|
||||
<div class="pq-key-meta">FIPS 206 (draft) · Cat 1 · 897-byte pubkey · m/44'/1237'/0'/0/5+6</div>
|
||||
<div class="pq-key-pubkey" id="pqKeyFalconPub"></div>
|
||||
<div style="font-size: 11px; color: var(--accent-color); margin-top: 4px;">⚠ Draft standard — may need re-issuing if FIPS 206 changes</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pq-key-item" id="pqKeyMlKem">
|
||||
@@ -400,8 +454,10 @@
|
||||
<div id="pqSignStep" class="pq-card pq-hidden">
|
||||
<div class="pq-card-title">Sign Migration Event</div>
|
||||
<div class="pq-info-text">
|
||||
We'll now create the NIP-QR migration event. Each PQ key signs the attestation statement,
|
||||
and your signer signs the Nostr event with your secp256k1 key.
|
||||
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
|
||||
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>
|
||||
<div id="pqSignStatus"></div>
|
||||
<div class="pq-key-list" id="pqSignChecklist">
|
||||
@@ -436,7 +492,14 @@
|
||||
<div class="pq-key-item" id="pqSignSecp">
|
||||
<div class="pq-key-icon"></div>
|
||||
<div class="pq-key-details">
|
||||
<div class="pq-key-name">secp256k1 signature (NIP-01)</div>
|
||||
<div class="pq-key-name">secp256k1 signature: kind 1 announcement</div>
|
||||
<div class="pq-key-meta">Requires approval from your signer</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pq-key-item" id="pqSignSecpWrapper">
|
||||
<div class="pq-key-icon"></div>
|
||||
<div class="pq-key-details">
|
||||
<div class="pq-key-name">secp256k1 signature: kind 11112 wrapper</div>
|
||||
<div class="pq-key-meta">Requires approval from your signer</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,12 +513,19 @@
|
||||
|
||||
<!-- STEP 5: REVIEW & PUBLISH -->
|
||||
<div id="pqPublishStep" class="pq-card pq-hidden">
|
||||
<div class="pq-card-title">Review & Publish</div>
|
||||
<div class="pq-card-title">Review & Publish Events</div>
|
||||
<div class="pq-info-text">
|
||||
Your NIP-QR migration event has been signed. Review the event below, then publish to relays when ready.
|
||||
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
|
||||
both to relays when ready.
|
||||
</div>
|
||||
<div id="pqEventIdDisplay" style="margin: 10px 0; font-size: 11px; color: var(--muted-color); word-break: break-all;"></div>
|
||||
<div class="pq-info-text" style="margin-bottom: 5px;">Full event JSON:</div>
|
||||
|
||||
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Kind 1 announcement event:</strong></div>
|
||||
<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 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 style="margin-top: 15px;">
|
||||
@@ -468,7 +538,7 @@
|
||||
<div id="pqPublishStatus"></div>
|
||||
|
||||
<div class="pq-button-row" style="margin-top: 15px;">
|
||||
<button class="pq-button" id="pqPublishBtn">Publish to Relays</button>
|
||||
<button class="pq-button" id="pqPublishBtn">Publish Both Events to Relays</button>
|
||||
</div>
|
||||
<div class="pq-button-row">
|
||||
<button class="pq-button pq-button-secondary" id="pqDoneBtn">Done</button>
|
||||
@@ -503,8 +573,8 @@
|
||||
<div class="pq-key-item" id="pqOtsPendingPublish">
|
||||
<div class="pq-key-icon"></div>
|
||||
<div class="pq-key-details">
|
||||
<div class="pq-key-name">Publish pending NIP-03 event (kind 1040)</div>
|
||||
<div class="pq-key-meta">Publishes the pending .ots proof immediately</div>
|
||||
<div class="pq-key-name">Pending proof embedded in kind 11112 event</div>
|
||||
<div class="pq-key-meta">The pending .ots proof is embedded as a tag in the initial event</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pq-key-item" id="pqOtsConfirm">
|
||||
@@ -517,8 +587,8 @@
|
||||
<div class="pq-key-item" id="pqOtsConfirmedPublish">
|
||||
<div class="pq-key-icon"></div>
|
||||
<div class="pq-key-details">
|
||||
<div class="pq-key-name">Publish confirmed NIP-03 event (kind 1040)</div>
|
||||
<div class="pq-key-meta">Publishes the upgraded proof with a Bitcoin attestation</div>
|
||||
<div class="pq-key-name">Republish kind 11112 with confirmed proof</div>
|
||||
<div class="pq-key-meta">Republishes the event with the upgraded Bitcoin-attested proof</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,19 +620,27 @@
|
||||
<script type="module">
|
||||
import {
|
||||
generateSeedPhrase,
|
||||
generateSeedPhraseWithEntropy,
|
||||
mnemonicToSeed,
|
||||
isValidMnemonic,
|
||||
deriveSecp256k1FromSeed,
|
||||
derivePQKeysFromSeed,
|
||||
buildNIPQRContent,
|
||||
buildKind1Announcement,
|
||||
buildKind11112Wrapper,
|
||||
computeEventId,
|
||||
hashFullEvent,
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
bytesToHex,
|
||||
PQ_KEY_INFO,
|
||||
NIP_QR_KIND,
|
||||
buildUpgradedEvent,
|
||||
timestampEvent,
|
||||
upgradeOts,
|
||||
isOtsConfirmed,
|
||||
verifyOtsProof,
|
||||
savePendingOts,
|
||||
loadPendingOts,
|
||||
clearPendingOts,
|
||||
@@ -578,7 +656,8 @@
|
||||
let pqSeed = null;
|
||||
let pqKeys = null;
|
||||
let pqSecpKeys = null;
|
||||
let pqEvent = null;
|
||||
let pqEvent = null; // kind 11112 wrapper event
|
||||
let kind1Event = null; // kind 1 announcement event
|
||||
|
||||
/* ================================================================
|
||||
DOM REFERENCES
|
||||
@@ -670,17 +749,25 @@
|
||||
// Offer a persisted OTS workflow only to the same signed-in identity.
|
||||
const pendingOts = loadPendingOts();
|
||||
document.querySelectorAll('.pq-ots-resume-notice').forEach(el => el.remove());
|
||||
if (pendingOts && (!pendingOts.ownerPubkey || pendingOts.ownerPubkey === currentPubkey)) {
|
||||
if (pendingOts && pendingOts.ownerPubkey && pendingOts.ownerPubkey === currentPubkey) {
|
||||
console.log('[ots] Found saved OTS workflow from previous session, event:', pendingOts.eventId);
|
||||
pendingOtsBytes = pendingOts.ots;
|
||||
pqEvent = { id: pendingOts.eventId, kind: pendingOts.targetKind || 30078 };
|
||||
pqEvent = { id: pendingOts.eventId, kind: pendingOts.targetKind || NIP_QR_KIND };
|
||||
if (pendingOts.relayUrls) document.getElementById('pqRelaysInput').value = pendingOts.relayUrls;
|
||||
|
||||
// F-H3: Build the OTS resume notice with safe DOM construction
|
||||
const otsNotice = document.createElement('div');
|
||||
otsNotice.className = 'pq-status pq-status-info pq-ots-resume-notice';
|
||||
otsNotice.style.marginTop = '15px';
|
||||
const stateLabel = pendingOts.confirmedPublished ? 'completed' : (pendingOts.pendingPublished ? 'pending Bitcoin confirmation' : 'not yet published');
|
||||
otsNotice.innerHTML = `You have an OpenTimestamps workflow from a previous session (${stateLabel}; event: ${pendingOts.eventId.substring(0, 16)}...). <a href="#" id="pqResumeOtsLink" style="color: var(--accent-color); cursor: pointer;">View / resume timestamping</a>`;
|
||||
otsNotice.textContent = `You have an OpenTimestamps workflow from a previous session (${stateLabel}; event: ${pendingOts.eventId.substring(0, 16)}...). `;
|
||||
const resumeLink = document.createElement('a');
|
||||
resumeLink.href = '#';
|
||||
resumeLink.id = 'pqResumeOtsLink';
|
||||
resumeLink.style.color = 'var(--accent-color)';
|
||||
resumeLink.style.cursor = 'pointer';
|
||||
resumeLink.textContent = 'View / resume timestamping';
|
||||
otsNotice.appendChild(resumeLink);
|
||||
pqAuthed.appendChild(otsNotice);
|
||||
document.getElementById('pqResumeOtsLink').addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -691,8 +778,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
|
||||
function setStatus(element, type, message) {
|
||||
element.innerHTML = `<div class="pq-status pq-status-${type}">${message}</div>`;
|
||||
const div = document.createElement('div');
|
||||
div.className = `pq-status pq-status-${type}`;
|
||||
div.textContent = message;
|
||||
element.innerHTML = '';
|
||||
element.appendChild(div);
|
||||
}
|
||||
|
||||
function setKeyIcon(elementId, icon) {
|
||||
@@ -818,8 +910,18 @@
|
||||
/* ================================================================
|
||||
STEP 2: SEED PHRASE
|
||||
================================================================ */
|
||||
let userEntropyChunks = [];
|
||||
let entropyCollecting = false;
|
||||
|
||||
function generateAndShowSeed() {
|
||||
// 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.`);
|
||||
} else {
|
||||
pqMnemonic = generateSeedPhrase();
|
||||
}
|
||||
const words = pqMnemonic.split(' ');
|
||||
pqSeedDisplay.innerHTML = words.map((word, i) =>
|
||||
`<div class="pq-seed-word"><span class="pq-seed-number">${i + 1}.</span>${word}</div>`
|
||||
@@ -828,6 +930,74 @@
|
||||
pqSeedContinueBtn.disabled = true;
|
||||
}
|
||||
|
||||
// Entropy collection from mouse and keyboard (optional)
|
||||
function startEntropyCollection() {
|
||||
const entropyBox = document.getElementById('pqEntropyBox');
|
||||
const entropyBar = document.getElementById('pqEntropyBar');
|
||||
const entropyHint = document.getElementById('pqEntropyHint');
|
||||
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
|
||||
const maxChunks = 64; // collect up to 64 events
|
||||
let collected = 0;
|
||||
|
||||
function updateBar() {
|
||||
const pct = Math.min(100, Math.round((collected / maxChunks) * 100));
|
||||
entropyBar.style.width = pct + '%';
|
||||
if (collected >= maxChunks) {
|
||||
entropyHint.textContent = 'Enough entropy collected. Click "Generate with Extra Entropy" to use it.';
|
||||
} else if (collected > 0) {
|
||||
entropyHint.textContent = `Collected ${collected}/${maxChunks} entropy samples. Keep going, or click "Generate with Extra Entropy" to use what you have.`;
|
||||
}
|
||||
// Show the "Generate with Extra Entropy" button once we have at least 1 chunk
|
||||
if (collected > 0 && regenWithEntropyBtn) {
|
||||
regenWithEntropyBtn.classList.remove('pq-hidden');
|
||||
regenWithEntropyBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function collectEvent(data) {
|
||||
if (collected >= maxChunks) return;
|
||||
userEntropyChunks.push(data + ':' + Date.now() + ':' + Math.random());
|
||||
collected++;
|
||||
updateBar();
|
||||
}
|
||||
|
||||
entropyBox.addEventListener('mousemove', (e) => {
|
||||
collectEvent(`m${e.clientX},${e.clientY}`);
|
||||
});
|
||||
entropyBox.addEventListener('keypress', (e) => {
|
||||
collectEvent(`k${e.key}:${e.keyCode}`);
|
||||
});
|
||||
entropyBox.addEventListener('keydown', (e) => {
|
||||
// Also capture non-printable keys
|
||||
if (e.key.length > 1) collectEvent(`k${e.key}:${e.keyCode}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Seed mode toggle
|
||||
function showSeedGeneratePanel() {
|
||||
document.getElementById('pqSeedGeneratePanel').style.display = 'block';
|
||||
document.getElementById('pqSeedOwnPanel').style.display = 'none';
|
||||
document.getElementById('pqSeedToggleGenerate').classList.add('pq-seed-toggle-active');
|
||||
document.getElementById('pqSeedToggleOwn').classList.remove('pq-seed-toggle-active');
|
||||
// Generate a fresh seed when switching to the generate panel
|
||||
userEntropyChunks = [];
|
||||
generateAndShowSeed();
|
||||
// Reset the 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; }
|
||||
}
|
||||
|
||||
function showSeedOwnPanel() {
|
||||
document.getElementById('pqSeedGeneratePanel').style.display = 'none';
|
||||
document.getElementById('pqSeedOwnPanel').style.display = 'block';
|
||||
document.getElementById('pqSeedToggleGenerate').classList.remove('pq-seed-toggle-active');
|
||||
document.getElementById('pqSeedToggleOwn').classList.add('pq-seed-toggle-active');
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
STEP 3: DERIVE PQ KEYS
|
||||
================================================================ */
|
||||
@@ -886,49 +1056,91 @@
|
||||
const blockHeightEl = document.getElementById('pqBlockHeight');
|
||||
if (blockHeightEl && blockHeight > 0) blockHeightEl.textContent = String(blockHeight);
|
||||
|
||||
setStatus(pqSignStatus, 'info', ' Building NIP-QR event...');
|
||||
const { content: eventContent, tags: pqTags } = buildNIPQRContent(currentPubkey, blockHeight, pqKeys);
|
||||
if (!window.nostr || !window.nostr.signEvent) {
|
||||
throw new Error('Nostr signer not available (window.nostr.signEvent missing)');
|
||||
}
|
||||
|
||||
// ---- Phase 1: Build and sign the kind 1 announcement event ----
|
||||
setStatus(pqSignStatus, 'info', ' Building kind 1 announcement event...');
|
||||
const kind1Template = buildKind1Announcement(currentPubkey, blockHeight, pqKeys);
|
||||
|
||||
setKeyIcon('pqSignPqMlDsa44', 'Done');
|
||||
setKeyIcon('pqSignPqMlDsa65', 'Done');
|
||||
setKeyIcon('pqSignPqSlhDsa', 'Done');
|
||||
setKeyIcon('pqSignPqFalcon', 'Done');
|
||||
setStatus(pqSignStatus, 'info', 'PQ signatures complete. Requesting secp256k1 signature from your signer...');
|
||||
setStatus(pqSignStatus, 'info', 'PQ signatures complete. Requesting secp256k1 signature for kind 1 announcement...');
|
||||
|
||||
const eventTemplate = {
|
||||
kind: 30078,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: pqTags,
|
||||
content: eventContent,
|
||||
pubkey: currentPubkey
|
||||
const kind1Signed = await window.nostr.signEvent(kind1Template);
|
||||
// Ensure the signed event has all fields
|
||||
kind1Event = {
|
||||
id: kind1Signed.id,
|
||||
pubkey: kind1Signed.pubkey,
|
||||
created_at: kind1Template.created_at,
|
||||
kind: 1,
|
||||
content: kind1Template.content,
|
||||
tags: kind1Template.tags,
|
||||
sig: kind1Signed.sig
|
||||
};
|
||||
otsLog(`Kind 1 announcement signed. Event ID: ${kind1Event.id.substring(0, 32)}...`);
|
||||
|
||||
if (!window.nostr || !window.nostr.signEvent) {
|
||||
throw new Error('Nostr signer not available (window.nostr.signEvent missing)');
|
||||
// ---- Phase 2: Timestamp the full kind 1 event hash ----
|
||||
const fullHash = hashFullEvent(kind1Event);
|
||||
otsLog(`SHA-256 of full kind 1 event: ${fullHash.substring(0, 32)}...`);
|
||||
|
||||
let otsProof = null;
|
||||
try {
|
||||
setStatus(pqSignStatus, 'info', ' Submitting kind 1 event hash to OpenTimestamps...');
|
||||
otsProof = await timestampEvent(fullHash);
|
||||
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.`);
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(eventTemplate);
|
||||
// ---- Phase 3: Build and sign the kind 11112 wrapper event ----
|
||||
setStatus(pqSignStatus, 'info', ' Requesting secp256k1 signature for kind 11112 wrapper...');
|
||||
|
||||
let kind11112Template;
|
||||
if (otsProof && otsProof.length > 0) {
|
||||
kind11112Template = buildKind11112Wrapper(kind1Event, otsProof);
|
||||
} else {
|
||||
// No OTS proof — build wrapper without ots tag (will be added later)
|
||||
kind11112Template = buildKind11112Wrapper(kind1Event, new Uint8Array(0));
|
||||
// Remove the empty ots tag
|
||||
kind11112Template.tags = kind11112Template.tags.filter(t => t[0] !== 'ots');
|
||||
}
|
||||
|
||||
const kind11112Signed = await window.nostr.signEvent(kind11112Template);
|
||||
setKeyIcon('pqSignSecp', 'Done');
|
||||
setKeyIcon('pqSignSecpWrapper', 'Done');
|
||||
|
||||
pqEvent = {
|
||||
id: signedEvent.id,
|
||||
pubkey: signedEvent.pubkey,
|
||||
created_at: eventTemplate.created_at,
|
||||
kind: eventTemplate.kind,
|
||||
content: eventTemplate.content,
|
||||
tags: eventTemplate.tags,
|
||||
sig: signedEvent.sig
|
||||
id: kind11112Signed.id,
|
||||
pubkey: kind11112Signed.pubkey,
|
||||
created_at: kind11112Template.created_at,
|
||||
kind: NIP_QR_KIND,
|
||||
content: kind11112Template.content,
|
||||
tags: kind11112Template.tags,
|
||||
sig: kind11112Signed.sig
|
||||
};
|
||||
otsLog(`Kind 11112 wrapper signed. Event ID: ${pqEvent.id.substring(0, 32)}...`);
|
||||
|
||||
// Save the pending OTS proof for the OTS step
|
||||
if (otsProof && otsProof.length > 0) {
|
||||
pendingOtsBytes = otsProof;
|
||||
}
|
||||
|
||||
const eventJsonStr = JSON.stringify(pqEvent, null, 2);
|
||||
pqEventPreview.textContent = eventJsonStr;
|
||||
pqEventPreviewWrap.classList.remove('pq-hidden');
|
||||
|
||||
setStatus(pqSignStatus, 'success', 'Event signed successfully!');
|
||||
setStatus(pqSignStatus, 'success', 'Both events signed successfully!');
|
||||
setStepDone(4);
|
||||
setStepActive(5);
|
||||
|
||||
// Show publish step
|
||||
document.getElementById('pqKind1IdDisplay').textContent = `Event ID: ${kind1Event.id}`;
|
||||
document.getElementById('pqKind1Preview').textContent = JSON.stringify(kind1Event, null, 2);
|
||||
pqEventIdDisplay.textContent = `Event ID: ${pqEvent.id}`;
|
||||
document.getElementById('pqSuccessEventPreview').textContent = eventJsonStr;
|
||||
setTimeout(() => showView('pqPublishStep'), 1000);
|
||||
@@ -965,7 +1177,7 @@
|
||||
} catch (e) {}
|
||||
};
|
||||
ws.onerror = () => { if (!resolved) { resolved = true; clearTimeout(timeout); resolve({ relay: relayUrl, success: false, error: 'connection error' }); } };
|
||||
ws.onclose = () => { if (!resolved) { resolved = true; clearTimeout(timeout); resolve({ relay: relayUrl, success: true }); } };
|
||||
ws.onclose = () => { if (!resolved) { resolved = true; clearTimeout(timeout); resolve({ relay: relayUrl, success: false, error: 'closed without OK' }); } };
|
||||
} catch (e) { resolve({ relay: relayUrl, success: false, error: e.message }); }
|
||||
});
|
||||
}));
|
||||
@@ -977,13 +1189,47 @@
|
||||
document.getElementById('pqSignInBtn').addEventListener('click', async () => {
|
||||
pqSignInError.innerHTML = '';
|
||||
try { await signIn(); }
|
||||
catch (error) { console.error('[post-quantum] Sign-in failed:', error); pqSignInError.innerHTML = `<div class="pq-status pq-status-error">${error.message}</div>`; }
|
||||
catch (error) { console.error('[post-quantum] Sign-in failed:', error); setStatus(pqSignInError, 'error', error.message); }
|
||||
});
|
||||
|
||||
document.getElementById('pqStartBtn').addEventListener('click', () => {
|
||||
userEntropyChunks = [];
|
||||
generateAndShowSeed();
|
||||
showView('pqSeedStep');
|
||||
setStepActive(2);
|
||||
startEntropyCollection();
|
||||
});
|
||||
|
||||
// Seed mode toggle
|
||||
document.getElementById('pqSeedToggleGenerate').addEventListener('click', () => showSeedGeneratePanel());
|
||||
document.getElementById('pqSeedToggleOwn').addEventListener('click', () => showSeedOwnPanel());
|
||||
|
||||
// Bring your own seed: real-time validation
|
||||
const pqSeedInput = document.getElementById('pqSeedInput');
|
||||
const pqSeedValidation = document.getElementById('pqSeedValidation');
|
||||
const pqSeedOwnContinueBtn = document.getElementById('pqSeedOwnContinueBtn');
|
||||
pqSeedInput.addEventListener('input', () => {
|
||||
const value = pqSeedInput.value.trim();
|
||||
if (!value) {
|
||||
pqSeedValidation.innerHTML = '';
|
||||
pqSeedOwnContinueBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (isValidMnemonic(value)) {
|
||||
const wordCount = value.split(/\s+/).length;
|
||||
pqSeedValidation.textContent = `Valid ${wordCount}-word BIP39 seed phrase`;
|
||||
pqSeedValidation.style.color = '#00aa00';
|
||||
pqSeedOwnContinueBtn.disabled = false;
|
||||
} else {
|
||||
pqSeedValidation.textContent = 'Invalid seed phrase (checksum or word list mismatch)';
|
||||
pqSeedValidation.style.color = '#cc0000';
|
||||
pqSeedOwnContinueBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
pqSeedOwnContinueBtn.addEventListener('click', () => {
|
||||
pqMnemonic = pqSeedInput.value.trim();
|
||||
setStepDone(2);
|
||||
derivePQKeys();
|
||||
});
|
||||
|
||||
document.getElementById('pqCopySeedBtn').addEventListener('click', () => {
|
||||
@@ -996,7 +1242,21 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('pqRegenerateBtn').addEventListener('click', () => { generateAndShowSeed(); });
|
||||
// "Generate New Seed" — generates a fresh seed using pure CSPRNG (no user entropy)
|
||||
document.getElementById('pqRegenerateBtn').addEventListener('click', () => {
|
||||
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; }
|
||||
});
|
||||
|
||||
// "Generate with Extra Entropy" — generates a fresh seed mixing CSPRNG + user entropy
|
||||
document.getElementById('pqRegenerateWithEntropyBtn').addEventListener('click', () => { generateAndShowSeed(); });
|
||||
|
||||
pqSeedConfirmed.addEventListener('change', () => { pqSeedContinueBtn.disabled = !pqSeedConfirmed.checked; });
|
||||
pqSeedContinueBtn.addEventListener('click', () => { setStepDone(2); derivePQKeys(); });
|
||||
@@ -1008,19 +1268,30 @@
|
||||
const publishStatus = document.getElementById('pqPublishStatus');
|
||||
publishBtn.disabled = true;
|
||||
setStepActive(5);
|
||||
publishStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing to relays...</div>';
|
||||
publishStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing kind 1 announcement to relays...</div>';
|
||||
try {
|
||||
const relayUrls = document.getElementById('pqRelaysInput').value.split(',').map(s => s.trim()).filter(s => s);
|
||||
const results = await publishToRelays(pqEvent, relayUrls);
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
publishStatus.innerHTML = `<div class="pq-status pq-status-success">Published! Successful: ${successCount} relays, Failed: ${failCount} relays</div>`;
|
||||
|
||||
// Publish kind 1 announcement first
|
||||
const results1 = await publishToRelays(kind1Event, relayUrls);
|
||||
const success1 = results1.filter(r => r.success).length;
|
||||
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>';
|
||||
const results2 = await publishToRelays(pqEvent, relayUrls);
|
||||
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.`);
|
||||
|
||||
setStatus(publishStatus, 'success', `Published! Kind 1: ${success1}/${relayUrls.length} relays. Kind 11112: ${success2}/${relayUrls.length} relays.`);
|
||||
publishBtn.textContent = 'Published';
|
||||
setStepDone(5);
|
||||
// Present the OpenTimestamps approval step; submission starts on user approval.
|
||||
prepareOtsStep();
|
||||
} catch (error) {
|
||||
publishStatus.innerHTML = `<div class="pq-status pq-status-error">Publish failed: ${error.message}</div>`;
|
||||
setStatus(publishStatus, 'error', `Publish failed: ${error.message}`);
|
||||
publishBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -1046,6 +1317,10 @@
|
||||
pqKeys = null;
|
||||
pqSecpKeys = null;
|
||||
pqEvent = null;
|
||||
kind1Event = null; // F-L1: clear all secret references
|
||||
userEntropyChunks = [];
|
||||
pendingOtsBytes = null;
|
||||
currentBlockHeight = 0;
|
||||
// Clear auth/session storage, then restore only the OTS workflow record.
|
||||
try {
|
||||
localStorage.clear();
|
||||
@@ -1105,12 +1380,13 @@
|
||||
const otsStatus = document.getElementById('pqOtsStatus');
|
||||
const startBtn = document.getElementById('pqOtsStartBtn');
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = 'Submitting...';
|
||||
startBtn.textContent = 'Processing...';
|
||||
setKeyIcon('pqOtsSubmit', '');
|
||||
setKeyIcon('pqOtsPendingPublish', '');
|
||||
setKeyIcon('pqOtsConfirm', '');
|
||||
setKeyIcon('pqOtsConfirmedPublish', '');
|
||||
|
||||
// Check for a saved workflow from a previous session (same event ID)
|
||||
let existing = loadPendingOts();
|
||||
if (existing && !isDetachedOtsFile(existing.ots)) {
|
||||
otsLog('Discarding stale proof data created by an older implementation (not a complete detached .ots file).');
|
||||
@@ -1133,40 +1409,80 @@
|
||||
return;
|
||||
}
|
||||
showOtsProof(pendingOtsBytes);
|
||||
if (!existing.pendingPublished) {
|
||||
await publishOtsEvent(pendingOtsBytes, 'pending');
|
||||
}
|
||||
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof published. Waiting for Bitcoin confirmation...</div>';
|
||||
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>';
|
||||
startBtn.textContent = 'Submitted';
|
||||
startOtsPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
otsLog('Submitting event hash to OpenTimestamps calendar...');
|
||||
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Submitting event hash to OpenTimestamps...</div>';
|
||||
// The pending proof should already be embedded in the event from step 4 (signEvent).
|
||||
// Extract it from the event's 'ots' tag.
|
||||
const otsTag = pqEvent.tags && pqEvent.tags.find(t => t[0] === 'ots');
|
||||
if (otsTag && otsTag[1]) {
|
||||
try {
|
||||
const otsBytes = await timestampEvent(pqEvent.id);
|
||||
pendingOtsBytes = base64ToBytes(otsTag[1]);
|
||||
if (isDetachedOtsFile(pendingOtsBytes)) {
|
||||
otsLog(`Found pending OTS proof embedded in event (${pendingOtsBytes.length} bytes).`);
|
||||
setKeyIcon('pqOtsSubmit', 'Done');
|
||||
showOtsProof(pendingOtsBytes);
|
||||
savePendingOts(pqEvent.id, pendingOtsBytes, {
|
||||
ownerPubkey: currentPubkey,
|
||||
targetKind: pqEvent.kind,
|
||||
relayUrls: document.getElementById('pqRelaysInput').value,
|
||||
pendingPublished: true,
|
||||
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>';
|
||||
startBtn.textContent = 'Submitted';
|
||||
startOtsPolling();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
otsLog(`WARNING: Could not parse OTS tag from event: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.');
|
||||
otsStatus.innerHTML = '<div class="pq-status pq-status-error">Event is missing the sha256 tag.</div>';
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = 'Retry';
|
||||
return;
|
||||
}
|
||||
const fullHash = sha256Tag[1];
|
||||
|
||||
otsLog(`Submitting kind 1 event hash (${fullHash.substring(0, 16)}...) to OpenTimestamps calendar...`);
|
||||
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Submitting kind 1 event hash to OpenTimestamps...</div>';
|
||||
try {
|
||||
const otsBytes = await timestampEvent(fullHash);
|
||||
pendingOtsBytes = otsBytes;
|
||||
otsLog(`Created pending detached .ots file (${otsBytes.length} bytes).`);
|
||||
setKeyIcon('pqOtsSubmit', 'Done');
|
||||
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>';
|
||||
await publishUpgradedEvent(otsBytes);
|
||||
setKeyIcon('pqOtsPendingPublish', 'Done');
|
||||
savePendingOts(pqEvent.id, otsBytes, {
|
||||
ownerPubkey: currentPubkey,
|
||||
targetKind: pqEvent.kind,
|
||||
relayUrls: document.getElementById('pqRelaysInput').value,
|
||||
pendingPublished: false,
|
||||
pendingPublished: true,
|
||||
confirmedPublished: false
|
||||
});
|
||||
|
||||
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof created. Publishing pending NIP-03 event...</div>';
|
||||
await publishOtsEvent(otsBytes, 'pending');
|
||||
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof published. Waiting for Bitcoin confirmation (polling every 60 seconds)...</div>';
|
||||
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof embedded. Waiting for Bitcoin confirmation (polling every 60 seconds)...</div>';
|
||||
startBtn.textContent = 'Submitted';
|
||||
startOtsPolling();
|
||||
} catch (error) {
|
||||
console.error('[ots] Submission failed:', error);
|
||||
otsLog(`ERROR: ${error.message}`);
|
||||
otsStatus.innerHTML = `<div class="pq-status pq-status-error">OpenTimestamps workflow failed: ${error.message}</div>`;
|
||||
setStatus(otsStatus, 'error', `OpenTimestamps workflow failed: ${error.message}`);
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = 'Retry OpenTimestamps Submission';
|
||||
}
|
||||
@@ -1197,17 +1513,32 @@
|
||||
});
|
||||
showOtsProof(pendingOtsBytes);
|
||||
|
||||
if (result.confirmed || isOtsConfirmed(pendingOtsBytes)) {
|
||||
otsLog(`Poll ${pollCount}: Bitcoin attestation found (${pendingOtsBytes.length} bytes).`);
|
||||
// Run full cryptographic verification: parse the proof, bind the
|
||||
// target digest to the event's sha256 tag, walk the Merkle path,
|
||||
// and check the block header against a Bitcoin API.
|
||||
const sha256Tag = pqEvent.tags && pqEvent.tags.find(t => t[0] === 'sha256');
|
||||
const verifyResult = await verifyOtsProof(pendingOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
|
||||
|
||||
if (verifyResult.verified) {
|
||||
const att = verifyResult.bitcoinAttestations[0];
|
||||
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
|
||||
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC). Proof: ${pendingOtsBytes.length} bytes.`);
|
||||
setKeyIcon('pqOtsConfirm', 'Done');
|
||||
confirmDetail.textContent = 'Confirmed on Bitcoin blockchain!';
|
||||
confirmDetail.textContent = `Verified on Bitcoin blockchain (block ${att ? att.height : '?'})!`;
|
||||
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
|
||||
|
||||
const saved = loadPendingOts();
|
||||
if (!saved || !saved.confirmedPublished) {
|
||||
document.getElementById('pqOtsStatus').innerHTML = '<div class="pq-status pq-status-success">Bitcoin attestation received. Publishing confirmed NIP-03 event...</div>';
|
||||
await publishOtsEvent(pendingOtsBytes, 'confirmed');
|
||||
setStatus(document.getElementById('pqOtsStatus'), 'success', 'Bitcoin attestation verified. Republishing kind 11112 with confirmed proof...');
|
||||
await publishUpgradedEvent(pendingOtsBytes);
|
||||
}
|
||||
} else if (isOtsConfirmed(pendingOtsBytes)) {
|
||||
// Structural check found a Bitcoin attestation tag but full
|
||||
// verification failed — log the errors for diagnosis.
|
||||
const errs = verifyResult.errors.length > 0 ? ` Errors: ${verifyResult.errors.join('; ')}` : '';
|
||||
otsLog(`Poll ${pollCount}: Bitcoin attestation tag found but verification failed.${errs} Will retry.`);
|
||||
const detail = result.detail ? ` (${result.detail.replace(/\s+/g, ' ').slice(0, 180)})` : '';
|
||||
confirmDetail.textContent = `Attestation found but unverified (attempt ${pollCount}). Retrying in 60s...`;
|
||||
} else {
|
||||
const detail = result.detail ? ` (${result.detail.replace(/\s+/g, ' ').slice(0, 180)})` : '';
|
||||
otsLog(`Poll ${pollCount}: Still pending${detail}. Next poll in 60 seconds.`);
|
||||
@@ -1223,58 +1554,44 @@
|
||||
otsPollInterval = setInterval(poll, 60000);
|
||||
}
|
||||
|
||||
async function publishOtsEvent(otsBytes, proofStatus) {
|
||||
async function publishUpgradedEvent(upgradedOtsBytes) {
|
||||
const otsStatus = document.getElementById('pqOtsStatus');
|
||||
const isConfirmed = proofStatus === 'confirmed';
|
||||
const iconId = isConfirmed ? 'pqOtsConfirmedPublish' : 'pqOtsPendingPublish';
|
||||
const label = isConfirmed ? 'confirmed' : 'pending';
|
||||
try {
|
||||
otsLog(`Building ${label} NIP-03 event (kind 1040)...`);
|
||||
otsLog('Building upgraded kind 11112 event with confirmed OTS proof...');
|
||||
const savedWorkflow = loadPendingOts();
|
||||
const configuredRelays = document.getElementById('pqRelaysInput').value || (savedWorkflow && savedWorkflow.relayUrls) || 'wss://laantungir.net/relay';
|
||||
const relayUrl = configuredRelays.split(',')[0].trim() || 'wss://laantungir.net/relay';
|
||||
const eventTemplate = {
|
||||
kind: 1040,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['e', pqEvent.id, relayUrl],
|
||||
['k', String(pqEvent.kind)],
|
||||
['status', label]
|
||||
],
|
||||
content: bytesToBase64(otsBytes),
|
||||
pubkey: currentPubkey
|
||||
};
|
||||
|
||||
// Build the upgraded event: copy all tags from the original, replace the ots tag
|
||||
const upgradedTemplate = buildUpgradedEvent(pqEvent, upgradedOtsBytes);
|
||||
|
||||
if (!window.nostr || !window.nostr.signEvent) throw new Error('Nostr signer not available');
|
||||
otsLog(`Requesting signature for ${label} NIP-03 event...`);
|
||||
const signedEvent = await window.nostr.signEvent(eventTemplate);
|
||||
otsLog('Requesting secp256k1 signature for upgraded kind 11112 event...');
|
||||
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 ${label} NIP-03 event`);
|
||||
if (successCount === 0) throw new Error('No relay accepted the upgraded kind 11112 event');
|
||||
|
||||
setKeyIcon(iconId, 'Done');
|
||||
otsLog(`${label[0].toUpperCase() + label.slice(1)} NIP-03 event published. Successful: ${successCount}; failed: ${failCount}.`);
|
||||
savePendingOts(pqEvent.id, otsBytes, {
|
||||
setKeyIcon('pqOtsConfirmedPublish', 'Done');
|
||||
otsLog(`Upgraded kind 11112 event published. Successful: ${successCount}; failed: ${failCount}.`);
|
||||
// Update pqEvent to the new event so future operations use it
|
||||
pqEvent = signedEvent;
|
||||
savePendingOts(pqEvent.id, upgradedOtsBytes, {
|
||||
ownerPubkey: currentPubkey,
|
||||
targetKind: pqEvent.kind,
|
||||
targetKind: NIP_QR_KIND,
|
||||
relayUrls: configuredRelays,
|
||||
pendingPublished: true,
|
||||
confirmedPublished: isConfirmed
|
||||
confirmedPublished: true
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
otsStatus.innerHTML = `<div class="pq-status pq-status-success">Confirmed NIP-03 event published to ${successCount} relays. Timestamping complete.</div>`;
|
||||
setStatus(otsStatus, 'success', `Upgraded kind 11112 event published to ${successCount} relays. Timestamping complete.`);
|
||||
setStepDone(6);
|
||||
setStepActive(7);
|
||||
} else {
|
||||
otsStatus.innerHTML = `<div class="pq-status pq-status-info">Pending NIP-03 event published to ${successCount} relays. Waiting for Bitcoin attestation...</div>`;
|
||||
}
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
otsLog(`ERROR publishing ${label} NIP-03 event: ${error.message}`);
|
||||
otsStatus.innerHTML = `<div class="pq-status pq-status-error">Failed to publish ${label} NIP-03 event: ${error.message}</div>`;
|
||||
otsLog(`ERROR publishing upgraded event: ${error.message}`);
|
||||
setStatus(otsStatus, 'error', `Failed to publish upgraded event: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+646
-29
@@ -19,12 +19,13 @@
|
||||
* 7+8 — ML-KEM-768 (64-byte seed, two 32-byte children concatenated)
|
||||
*/
|
||||
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic, entropyToMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { bech32 } from '@scure/base';
|
||||
import { schnorr } from '@noble/curves/secp256k1.js';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { ripemd160, sha1 } from '@noble/hashes/legacy.js';
|
||||
import { ml_dsa44, ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
|
||||
import { slh_dsa_sha2_128s } from '@noble/post-quantum/slh-dsa.js';
|
||||
import { falcon512 } from '@noble/post-quantum/falcon.js';
|
||||
@@ -76,6 +77,32 @@ export function generateSeedPhrase() {
|
||||
return generateMnemonic(wordlist, 128); // 128 bits = 12 words
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a seed phrase using both CSPRNG entropy and user-provided entropy.
|
||||
*
|
||||
* This mixes the browser's crypto.getRandomValues() with user-supplied entropy
|
||||
* (e.g. from mouse movements or keyboard input), so even if the CSPRNG is
|
||||
* compromised, the seed remains unpredictable to an attacker who doesn't have
|
||||
* the user's entropy.
|
||||
*
|
||||
* @param {Uint8Array} userEntropy - Additional entropy from the user (any length)
|
||||
* @returns {string} A valid 12-word BIP39 mnemonic
|
||||
*/
|
||||
export function generateSeedPhraseWithEntropy(userEntropy) {
|
||||
// Get 16 bytes (128 bits) of CSPRNG entropy
|
||||
const csprngBytes = new Uint8Array(16);
|
||||
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);
|
||||
|
||||
return entropyToMnemonic(finalEntropy, wordlist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a mnemonic to a 64-byte BIP39 seed (PBKDF2-HMAC-SHA512).
|
||||
* @param {string} mnemonic - 12/24 word seed phrase
|
||||
@@ -332,8 +359,21 @@ export function bytesToHex(bytes) {
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array.
|
||||
* F-L3: Validates input format and length.
|
||||
* @param {string} hex - hex string (must have even length, hex chars only)
|
||||
* @returns {Uint8Array}
|
||||
* @throws {Error} if input is malformed
|
||||
*/
|
||||
export function hexToBytes(hex) {
|
||||
if (typeof hex !== 'string' || hex.length === 0) {
|
||||
throw new Error('hexToBytes: expected non-empty hex string');
|
||||
}
|
||||
if (hex.length % 2 !== 0) {
|
||||
throw new Error(`hexToBytes: odd-length hex string (${hex.length} chars)`);
|
||||
}
|
||||
if (!/^[0-9a-fA-F]*$/.test(hex)) {
|
||||
throw new Error('hexToBytes: invalid hex characters');
|
||||
}
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
@@ -353,8 +393,9 @@ export function hexToNpub(hexPubkey) {
|
||||
|
||||
/**
|
||||
* Verify a Nostr event's secp256k1 Schnorr signature (NIP-01).
|
||||
* Checks both the signature validity AND that event.id matches the computed hash.
|
||||
* @param {object} event - Nostr event with id, pubkey, created_at, kind, tags, content, sig
|
||||
* @returns {boolean} true if signature is valid
|
||||
* @returns {boolean} true if signature is valid and id matches
|
||||
*/
|
||||
export function verifyNostrEvent(event) {
|
||||
// Build the event hash (NIP-01 serialization)
|
||||
@@ -367,6 +408,11 @@ export function verifyNostrEvent(event) {
|
||||
event.content
|
||||
]);
|
||||
const hash = sha256(new TextEncoder().encode(serialized));
|
||||
const computedId = bytesToHex(hash);
|
||||
// F-H2: Verify event.id matches the computed hash
|
||||
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
const sig = hexToBytes(event.sig);
|
||||
const pubkey = hexToBytes(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
@@ -377,26 +423,54 @@ export function verifyNostrEvent(event) {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build the NIP-QR event content and tags.
|
||||
* 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.
|
||||
*/
|
||||
export const NIP_QR_KIND = 11112;
|
||||
|
||||
/**
|
||||
* Build the kind 1 announcement event (unsigned template).
|
||||
*
|
||||
* This is a regular Nostr text note (kind 1) that serves as a public announcement
|
||||
* of the post-quantum key migration. The content is human-readable text with
|
||||
* newlines. The PQ public keys and signatures go in the tags.
|
||||
*
|
||||
* The content is a human-readable attestation statement (plain text).
|
||||
* The PQ public keys and signatures go in the event tags.
|
||||
* Each PQ key signs the content text (the attestation statement).
|
||||
*
|
||||
* Tag format: ['algorithm', '<algorithm>', '<base64 pubkey>', '<base64 signature>']
|
||||
* For ML-KEM (KEM, can't sign): ['algorithm', 'ml-kem-768', '<base64 pubkey>']
|
||||
* Also: ['block_height', '<height>']
|
||||
*
|
||||
* @param {string} hexPubkey - The user's Nostr hex pubkey (Account #1)
|
||||
* @param {number} blockHeight - Current Bitcoin block height for pre-quantum anchoring
|
||||
* @param {object} pqKeys - PQ keypairs from derivePQKeysFromSeed()
|
||||
* @returns {{content: string, tags: Array, statementBytes: Uint8Array}}
|
||||
* @returns {{kind: number, content: string, tags: Array, pubkey: string, created_at: number, statementBytes: Uint8Array}}
|
||||
*/
|
||||
export function buildNIPQRContent(hexPubkey, blockHeight, pqKeys) {
|
||||
export function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
|
||||
const npub = hexToNpub(hexPubkey);
|
||||
|
||||
// Human-readable attestation statement (this is the event's content field)
|
||||
// Single line with spaces (no newlines) for clean JSON display
|
||||
const content = `NOSTR identity: [hex] ${hexPubkey} [npub] ${npub} is signaling migration to successor post quantum pubkeys listed in the tags of this event. This link is established pre-quantum at blockheight ${blockHeight}.`;
|
||||
// Human-readable attestation statement (signed by each PQ key)
|
||||
// Uses newlines for nice display in Nostr clients
|
||||
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.
|
||||
|
||||
My current identity:
|
||||
npub: ${npub}
|
||||
hex: ${hexPubkey}
|
||||
|
||||
This attestation is established pre-quantum at Bitcoin block height ${blockHeight}.
|
||||
|
||||
Post-quantum public keys in tags:
|
||||
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
|
||||
ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3)
|
||||
SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1)
|
||||
Falcon-512 (FIPS 206 draft, NIST Level 1)
|
||||
ML-KEM-768 (Kyber, FIPS 203, NIST Level 3)
|
||||
|
||||
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
|
||||
|
||||
Verify this attestation: https://laantungir.net/post-quantum/verify.html
|
||||
Created at: https://laantungir.net/post-quantum/`;
|
||||
|
||||
const statementBytes = new TextEncoder().encode(content);
|
||||
|
||||
@@ -406,9 +480,8 @@ export function buildNIPQRContent(hexPubkey, blockHeight, pqKeys) {
|
||||
const slhDsaSig = signWithSLHDSA(statementBytes, pqKeys.slhDsa.secretKey);
|
||||
const falconSig = signWithFalcon(statementBytes, pqKeys.falcon512.secretKey);
|
||||
|
||||
// Build tags: each PQ key's pubkey and signature in a tag
|
||||
// Tags: block height + each PQ key's pubkey and signature
|
||||
const tags = [
|
||||
['d', 'nip-qr-migration'],
|
||||
['block_height', String(blockHeight)],
|
||||
['algorithm', 'ml-dsa-44', bytesToBase64(pqKeys.mlDsa44.publicKey), bytesToBase64(mlDsa44Sig)],
|
||||
['algorithm', 'ml-dsa-65', bytesToBase64(pqKeys.mlDsa65.publicKey), bytesToBase64(mlDsa65Sig)],
|
||||
@@ -417,22 +490,190 @@ export function buildNIPQRContent(hexPubkey, blockHeight, pqKeys) {
|
||||
['algorithm', 'ml-kem-768', bytesToBase64(pqKeys.mlKem.publicKey)]
|
||||
];
|
||||
|
||||
return { content, tags, statementBytes };
|
||||
return {
|
||||
kind: 1,
|
||||
content,
|
||||
tags,
|
||||
pubkey: hexPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
statementBytes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a NIP-QR event's PQ signatures.
|
||||
* Each PQ signature is verified against the event's content text.
|
||||
* Compute the NIP-01 event ID for an event (before signing).
|
||||
* The event ID is SHA-256 of JSON.stringify([0, pubkey, created_at, kind, tags, content]).
|
||||
*
|
||||
* @param {string} contentText - The event's content field (human-readable statement)
|
||||
* @param {Array} tags - The event's tags array
|
||||
* @returns {{valid: boolean, results: Array}} verification results
|
||||
* @param {object} event - The event template (must have pubkey, created_at, kind, tags, content)
|
||||
* @returns {string} hex event ID
|
||||
*/
|
||||
export function verifyNIPQRContent(contentText, tags) {
|
||||
const results = [];
|
||||
const msg = new TextEncoder().encode(contentText);
|
||||
export function computeEventId(event) {
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content
|
||||
]);
|
||||
return bytesToHex(sha256(new TextEncoder().encode(serialized)));
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
/**
|
||||
* Compute the SHA-256 hash of the full signed event JSON (including id and sig).
|
||||
* This is the hash that gets submitted to OpenTimestamps — it covers everything:
|
||||
* content, tags, PQ signatures, AND the secp256k1 signature.
|
||||
*
|
||||
* @param {object} signedEvent - The fully signed kind 1 event (with id and sig)
|
||||
* @returns {string} hex SHA-256 hash
|
||||
*/
|
||||
export function hashFullEvent(signedEvent) {
|
||||
const json = JSON.stringify(signedEvent);
|
||||
return bytesToHex(sha256(new TextEncoder().encode(json)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the kind 11112 wrapper 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.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* @param {object} kind1Event - The fully signed kind 1 event
|
||||
* @param {Uint8Array} otsProof - The OTS proof bytes (pending or confirmed)
|
||||
* @returns {{kind: number, content: string, tags: Array, pubkey: string, created_at: number}}
|
||||
*/
|
||||
export function buildKind11112Wrapper(kind1Event, otsProof) {
|
||||
const fullHash = hashFullEvent(kind1Event);
|
||||
|
||||
return {
|
||||
kind: NIP_QR_KIND,
|
||||
content: JSON.stringify(kind1Event),
|
||||
tags: [
|
||||
['e', kind1Event.id],
|
||||
['sha256', fullHash],
|
||||
['ots', bytesToBase64(otsProof)]
|
||||
],
|
||||
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.
|
||||
*
|
||||
* @param {object} originalEvent - The original kind 11112 event
|
||||
* @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)]);
|
||||
|
||||
return {
|
||||
kind: NIP_QR_KIND,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: tags,
|
||||
content: originalEvent.content,
|
||||
pubkey: originalEvent.pubkey
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a NIP-QR kind 11112 event.
|
||||
*
|
||||
* The kind 11112 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
|
||||
*
|
||||
* @param {string} kind11112Content - The kind 11112 content (JSON string of kind 1 event)
|
||||
* @param {Array} kind11112Tags - The kind 11112 tags array
|
||||
* @returns {{valid: boolean, results: Array, kind1Event: object|null, fullHash: string|null, sha256Valid: boolean, eTagValid: boolean}}
|
||||
*/
|
||||
export function verifyNIPQRContent(kind11112Content, kind11112Tags) {
|
||||
const results = [];
|
||||
|
||||
// 1. Parse the kind 1 event from the kind 11112 content
|
||||
let kind1Event;
|
||||
try {
|
||||
kind1Event = JSON.parse(kind11112Content);
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
results: [{ algorithm: 'kind 1 content', valid: false, note: 'Content is not valid JSON' }],
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false
|
||||
};
|
||||
}
|
||||
|
||||
if (!kind1Event.pubkey || !kind1Event.sig || !kind1Event.content || !kind1Event.tags || kind1Event.kind !== 1) {
|
||||
return {
|
||||
valid: false,
|
||||
results: [{ algorithm: 'kind 1 content', valid: false, note: 'Embedded event is not a valid kind 1 event' }],
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Verify the kind 1 secp256k1 signature
|
||||
const kind1SigValid = verifyNostrEvent(kind1Event);
|
||||
results.push({
|
||||
algorithm: 'secp256k1 (kind 1 announcement)',
|
||||
valid: kind1SigValid,
|
||||
note: kind1SigValid ? 'valid' : 'INVALID'
|
||||
});
|
||||
|
||||
// 3. Verify the 'e' tag matches the kind 1 event ID
|
||||
const computedKind1Id = computeEventId(kind1Event);
|
||||
const eTag = kind11112Tags.find(t => t[0] === 'e');
|
||||
let eTagValid = false;
|
||||
if (eTag && eTag[1]) {
|
||||
eTagValid = (eTag[1].toLowerCase() === computedKind1Id.toLowerCase());
|
||||
// F-L4: Also check that kind1Event.id (if present) matches the computed id
|
||||
if (kind1Event.id && kind1Event.id.toLowerCase() !== computedKind1Id.toLowerCase()) {
|
||||
eTagValid = false;
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
algorithm: 'e tag (kind 1 event ID)',
|
||||
valid: eTagValid,
|
||||
note: eTagValid ? `matches (${computedKind1Id.substring(0, 16)}...)` : (eTag ? `mismatch: e tag=${eTag[1].substring(0, 16)}... computed=${computedKind1Id.substring(0, 16)}...` : 'tag missing')
|
||||
});
|
||||
|
||||
// 4. Verify the 'sha256' tag matches SHA-256 of the full kind 1 event JSON
|
||||
const fullHash = hashFullEvent(kind1Event);
|
||||
const sha256Tag = kind11112Tags.find(t => t[0] === 'sha256');
|
||||
let sha256Valid = false;
|
||||
if (sha256Tag && sha256Tag[1]) {
|
||||
sha256Valid = (sha256Tag[1].toLowerCase() === fullHash.toLowerCase());
|
||||
}
|
||||
results.push({
|
||||
algorithm: 'sha256 (full kind 1 event hash)',
|
||||
valid: sha256Valid,
|
||||
note: sha256Valid ? `matches (${fullHash.substring(0, 16)}...)` : (sha256Tag ? `mismatch: tag=${sha256Tag[1].substring(0, 16)}... computed=${fullHash.substring(0, 16)}...` : 'tag missing')
|
||||
});
|
||||
|
||||
// 5. Verify each PQ signature in the kind 1 tags against the content text
|
||||
const msg = new TextEncoder().encode(kind1Event.content);
|
||||
|
||||
for (const tag of kind1Event.tags) {
|
||||
if (tag[0] !== 'algorithm') continue;
|
||||
|
||||
const algorithm = tag[1];
|
||||
@@ -464,7 +705,11 @@ export function verifyNIPQRContent(contentText, tags) {
|
||||
|
||||
return {
|
||||
valid: results.every(r => r.valid),
|
||||
results
|
||||
results,
|
||||
kind1Event,
|
||||
fullHash,
|
||||
sha256Valid,
|
||||
eTagValid
|
||||
};
|
||||
}
|
||||
|
||||
@@ -581,7 +826,7 @@ export async function timestampEvent(eventIdHex) {
|
||||
body: hashBytes,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.opentimestamps.v1',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -640,15 +885,25 @@ export async function upgradeOts(otsBytes) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an .ots file contains a confirmed Bitcoin attestation.
|
||||
* A pending .ots file contains only "pending" attestations.
|
||||
* A confirmed .ots file contains at least one Bitcoin attestation.
|
||||
* Check if an .ots file contains a Bitcoin attestation tag.
|
||||
*
|
||||
* NOTE: This is a quick structural check (parses the OTS op stream to find
|
||||
* attestation tags), NOT a cryptographic verification. It only tells you that
|
||||
* a Bitcoin attestation *appears* in the proof. To cryptographically verify
|
||||
* that the attestation is valid (Merkle path + block header), use
|
||||
* `verifyOtsProof()` instead.
|
||||
*
|
||||
* @param {Uint8Array} otsBytes - The .ots file bytes
|
||||
* @returns {boolean} true if the .ots file has a Bitcoin attestation
|
||||
* @returns {boolean} true if the .ots file structurally contains a Bitcoin attestation
|
||||
*/
|
||||
export function isOtsConfirmed(otsBytes) {
|
||||
// Exact 8-byte tag for BitcoinBlockHeaderAttestation.
|
||||
try {
|
||||
const parsed = parseOtsFile(otsBytes);
|
||||
if (!parsed) return false;
|
||||
return parsed.attestations.some(a => a.type === 'bitcoin');
|
||||
} catch (e) {
|
||||
// Fall back to the legacy byte-pattern search if parsing fails.
|
||||
// This handles malformed proofs gracefully but is NOT a verification.
|
||||
const bitcoinTag = hexToBytes('0588960d73d71901');
|
||||
outer: for (let i = 0; i <= otsBytes.length - bitcoinTag.length; i++) {
|
||||
for (let j = 0; j < bitcoinTag.length; j++) {
|
||||
@@ -657,6 +912,368 @@ export function isOtsConfirmed(otsBytes) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OTS BINARY FORMAT PARSER
|
||||
// ============================================================================
|
||||
//
|
||||
// The OTS detached file format (binary):
|
||||
// magic header (31 bytes) + version varuint + file hash op tag + file digest
|
||||
// + timestamp tree (recursive: tag/attestation or op + result + subtree)
|
||||
//
|
||||
// Operation tags:
|
||||
// 0x00 = attestation (followed by 8-byte attestation tag + varbytes payload)
|
||||
// 0xff = continuation marker (more ops/attestations follow for this timestamp)
|
||||
// 0x08 = SHA-256
|
||||
// 0x02 = SHA-1
|
||||
// 0x03 = RIPEMD160
|
||||
// 0xf0 = append (varbytes arg)
|
||||
// 0xf1 = prepend (varbytes arg)
|
||||
// 0xf2 = reverse
|
||||
//
|
||||
// Attestation tags (8 bytes):
|
||||
// 0588960d73d71901 = BitcoinBlockHeaderAttestation (payload: varuint height)
|
||||
// 06869a0d73d71b45 = LitecoinBlockHeaderAttestation
|
||||
// 83df830d1c9d4a51 = PendingAttestation (payload: varbytes URI)
|
||||
|
||||
/**
|
||||
* Binary stream reader for OTS deserialization.
|
||||
*/
|
||||
class OtsReader {
|
||||
constructor(bytes) {
|
||||
this.bytes = bytes;
|
||||
this.pos = 0;
|
||||
}
|
||||
|
||||
readByte() {
|
||||
if (this.pos >= this.bytes.length) throw new Error('OTS: unexpected end of data');
|
||||
return this.bytes[this.pos++];
|
||||
}
|
||||
|
||||
readBytes(n) {
|
||||
if (this.pos + n > this.bytes.length) throw new Error('OTS: unexpected end of data');
|
||||
const out = this.bytes.slice(this.pos, this.pos + n);
|
||||
this.pos += n;
|
||||
return out;
|
||||
}
|
||||
|
||||
readVaruint() {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
let b;
|
||||
do {
|
||||
b = this.readByte();
|
||||
value |= (b & 0x7f) << shift;
|
||||
shift += 7;
|
||||
} while (b & 0x80);
|
||||
return value;
|
||||
}
|
||||
|
||||
readVarbytes(maxLen = 4096) {
|
||||
const len = this.readVaruint();
|
||||
if (len > maxLen) throw new Error(`OTS: varbytes length ${len} exceeds max ${maxLen}`);
|
||||
return this.readBytes(len);
|
||||
}
|
||||
|
||||
remaining() {
|
||||
return this.bytes.length - this.pos;
|
||||
}
|
||||
}
|
||||
|
||||
// OTS magic header (31 bytes)
|
||||
const OTS_MAGIC = hexToBytes('004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294');
|
||||
|
||||
// Attestation tags
|
||||
const BITCOIN_ATTESTATION_TAG = hexToBytes('0588960d73d71901');
|
||||
const LITECOIN_ATTESTATION_TAG = hexToBytes('06869a0d73d71b45');
|
||||
const PENDING_ATTESTATION_TAG = hexToBytes('83dfe30d2ef90c8e');
|
||||
|
||||
function bytesEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the OTS detached timestamp file.
|
||||
*
|
||||
* @param {Uint8Array} otsBytes
|
||||
* @returns {{fileHashOp: string, targetDigest: Uint8Array, attestations: Array, timestamp: object}|null}
|
||||
* attestations: [{type: 'bitcoin'|'litecoin'|'pending'|'unknown', height?: number, uri?: string, digest: Uint8Array}]
|
||||
* The `digest` in each attestation is the computed merkle root / commitment
|
||||
* that the attestation covers (after walking the op tree from the target).
|
||||
*/
|
||||
export function parseOtsFile(otsBytes) {
|
||||
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_MAGIC.length) return null;
|
||||
|
||||
const reader = new OtsReader(otsBytes);
|
||||
|
||||
// 1. Magic header
|
||||
const magic = reader.readBytes(OTS_MAGIC.length);
|
||||
if (!bytesEqual(magic, OTS_MAGIC)) return null;
|
||||
|
||||
// 2. Version (varuint) — we support major version 1
|
||||
const version = reader.readVaruint();
|
||||
if (version !== 1) return null;
|
||||
|
||||
// 3. File hash operation tag (1 byte)
|
||||
const hashOpTag = reader.readByte();
|
||||
let fileHashOp = 'unknown';
|
||||
let digestLen = 32;
|
||||
if (hashOpTag === 0x08) { fileHashOp = 'sha256'; digestLen = 32; }
|
||||
else if (hashOpTag === 0x02) { fileHashOp = 'sha1'; digestLen = 20; }
|
||||
else if (hashOpTag === 0x03) { fileHashOp = 'ripemd160'; digestLen = 20; }
|
||||
else { return null; } // unsupported hash op
|
||||
|
||||
// 4. File digest (the target — what was timestamped)
|
||||
const targetDigest = reader.readBytes(digestLen);
|
||||
|
||||
// 5. Timestamp tree
|
||||
const attestations = [];
|
||||
_parseTimestamp(reader, targetDigest, attestations, 0);
|
||||
|
||||
return { fileHashOp, targetDigest, attestations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively parse a timestamp node, collecting attestations with their
|
||||
* computed commitment digests (the result of walking the op tree).
|
||||
*
|
||||
* The OTS timestamp format for a node (per the reference implementation):
|
||||
* Read a byte.
|
||||
* While that byte is 0xff (continuation marker):
|
||||
* Read the next byte as the actual tag; process it.
|
||||
* Read another byte.
|
||||
* Process the final (non-0xff) tag.
|
||||
*
|
||||
* So a node is a sequence of one or more entries. 0xff prefixes all entries
|
||||
* except the last one. Each entry is either:
|
||||
* - 0x00 = attestation (8-byte tag + varbytes payload)
|
||||
* - other = operation tag; apply op to current msg → result; recurse into subtree with result
|
||||
*/
|
||||
function _parseTimestamp(reader, msg, attestations, depth) {
|
||||
if (depth > 64) throw new Error('OTS: recursion limit exceeded');
|
||||
|
||||
let tag = reader.readByte();
|
||||
while (tag === 0xff) {
|
||||
// Continuation: read the actual tag, process it, then read the next byte
|
||||
const current = reader.readByte();
|
||||
_processTimestampEntry(reader, current, msg, attestations, depth);
|
||||
tag = reader.readByte();
|
||||
}
|
||||
// Process the final (non-0xff) entry
|
||||
_processTimestampEntry(reader, tag, msg, attestations, depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single timestamp entry (attestation or operation+subtree).
|
||||
*/
|
||||
function _processTimestampEntry(reader, tag, msg, attestations, depth) {
|
||||
if (tag === 0x00) {
|
||||
// Attestation
|
||||
const attTag = reader.readBytes(8);
|
||||
const payload = reader.readVarbytes(8192);
|
||||
_classifyAttestation(attTag, payload, msg, attestations);
|
||||
} else {
|
||||
// Operation — apply to msg, then recurse into the subtree
|
||||
const result = _applyOp(reader, tag, msg);
|
||||
_parseTimestamp(reader, result, attestations, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OTS operation to a message, returning the result.
|
||||
* May read additional bytes from the reader (for binary ops with args).
|
||||
*/
|
||||
function _applyOp(reader, tag, msg) {
|
||||
if (tag === 0x08) {
|
||||
// SHA-256
|
||||
return sha256(msg);
|
||||
} else if (tag === 0x02) {
|
||||
// SHA-1
|
||||
return sha1(msg);
|
||||
} else if (tag === 0x03) {
|
||||
// RIPEMD160
|
||||
return ripemd160(msg);
|
||||
} else if (tag === 0xf0) {
|
||||
// Append
|
||||
const arg = reader.readVarbytes(4096);
|
||||
return concatBytes(msg, arg);
|
||||
} else if (tag === 0xf1) {
|
||||
// Prepend
|
||||
const arg = reader.readVarbytes(4096);
|
||||
return concatBytes(arg, msg);
|
||||
} else if (tag === 0xf2) {
|
||||
// Reverse
|
||||
return msg.slice().reverse();
|
||||
} else {
|
||||
throw new Error(`OTS: unknown operation tag 0x${tag.toString(16)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an attestation and add it to the attestations list.
|
||||
*/
|
||||
function _classifyAttestation(tag, payload, digest, attestations) {
|
||||
if (bytesEqual(tag, BITCOIN_ATTESTATION_TAG)) {
|
||||
// Payload: varuint height
|
||||
const r = new OtsReader(payload);
|
||||
const height = r.readVaruint();
|
||||
attestations.push({ type: 'bitcoin', height, digest });
|
||||
} else if (bytesEqual(tag, LITECOIN_ATTESTATION_TAG)) {
|
||||
const r = new OtsReader(payload);
|
||||
const height = r.readVaruint();
|
||||
attestations.push({ type: 'litecoin', height, digest });
|
||||
} else if (bytesEqual(tag, PENDING_ATTESTATION_TAG)) {
|
||||
const r = new OtsReader(payload);
|
||||
const uri = new TextDecoder().decode(r.readVarbytes(1024));
|
||||
attestations.push({ type: 'pending', uri, digest });
|
||||
} else {
|
||||
attestations.push({ type: 'unknown', tag, digest });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OTS CRYPTOGRAPHIC VERIFICATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Bitcoin API endpoints for fetching block headers (for lite-client verification).
|
||||
* We try multiple providers for resilience.
|
||||
*/
|
||||
const BITCOIN_API_PROVIDERS = [
|
||||
{ name: 'blockstream', blockHash: (h) => `https://blockstream.info/api/block-height/${h}`, block: (hash) => `https://blockstream.info/api/block/${hash}` },
|
||||
{ name: 'mempool', blockHash: (h) => `https://mempool.space/api/block-height/${h}`, block: (hash) => `https://mempool.space/api/block/${hash}` },
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetch a Bitcoin block header (merkle root + timestamp) from a public API.
|
||||
* Uses lite-client verification: we trust the API for the block header, which
|
||||
* is the standard OTS lite-client model (the block header is independently
|
||||
* verifiable against the Bitcoin PoW chain).
|
||||
*
|
||||
* @param {number} height - Bitcoin block height
|
||||
* @returns {Promise<{merkleroot: string, time: number, height: number}>}
|
||||
*/
|
||||
async function fetchBitcoinBlockHeader(height) {
|
||||
for (const provider of BITCOIN_API_PROVIDERS) {
|
||||
try {
|
||||
// 1. Get block hash from height
|
||||
const hashResp = await fetch(provider.blockHash(height), { headers: { 'Accept': 'text/plain' } });
|
||||
if (!hashResp.ok) continue;
|
||||
const blockHash = (await hashResp.text()).trim();
|
||||
if (!blockHash || blockHash.length !== 64) continue;
|
||||
|
||||
// 2. Get block header info (merkle_root, timestamp)
|
||||
const blockResp = await fetch(provider.block(blockHash), { headers: { 'Accept': 'application/json' } });
|
||||
if (!blockResp.ok) continue;
|
||||
const blockData = await blockResp.json();
|
||||
if (!blockData.merkle_root && !blockData.merkleroot) continue;
|
||||
if (!blockData.timestamp && !blockData.time) continue;
|
||||
|
||||
return {
|
||||
merkleroot: blockData.merkle_root || blockData.merkleroot,
|
||||
time: blockData.timestamp || blockData.time,
|
||||
height
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`[ots] Bitcoin API ${provider.name} failed for height ${height}:`, e.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cryptographically verify an OTS proof against an expected target digest.
|
||||
*
|
||||
* This performs REAL verification:
|
||||
* 1. Parses the OTS file (op stream, not byte-pattern search)
|
||||
* 2. Checks the proof's target digest equals the expected digest (F-C3 fix)
|
||||
* 3. Walks the op tree to compute the commitment at each attestation
|
||||
* 4. For Bitcoin attestations: fetches the block header and checks the
|
||||
* computed merkle root matches the block's merkle root
|
||||
*
|
||||
* @param {Uint8Array} otsBytes - The .ots file bytes
|
||||
* @param {string} [expectedDigestHex] - The expected target digest (hex). If
|
||||
* provided, the proof's target digest must match this exactly (F-C3 binding).
|
||||
* @returns {Promise<{verified: boolean, targetDigest: string, attestations: Array, bitcoinAttestations: Array, errors: Array}>}
|
||||
* - verified: true if at least one Bitcoin attestation is cryptographically valid
|
||||
* - targetDigest: hex of the proof's target digest
|
||||
* - attestations: all attestations found (parsed)
|
||||
* - bitcoinAttestations: verified Bitcoin attestations with block time + height
|
||||
* - errors: list of error messages (e.g., digest mismatch, pending only)
|
||||
*/
|
||||
export async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
const errors = [];
|
||||
|
||||
// 1. Parse the OTS file
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseOtsFile(otsBytes);
|
||||
} catch (e) {
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: [`Failed to parse OTS file: ${e.message}`] };
|
||||
}
|
||||
if (!parsed) {
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: ['Invalid OTS file format'] };
|
||||
}
|
||||
|
||||
const targetDigestHex = bytesToHex(parsed.targetDigest);
|
||||
|
||||
// 2. Bind the target digest to the expected digest (F-C3 fix)
|
||||
if (expectedDigestHex) {
|
||||
const expected = expectedDigestHex.toLowerCase();
|
||||
const actual = targetDigestHex.toLowerCase();
|
||||
if (expected !== actual) {
|
||||
errors.push(`Target digest mismatch: proof commits to ${actual.substring(0, 16)}... but expected ${expected.substring(0, 16)}...`);
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Find Bitcoin attestations and verify them
|
||||
const bitcoinAttestations = parsed.attestations.filter(a => a.type === 'bitcoin');
|
||||
const pendingAttestations = parsed.attestations.filter(a => a.type === 'pending');
|
||||
|
||||
if (bitcoinAttestations.length === 0) {
|
||||
if (pendingAttestations.length > 0) {
|
||||
errors.push('Proof contains only pending attestations (not yet confirmed on Bitcoin)');
|
||||
} else {
|
||||
errors.push('Proof contains no Bitcoin attestations');
|
||||
}
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
|
||||
}
|
||||
|
||||
// 4. Verify each Bitcoin attestation against the actual Bitcoin block header
|
||||
const verified = [];
|
||||
for (const att of bitcoinAttestations) {
|
||||
try {
|
||||
const blockHeader = await fetchBitcoinBlockHeader(att.height);
|
||||
// The attestation's digest is the merkle root (after walking the op tree).
|
||||
// Bitcoin OTS stores the digest in reversed byte order (little-endian).
|
||||
const computedMerkleRoot = bytesToHex(att.digest.slice().reverse());
|
||||
if (computedMerkleRoot.toLowerCase() === blockHeader.merkleroot.toLowerCase()) {
|
||||
verified.push({
|
||||
height: att.height,
|
||||
time: blockHeader.time,
|
||||
merkleroot: blockHeader.merkleroot
|
||||
});
|
||||
} else {
|
||||
errors.push(`Bitcoin attestation for block ${att.height}: merkle root mismatch (computed ${computedMerkleRoot.substring(0, 16)}..., block has ${blockHeader.merkleroot.substring(0, 16)}...)`);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`Could not verify Bitcoin attestation for block ${att.height}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
verified: verified.length > 0,
|
||||
targetDigest: targetDigestHex,
|
||||
attestations: parsed.attestations,
|
||||
bitcoinAttestations: verified,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+452
-10
@@ -5329,6 +5329,79 @@ var schnorr = /* @__PURE__ */ (() => {
|
||||
})();
|
||||
|
||||
// node_modules/@noble/hashes/legacy.js
|
||||
var SHA1_IV = /* @__PURE__ */ Uint32Array.from([
|
||||
1732584193,
|
||||
4023233417,
|
||||
2562383102,
|
||||
271733878,
|
||||
3285377520
|
||||
]);
|
||||
var SHA1_W = /* @__PURE__ */ new Uint32Array(80);
|
||||
var _SHA1 = class extends HashMD {
|
||||
constructor() {
|
||||
super(64, 20, 8, false);
|
||||
__publicField(this, "A", SHA1_IV[0] | 0);
|
||||
__publicField(this, "B", SHA1_IV[1] | 0);
|
||||
__publicField(this, "C", SHA1_IV[2] | 0);
|
||||
__publicField(this, "D", SHA1_IV[3] | 0);
|
||||
__publicField(this, "E", SHA1_IV[4] | 0);
|
||||
}
|
||||
get() {
|
||||
const { A, B, C, D: D2, E } = this;
|
||||
return [A, B, C, D2, E];
|
||||
}
|
||||
set(A, B, C, D2, E) {
|
||||
this.A = A | 0;
|
||||
this.B = B | 0;
|
||||
this.C = C | 0;
|
||||
this.D = D2 | 0;
|
||||
this.E = E | 0;
|
||||
}
|
||||
process(view, offset) {
|
||||
for (let i = 0; i < 16; i++, offset += 4)
|
||||
SHA1_W[i] = view.getUint32(offset, false);
|
||||
for (let i = 16; i < 80; i++)
|
||||
SHA1_W[i] = rotl(SHA1_W[i - 3] ^ SHA1_W[i - 8] ^ SHA1_W[i - 14] ^ SHA1_W[i - 16], 1);
|
||||
let { A, B, C, D: D2, E } = this;
|
||||
for (let i = 0; i < 80; i++) {
|
||||
let F3, K;
|
||||
if (i < 20) {
|
||||
F3 = Chi(B, C, D2);
|
||||
K = 1518500249;
|
||||
} else if (i < 40) {
|
||||
F3 = B ^ C ^ D2;
|
||||
K = 1859775393;
|
||||
} else if (i < 60) {
|
||||
F3 = Maj(B, C, D2);
|
||||
K = 2400959708;
|
||||
} else {
|
||||
F3 = B ^ C ^ D2;
|
||||
K = 3395469782;
|
||||
}
|
||||
const T = rotl(A, 5) + F3 + E + K + SHA1_W[i] | 0;
|
||||
E = D2;
|
||||
D2 = C;
|
||||
C = rotl(B, 30);
|
||||
B = A;
|
||||
A = T;
|
||||
}
|
||||
A = A + this.A | 0;
|
||||
B = B + this.B | 0;
|
||||
C = C + this.C | 0;
|
||||
D2 = D2 + this.D | 0;
|
||||
E = E + this.E | 0;
|
||||
this.set(A, B, C, D2, E);
|
||||
}
|
||||
roundClean() {
|
||||
clean(SHA1_W);
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.set(0, 0, 0, 0, 0);
|
||||
clean(this.buffer);
|
||||
}
|
||||
};
|
||||
var sha1 = /* @__PURE__ */ createHasher(() => new _SHA1());
|
||||
var Rho160 = /* @__PURE__ */ Uint8Array.from([
|
||||
7,
|
||||
4,
|
||||
@@ -9539,6 +9612,14 @@ var PQ_SEED_LENGTHS = {
|
||||
function generateSeedPhrase() {
|
||||
return generateMnemonic(wordlist, 128);
|
||||
}
|
||||
function generateSeedPhraseWithEntropy(userEntropy) {
|
||||
const csprngBytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(csprngBytes);
|
||||
const combined = concatBytes4(csprngBytes, userEntropy);
|
||||
const hash = sha256(combined);
|
||||
const finalEntropy = hash.slice(0, 16);
|
||||
return entropyToMnemonic(finalEntropy, wordlist);
|
||||
}
|
||||
function mnemonicToSeed(mnemonic, passphrase = "") {
|
||||
if (!validateMnemonic(mnemonic, wordlist)) {
|
||||
throw new Error("Invalid mnemonic");
|
||||
@@ -9653,6 +9734,15 @@ function bytesToHex3(bytes) {
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
function hexToBytes3(hex) {
|
||||
if (typeof hex !== "string" || hex.length === 0) {
|
||||
throw new Error("hexToBytes: expected non-empty hex string");
|
||||
}
|
||||
if (hex.length % 2 !== 0) {
|
||||
throw new Error(`hexToBytes: odd-length hex string (${hex.length} chars)`);
|
||||
}
|
||||
if (!/^[0-9a-fA-F]*$/.test(hex)) {
|
||||
throw new Error("hexToBytes: invalid hex characters");
|
||||
}
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
@@ -9673,20 +9763,42 @@ function verifyNostrEvent(event) {
|
||||
event.content
|
||||
]);
|
||||
const hash = sha256(new TextEncoder().encode(serialized));
|
||||
const computedId = bytesToHex3(hash);
|
||||
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
const sig = hexToBytes3(event.sig);
|
||||
const pubkey = hexToBytes3(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
}
|
||||
function buildNIPQRContent(hexPubkey, blockHeight, pqKeys) {
|
||||
var NIP_QR_KIND = 11112;
|
||||
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
|
||||
const npub = hexToNpub(hexPubkey);
|
||||
const content = `NOSTR identity: [hex] ${hexPubkey} [npub] ${npub} is signaling migration to successor post quantum pubkeys listed in the tags of this event. This link is established pre-quantum at blockheight ${blockHeight}.`;
|
||||
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.
|
||||
|
||||
My current identity:
|
||||
npub: ${npub}
|
||||
hex: ${hexPubkey}
|
||||
|
||||
This attestation is established pre-quantum at Bitcoin block height ${blockHeight}.
|
||||
|
||||
Post-quantum public keys in tags:
|
||||
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
|
||||
ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3)
|
||||
SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1)
|
||||
Falcon-512 (FIPS 206 draft, NIST Level 1)
|
||||
ML-KEM-768 (Kyber, FIPS 203, NIST Level 3)
|
||||
|
||||
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
|
||||
|
||||
Verify this attestation: https://laantungir.net/post-quantum/verify.html
|
||||
Created at: https://laantungir.net/post-quantum/`;
|
||||
const statementBytes = new TextEncoder().encode(content);
|
||||
const mlDsa44Sig = signWithMLDSA44(statementBytes, pqKeys.mlDsa44.secretKey);
|
||||
const mlDsa65Sig = signWithMLDSA65(statementBytes, pqKeys.mlDsa65.secretKey);
|
||||
const slhDsaSig = signWithSLHDSA(statementBytes, pqKeys.slhDsa.secretKey);
|
||||
const falconSig = signWithFalcon(statementBytes, pqKeys.falcon512.secretKey);
|
||||
const tags = [
|
||||
["d", "nip-qr-migration"],
|
||||
["block_height", String(blockHeight)],
|
||||
["algorithm", "ml-dsa-44", bytesToBase64(pqKeys.mlDsa44.publicKey), bytesToBase64(mlDsa44Sig)],
|
||||
["algorithm", "ml-dsa-65", bytesToBase64(pqKeys.mlDsa65.publicKey), bytesToBase64(mlDsa65Sig)],
|
||||
@@ -9694,12 +9806,113 @@ function buildNIPQRContent(hexPubkey, blockHeight, pqKeys) {
|
||||
["algorithm", "falcon-512", bytesToBase64(pqKeys.falcon512.publicKey), bytesToBase64(falconSig)],
|
||||
["algorithm", "ml-kem-768", bytesToBase64(pqKeys.mlKem.publicKey)]
|
||||
];
|
||||
return { content, tags, statementBytes };
|
||||
return {
|
||||
kind: 1,
|
||||
content,
|
||||
tags,
|
||||
pubkey: hexPubkey,
|
||||
created_at: Math.floor(Date.now() / 1e3),
|
||||
statementBytes
|
||||
};
|
||||
}
|
||||
function verifyNIPQRContent(contentText, tags) {
|
||||
function computeEventId(event) {
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content
|
||||
]);
|
||||
return bytesToHex3(sha256(new TextEncoder().encode(serialized)));
|
||||
}
|
||||
function hashFullEvent(signedEvent) {
|
||||
const json = JSON.stringify(signedEvent);
|
||||
return bytesToHex3(sha256(new TextEncoder().encode(json)));
|
||||
}
|
||||
function buildKind11112Wrapper(kind1Event, otsProof) {
|
||||
const fullHash = hashFullEvent(kind1Event);
|
||||
return {
|
||||
kind: NIP_QR_KIND,
|
||||
content: JSON.stringify(kind1Event),
|
||||
tags: [
|
||||
["e", kind1Event.id],
|
||||
["sha256", fullHash],
|
||||
["ots", bytesToBase64(otsProof)]
|
||||
],
|
||||
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 verifyNIPQRContent(kind11112Content, kind11112Tags) {
|
||||
const results = [];
|
||||
const msg = new TextEncoder().encode(contentText);
|
||||
for (const tag of tags) {
|
||||
let kind1Event;
|
||||
try {
|
||||
kind1Event = JSON.parse(kind11112Content);
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
results: [{ algorithm: "kind 1 content", valid: false, note: "Content is not valid JSON" }],
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false
|
||||
};
|
||||
}
|
||||
if (!kind1Event.pubkey || !kind1Event.sig || !kind1Event.content || !kind1Event.tags || kind1Event.kind !== 1) {
|
||||
return {
|
||||
valid: false,
|
||||
results: [{ algorithm: "kind 1 content", valid: false, note: "Embedded event is not a valid kind 1 event" }],
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false
|
||||
};
|
||||
}
|
||||
const kind1SigValid = verifyNostrEvent(kind1Event);
|
||||
results.push({
|
||||
algorithm: "secp256k1 (kind 1 announcement)",
|
||||
valid: kind1SigValid,
|
||||
note: kind1SigValid ? "valid" : "INVALID"
|
||||
});
|
||||
const computedKind1Id = computeEventId(kind1Event);
|
||||
const eTag = kind11112Tags.find((t) => t[0] === "e");
|
||||
let eTagValid = false;
|
||||
if (eTag && eTag[1]) {
|
||||
eTagValid = eTag[1].toLowerCase() === computedKind1Id.toLowerCase();
|
||||
if (kind1Event.id && kind1Event.id.toLowerCase() !== computedKind1Id.toLowerCase()) {
|
||||
eTagValid = false;
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
algorithm: "e tag (kind 1 event ID)",
|
||||
valid: eTagValid,
|
||||
note: eTagValid ? `matches (${computedKind1Id.substring(0, 16)}...)` : eTag ? `mismatch: e tag=${eTag[1].substring(0, 16)}... computed=${computedKind1Id.substring(0, 16)}...` : "tag missing"
|
||||
});
|
||||
const fullHash = hashFullEvent(kind1Event);
|
||||
const sha256Tag = kind11112Tags.find((t) => t[0] === "sha256");
|
||||
let sha256Valid = false;
|
||||
if (sha256Tag && sha256Tag[1]) {
|
||||
sha256Valid = sha256Tag[1].toLowerCase() === fullHash.toLowerCase();
|
||||
}
|
||||
results.push({
|
||||
algorithm: "sha256 (full kind 1 event hash)",
|
||||
valid: sha256Valid,
|
||||
note: sha256Valid ? `matches (${fullHash.substring(0, 16)}...)` : sha256Tag ? `mismatch: tag=${sha256Tag[1].substring(0, 16)}... computed=${fullHash.substring(0, 16)}...` : "tag missing"
|
||||
});
|
||||
const msg = new TextEncoder().encode(kind1Event.content);
|
||||
for (const tag of kind1Event.tags) {
|
||||
if (tag[0] !== "algorithm") continue;
|
||||
const algorithm = tag[1];
|
||||
const pubKeyBase64 = tag[2];
|
||||
@@ -9724,7 +9937,11 @@ function verifyNIPQRContent(contentText, tags) {
|
||||
}
|
||||
return {
|
||||
valid: results.every((r) => r.valid),
|
||||
results
|
||||
results,
|
||||
kind1Event,
|
||||
fullHash,
|
||||
sha256Valid,
|
||||
eTagValid
|
||||
};
|
||||
}
|
||||
var PQ_KEY_INFO = {
|
||||
@@ -9807,7 +10024,7 @@ async function timestampEvent(eventIdHex) {
|
||||
body: hashBytes,
|
||||
headers: {
|
||||
"Accept": "application/vnd.opentimestamps.v1",
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
"Content-Type": "application/octet-stream"
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -9850,6 +10067,11 @@ async function upgradeOts(otsBytes) {
|
||||
};
|
||||
}
|
||||
function isOtsConfirmed(otsBytes) {
|
||||
try {
|
||||
const parsed = parseOtsFile(otsBytes);
|
||||
if (!parsed) return false;
|
||||
return parsed.attestations.some((a) => a.type === "bitcoin");
|
||||
} catch (e) {
|
||||
const bitcoinTag = hexToBytes3("0588960d73d71901");
|
||||
outer: for (let i = 0; i <= otsBytes.length - bitcoinTag.length; i++) {
|
||||
for (let j = 0; j < bitcoinTag.length; j++) {
|
||||
@@ -9858,6 +10080,218 @@ function isOtsConfirmed(otsBytes) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
var OtsReader = class {
|
||||
constructor(bytes) {
|
||||
this.bytes = bytes;
|
||||
this.pos = 0;
|
||||
}
|
||||
readByte() {
|
||||
if (this.pos >= this.bytes.length) throw new Error("OTS: unexpected end of data");
|
||||
return this.bytes[this.pos++];
|
||||
}
|
||||
readBytes(n) {
|
||||
if (this.pos + n > this.bytes.length) throw new Error("OTS: unexpected end of data");
|
||||
const out = this.bytes.slice(this.pos, this.pos + n);
|
||||
this.pos += n;
|
||||
return out;
|
||||
}
|
||||
readVaruint() {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
let b;
|
||||
do {
|
||||
b = this.readByte();
|
||||
value |= (b & 127) << shift;
|
||||
shift += 7;
|
||||
} while (b & 128);
|
||||
return value;
|
||||
}
|
||||
readVarbytes(maxLen = 4096) {
|
||||
const len = this.readVaruint();
|
||||
if (len > maxLen) throw new Error(`OTS: varbytes length ${len} exceeds max ${maxLen}`);
|
||||
return this.readBytes(len);
|
||||
}
|
||||
remaining() {
|
||||
return this.bytes.length - this.pos;
|
||||
}
|
||||
};
|
||||
var OTS_MAGIC = hexToBytes3("004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294");
|
||||
var BITCOIN_ATTESTATION_TAG = hexToBytes3("0588960d73d71901");
|
||||
var LITECOIN_ATTESTATION_TAG = hexToBytes3("06869a0d73d71b45");
|
||||
var PENDING_ATTESTATION_TAG = hexToBytes3("83dfe30d2ef90c8e");
|
||||
function bytesEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
function parseOtsFile(otsBytes) {
|
||||
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_MAGIC.length) return null;
|
||||
const reader = new OtsReader(otsBytes);
|
||||
const magic = reader.readBytes(OTS_MAGIC.length);
|
||||
if (!bytesEqual(magic, OTS_MAGIC)) return null;
|
||||
const version = reader.readVaruint();
|
||||
if (version !== 1) return null;
|
||||
const hashOpTag = reader.readByte();
|
||||
let fileHashOp = "unknown";
|
||||
let digestLen = 32;
|
||||
if (hashOpTag === 8) {
|
||||
fileHashOp = "sha256";
|
||||
digestLen = 32;
|
||||
} else if (hashOpTag === 2) {
|
||||
fileHashOp = "sha1";
|
||||
digestLen = 20;
|
||||
} else if (hashOpTag === 3) {
|
||||
fileHashOp = "ripemd160";
|
||||
digestLen = 20;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const targetDigest = reader.readBytes(digestLen);
|
||||
const attestations = [];
|
||||
_parseTimestamp(reader, targetDigest, attestations, 0);
|
||||
return { fileHashOp, targetDigest, attestations };
|
||||
}
|
||||
function _parseTimestamp(reader, msg, attestations, depth) {
|
||||
if (depth > 64) throw new Error("OTS: recursion limit exceeded");
|
||||
let tag = reader.readByte();
|
||||
while (tag === 255) {
|
||||
const current = reader.readByte();
|
||||
_processTimestampEntry(reader, current, msg, attestations, depth);
|
||||
tag = reader.readByte();
|
||||
}
|
||||
_processTimestampEntry(reader, tag, msg, attestations, depth);
|
||||
}
|
||||
function _processTimestampEntry(reader, tag, msg, attestations, depth) {
|
||||
if (tag === 0) {
|
||||
const attTag = reader.readBytes(8);
|
||||
const payload = reader.readVarbytes(8192);
|
||||
_classifyAttestation(attTag, payload, msg, attestations);
|
||||
} else {
|
||||
const result = _applyOp(reader, tag, msg);
|
||||
_parseTimestamp(reader, result, attestations, depth + 1);
|
||||
}
|
||||
}
|
||||
function _applyOp(reader, tag, msg) {
|
||||
if (tag === 8) {
|
||||
return sha256(msg);
|
||||
} else if (tag === 2) {
|
||||
return sha1(msg);
|
||||
} else if (tag === 3) {
|
||||
return ripemd160(msg);
|
||||
} else if (tag === 240) {
|
||||
const arg = reader.readVarbytes(4096);
|
||||
return concatBytes4(msg, arg);
|
||||
} else if (tag === 241) {
|
||||
const arg = reader.readVarbytes(4096);
|
||||
return concatBytes4(arg, msg);
|
||||
} else if (tag === 242) {
|
||||
return msg.slice().reverse();
|
||||
} else {
|
||||
throw new Error(`OTS: unknown operation tag 0x${tag.toString(16)}`);
|
||||
}
|
||||
}
|
||||
function _classifyAttestation(tag, payload, digest, attestations) {
|
||||
if (bytesEqual(tag, BITCOIN_ATTESTATION_TAG)) {
|
||||
const r = new OtsReader(payload);
|
||||
const height = r.readVaruint();
|
||||
attestations.push({ type: "bitcoin", height, digest });
|
||||
} else if (bytesEqual(tag, LITECOIN_ATTESTATION_TAG)) {
|
||||
const r = new OtsReader(payload);
|
||||
const height = r.readVaruint();
|
||||
attestations.push({ type: "litecoin", height, digest });
|
||||
} else if (bytesEqual(tag, PENDING_ATTESTATION_TAG)) {
|
||||
const r = new OtsReader(payload);
|
||||
const uri = new TextDecoder().decode(r.readVarbytes(1024));
|
||||
attestations.push({ type: "pending", uri, digest });
|
||||
} else {
|
||||
attestations.push({ type: "unknown", tag, digest });
|
||||
}
|
||||
}
|
||||
var BITCOIN_API_PROVIDERS = [
|
||||
{ name: "blockstream", blockHash: (h) => `https://blockstream.info/api/block-height/${h}`, block: (hash) => `https://blockstream.info/api/block/${hash}` },
|
||||
{ name: "mempool", blockHash: (h) => `https://mempool.space/api/block-height/${h}`, block: (hash) => `https://mempool.space/api/block/${hash}` }
|
||||
];
|
||||
async function fetchBitcoinBlockHeader(height) {
|
||||
for (const provider of BITCOIN_API_PROVIDERS) {
|
||||
try {
|
||||
const hashResp = await fetch(provider.blockHash(height), { headers: { "Accept": "text/plain" } });
|
||||
if (!hashResp.ok) continue;
|
||||
const blockHash = (await hashResp.text()).trim();
|
||||
if (!blockHash || blockHash.length !== 64) continue;
|
||||
const blockResp = await fetch(provider.block(blockHash), { headers: { "Accept": "application/json" } });
|
||||
if (!blockResp.ok) continue;
|
||||
const blockData = await blockResp.json();
|
||||
if (!blockData.merkle_root && !blockData.merkleroot) continue;
|
||||
if (!blockData.timestamp && !blockData.time) continue;
|
||||
return {
|
||||
merkleroot: blockData.merkle_root || blockData.merkleroot,
|
||||
time: blockData.timestamp || blockData.time,
|
||||
height
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`[ots] Bitcoin API ${provider.name} failed for height ${height}:`, e.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
|
||||
}
|
||||
async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
const errors = [];
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseOtsFile(otsBytes);
|
||||
} catch (e) {
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: [`Failed to parse OTS file: ${e.message}`] };
|
||||
}
|
||||
if (!parsed) {
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: ["Invalid OTS file format"] };
|
||||
}
|
||||
const targetDigestHex = bytesToHex3(parsed.targetDigest);
|
||||
if (expectedDigestHex) {
|
||||
const expected = expectedDigestHex.toLowerCase();
|
||||
const actual = targetDigestHex.toLowerCase();
|
||||
if (expected !== actual) {
|
||||
errors.push(`Target digest mismatch: proof commits to ${actual.substring(0, 16)}... but expected ${expected.substring(0, 16)}...`);
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
|
||||
}
|
||||
}
|
||||
const bitcoinAttestations = parsed.attestations.filter((a) => a.type === "bitcoin");
|
||||
const pendingAttestations = parsed.attestations.filter((a) => a.type === "pending");
|
||||
if (bitcoinAttestations.length === 0) {
|
||||
if (pendingAttestations.length > 0) {
|
||||
errors.push("Proof contains only pending attestations (not yet confirmed on Bitcoin)");
|
||||
} else {
|
||||
errors.push("Proof contains no Bitcoin attestations");
|
||||
}
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
|
||||
}
|
||||
const verified = [];
|
||||
for (const att of bitcoinAttestations) {
|
||||
try {
|
||||
const blockHeader = await fetchBitcoinBlockHeader(att.height);
|
||||
const computedMerkleRoot = bytesToHex3(att.digest.slice().reverse());
|
||||
if (computedMerkleRoot.toLowerCase() === blockHeader.merkleroot.toLowerCase()) {
|
||||
verified.push({
|
||||
height: att.height,
|
||||
time: blockHeader.time,
|
||||
merkleroot: blockHeader.merkleroot
|
||||
});
|
||||
} else {
|
||||
errors.push(`Bitcoin attestation for block ${att.height}: merkle root mismatch (computed ${computedMerkleRoot.substring(0, 16)}..., block has ${blockHeader.merkleroot.substring(0, 16)}...)`);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`Could not verify Bitcoin attestation for block ${att.height}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
verified: verified.length > 0,
|
||||
targetDigest: targetDigestHex,
|
||||
attestations: parsed.attestations,
|
||||
bitcoinAttestations: verified,
|
||||
errors
|
||||
};
|
||||
}
|
||||
function savePendingOts(eventId, otsBytes, metadata = {}) {
|
||||
let existing = {};
|
||||
@@ -9897,15 +10331,21 @@ function clearPendingOts() {
|
||||
localStorage.removeItem("pq-pending-ots");
|
||||
}
|
||||
export {
|
||||
NIP_QR_KIND,
|
||||
PQ_KEY_INFO,
|
||||
base64ToBytes,
|
||||
buildNIPQRContent,
|
||||
buildKind11112Wrapper,
|
||||
buildKind1Announcement,
|
||||
buildUpgradedEvent,
|
||||
bytesToBase64,
|
||||
bytesToHex3 as bytesToHex,
|
||||
clearPendingOts,
|
||||
computeEventId,
|
||||
derivePQKeysFromSeed,
|
||||
deriveSecp256k1FromSeed,
|
||||
generateSeedPhrase,
|
||||
generateSeedPhraseWithEntropy,
|
||||
hashFullEvent,
|
||||
hexToBytes3 as hexToBytes,
|
||||
hexToNpub,
|
||||
isDetachedOtsFile,
|
||||
@@ -9913,6 +10353,7 @@ export {
|
||||
isValidMnemonic,
|
||||
loadPendingOts,
|
||||
mnemonicToSeed,
|
||||
parseOtsFile,
|
||||
savePendingOts,
|
||||
signWithFalcon,
|
||||
signWithMLDSA44,
|
||||
@@ -9925,6 +10366,7 @@ export {
|
||||
verifyMLDSA65,
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent,
|
||||
verifyOtsProof,
|
||||
verifySLHDSA
|
||||
};
|
||||
/*! Bundled license information:
|
||||
|
||||
+558
-44
@@ -4,6 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none';" />
|
||||
<title>Verify NIP-QR Event</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
@@ -19,7 +20,7 @@
|
||||
}
|
||||
|
||||
.pq-container {
|
||||
max-width: 700px;
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -41,7 +42,7 @@
|
||||
|
||||
.pq-info-text {
|
||||
font-size: 14px;
|
||||
color: var(--muted-color);
|
||||
color: var(--primary-color);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
@@ -64,6 +65,31 @@
|
||||
.pq-button:hover { opacity: 0.7; }
|
||||
.pq-button:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.pq-button-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pq-button-row .pq-button { width: auto; }
|
||||
|
||||
.pq-input {
|
||||
width: 100%;
|
||||
background: var(--secondary-color);
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--primary-color);
|
||||
margin: 10px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pq-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.pq-textarea {
|
||||
width: 100%;
|
||||
background: var(--secondary-color);
|
||||
@@ -73,9 +99,10 @@
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
margin: 10px 0;
|
||||
min-height: 200px;
|
||||
min-height: 160px;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pq-textarea:focus {
|
||||
@@ -151,6 +178,57 @@
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pq-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
.pq-tab {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--muted-color);
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.pq-tab:hover { color: var(--primary-color); }
|
||||
|
||||
.pq-tab.pq-tab-active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pq-tab-panel { display: none; }
|
||||
.pq-tab-panel.pq-tab-panel-active { display: block; }
|
||||
|
||||
.pq-ots-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.pq-ots-badge-pending {
|
||||
background: #f0ad4e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pq-ots-badge-confirmed {
|
||||
background: #00aa00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pq-ots-badge-none {
|
||||
background: var(--muted-color);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -171,32 +249,65 @@
|
||||
<div class="pq-card">
|
||||
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
|
||||
<div class="pq-info-text">
|
||||
Paste a NIP-QR event JSON below to verify all signatures.
|
||||
This will check the secp256k1 signature and all 5 post-quantum signatures (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, and ML-KEM-768).
|
||||
Verify post-quantum migration events (kind 11112) by querying relays for a user's pubkey,
|
||||
or by pasting event JSON directly. The kind 11112 event wraps a kind 1 announcement
|
||||
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks:
|
||||
the kind 11112 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
|
||||
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), all PQ signatures
|
||||
(ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768), and the OpenTimestamps proof.
|
||||
</div>
|
||||
<div class="pq-info-text">
|
||||
<a href="./" class="pq-link">Back to migration page</a>
|
||||
</div>
|
||||
|
||||
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste event JSON here, e.g.:
|
||||
<div class="pq-tabs">
|
||||
<div class="pq-tab pq-tab-active" id="pqTabRelay">Query Relay</div>
|
||||
<div class="pq-tab" id="pqTabPaste">Paste Event JSON</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1: Query Relay -->
|
||||
<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.
|
||||
</div>
|
||||
<input type="text" class="pq-input" id="pqPubkeyInput"
|
||||
placeholder="Pubkey (hex or npub), e.g. 3bf0c63f869a8972... or npub1..." />
|
||||
<input type="text" class="pq-input" id="pqRelayInput"
|
||||
value="wss://laantungir.net/relay,wss://relay.damus.io,wss://nos.lol"
|
||||
placeholder="wss://relay1.com,wss://relay2.com" />
|
||||
<button class="pq-button" id="pqQueryBtn">Query & Verify</button>
|
||||
<div id="pqQueryStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- 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.
|
||||
</div>
|
||||
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste kind 11112 event JSON here, e.g.:
|
||||
{
|
||||
"id": "...",
|
||||
"pubkey": "...",
|
||||
"content": "NOSTR identity: ...",
|
||||
"kind": 11112,
|
||||
"content": "{\"id\":\"...\",\"pubkey\":\"...\",\"kind\":1,\"content\":\"I am signaling...\",\"tags\":[...],\"sig\":\"...\"}",
|
||||
"tags": [
|
||||
["d", "nip-qr-migration"],
|
||||
["algorithm", "ml-dsa-44", "...", "..."],
|
||||
...
|
||||
["e", "<kind 1 event id>"],
|
||||
["sha256", "<hash of full kind 1 event>"],
|
||||
["ots", "<base64 .ots proof>"]
|
||||
],
|
||||
"sig": "..."
|
||||
}'></textarea>
|
||||
|
||||
<button class="pq-button" id="pqVerifyBtn">Verify Signatures</button>
|
||||
|
||||
<div id="pqVerifyStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- Shared results area -->
|
||||
<div id="pqResultsWrap" style="display: none;">
|
||||
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;"><strong>Verification Results:</strong></div>
|
||||
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;">
|
||||
<strong>Verification Results:</strong>
|
||||
<span id="pqOtsBadge"></span>
|
||||
</div>
|
||||
<div class="pq-result-list" id="pqResultList"></div>
|
||||
</div>
|
||||
|
||||
@@ -204,6 +315,22 @@
|
||||
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Event content:</strong></div>
|
||||
<div class="pq-event-preview" id="pqEventContent"></div>
|
||||
</div>
|
||||
|
||||
<div id="pqEventJsonWrap" style="display: none; margin-top: 15px;">
|
||||
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Full event JSON:</strong></div>
|
||||
<div class="pq-event-preview" id="pqEventJson"></div>
|
||||
</div>
|
||||
|
||||
<!-- OTS upgrade section -->
|
||||
<div id="pqOtsUpgradeWrap" style="display: none; margin-top: 15px;">
|
||||
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>OpenTimestamps:</strong></div>
|
||||
<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>
|
||||
</div>
|
||||
<div id="pqOtsUpgradeStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -217,22 +344,70 @@
|
||||
</div>
|
||||
|
||||
<!-- SCRIPTS -->
|
||||
<script src="/nostr-login-lite/nostr.bundle.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent
|
||||
verifyNostrEvent,
|
||||
NIP_QR_KIND,
|
||||
buildUpgradedEvent,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
hexToBytes,
|
||||
bytesToHex,
|
||||
isOtsConfirmed,
|
||||
verifyOtsProof,
|
||||
upgradeOts,
|
||||
isDetachedOtsFile
|
||||
} from './pq-crypto.bundle.js';
|
||||
|
||||
const verifyBtn = document.getElementById('pqVerifyBtn');
|
||||
const eventInput = document.getElementById('pqEventInput');
|
||||
const verifyStatus = document.getElementById('pqVerifyStatus');
|
||||
/* ================================================================
|
||||
TAB MANAGEMENT
|
||||
================================================================ */
|
||||
const tabRelay = document.getElementById('pqTabRelay');
|
||||
const tabPaste = document.getElementById('pqTabPaste');
|
||||
const panelRelay = document.getElementById('pqPanelRelay');
|
||||
const panelPaste = document.getElementById('pqPanelPaste');
|
||||
|
||||
function switchTab(which) {
|
||||
const isRelay = which === 'relay';
|
||||
tabRelay.classList.toggle('pq-tab-active', isRelay);
|
||||
tabPaste.classList.toggle('pq-tab-active', !isRelay);
|
||||
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
|
||||
panelPaste.classList.toggle('pq-tab-panel-active', !isRelay);
|
||||
}
|
||||
|
||||
tabRelay.addEventListener('click', () => switchTab('relay'));
|
||||
tabPaste.addEventListener('click', () => switchTab('paste'));
|
||||
|
||||
/* ================================================================
|
||||
SHARED DOM + STATE
|
||||
================================================================ */
|
||||
const resultsWrap = document.getElementById('pqResultsWrap');
|
||||
const resultList = document.getElementById('pqResultList');
|
||||
const eventDisplayWrap = document.getElementById('pqEventDisplayWrap');
|
||||
const eventContent = document.getElementById('pqEventContent');
|
||||
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
|
||||
const eventJsonEl = document.getElementById('pqEventJson');
|
||||
const otsBadge = document.getElementById('pqOtsBadge');
|
||||
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
|
||||
const otsInfo = document.getElementById('pqOtsInfo');
|
||||
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
|
||||
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
|
||||
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
|
||||
|
||||
function setStatus(type, message) {
|
||||
verifyStatus.innerHTML = `<div class="pq-status pq-status-${type}">${message}</div>`;
|
||||
let currentEvent = null;
|
||||
let currentOtsBytes = null;
|
||||
|
||||
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
|
||||
function setStatus(element, type, message) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `pq-status pq-status-${type}`;
|
||||
div.textContent = message;
|
||||
element.innerHTML = '';
|
||||
element.appendChild(div);
|
||||
}
|
||||
|
||||
function addResult(algorithm, valid, detail) {
|
||||
@@ -242,57 +417,396 @@
|
||||
resultList.appendChild(item);
|
||||
}
|
||||
|
||||
verifyBtn.addEventListener('click', async () => {
|
||||
verifyBtn.disabled = true;
|
||||
resultsWrap.style.display = 'none';
|
||||
eventDisplayWrap.style.display = 'none';
|
||||
resultList.innerHTML = '';
|
||||
setStatus('info', 'Verifying...');
|
||||
|
||||
try {
|
||||
const event = JSON.parse(eventInput.value.trim());
|
||||
if (!event || !event.pubkey || !event.sig || !event.content || !event.tags) {
|
||||
throw new Error('Invalid event: missing required fields (pubkey, sig, content, tags)');
|
||||
// F-H3: Helper to set OTS badge (static HTML, safe) and info (textContent, safe)
|
||||
function setOtsBadge(className, text) {
|
||||
otsBadge.innerHTML = `<span class="pq-ots-badge ${className}"></span>`;
|
||||
otsBadge.querySelector('span').textContent = text;
|
||||
}
|
||||
function setOtsInfo(text) {
|
||||
otsInfo.textContent = text;
|
||||
}
|
||||
|
||||
// Display the event content
|
||||
/* ================================================================
|
||||
NPUB <-> HEX CONVERSION (NIP-19)
|
||||
================================================================ */
|
||||
function npubToHex(npub) {
|
||||
try {
|
||||
const decoded = window.NostrTools.nip19.decode(npub);
|
||||
if (decoded.type === 'npub') return decoded.data;
|
||||
} catch (e) { /* fall through */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function hexToNpub(hex) {
|
||||
try {
|
||||
return window.NostrTools.nip19.npubEncode(hex);
|
||||
} catch (e) { return hex; }
|
||||
}
|
||||
|
||||
function normalizePubkey(input) {
|
||||
const trimmed = input.trim();
|
||||
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase();
|
||||
if (trimmed.startsWith('npub1')) {
|
||||
const hex = npubToHex(trimmed);
|
||||
if (hex) return hex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
QUERY RELAY FOR KIND 11112 EVENT
|
||||
================================================================ */
|
||||
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);
|
||||
|
||||
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.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.onerror = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('connection error'));
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
VERIFY EVENT (shared by both tabs)
|
||||
================================================================ */
|
||||
async function verifyEvent(event) {
|
||||
currentEvent = event;
|
||||
resultList.innerHTML = '';
|
||||
resultsWrap.style.display = 'none';
|
||||
eventDisplayWrap.style.display = 'none';
|
||||
eventJsonWrap.style.display = 'none';
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
otsBadge.innerHTML = '';
|
||||
|
||||
// Display content (the embedded kind 1 event, pretty-printed) and full kind 11112 JSON
|
||||
try {
|
||||
const parsed = JSON.parse(event.content);
|
||||
eventContent.textContent = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
eventContent.textContent = event.content;
|
||||
}
|
||||
eventDisplayWrap.style.display = 'block';
|
||||
eventJsonEl.textContent = JSON.stringify(event, null, 2);
|
||||
eventJsonWrap.style.display = 'block';
|
||||
|
||||
let allValid = true;
|
||||
|
||||
// 1. Verify secp256k1 signature
|
||||
setStatus('info', 'Verifying secp256k1 signature...');
|
||||
// 1. Verify kind 11112 secp256k1 signature
|
||||
const secpValid = verifyNostrEvent(event);
|
||||
addResult('secp256k1 (NIP-01 Schnorr)', secpValid, secpValid ? 'valid' : 'INVALID');
|
||||
addResult('secp256k1 (kind 11112 wrapper)', secpValid, secpValid ? 'valid' : 'INVALID');
|
||||
if (!secpValid) allValid = false;
|
||||
|
||||
// 2. Verify PQ signatures
|
||||
setStatus('info', 'Verifying PQ signatures...');
|
||||
await new Promise(r => setTimeout(r, 50)); // let UI update
|
||||
|
||||
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag
|
||||
const pqResults = verifyNIPQRContent(event.content, event.tags);
|
||||
for (const r of pqResults.results) {
|
||||
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
|
||||
if (!r.valid) allValid = false;
|
||||
}
|
||||
|
||||
// Show results
|
||||
resultsWrap.style.display = 'block';
|
||||
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
|
||||
// First do a quick structural check for immediate UI feedback, then
|
||||
// run full cryptographic verification (async — fetches Bitcoin block
|
||||
// headers and validates the Merkle path).
|
||||
const otsTag = event.tags.find(t => t[0] === 'ots');
|
||||
const sha256Tag = event.tags.find(t => t[0] === 'sha256');
|
||||
if (otsTag && otsTag[1]) {
|
||||
try {
|
||||
currentOtsBytes = base64ToBytes(otsTag[1]);
|
||||
const hasBitcoinAttestation = isOtsConfirmed(currentOtsBytes);
|
||||
const validFile = isDetachedOtsFile(currentOtsBytes);
|
||||
const fullHash = pqResults.fullHash;
|
||||
const hashMatchNote = (sha256Tag && fullHash)
|
||||
? (sha256Tag[1].toLowerCase() === fullHash.toLowerCase()
|
||||
? `Hash matches full kind 1 event: ${fullHash.substring(0, 16)}...`
|
||||
: `WARNING: sha256 tag (${sha256Tag[1].substring(0, 16)}...) does not match computed hash (${fullHash.substring(0, 16)}...)`)
|
||||
: 'No sha256 tag found';
|
||||
|
||||
if (allValid) {
|
||||
setStatus('success', 'All signatures verified successfully!');
|
||||
// Quick structural display first
|
||||
if (!validFile) {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: Invalid format');
|
||||
setOtsInfo(`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
} else if (hasBitcoinAttestation) {
|
||||
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verifying...');
|
||||
setOtsInfo(`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
|
||||
otsUpgradeWrap.style.display = 'block';
|
||||
otsUpgradeBtn.style.display = 'none';
|
||||
otsRepublishBtn.style.display = 'none';
|
||||
|
||||
// Run full async verification: parse the proof, bind the target
|
||||
// digest to the sha256 tag, walk the Merkle path, and check the
|
||||
// block header against a Bitcoin API.
|
||||
verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined).then(result => {
|
||||
if (result.verified && result.bitcoinAttestations.length > 0) {
|
||||
const att = result.bitcoinAttestations[0];
|
||||
const date = new Date(att.time * 1000).toISOString().substring(0, 19);
|
||||
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
|
||||
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
|
||||
otsUpgradeBtn.style.display = 'none';
|
||||
} else {
|
||||
setStatus('error', 'Some signatures failed verification.');
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
|
||||
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
|
||||
setOtsInfo(`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
|
||||
otsUpgradeBtn.style.display = 'inline-block';
|
||||
}
|
||||
}).catch(err => {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
|
||||
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
|
||||
otsUpgradeBtn.style.display = 'inline-block';
|
||||
});
|
||||
} else {
|
||||
setOtsBadge('pq-ots-badge-pending', 'OTS: Pending');
|
||||
setOtsInfo(`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
|
||||
otsUpgradeWrap.style.display = 'block';
|
||||
otsUpgradeBtn.style.display = 'inline-block';
|
||||
otsRepublishBtn.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
|
||||
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: None');
|
||||
setOtsInfo('No OpenTimestamps proof tag found in this event.');
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
}
|
||||
|
||||
resultsWrap.style.display = 'block';
|
||||
return allValid;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
TAB 1: QUERY RELAY
|
||||
================================================================ */
|
||||
const queryBtn = document.getElementById('pqQueryBtn');
|
||||
const queryStatus = document.getElementById('pqQueryStatus');
|
||||
|
||||
queryBtn.addEventListener('click', async () => {
|
||||
queryBtn.disabled = true;
|
||||
setStatus(queryStatus, 'info', 'Querying relays...');
|
||||
|
||||
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
|
||||
const relayInput = document.getElementById('pqRelayInput').value.trim();
|
||||
|
||||
const pubkeyHex = normalizePubkey(pubkeyInput);
|
||||
if (!pubkeyHex) {
|
||||
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
|
||||
queryBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (relayUrls.length === 0) {
|
||||
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
|
||||
queryBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let foundEvent = null;
|
||||
let lastError = null;
|
||||
let successCount = 0;
|
||||
|
||||
// Query relays in parallel, take the first event returned
|
||||
const promises = relayUrls.map(async (url) => {
|
||||
try {
|
||||
const event = await queryRelayForEvent(pubkeyHex, url);
|
||||
if (event) {
|
||||
successCount++;
|
||||
if (!foundEvent || (event.created_at > foundEvent.created_at)) {
|
||||
foundEvent = event;
|
||||
}
|
||||
}
|
||||
return { url, ok: true, event };
|
||||
} catch (e) {
|
||||
lastError = e.message;
|
||||
return { url, ok: false, error: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
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 + ')' : ''}.`);
|
||||
}
|
||||
|
||||
queryBtn.disabled = false;
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
TAB 2: PASTE EVENT JSON
|
||||
================================================================ */
|
||||
const verifyBtn = document.getElementById('pqVerifyBtn');
|
||||
const eventInput = document.getElementById('pqEventInput');
|
||||
const verifyStatus = document.getElementById('pqVerifyStatus');
|
||||
|
||||
verifyBtn.addEventListener('click', async () => {
|
||||
verifyBtn.disabled = true;
|
||||
setStatus(verifyStatus, 'info', 'Verifying...');
|
||||
|
||||
try {
|
||||
const event = JSON.parse(eventInput.value.trim());
|
||||
if (!event || !event.pubkey || !event.sig || !event.content || !event.tags) {
|
||||
throw new Error('Invalid event: missing required fields (pubkey, sig, content, tags)');
|
||||
}
|
||||
if (event.kind !== NIP_QR_KIND) {
|
||||
setStatus(verifyStatus, 'error', `Warning: event kind is ${event.kind}, expected ${NIP_QR_KIND}. Verifying anyway...`);
|
||||
}
|
||||
const allValid = await verifyEvent(event);
|
||||
if (allValid) {
|
||||
setStatus(verifyStatus, 'success', 'All signatures verified successfully!');
|
||||
} else {
|
||||
setStatus(verifyStatus, 'error', 'Some signatures failed verification.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[verify] Error:', error);
|
||||
setStatus('error', `Error: ${error.message}`);
|
||||
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
|
||||
} finally {
|
||||
verifyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
OTS UPGRADE (from verify page)
|
||||
================================================================ */
|
||||
otsUpgradeBtn.addEventListener('click', async () => {
|
||||
if (!currentOtsBytes) return;
|
||||
otsUpgradeBtn.disabled = true;
|
||||
setStatus(otsUpgradeStatus, 'info', 'Sending .ots proof to upgrade service...');
|
||||
|
||||
try {
|
||||
const result = await upgradeOts(currentOtsBytes);
|
||||
currentOtsBytes = result.proof;
|
||||
// After upgrading, run full cryptographic verification to confirm
|
||||
// the Bitcoin attestation is real (not just a byte-pattern match).
|
||||
const sha256Tag = currentEvent && currentEvent.tags ? currentEvent.tags.find(t => t[0] === 'sha256') : null;
|
||||
const verifyResult = await verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
|
||||
const confirmed = verifyResult.verified;
|
||||
|
||||
if (confirmed) {
|
||||
const att = verifyResult.bitcoinAttestations[0];
|
||||
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
|
||||
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can republish the event with the confirmed proof below.`);
|
||||
otsUpgradeBtn.style.display = 'none';
|
||||
otsRepublishBtn.style.display = 'inline-block';
|
||||
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
|
||||
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att ? att.height : '?'}. Proof size: ${currentOtsBytes.length} bytes.`);
|
||||
} else {
|
||||
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(otsUpgradeStatus, 'error', `Upgrade failed: ${error.message}`);
|
||||
} finally {
|
||||
otsUpgradeBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
otsRepublishBtn.addEventListener('click', async () => {
|
||||
if (!currentEvent || !currentOtsBytes) return;
|
||||
otsRepublishBtn.disabled = true;
|
||||
setStatus(otsUpgradeStatus, 'info', 'Requesting secp256k1 signature for upgraded event...');
|
||||
|
||||
try {
|
||||
if (!window.nostr || !window.nostr.signEvent) {
|
||||
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to republish.');
|
||||
}
|
||||
|
||||
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
|
||||
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
|
||||
|
||||
// Publish to the relays from the relay input field
|
||||
const relayInput = document.getElementById('pqRelayInput').value.trim();
|
||||
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (relayUrls.length === 0) {
|
||||
throw new Error('No relay URLs specified in the relay input field.');
|
||||
}
|
||||
|
||||
const eventJson = JSON.stringify(['EVENT', signedEvent]);
|
||||
const publishResults = await Promise.all(relayUrls.map(url => {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const ws = new WebSocket(url);
|
||||
let done = false;
|
||||
const t = setTimeout(() => { if (!done) { done = true; try { ws.close(); } catch(e){} resolve({ url, success: false, error: 'timeout' }); } }, 15000);
|
||||
ws.onopen = () => ws.send(eventJson);
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data[0] === 'OK' && data[1] === signedEvent.id) {
|
||||
if (!done) { done = true; clearTimeout(t); try { ws.close(); } catch(e){} resolve({ url, success: true }); }
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
ws.onclose = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'closed without OK' }); } };
|
||||
ws.onerror = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'error' }); } };
|
||||
} catch (e) { resolve({ url, success: false, error: e.message }); }
|
||||
});
|
||||
}));
|
||||
|
||||
const ok = publishResults.filter(r => r.success).length;
|
||||
const fail = publishResults.filter(r => !r.success).length;
|
||||
|
||||
if (ok === 0) {
|
||||
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
|
||||
}
|
||||
|
||||
currentEvent = signedEvent;
|
||||
setStatus(otsUpgradeStatus, 'success', `Upgraded kind 11112 event published to ${ok} relay(s) (${fail} failed).`);
|
||||
// Re-verify the new event
|
||||
await verifyEvent(signedEvent);
|
||||
} catch (error) {
|
||||
setStatus(otsUpgradeStatus, 'error', `Republish failed: ${error.message}`);
|
||||
} finally {
|
||||
otsRepublishBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user