184 lines
8.9 KiB
Markdown
184 lines
8.9 KiB
Markdown
# 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
|