Files
nostr_quantum_preparation/plans/g56-02-non-replaceable-proof-carrier.md
T

184 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# G56-02 Implementation Plan: Non-Replaceable Proof Carrier with Append-Only Upgrades
**Date:** 2026-07-24
**Finding:** G56-02 — Earliest-valid-anchor rule is not implemented; relay discovery selects the latest replaceable event
**Approach:** Switch the proof carrier from replaceable kind 11112 to non-replaceable kind 9999, with append-only upgrade events and earliest-anchor selection
**No migration needed:** the project has not been publicly released
## Problem summary
Kind 11112 is in the 10000–19999 range, which Nostr relays treat as **replaceable** — they keep only the latest event per pubkey. This means:
1. An attacker who breaks secp256k1 can publish a new kind 11112 that replaces the original on relays
2. The code queries with `limit: 1` and selects the newest `created_at`, which is the opposite of the proposal's "earliest valid Bitcoin anchor wins" rule
3. The original event — with its earlier Bitcoin anchor — can be hidden
## Design
### Two-event structure (unchanged conceptually, changed kind)
**Event 1: Kind 1 Announcement** (non-replaceable, already correct)
- Contains the human-readable attestation text and PQ keys/signatures in `algorithm` tags
- Signed by the user's existing secp256k1 identity
- Its full signed JSON hash is what gets submitted to OpenTimestamps
- No changes needed — kind 1 is already in the 0–9999 non-replaceable range
**Event 2: Kind 9999 Proof Carrier** (non-replaceable, changed from 11112)
- References Event 1 by `e` tag
- Carries the `sha256` tag (hash of Event 1's full signed JSON)
- Carries the `ots` tag (base64 .ots proof)
- New `ots_status` tag: `"pending"` or `"confirmed"`
- New `upgrade_of` tag: references the previous proof carrier event ID (only on upgrade events)
- Published as append-only — upgrades publish a NEW event, never replace
### Append-only upgrade flow
```
1. User signs in → generates seed → derives PQ keys → signs kind 1 announcement
2. Submit kind 1 hash to OTS calendars → get pending .ots proof
3. Publish kind 1 announcement (permanent, non-replaceable)
4. Publish kind 9999 proof carrier with ots_status="pending" (permanent, non-replaceable)
5. Wait 10-30 min for Bitcoin confirmation
6. Upgrade the .ots proof via /ots-upgrade helper
7. Publish a NEW kind 9999 proof carrier with ots_status="confirmed" and upgrade_of="<pending event id>"
8. Both proof carriers remain on relays permanently — client picks the one with the earliest valid Bitcoin anchor
```
### Re-migration flow (user lost seed, runs process again years later)
```
1. User signs in with same Nostr identity → generates new seed → derives new PQ keys
2. Publishes a new kind 1 announcement (new PQ keys, new block height)
3. Publishes new kind 9999 proof carriers (pending, then confirmed)
4. Client sees multiple kind 1 announcements + multiple proof carriers
5. Earliest valid Bitcoin anchor across all of them is the canonical root
6. Later migrations are valid if published while secp256k1 is still trustworthy
```
### Earliest-anchor selection logic
A new pure function `selectCanonicalProofCarrier(candidates, kind1Event)`:
1. Input: array of kind 9999 events for a pubkey, plus the kind 1 announcement they reference
2. Filter: only candidates whose `e` tag matches the kind 1 event ID
3. Filter: only candidates whose `sha256` tag matches `hashFullEvent(kind1Event)`
4. Filter: only candidates with a valid secp256k1 signature
5. For each remaining candidate, parse and verify its OTS proof:
- If `ots_status` is "confirmed" and the OTS proof has a valid Bitcoin attestation → record the Bitcoin block height
- If `ots_status` is "pending" or the OTS proof has no Bitcoin attestation → skip (not yet anchored)
6. Sort valid candidates by Bitcoin block height (ascending)
7. Return the one with the earliest block height
8. If tie, sort by event ID lexicographically (deterministic tie-break)
9. If no candidate has a confirmed Bitcoin anchor, return the best pending candidate with a note
## Files to change
### 1. `www/js/pq-crypto.mjs`
**Change `NIP_QR_KIND`:**
- Line 430: change `11112` to `9999`
- Update comment: "Kind 9999 is a non-replaceable event (0–9999 range). Each publication is permanent. Upgrades publish a new event with `upgrade_of` referencing the previous one."
**Update `buildKind11112Wrapper()` → rename to `buildProofCarrier()`:**
- Add `['ots_status', 'pending']` tag (or 'confirmed' when applicable)
- Add optional `['upgrade_of', previousEventId]` tag when upgrading
- Keep existing `e`, `sha256`, `ots` tags
**Update `buildUpgradedEvent()`:**
- Instead of copying tags and replacing the `ots` tag, build a completely new proof carrier event
- Include `ots_status: 'confirmed'`
- Include `upgrade_of: <original proof carrier event id>`
- Keep the same `e` and `sha256` tags (they reference the same kind 1 event)
**Add `selectCanonicalProofCarrier(candidates, kind1Event)`:**
- Pure function, no network calls
- Takes array of proof carrier events and the kind 1 announcement
- Returns `{ canonical, allCandidates, errors }` where `canonical` is the event with the earliest valid Bitcoin anchor
- OTS verification is async, so this function should be async and call `verifyOtsProof()` for each candidate's OTS proof
### 2. `www/verify.html`
**Update `queryRelayForEvent()`:**
- Change kind from `NIP_QR_KIND` (which will be 9999) — already uses the constant, so this is automatic
- Remove `limit: 1` — fetch all events
- Return an array of events instead of a single event
**Update `verifyEvent()`:**
- Accept an array of proof carrier candidates
- Call `selectCanonicalProofCarrier()` to pick the canonical one
- Display all candidates in the UI with their OTS status and Bitcoin anchor height
- Highlight the canonical (earliest-anchor) one
- Still verify the kind 1 announcement and PQ signatures as before
**Update the relay query tab:**
- Query for both kind 1 and kind 9999 events for the pubkey
- Match proof carriers to announcements by `e` tag
- Display the full migration history
### 3. `www/index.html`
**Update `queryRelayForKind11112()` → rename to `queryRelayForProofCarriers()`:**
- Remove `limit: 1`
- Return an array
**Update the sign/publish flow:**
- After signing the kind 1 announcement and getting the pending OTS proof, publish the kind 9999 proof carrier with `ots_status: 'pending'`
**Update `publishUpgradedEvent()`:**
- Build a NEW proof carrier event (not a replacement)
- Include `ots_status: 'confirmed'` and `upgrade_of: <pending event id>`
- Publish it as a new event
- Do NOT try to replace the old event
**Update the resume workflow:**
- Fetch all kind 9999 proof carriers for the pubkey
- Find the one that matches the persisted kind 1 event hash
- If a confirmed proof carrier already exists, the workflow is complete
- If only a pending proof carrier exists, continue the upgrade polling
**Update `savePendingOts()`:**
- Store both the pending proof carrier event ID and the kind 1 event ID
- Store the `sha256` target digest explicitly
- On resume, validate that fetched events match persisted IDs
### 4. `test/pq-crypto.test.mjs`
**Update existing tests:**
- Any test referencing kind 11112 → change to 9999
**Add new tests:**
- `buildProofCarrier` includes `ots_status` tag
- `buildUpgradedEvent` includes `ots_status: 'confirmed'` and `upgrade_of` tag
- `selectCanonicalProofCarrier` returns the event with the earliest Bitcoin anchor
- `selectCanonicalProofCarrier` handles ties deterministically
- `selectCanonicalProofCarrier` skips pending-only candidates when confirmed ones exist
- `selectCanonicalProofCarrier` rejects candidates with wrong `e` tag or `sha256` tag
- `selectCanonicalProofCarrier` rejects candidates with invalid secp signatures
- Multiple migration attempts (re-migration) — earliest anchor wins across all
### 5. `nip_proposal.md`
- Change kind 11112 to kind 9999
- Update the description: "non-replaceable event (0–9999 range)"
- Add `ots_status` and `upgrade_of` tags to the tag specification
- Update the verification rule: "fetch all kind 9999 events, select earliest valid Bitcoin anchor"
- Update the proof states section: upgrades publish new events, not replacements
### 6. `README.md`
- Update any reference to kind 11112 → kind 9999
- Update the migration flow description
- Update the implementation status table
## Acceptance criteria
1. No event in the system is replaceable — both kind 1 and kind 9999 are in the 0–9999 range
2. Upgrades publish new events with `upgrade_of` referencing the previous one — no event is ever replaced
3. Relay queries fetch all candidates, not `limit: 1`
4. `selectCanonicalProofCarrier` deterministically picks the earliest valid Bitcoin anchor
5. A later forged proof carrier cannot hide or supersede an earlier valid one
6. Re-migration (publishing a second migration attempt) works naturally — all events are permanent
7. All existing tests pass (updated for kind 9999)
8. New tests for append-only upgrades and earliest-anchor selection pass
9. The verify page displays the full candidate history and highlights the canonical one