G56-05: validateSignerOutput() helper — checks all fields, verifies no template field changed, pubkey matches identity, recomputes ID, verifies Schnorr sig, rejects extra fields; replaced reconstruction at all 4 signEvent() call sites; 15 new tests
This commit is contained in:
@@ -9,10 +9,10 @@
|
||||
| Severity | Total | Fixed | In Progress | Not Started |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Critical | 2 | 2 | 0 | 0 |
|
||||
| High | 6 | 2 | 0 | 4 |
|
||||
| High | 6 | 3 | 0 | 3 |
|
||||
| Medium | 8 | 2 | 0 | 6 |
|
||||
| Low / Info | 4 | 0 | 0 | 4 |
|
||||
| **Total** | **20** | **6** | **0** | **14** |
|
||||
| **Total** | **20** | **7** | **0** | **13** |
|
||||
|
||||
## Findings status
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| 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 | ✅ 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 |
|
||||
@@ -100,6 +100,21 @@
|
||||
- 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
|
||||
@@ -108,4 +123,5 @@
|
||||
- **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
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "post_quantum_nostr",
|
||||
"version": "0.0.19",
|
||||
"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": {
|
||||
|
||||
@@ -853,3 +853,139 @@ describe('G56-07: isOtsConfirmed never throws on bad input', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
|
||||
+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}`);
|
||||
|
||||
@@ -454,6 +454,122 @@ export function verifyNostrEvent(event) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NIP-QR EVENT CONSTRUCTION
|
||||
// ============================================================================
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.0.19",
|
||||
"VERSION_NUMBER": "0.0.19",
|
||||
"BUILD_DATE": "2026-07-24T13:25:51.884Z"
|
||||
"VERSION": "v0.0.20",
|
||||
"VERSION_NUMBER": "0.0.20",
|
||||
"BUILD_DATE": "2026-07-24T13:42:06.579Z"
|
||||
}
|
||||
|
||||
@@ -9788,6 +9788,89 @@ function verifyNostrEvent(event) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
const npub = hexToNpub(hexPubkey);
|
||||
@@ -10722,6 +10805,7 @@ export {
|
||||
signWithSLHDSA,
|
||||
timestampEvent,
|
||||
upgradeOts,
|
||||
validateSignerOutput,
|
||||
verifyFalcon,
|
||||
verifyMLDSA44,
|
||||
verifyMLDSA65,
|
||||
|
||||
+7
-4
@@ -375,6 +375,7 @@
|
||||
import {
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent,
|
||||
validateSignerOutput,
|
||||
NIP_QR_KIND,
|
||||
buildUpgradedEvent,
|
||||
buildProofCarrier,
|
||||
@@ -879,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();
|
||||
@@ -887,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 {
|
||||
@@ -898,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) {}
|
||||
@@ -916,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