Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1e1759704 | ||
|
|
113885eb96 | ||
|
|
cfa0ecc220 |
@@ -1,5 +1,12 @@
|
||||
# Post-Quantum Nostr
|
||||
|
||||
> **⚠️ Research prototype.** This project is an experimental pre-quantum key-commitment and
|
||||
> identity-link system. It does **not**, by itself, make a Nostr identity post-quantum secure:
|
||||
> it links PQ keys to an existing identity and anchors that link in Bitcoin. Complete migration
|
||||
> requires companion protocols (PQ event authentication, rotation, encryption, client adoption)
|
||||
> that are not yet specified or implemented. Do not enter a valuable existing mnemonic into the
|
||||
> web app. See the [implementation status](#implementation-status) section for what exists today.
|
||||
|
||||
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.
|
||||
|
||||
## Table of Contents
|
||||
@@ -374,7 +381,28 @@ The kind 9999 proof carrier *carries* the OTS proof; the kind 1 event *is* what'
|
||||
|
||||
### 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.
|
||||
> **Research prototype — trust model.** The current OTS verification is **API-assisted**, not a
|
||||
> full Bitcoin light-client verification. Treat results accordingly.
|
||||
|
||||
The current implementation:
|
||||
|
||||
- Parses the `.ots` proof and validates the Merkle path against a block header obtained from a
|
||||
public Bitcoin explorer API (mempool.space).
|
||||
- Detects Bitcoin confirmation by searching the proof bytes for the block-header attestation magic
|
||||
bytes and confirming the attested block height via the same API.
|
||||
- Delegates proof *upgrading* (asking calendars for a confirmed proof) to a server-side helper.
|
||||
|
||||
**What is NOT done:**
|
||||
|
||||
- **Full light-client verification** — validating the block header's proof-of-work, difficulty, and
|
||||
chain linkage independently, without trusting an explorer API. The vendored
|
||||
`javascript-opentimestamps` library in `resources/` provides primitives for this and will be
|
||||
integrated in a future release.
|
||||
- **Multi-explorer cross-checking** — comparing independent APIs and rejecting disagreement.
|
||||
|
||||
Because the block header is trusted from a single API, a compromised or malicious explorer could
|
||||
falsify a confirmation. The UI labels this as "API-checked," not "cryptographically verified on
|
||||
Bitcoin."
|
||||
|
||||
### What Should Be OpenTimestamped
|
||||
|
||||
@@ -762,12 +790,12 @@ The current implementation is a static web app (`www/`) that performs the full m
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| Full light-client Bitcoin verification | Validate block headers, proof-of-work, difficulty, and chain linkage independently of an explorer API (the vendored `javascript-opentimestamps` library in `resources/` provides primitives). Current path trusts a single explorer API for the header. |
|
||||
| Multi-explorer cross-checking | Compare independent Bitcoin APIs and reject disagreement |
|
||||
| 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 |
|
||||
| PQ event authentication / rotation / revocation | Companion protocols for signing future events with PQ keys, rotating/revoking keys, PQ encryption |
|
||||
| Independent implementation / test vectors | A second implementation reproducing canonical encoding, derivation, and selection |
|
||||
|
||||
### Dependencies
|
||||
|
||||
@@ -778,7 +806,7 @@ The current implementation is a static web app (`www/`) that performs the full m
|
||||
- [`@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)
|
||||
- [`javascript-opentimestamps`](resources/javascript-opentimestamps/) — vendored OTS library (used as a fixture source; full light-client integration pending)
|
||||
- [nostr-login-lite](https://github.com/nostrband/nostr-login-lite) — NIP-07 / NIP-46 authentication
|
||||
|
||||
---
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
| Severity | Total | Fixed | In Progress | Not Started |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Critical | 2 | 2 | 0 | 0 |
|
||||
| High | 6 | 1 | 0 | 5 |
|
||||
| Medium | 8 | 1 | 0 | 7 |
|
||||
| High | 6 | 3 | 0 | 3 |
|
||||
| Medium | 8 | 2 | 0 | 6 |
|
||||
| Low / Info | 4 | 0 | 0 | 4 |
|
||||
| **Total** | **20** | **4** | **0** | **16** |
|
||||
| **Total** | **20** | **7** | **0** | **13** |
|
||||
|
||||
## Findings status
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
| G56-02 | Critical | Earliest-valid-anchor rule not implemented; relay discovery selects latest replaceable event | ✅ Fixed | v0.0.14–v0.0.16 | Switched to non-replaceable kind 9999, append-only upgrades with `upgrade_of` tag, `selectCanonicalProofCarrier()` function, removed `limit: 1` from relay queries, updated all docs |
|
||||
| G56-03 | High | Outer wrapper identity not bound to embedded identity | ✅ Fixed | v0.0.14 | Added `outerPubkey` and `expectedAuthor` options to `verifyNIPQRContent()`, `identityBound` field in result, `validForMigration` requires identity binding, 4 identity binding tests |
|
||||
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ❌ Not started | — | Needs trust-mode labeling or real header validation |
|
||||
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ❌ Not started | — | Needs signer-validation helper |
|
||||
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ✅ Fixed | v0.0.20 | Added `validateSignerOutput()` helper: checks all required fields present + typed, verifies no template field (content/tags/created_at/kind) changed, verifies pubkey equals expected identity, recomputes + verifies event ID, verifies Schnorr signature, rejects extra fields. Replaced reconstruction at all 4 `signEvent()` call sites (2 in index.html, 2 in verify.html) with validation + retain-exact-signed-object. 15 new tests (mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed) |
|
||||
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ❌ Not started | — | Protocol design decision needed |
|
||||
| G56-07 | High | Verifier throws on malformed relay/paste input | ⚠️ Partially fixed | v0.0.12 | G56-01 fix wrapped base64/hex decoding in try/catch and made verifyNIPQRContent fail closed. Broader call-site wrapping in verify.html also done. Remaining: fuzz testing, all export functions |
|
||||
| G56-07 | High | Verifier throws on malformed relay/paste input | ✅ Fixed | v0.0.12, v0.0.19 | v0.0.12 wrapped base64/hex in try/catch + verifyNIPQRContent fail closed + verify.html call-site wrapping. v0.0.19 added structural validation + try/catch to `verifyNostrEvent()` (returns false on malformed input), wrapped `parseOtsFile()` body in try/catch (returns null), added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards, 23 fuzz/property tests (random JSON, random bytes, truncation, oversized inputs, wrong-typed objects) |
|
||||
| G56-08 | High | Browser origin security insufficient for seed handling | ❌ Not started | — | Needs CSP hardening, header fixes, asset pinning |
|
||||
| G56-09 | Medium | 12-word seed default, warning removed | ✅ Fixed | v0.0.17 | Defaulted `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` to 24 words (256-bit); added 12/24-word selector UI; restored entropy-strength warning; removed "quantum-safe root of trust" / "Make your Nostr identity quantum-resistant" claims from index.html, README.md, nip_proposal.md; 4 new tests |
|
||||
| G56-10 | Medium | Secret material not zeroized, retained in globals/DOM/clipboard | ❌ Not started | — | Minimize secret lifetime, overwrite typed arrays |
|
||||
@@ -33,7 +33,7 @@
|
||||
| G56-13 | Medium | OTS parser lacks total input/operation budgets, unsafe 32-bit varuint | ❌ Not started | — | Add size/operation/branch limits, safe varuint parsing |
|
||||
| G56-14 | Medium | Dependency resolution not reproducible; lockfile ignored and inconsistent | ❌ Not started | — | Commit lockfile, use npm ci, add CI |
|
||||
| G56-15 | Medium | OpenTimestamps submodule not clonable; historical dependency risk | ❌ Not started | — | Fix .gitmodules or vendor immutable snapshot |
|
||||
| G56-16 | Medium | Documentation and UI overclaim implementation status | ⚠️ Partially fixed | v0.0.14–v0.0.16 | Updated nip_proposal.md, README.md, explanation.md, why_what_how.md, laans_explanation.md for kind 9999. Remaining: broader status reconciliation |
|
||||
| G56-16 | Medium | Documentation and UI overclaim implementation status | ✅ Fixed | v0.0.14–v0.0.18 | Kind 9999 docs (v0.0.14–v0.0.16); v0.0.17 fixed index.html claims + 24-word default; v0.0.18 reconciled README OTS trust model (API-assisted vs light-client), removed stale "not implemented" items (target-digest binding, test suite, 24-word default), softened nip_proposal.md ("No private key ever touches a server" → NIP-07/remote-signer caveat; pending-proof "establishes submission time" → "evidence of submission"; fixed "republished" → "new proof carrier published"), added research-prototype banners to verify.html + README + nip_proposal + explanation.md + laans_explanation.md + why_what_how.md + all 5 research/ docs |
|
||||
| G56-17 | Low | Block-height statement not validated | ❌ Not started | — | Validate against OTS height or remove from signed content |
|
||||
| G56-18 | Low | Relay URL policy permits insecure ws:// | ❌ Not started | — | Require wss:// in production |
|
||||
| G56-19 | Low | Missing event IDs accepted by library verifier | ❌ Not started | — | Require exact NIP-01 shape with 64-hex ID |
|
||||
@@ -84,10 +84,44 @@
|
||||
- Updated README.md entropy table and nip_proposal.md mermaid diagram
|
||||
- Added 4 new tests (24-word default, 12-word option, invalid entropyBits rejection for both functions)
|
||||
|
||||
### v0.0.18 — G56-16 fix (status reconciliation)
|
||||
- README.md: rewrote OTS verification status section to distinguish API-assisted verification (current, trusts explorer) from full light-client verification (not implemented); added research-prototype banner
|
||||
- README.md: removed stale "not implemented" items (OTS target-digest binding, test suite, 24-word default); added accurate not-implemented items (light-client verification, multi-explorer cross-check, PQ event auth/rotation, independent implementation)
|
||||
- nip_proposal.md: softened "No private key ever touches a server" → NIP-07/remote-signer caveat + API-assisted OTS trust model; pending-proof "establishes submission time" → "evidence of submission, not an independent timestamp"; fixed "republished" → "new proof carrier published with upgrade_of tag"
|
||||
- verify.html: added research-prototype note; rewrote card description to list each verification check as a separate result line (PQ signatures individually, policy sufficiency, identity binding)
|
||||
- Added research-prototype / design-only banners to explanation.md, laans_explanation.md, why_what_how.md, and all 5 research/ docs
|
||||
- No new tests (documentation-only change)
|
||||
|
||||
### v0.0.19 — G56-07 fix (fuzz testing + fail-closed verifiers)
|
||||
- `verifyNostrEvent()`: added structural validation (type-checks all required fields) + try/catch; returns `false` on any malformed input instead of throwing
|
||||
- `parseOtsFile()`: wrapped parser body in try/catch; returns `null` on any malformed/truncated input
|
||||
- Added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards; oversized inputs rejected before any parsing/decoding work
|
||||
- `verifyNIPQRContent()`: added content-size guard at entry
|
||||
- verify.html relay path already had try/catch (G56-01); now defense-in-depth with internal fail-closed
|
||||
- 23 new fuzz/property tests: random JSON shapes to `verifyNIPQRContent` (1000 iterations), random bytes to `parseOtsFile` (2000 iterations), random objects to `verifyNostrEvent` (500 iterations), truncated valid-prefix bytes, oversized inputs, wrong-typed objects, `isOtsConfirmed` fuzz
|
||||
|
||||
### v0.0.20 — G56-05 fix (signer output validation)
|
||||
- Added `validateSignerOutput(signedEvent, template, expectedPubkey)` helper in `pq-crypto.mjs`:
|
||||
- Checks all 7 required NIP-01 fields present + correctly typed
|
||||
- Verifies id (64 hex), pubkey (64 hex), sig (128 hex) format
|
||||
- Verifies no template field changed (content, tags deep-compare, created_at, kind)
|
||||
- Verifies returned pubkey equals expected identity
|
||||
- Recomputes event ID and verifies match
|
||||
- Verifies Schnorr signature
|
||||
- Rejects extra unexpected fields
|
||||
- Returns the validated signed object (not a reconstruction)
|
||||
- Replaced reconstruction at all 4 `signEvent()` call sites:
|
||||
- `index.html`: kind 1 announcement (was mixing template + signer fields), proof carrier (same), upgrade (now validated)
|
||||
- `verify.html`: upgrade (now validated)
|
||||
- 15 new tests: valid output, mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed, no-expectedPubkey backward compat
|
||||
|
||||
## Test count
|
||||
|
||||
- **Before remediation:** 38 tests
|
||||
- **After G56-01:** 49 tests
|
||||
- **After G56-02+03:** 61 tests
|
||||
- **After G56-09:** 65 tests
|
||||
- **After G56-16:** 65 tests (docs-only)
|
||||
- **After G56-07:** 88 tests
|
||||
- **After G56-05:** 103 tests
|
||||
- **All passing:** ✅
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# How the NIP-QR Event Is Constructed: Step by Step
|
||||
|
||||
> **⚠️ Research prototype.** This document describes an experimental pre-quantum key-commitment
|
||||
> system. It links PQ keys to an existing Nostr identity and anchors that link in Bitcoin; it does
|
||||
> not, by itself, make the identity post-quantum secure. Complete migration requires companion
|
||||
> protocols not yet specified or implemented.
|
||||
|
||||
## The Misconception
|
||||
|
||||
The seed phrase is NOT used directly as the private key for PQ signing. The seed phrase is a source of entropy that is used to **derive** separate keys for each PQ algorithm via **BIP32 HD wallet derivation paths** — the same standard used by NIP-06 for secp256k1 keys.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Laan's explanation
|
||||
|
||||
> **⚠️ Research prototype.** This is a short summary of an experimental pre-quantum key-commitment
|
||||
> system. It does not, by itself, make a Nostr identity post-quantum secure.
|
||||
|
||||
A short summary of how the post-quantum Nostr migration works, matching the implementation.
|
||||
|
||||
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.
|
||||
1. **Create a new seed phrase.** This is your post-quantum (PQ) Nostr backup. It's a BIP39 mnemonic (24 words by default; 12 words available for testing) — pure entropy, not tied to any algorithm.
|
||||
|
||||
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
|
||||
|
||||
+13
-2
@@ -209,7 +209,7 @@ When a client finds multiple kind 9999 proof carriers for the same attesting ide
|
||||
|
||||
### OTS proof states
|
||||
|
||||
An OTS proof may be **pending** (not yet anchored in a Bitcoin block) or **confirmed** (anchored). A pending proof asserts that the digest has been submitted to calendar servers; a confirmed proof asserts a Bitcoin block commitment. Clients SHOULD treat a confirmed proof as authoritative. A pending proof is still useful: it establishes that the digest existed at submission time, and it can be upgraded to a confirmed proof later by re-querying the calendars. The wrapper is republished with the upgraded proof.
|
||||
An OTS proof may be **pending** (not yet anchored in a Bitcoin block) or **confirmed** (anchored). A pending proof is evidence that the digest was submitted to calendar servers; it is **not** an independent timestamp and depends on calendar behavior. A confirmed proof asserts a Bitcoin block commitment. Clients SHOULD treat a confirmed proof as authoritative. A pending proof can be upgraded to a confirmed proof later by re-querying the calendars; when that happens, a **new** proof carrier event is published carrying the upgraded proof and referencing the original via an `upgrade_of` tag. The original event remains on relays permanently.
|
||||
|
||||
### Client-side verification
|
||||
|
||||
@@ -294,4 +294,15 @@ This NIP builds on and is compatible with several existing community proposals:
|
||||
|
||||
## Reference implementation
|
||||
|
||||
A static, client-side-only web implementation exists at `https://laantungir.net/post-quantum/`. All cryptographic operations (BIP39, BIP32, PQ keygen/sign/verify, OTS submission and verification) happen in the browser. No private key ever touches a server. Source: [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs:1) in this repository.
|
||||
> **⚠️ Research prototype.** The implementation is experimental and is not a complete post-quantum
|
||||
> migration. It links PQ keys to an existing identity and anchors that link in Bitcoin; it does not
|
||||
> yet define PQ authentication for routine events, key rotation/revocation, or PQ encryption.
|
||||
|
||||
A static, client-side-only web implementation exists at `https://laantungir.net/post-quantum/`.
|
||||
Cryptographic operations (BIP39, BIP32, PQ keygen/sign/verify, OTS submission and Merkle-path
|
||||
verification) happen in the browser. Private keys are handled in the browser or via a NIP-07 signer
|
||||
extension, which may be a remote signer (NIP-46); in that case the signing key does not touch the
|
||||
page origin. OTS proof *upgrading* uses a server-side helper, and Bitcoin block-header confirmation
|
||||
relies on a public explorer API (mempool.space) — this is **API-assisted verification**, not a full
|
||||
Bitcoin light-client verification. Source: [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs:1) in this
|
||||
repository.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "post_quantum_nostr",
|
||||
"version": "0.0.17",
|
||||
"version": "0.0.20",
|
||||
"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": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Amber Integration Analysis for PQ Nostr Web App
|
||||
|
||||
> **⚠️ Design-only research document.** This is an analysis of a possible integration, not an
|
||||
> implemented feature. The current system is a research prototype and does not make Nostr
|
||||
> identities post-quantum secure by itself.
|
||||
|
||||
## Amber Capabilities (from source code analysis)
|
||||
|
||||
[Amber](https://github.com/greenart7c3/Amber) is an Android Nostr event signer that keeps the nsec segregated in a dedicated app. Based on source code inspection:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Amber Integration Plan: End-User Experience
|
||||
|
||||
> **⚠️ Design-only research document.** This describes a planned user experience, not a shipped
|
||||
> feature. The current system is a research prototype; it links PQ keys to an identity and anchors
|
||||
> that link in Bitcoin, but does not by itself make the identity post-quantum secure.
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the complete end-to-end user experience for migrating a Nostr identity to post-quantum security using the PQ web app and Amber signer. There are **two paths** depending on how the user's account was created in Amber:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Existing Post-Quantum Proposals in the Nostr Ecosystem
|
||||
|
||||
> **⚠️ Research document.** A survey of community discussion; not an implementation spec.
|
||||
|
||||
A survey of pull requests and issues in `nostr-protocol/nips` related to post-quantum cryptography, key migration, and key rotation — and how they compare to our plan in [`README.md`](../README.md).
|
||||
|
||||
Research date: 2026-07-12
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# NIP-QR: Two-Account Flow Architecture
|
||||
|
||||
> **⚠️ Design-only research document.** This describes the proposed architecture; the current
|
||||
> implementation is a research prototype and does not by itself make Nostr identities
|
||||
> post-quantum secure.
|
||||
|
||||
## The Flow (as proposed)
|
||||
|
||||
```
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Post-Quantum Cryptography: JavaScript Package Survey
|
||||
|
||||
> **⚠️ Research document.** A library survey; not an implementation spec.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**We do NOT need to write our own implementations.** There are mature, well-maintained JavaScript packages for all NIST-standardized post-quantum algorithms. The standout recommendation is **[`@noble/post-quantum`](https://www.npmjs.com/package/@noble/post-quantum)** — a pure JavaScript implementation of all FIPS 203/204/205 algorithms, from the highly respected noble cryptography family (335 GitHub stars, MIT licensed, self-audited, actively maintained).
|
||||
|
||||
@@ -719,3 +719,273 @@ describe('G56-03: identity binding (verifyNIPQRContent)', () => {
|
||||
assert.equal(result.identityBound, true, 'without options, identity binding should pass');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// G56-07: Fuzz / property tests — untrusted input must never throw
|
||||
// ============================================================================
|
||||
|
||||
describe('G56-07: verifyNostrEvent never throws on malformed input', () => {
|
||||
const malformedInputs = [
|
||||
null,
|
||||
undefined,
|
||||
'string',
|
||||
42,
|
||||
true,
|
||||
[],
|
||||
{},
|
||||
{ id: 'abc' },
|
||||
{ pubkey: 'x', sig: 'y', created_at: 'not-a-number', kind: 1, tags: [], content: '' },
|
||||
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 'not-a-number', tags: [], content: '' },
|
||||
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 1, tags: 'not-an-array', content: '' },
|
||||
{ pubkey: 'x', sig: 'y', created_at: 1, kind: 1, tags: [], content: 123 },
|
||||
{ pubkey: 123, sig: 'y', created_at: 1, kind: 1, tags: [], content: '' },
|
||||
{ pubkey: 'x', sig: 123, created_at: 1, kind: 1, tags: [], content: '' },
|
||||
];
|
||||
|
||||
for (const input of malformedInputs) {
|
||||
test(`verifyNostrEvent returns false for ${JSON.stringify(input)?.substring(0, 50)}`, () => {
|
||||
assert.equal(m.verifyNostrEvent(input), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('verifyNostrEvent returns false for random-shaped objects (fuzz)', () => {
|
||||
const types = [null, true, false, 0, 1, 'x', '', [], {}, { id: 1 }, { tags: 'x' }];
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const obj = {};
|
||||
const keys = ['id', 'pubkey', 'sig', 'created_at', 'kind', 'tags', 'content', 'extra'];
|
||||
for (const k of keys) {
|
||||
if (Math.random() < 0.6) obj[k] = types[Math.floor(Math.random() * types.length)];
|
||||
}
|
||||
assert.equal(m.verifyNostrEvent(obj), false, `threw or passed for iteration ${i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('G56-07: verifyNIPQRContent never throws on arbitrary input', () => {
|
||||
function randomJson() {
|
||||
const types = [null, true, false, 0, 'x', [], {}, { id: 1 }, { tags: 'not-array' }];
|
||||
return types[Math.floor(Math.random() * types.length)];
|
||||
}
|
||||
|
||||
test('random content + tags never throw and never pass (fuzz)', () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const content = Math.random() < 0.5
|
||||
? JSON.stringify(randomJson())
|
||||
: 'not-json-at-' + i;
|
||||
const tags = Math.random() < 0.5
|
||||
? randomJson()
|
||||
: (Math.random() < 0.5 ? [['algorithm', 'x']] : 'not-an-array');
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = m.verifyNIPQRContent(content, tags);
|
||||
}, `threw on iteration ${i}`);
|
||||
assert.equal(result.validForMigration, false, `passed on iteration ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('null / undefined / wrong-typed content returns structured failure', () => {
|
||||
for (const content of [null, undefined, 42, true, {}]) {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = m.verifyNIPQRContent(content, []);
|
||||
});
|
||||
assert.equal(result.validForMigration, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('oversized content is rejected before parsing', () => {
|
||||
const huge = 'x'.repeat(70 * 1024); // > MAX_EVENT_JSON_SIZE (64 KiB)
|
||||
const result = m.verifyNIPQRContent(huge, []);
|
||||
assert.equal(result.validForMigration, false);
|
||||
assert.ok(result.errors.some(e => e.includes('maximum size')), 'should report size error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('G56-07: parseOtsFile never throws on random/truncated/oversized bytes', () => {
|
||||
test('random bytes never throw (fuzz)', () => {
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const len = Math.floor(Math.random() * 2048);
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let j = 0; j < len; j++) bytes[j] = Math.floor(Math.random() * 256);
|
||||
let result;
|
||||
assert.doesNotThrow(() => { result = m.parseOtsFile(bytes); }, `threw on iteration ${i}, len=${len}`);
|
||||
// result is either null or a valid parse object — never throws
|
||||
if (result !== null) {
|
||||
assert.ok(typeof result === 'object' && 'fileHashOp' in result, 'should be null or valid object');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('non-Uint8Array inputs return null without throwing', () => {
|
||||
for (const input of [null, undefined, 'string', 42, true, [], {}, new Int8Array(10)]) {
|
||||
assert.equal(m.parseOtsFile(input), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('oversized input returns null without parsing', () => {
|
||||
const huge = new Uint8Array(2 * 1024 * 1024); // 2 MiB > MAX_OTS_PROOF_SIZE (1 MiB)
|
||||
assert.equal(m.parseOtsFile(huge), null);
|
||||
});
|
||||
|
||||
test('truncated valid-prefix bytes never throw', () => {
|
||||
// OTS magic header as a starting point
|
||||
const magic = m.hexToBytes('004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294');
|
||||
for (let cut = 0; cut <= magic.length; cut++) {
|
||||
const truncated = magic.slice(0, cut);
|
||||
let result;
|
||||
assert.doesNotThrow(() => { result = m.parseOtsFile(truncated); });
|
||||
// short truncations return null (too short); longer ones may parse partially or return null
|
||||
assert.ok(result === null || typeof result === 'object');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('G56-07: isOtsConfirmed never throws on bad input', () => {
|
||||
test('non-Uint8Array and random bytes never throw', () => {
|
||||
for (const input of [null, undefined, 'string', 42, [], {}]) {
|
||||
assert.doesNotThrow(() => m.isOtsConfirmed(input));
|
||||
}
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const len = Math.floor(Math.random() * 512);
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let j = 0; j < len; j++) bytes[j] = Math.floor(Math.random() * 256);
|
||||
assert.doesNotThrow(() => m.isOtsConfirmed(bytes), `threw on iteration ${i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// G56-05: Signer output validation
|
||||
// ============================================================================
|
||||
|
||||
describe('G56-05: validateSignerOutput', () => {
|
||||
const sk = new Uint8Array(32);
|
||||
sk[31] = 5;
|
||||
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
|
||||
const otherSk = new Uint8Array(32);
|
||||
otherSk[31] = 9;
|
||||
const otherPkHex = m.bytesToHex(schnorr.getPublicKey(otherSk));
|
||||
|
||||
// Helper: create a valid template + correctly signed event
|
||||
function makeValidSignedEvent(content = 'test content', tags = [['t', 'x']]) {
|
||||
const template = {
|
||||
pubkey: pkHex,
|
||||
created_at: 1700000000,
|
||||
kind: 1,
|
||||
tags,
|
||||
content
|
||||
};
|
||||
const event = { ...template };
|
||||
event.id = m.computeEventId(event);
|
||||
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
|
||||
return { template, event };
|
||||
}
|
||||
|
||||
test('valid signer output passes validation', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const result = m.validateSignerOutput(event, template, pkHex);
|
||||
assert.equal(result.id, event.id);
|
||||
assert.equal(result.sig, event.sig);
|
||||
});
|
||||
|
||||
test('signer changes content → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, content: 'tampered content' };
|
||||
// Recompute id+sig so the tampered event is internally consistent
|
||||
// (a real malicious signer would do this, but the content must still match the template)
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /content was changed/);
|
||||
});
|
||||
|
||||
test('signer changes tags → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, tags: [['t', 'x'], ['extra', 'tag']] };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tags length changed/);
|
||||
});
|
||||
|
||||
test('signer changes created_at → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, created_at: 9999999999 };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /created_at changed/);
|
||||
});
|
||||
|
||||
test('signer changes kind → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, kind: 9999 };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /kind changed/);
|
||||
});
|
||||
|
||||
test('signer returns wrong pubkey → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
// Sign with a different key, use that key's pubkey
|
||||
const tampered = { ...template, pubkey: otherPkHex };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), otherSk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /pubkey mismatch/);
|
||||
});
|
||||
|
||||
test('signer returns wrong ID → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, id: '0'.repeat(64) };
|
||||
// sig is valid for the real id, not for '0'.repeat(64)
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id mismatch/);
|
||||
});
|
||||
|
||||
test('signer returns invalid signature → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, sig: 'f'.repeat(128) };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /invalid Schnorr signature/);
|
||||
});
|
||||
|
||||
test('signer returns extra field → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, extra: 'malicious' };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /unexpected extra field/);
|
||||
});
|
||||
|
||||
test('signer returns missing field → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const { sig, ...noSig } = event;
|
||||
assert.throws(() => m.validateSignerOutput(noSig, template, pkHex), /missing field 'sig'/);
|
||||
});
|
||||
|
||||
test('signer returns non-object → rejected', () => {
|
||||
const { template } = makeValidSignedEvent();
|
||||
assert.throws(() => m.validateSignerOutput(null, template, pkHex), /non-object/);
|
||||
assert.throws(() => m.validateSignerOutput('string', template, pkHex), /non-object/);
|
||||
});
|
||||
|
||||
test('signer returns malformed id (not 64 hex) → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, id: 'short' };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id must be 64/);
|
||||
});
|
||||
|
||||
test('signer returns malformed sig (not 128 hex) → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, sig: 'short' };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /sig must be 128/);
|
||||
});
|
||||
|
||||
test('no expectedPubkey → pubkey check skipped', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
// Should pass without expectedPubkey (backward compatible)
|
||||
const result = m.validateSignerOutput(event, template);
|
||||
assert.equal(result.id, event.id);
|
||||
});
|
||||
|
||||
test('tag element value changed → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent('test', [['t', 'original']]);
|
||||
const tampered = { ...event, tags: [['t', 'changed']] };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tag 0 element 1 changed/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Post-Quantum Nostr: Why, What, How
|
||||
|
||||
> **⚠️ Research prototype.** This document describes an experimental pre-quantum key-commitment
|
||||
> and identity-link system. It does not, by itself, make a Nostr identity post-quantum secure;
|
||||
> complete migration requires companion protocols (PQ event authentication, rotation, encryption,
|
||||
> client adoption) that are not yet specified or implemented.
|
||||
|
||||
## Why: The Problem
|
||||
|
||||
### Quantum computers will break Nostr's cryptography
|
||||
|
||||
+10
-23
@@ -623,6 +623,7 @@
|
||||
hashFullEvent,
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent,
|
||||
validateSignerOutput,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
bytesToHex,
|
||||
@@ -1117,16 +1118,8 @@
|
||||
setStatus(pqSignStatus, 'info', 'PQ signatures complete. Requesting secp256k1 signature for kind 1 announcement...');
|
||||
|
||||
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
|
||||
};
|
||||
// G56-05: validate signer output — never reconstruct from mixed fields
|
||||
kind1Event = validateSignerOutput(kind1Signed, kind1Template, currentPubkey);
|
||||
otsLog(`Kind 1 announcement signed. Event ID: ${kind1Event.id.substring(0, 32)}...`);
|
||||
|
||||
// ---- Phase 2: Timestamp the full kind 1 event hash ----
|
||||
@@ -1157,16 +1150,8 @@
|
||||
}
|
||||
|
||||
const proofCarrierSigned = await window.nostr.signEvent(proofCarrierTemplate);
|
||||
|
||||
pqEvent = {
|
||||
id: proofCarrierSigned.id,
|
||||
pubkey: proofCarrierSigned.pubkey,
|
||||
created_at: proofCarrierTemplate.created_at,
|
||||
kind: NIP_QR_KIND,
|
||||
content: proofCarrierTemplate.content,
|
||||
tags: proofCarrierTemplate.tags,
|
||||
sig: proofCarrierSigned.sig
|
||||
};
|
||||
// G56-05: validate signer output — never reconstruct from mixed fields
|
||||
pqEvent = validateSignerOutput(proofCarrierSigned, proofCarrierTemplate, currentPubkey);
|
||||
otsLog(`Proof carrier signed. Event ID: ${pqEvent.id.substring(0, 32)}...`);
|
||||
|
||||
// Save the pending OTS proof for the OTS step
|
||||
@@ -1771,8 +1756,10 @@
|
||||
if (!window.nostr || !window.nostr.signEvent) throw new Error('Nostr signer not available');
|
||||
otsLog('Requesting secp256k1 signature for upgraded proof carrier...');
|
||||
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
|
||||
// G56-05: validate signer output before publishing
|
||||
const validatedEvent = validateSignerOutput(signedEvent, upgradedTemplate, currentPubkey);
|
||||
const relayUrls = configuredRelays.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const results = await publishToRelays(signedEvent, relayUrls);
|
||||
const results = await publishToRelays(validatedEvent, relayUrls);
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
if (successCount === 0) throw new Error('No relay accepted the upgraded proof carrier');
|
||||
@@ -1780,7 +1767,7 @@
|
||||
setKeyIcon('pqOtsConfirmedPublish', 'Done');
|
||||
otsLog(`Upgraded proof carrier published. Successful: ${successCount}; failed: ${failCount}.`);
|
||||
// Update pqEvent to the new event so future operations use it
|
||||
pqEvent = signedEvent;
|
||||
pqEvent = validatedEvent;
|
||||
savePendingOts(pqEvent.id, upgradedOtsBytes, {
|
||||
ownerPubkey: currentPubkey,
|
||||
targetKind: NIP_QR_KIND,
|
||||
@@ -1792,7 +1779,7 @@
|
||||
setStatus(otsStatus, 'success', `Upgraded proof carrier published to ${successCount} relays. Timestamping complete.`);
|
||||
setStepDone(6);
|
||||
setStepActive(7);
|
||||
return signedEvent;
|
||||
return validatedEvent;
|
||||
} catch (error) {
|
||||
otsLog(`ERROR publishing upgraded event: ${error.message}`);
|
||||
setStatus(otsStatus, 'error', `Failed to publish upgraded event: ${error.message}`);
|
||||
|
||||
+195
-38
@@ -412,28 +412,162 @@ 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.
|
||||
*
|
||||
* G56-07: This function accepts untrusted input (pasted JSON, relay events) and
|
||||
* must never throw on malformed data. It returns `false` for any structurally
|
||||
* invalid event.
|
||||
*
|
||||
* @param {object} event - Nostr event with id, pubkey, created_at, kind, tags, content, sig
|
||||
* @returns {boolean} true if signature is valid and id matches
|
||||
* @returns {boolean} true if signature is valid and id matches; false otherwise (never throws)
|
||||
*/
|
||||
export function verifyNostrEvent(event) {
|
||||
// Build the event hash (NIP-01 serialization)
|
||||
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);
|
||||
// F-H2: Verify event.id matches the computed hash
|
||||
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
|
||||
try {
|
||||
// Structural validation — fail closed on wrong shape
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (typeof event.pubkey !== 'string' || typeof event.sig !== 'string') return false;
|
||||
if (typeof event.created_at !== 'number') return false;
|
||||
if (typeof event.kind !== 'number') return false;
|
||||
if (!Array.isArray(event.tags)) return false;
|
||||
if (typeof event.content !== 'string') return false;
|
||||
|
||||
// Build the event hash (NIP-01 serialization)
|
||||
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);
|
||||
// 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);
|
||||
} catch (e) {
|
||||
// G56-07: never throw on untrusted malformed input
|
||||
return false;
|
||||
}
|
||||
const sig = hexToBytes(event.sig);
|
||||
const pubkey = hexToBytes(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the output of a NIP-07 signer's signEvent() call (G56-05).
|
||||
*
|
||||
* This ensures a malicious or buggy signer cannot substitute different content,
|
||||
* tags, pubkey, or an invalid signature. It verifies that the signed event
|
||||
* matches the template exactly (for the semantic fields), that the pubkey
|
||||
* equals the expected identity, that the computed ID matches, and that the
|
||||
* Schnorr signature is valid.
|
||||
*
|
||||
* Returns the validated signed event object (the signer's exact object, not a
|
||||
* reconstruction) on success, or throws on any mismatch.
|
||||
*
|
||||
* @param {object} signedEvent - The object returned by window.nostr.signEvent()
|
||||
* @param {object} template - The unsigned template that was passed to signEvent()
|
||||
* @param {string} expectedPubkey - The hex pubkey of the authenticated identity
|
||||
* @returns {object} the validated signed event
|
||||
* @throws {Error} if any field is missing, mismatched, or the signature is invalid
|
||||
*/
|
||||
export function validateSignerOutput(signedEvent, template, expectedPubkey) {
|
||||
if (!signedEvent || typeof signedEvent !== 'object') {
|
||||
throw new Error('validateSignerOutput: signer returned non-object');
|
||||
}
|
||||
|
||||
// 1. Check all required fields are present and correctly typed
|
||||
const required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
|
||||
for (const field of required) {
|
||||
if (!(field in signedEvent)) {
|
||||
throw new Error(`validateSignerOutput: missing field '${field}'`);
|
||||
}
|
||||
}
|
||||
if (typeof signedEvent.id !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.id)) {
|
||||
throw new Error('validateSignerOutput: id must be 64 lowercase hex chars');
|
||||
}
|
||||
if (typeof signedEvent.pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.pubkey)) {
|
||||
throw new Error('validateSignerOutput: pubkey must be 64 lowercase hex chars');
|
||||
}
|
||||
if (typeof signedEvent.sig !== 'string' || !/^[0-9a-f]{128}$/.test(signedEvent.sig)) {
|
||||
throw new Error('validateSignerOutput: sig must be 128 lowercase hex chars');
|
||||
}
|
||||
if (typeof signedEvent.created_at !== 'number') {
|
||||
throw new Error('validateSignerOutput: created_at must be a number');
|
||||
}
|
||||
if (typeof signedEvent.kind !== 'number') {
|
||||
throw new Error('validateSignerOutput: kind must be a number');
|
||||
}
|
||||
if (!Array.isArray(signedEvent.tags)) {
|
||||
throw new Error('validateSignerOutput: tags must be an array');
|
||||
}
|
||||
if (typeof signedEvent.content !== 'string') {
|
||||
throw new Error('validateSignerOutput: content must be a string');
|
||||
}
|
||||
|
||||
// 2. Verify no template field was changed by the signer
|
||||
if (signedEvent.kind !== template.kind) {
|
||||
throw new Error(`validateSignerOutput: kind changed (template=${template.kind}, signed=${signedEvent.kind})`);
|
||||
}
|
||||
if (signedEvent.content !== template.content) {
|
||||
throw new Error('validateSignerOutput: content was changed by signer');
|
||||
}
|
||||
if (signedEvent.created_at !== template.created_at) {
|
||||
throw new Error(`validateSignerOutput: created_at changed (template=${template.created_at}, signed=${signedEvent.created_at})`);
|
||||
}
|
||||
// Deep-compare tags (order-sensitive, as tag order affects the event ID)
|
||||
if (signedEvent.tags.length !== template.tags.length) {
|
||||
throw new Error(`validateSignerOutput: tags length changed (template=${template.tags.length}, signed=${signedEvent.tags.length})`);
|
||||
}
|
||||
for (let i = 0; i < template.tags.length; i++) {
|
||||
const tTag = template.tags[i];
|
||||
const sTag = signedEvent.tags[i];
|
||||
if (!Array.isArray(sTag) || sTag.length !== tTag.length) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} shape changed`);
|
||||
}
|
||||
for (let j = 0; j < tTag.length; j++) {
|
||||
if (sTag[j] !== tTag[j]) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} element ${j} changed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify the pubkey matches the expected identity
|
||||
if (expectedPubkey && signedEvent.pubkey !== expectedPubkey) {
|
||||
throw new Error(`validateSignerOutput: pubkey mismatch (expected=${expectedPubkey?.substring(0, 16)}..., got=${signedEvent.pubkey.substring(0, 16)}...)`);
|
||||
}
|
||||
|
||||
// 4. Recompute the event ID and verify it matches
|
||||
const computedId = computeEventId(signedEvent);
|
||||
if (signedEvent.id !== computedId) {
|
||||
throw new Error(`validateSignerOutput: id mismatch (signed=${signedEvent.id.substring(0, 16)}..., computed=${computedId.substring(0, 16)}...)`);
|
||||
}
|
||||
|
||||
// 5. Verify the Schnorr signature
|
||||
const hash = sha256(new TextEncoder().encode(JSON.stringify([
|
||||
0,
|
||||
signedEvent.pubkey,
|
||||
signedEvent.created_at,
|
||||
signedEvent.kind,
|
||||
signedEvent.tags,
|
||||
signedEvent.content
|
||||
])));
|
||||
const sig = hexToBytes(signedEvent.sig);
|
||||
const pubkey = hexToBytes(signedEvent.pubkey);
|
||||
if (!schnorr.verify(sig, hash, pubkey)) {
|
||||
throw new Error('validateSignerOutput: invalid Schnorr signature');
|
||||
}
|
||||
|
||||
// 6. Reject extra unexpected fields (allow only the 7 NIP-01 fields)
|
||||
const allowedFields = new Set(['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']);
|
||||
for (const key of Object.keys(signedEvent)) {
|
||||
if (!allowedFields.has(key)) {
|
||||
throw new Error(`validateSignerOutput: unexpected extra field '${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -695,6 +829,16 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}
|
||||
const errors = [];
|
||||
const { expectedAuthor, outerPubkey } = options;
|
||||
|
||||
// G56-07: reject oversized content before any parsing/decoding work
|
||||
if (typeof kind11112Content === 'string' && kind11112Content.length > MAX_EVENT_JSON_SIZE) {
|
||||
return {
|
||||
results: [{ algorithm: 'event', valid: false, note: 'content exceeds maximum size' }],
|
||||
kind1Event: null, fullHash: null, sha256Valid: false, eTagValid: false,
|
||||
pqProofsValid: false, policySufficient: false, identityBound: false,
|
||||
validForMigration: false, errors: ['content exceeds maximum size']
|
||||
};
|
||||
}
|
||||
|
||||
// --- Helper: push a result entry and track failures ---
|
||||
function pushResult(algorithm, valid, note) {
|
||||
results.push({ algorithm, valid, note });
|
||||
@@ -1519,6 +1663,11 @@ class OtsReader {
|
||||
// OTS magic header (31 bytes)
|
||||
const OTS_MAGIC = hexToBytes('004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294');
|
||||
|
||||
// G56-07: Maximum sizes for untrusted inputs. Reject larger inputs before any
|
||||
// parsing or cryptographic work to prevent denial-of-service via oversized data.
|
||||
const MAX_OTS_PROOF_SIZE = 1 << 20; // 1 MiB — generous for heavily upgraded proofs
|
||||
const MAX_EVENT_JSON_SIZE = 1 << 16; // 64 KiB — NIP-QR events are typically 30-50 KiB
|
||||
|
||||
// Attestation tags
|
||||
const BITCOIN_ATTESTATION_TAG = hexToBytes('0588960d73d71901');
|
||||
const LITECOIN_ATTESTATION_TAG = hexToBytes('06869a0d73d71b45');
|
||||
@@ -1540,35 +1689,43 @@ function bytesEqual(a, b) {
|
||||
* that the attestation covers (after walking the op tree from the target).
|
||||
*/
|
||||
export function parseOtsFile(otsBytes) {
|
||||
// G56-07: fail closed on untrusted input — never throw
|
||||
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_MAGIC.length) return null;
|
||||
// G56-07: reject oversized proofs before any parsing work
|
||||
if (otsBytes.length > MAX_OTS_PROOF_SIZE) return null;
|
||||
|
||||
const reader = new OtsReader(otsBytes);
|
||||
try {
|
||||
const reader = new OtsReader(otsBytes);
|
||||
|
||||
// 1. Magic header
|
||||
const magic = reader.readBytes(OTS_MAGIC.length);
|
||||
if (!bytesEqual(magic, OTS_MAGIC)) return null;
|
||||
// 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;
|
||||
// 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
|
||||
// 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);
|
||||
// 4. File digest (the target — what was timestamped)
|
||||
const targetDigest = reader.readBytes(digestLen);
|
||||
|
||||
// 5. Timestamp tree
|
||||
const attestations = [];
|
||||
_parseTimestamp(reader, targetDigest, attestations, 0);
|
||||
// 5. Timestamp tree
|
||||
const attestations = [];
|
||||
_parseTimestamp(reader, targetDigest, attestations, 0);
|
||||
|
||||
return { fileHashOp, targetDigest, attestations };
|
||||
return { fileHashOp, targetDigest, attestations };
|
||||
} catch (e) {
|
||||
// G56-07: never throw on malformed/truncated untrusted input
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.0.17",
|
||||
"VERSION_NUMBER": "0.0.17",
|
||||
"BUILD_DATE": "2026-07-24T12:44:54.874Z"
|
||||
"VERSION": "v0.0.20",
|
||||
"VERSION_NUMBER": "0.0.20",
|
||||
"BUILD_DATE": "2026-07-24T13:42:06.579Z"
|
||||
}
|
||||
|
||||
+151
-36
@@ -9761,22 +9761,115 @@ function hexToNpub(hexPubkey) {
|
||||
return bech32.encode("npub", bech32.toWords(bytes));
|
||||
}
|
||||
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 = bytesToHex3(hash);
|
||||
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
|
||||
try {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
if (typeof event.pubkey !== "string" || typeof event.sig !== "string") return false;
|
||||
if (typeof event.created_at !== "number") return false;
|
||||
if (typeof event.kind !== "number") return false;
|
||||
if (!Array.isArray(event.tags)) return false;
|
||||
if (typeof event.content !== "string") return false;
|
||||
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 = 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);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
const sig = hexToBytes3(event.sig);
|
||||
const pubkey = hexToBytes3(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
}
|
||||
function validateSignerOutput(signedEvent, template, expectedPubkey) {
|
||||
if (!signedEvent || typeof signedEvent !== "object") {
|
||||
throw new Error("validateSignerOutput: signer returned non-object");
|
||||
}
|
||||
const required = ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"];
|
||||
for (const field of required) {
|
||||
if (!(field in signedEvent)) {
|
||||
throw new Error(`validateSignerOutput: missing field '${field}'`);
|
||||
}
|
||||
}
|
||||
if (typeof signedEvent.id !== "string" || !/^[0-9a-f]{64}$/.test(signedEvent.id)) {
|
||||
throw new Error("validateSignerOutput: id must be 64 lowercase hex chars");
|
||||
}
|
||||
if (typeof signedEvent.pubkey !== "string" || !/^[0-9a-f]{64}$/.test(signedEvent.pubkey)) {
|
||||
throw new Error("validateSignerOutput: pubkey must be 64 lowercase hex chars");
|
||||
}
|
||||
if (typeof signedEvent.sig !== "string" || !/^[0-9a-f]{128}$/.test(signedEvent.sig)) {
|
||||
throw new Error("validateSignerOutput: sig must be 128 lowercase hex chars");
|
||||
}
|
||||
if (typeof signedEvent.created_at !== "number") {
|
||||
throw new Error("validateSignerOutput: created_at must be a number");
|
||||
}
|
||||
if (typeof signedEvent.kind !== "number") {
|
||||
throw new Error("validateSignerOutput: kind must be a number");
|
||||
}
|
||||
if (!Array.isArray(signedEvent.tags)) {
|
||||
throw new Error("validateSignerOutput: tags must be an array");
|
||||
}
|
||||
if (typeof signedEvent.content !== "string") {
|
||||
throw new Error("validateSignerOutput: content must be a string");
|
||||
}
|
||||
if (signedEvent.kind !== template.kind) {
|
||||
throw new Error(`validateSignerOutput: kind changed (template=${template.kind}, signed=${signedEvent.kind})`);
|
||||
}
|
||||
if (signedEvent.content !== template.content) {
|
||||
throw new Error("validateSignerOutput: content was changed by signer");
|
||||
}
|
||||
if (signedEvent.created_at !== template.created_at) {
|
||||
throw new Error(`validateSignerOutput: created_at changed (template=${template.created_at}, signed=${signedEvent.created_at})`);
|
||||
}
|
||||
if (signedEvent.tags.length !== template.tags.length) {
|
||||
throw new Error(`validateSignerOutput: tags length changed (template=${template.tags.length}, signed=${signedEvent.tags.length})`);
|
||||
}
|
||||
for (let i = 0; i < template.tags.length; i++) {
|
||||
const tTag = template.tags[i];
|
||||
const sTag = signedEvent.tags[i];
|
||||
if (!Array.isArray(sTag) || sTag.length !== tTag.length) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} shape changed`);
|
||||
}
|
||||
for (let j = 0; j < tTag.length; j++) {
|
||||
if (sTag[j] !== tTag[j]) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} element ${j} changed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expectedPubkey && signedEvent.pubkey !== expectedPubkey) {
|
||||
throw new Error(`validateSignerOutput: pubkey mismatch (expected=${expectedPubkey?.substring(0, 16)}..., got=${signedEvent.pubkey.substring(0, 16)}...)`);
|
||||
}
|
||||
const computedId = computeEventId(signedEvent);
|
||||
if (signedEvent.id !== computedId) {
|
||||
throw new Error(`validateSignerOutput: id mismatch (signed=${signedEvent.id.substring(0, 16)}..., computed=${computedId.substring(0, 16)}...)`);
|
||||
}
|
||||
const hash = sha256(new TextEncoder().encode(JSON.stringify([
|
||||
0,
|
||||
signedEvent.pubkey,
|
||||
signedEvent.created_at,
|
||||
signedEvent.kind,
|
||||
signedEvent.tags,
|
||||
signedEvent.content
|
||||
])));
|
||||
const sig = hexToBytes3(signedEvent.sig);
|
||||
const pubkey = hexToBytes3(signedEvent.pubkey);
|
||||
if (!schnorr.verify(sig, hash, pubkey)) {
|
||||
throw new Error("validateSignerOutput: invalid Schnorr signature");
|
||||
}
|
||||
const allowedFields = /* @__PURE__ */ new Set(["id", "pubkey", "created_at", "kind", "tags", "content", "sig"]);
|
||||
for (const key of Object.keys(signedEvent)) {
|
||||
if (!allowedFields.has(key)) {
|
||||
throw new Error(`validateSignerOutput: unexpected extra field '${key}'`);
|
||||
}
|
||||
}
|
||||
return signedEvent;
|
||||
}
|
||||
var NIP_QR_KIND = 9999;
|
||||
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
|
||||
@@ -9886,6 +9979,20 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}) {
|
||||
const results = [];
|
||||
const errors = [];
|
||||
const { expectedAuthor, outerPubkey } = options;
|
||||
if (typeof kind11112Content === "string" && kind11112Content.length > MAX_EVENT_JSON_SIZE) {
|
||||
return {
|
||||
results: [{ algorithm: "event", valid: false, note: "content exceeds maximum size" }],
|
||||
kind1Event: null,
|
||||
fullHash: null,
|
||||
sha256Valid: false,
|
||||
eTagValid: false,
|
||||
pqProofsValid: false,
|
||||
policySufficient: false,
|
||||
identityBound: false,
|
||||
validForMigration: false,
|
||||
errors: ["content exceeds maximum size"]
|
||||
};
|
||||
}
|
||||
function pushResult(algorithm, valid, note) {
|
||||
results.push({ algorithm, valid, note });
|
||||
if (!valid) errors.push(`${algorithm}: ${note || "INVALID"}`);
|
||||
@@ -10446,6 +10553,8 @@ var OtsReader = class {
|
||||
}
|
||||
};
|
||||
var OTS_MAGIC = hexToBytes3("004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294");
|
||||
var MAX_OTS_PROOF_SIZE = 1 << 20;
|
||||
var MAX_EVENT_JSON_SIZE = 1 << 16;
|
||||
var BITCOIN_ATTESTATION_TAG = hexToBytes3("0588960d73d71901");
|
||||
var LITECOIN_ATTESTATION_TAG = hexToBytes3("06869a0d73d71b45");
|
||||
var PENDING_ATTESTATION_TAG = hexToBytes3("83dfe30d2ef90c8e");
|
||||
@@ -10456,30 +10565,35 @@ function bytesEqual(a, b) {
|
||||
}
|
||||
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 {
|
||||
if (otsBytes.length > MAX_OTS_PROOF_SIZE) return null;
|
||||
try {
|
||||
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 };
|
||||
} catch (e) {
|
||||
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 > 512) throw new Error("OTS: recursion limit exceeded");
|
||||
@@ -10691,6 +10805,7 @@ export {
|
||||
signWithSLHDSA,
|
||||
timestampEvent,
|
||||
upgradeOts,
|
||||
validateSignerOutput,
|
||||
verifyFalcon,
|
||||
verifyMLDSA44,
|
||||
verifyMLDSA65,
|
||||
|
||||
+18
-7
@@ -253,13 +253,21 @@
|
||||
|
||||
<div class="pq-card">
|
||||
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
|
||||
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
|
||||
<strong>Research prototype.</strong> Verification confirms key linkage and signature
|
||||
validity; it does not mean the identity is post-quantum secure. Bitcoin OTS confirmation
|
||||
is API-assisted (trusts a public explorer), not a full light-client verification.
|
||||
</div>
|
||||
<div class="pq-info-text">
|
||||
Verify post-quantum migration events (kind 9999) by querying relays for a user's pubkey,
|
||||
or by pasting event JSON directly. The kind 9999 event wraps a kind 1 announcement
|
||||
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks:
|
||||
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks,
|
||||
reported as separate result lines:
|
||||
the kind 9999 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
|
||||
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), all PQ signatures
|
||||
(ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768), and the OpenTimestamps proof.
|
||||
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), each PQ signature
|
||||
individually (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512; ML-KEM-768 has no signature),
|
||||
the PQ algorithm policy (all mandatory algorithms present and verified), identity binding
|
||||
(outer/embedded/expected author match), and the OpenTimestamps proof.
|
||||
</div>
|
||||
<div class="pq-info-text">
|
||||
<a href="./" class="pq-link">Back to migration page</a>
|
||||
@@ -367,6 +375,7 @@
|
||||
import {
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent,
|
||||
validateSignerOutput,
|
||||
NIP_QR_KIND,
|
||||
buildUpgradedEvent,
|
||||
buildProofCarrier,
|
||||
@@ -871,6 +880,8 @@
|
||||
|
||||
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
|
||||
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
|
||||
// G56-05: validate signer output before publishing
|
||||
const validatedEvent = validateSignerOutput(signedEvent, upgradedTemplate, currentEvent.pubkey);
|
||||
|
||||
// Publish to the relays from the relay input field
|
||||
const relayInput = document.getElementById('pqRelayInput').value.trim();
|
||||
@@ -879,7 +890,7 @@
|
||||
throw new Error('No relay URLs specified in the relay input field.');
|
||||
}
|
||||
|
||||
const eventJson = JSON.stringify(['EVENT', signedEvent]);
|
||||
const eventJson = JSON.stringify(['EVENT', validatedEvent]);
|
||||
const publishResults = await Promise.all(relayUrls.map(url => {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
@@ -890,7 +901,7 @@
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data[0] === 'OK' && data[1] === signedEvent.id) {
|
||||
if (data[0] === 'OK' && data[1] === validatedEvent.id) {
|
||||
if (!done) { done = true; clearTimeout(t); try { ws.close(); } catch(e){} resolve({ url, success: true }); }
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -908,10 +919,10 @@
|
||||
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
|
||||
}
|
||||
|
||||
currentEvent = signedEvent;
|
||||
currentEvent = validatedEvent;
|
||||
setStatus(otsUpgradeStatus, 'success', `Upgraded proof carrier published to ${ok} relay(s) (${fail} failed).`);
|
||||
// Re-verify the new event
|
||||
await verifyEvent(signedEvent);
|
||||
await verifyEvent(validatedEvent);
|
||||
} catch (error) {
|
||||
setStatus(otsUpgradeStatus, 'error', `Publish failed: ${error.message}`);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user