G56-07: fail-closed verifiers (verifyNostrEvent, parseOtsFile never throw on malformed input), add MAX_OTS_PROOF_SIZE/MAX_EVENT_JSON_SIZE size guards, 23 fuzz/property tests (random JSON, random bytes, truncation, oversized inputs)

This commit is contained in:
Laan Tungir
2026-07-24 09:25:52 -04:00
parent cfa0ecc220
commit 113885eb96
6 changed files with 296 additions and 81 deletions
+12 -3
View File
@@ -9,10 +9,10 @@
| Severity | Total | Fixed | In Progress | Not Started |
|---|---:|---:|---:|---:|
| Critical | 2 | 2 | 0 | 0 |
| High | 6 | 1 | 0 | 5 |
| High | 6 | 2 | 0 | 4 |
| Medium | 8 | 2 | 0 | 6 |
| Low / Info | 4 | 0 | 0 | 4 |
| **Total** | **20** | **5** | **0** | **15** |
| **Total** | **20** | **6** | **0** | **14** |
## Findings status
@@ -24,7 +24,7 @@
| 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-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 |
@@ -92,6 +92,14 @@
- 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
## Test count
- **Before remediation:** 38 tests
@@ -99,4 +107,5 @@
- **After G56-02+03:** 61 tests
- **After G56-09:** 65 tests
- **After G56-16:** 65 tests (docs-only)
- **After G56-07:** 88 tests
- **All passing:** ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.18",
"version": "0.0.19",
"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": {
+134
View File
@@ -719,3 +719,137 @@ 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}`);
}
});
});
+79 -38
View File
@@ -412,28 +412,46 @@ 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);
}
// ============================================================================
@@ -695,6 +713,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 +1547,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 +1573,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
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.18",
"VERSION_NUMBER": "0.0.18",
"BUILD_DATE": "2026-07-24T13:09:51.134Z"
"VERSION": "v0.0.19",
"VERSION_NUMBER": "0.0.19",
"BUILD_DATE": "2026-07-24T13:25:51.884Z"
}
+67 -36
View File
@@ -9761,22 +9761,32 @@ 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);
}
var NIP_QR_KIND = 9999;
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
@@ -9886,6 +9896,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 +10470,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 +10482,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");