feat(verify): optional NIP-01 signature verification, on by default in the widget

Closes the gap the audit left open: administrative state fails closed without a
verifier, and BitGate shipped none.

@bitgate/verify is a separate package so core, nostr, and runtime keep no crypto
dependency and can be audited without one. The widget bundles it, so the
drop-in path is verified with no extra step; verify="off" disables it, sets
data-unverified on the element, and emits bitgate:unverified — meant for local
development, and meant to be visible.

Verification recomputes the event id before checking the signature. Checking
only the signature would let a forged event pass by reusing a genuine signature
over an unrelated id, which is the failure mode that matters here.

createVerifier also rejects events dated far in the future. A far-future
created_at wins replaceable-event selection indefinitely and pins stale state in
place; the signature is perfectly valid, so only a clock check catches it.

Found while testing: #adminIngestAllowed checked that a verifier *exists*, not
that this event was verified — so the public ingestEvent path, which
"bring your own relays" integrations use, bypassed verification entirely.
Verification now runs inside ingestEvent, with an already-verified flag for the
loaders. An async verifier cannot be awaited there and is treated as
unverified; ingestVerified() awaits properly.

Conformance validated against 120 live events from a public relay across kinds
0, 1, 3 and 10002 — 59 with non-ASCII content and 71 containing quotes,
newlines or tabs, which is where a serialization mismatch would surface. All
120 verified, zero id mismatches.

Bundle grows 72K -> 120K. 855 tests; audit clean.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-10 08:34:12 -04:00
1 parent 94ef6a72f5
commit 3b95a63058
14 files changed
+596 -12

No files matched your search

+41 -1
View File
@@ -58,6 +58,10 @@
"resolved": "packages/testing",
"link": true
},
"node_modules/@bitgate/verify": {
"resolved": "packages/verify",
"link": true
},
"node_modules/@bitgate/widget": {
"resolved": "packages/widget",
"link": true
@@ -528,6 +532,33 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
@@ -1813,12 +1844,21 @@
"@bitgate/core": "*"
}
},
"packages/verify": {
"name": "@bitgate/verify",
"version": "0.1.0",
"dependencies": {
"@noble/curves": "^1.9.0",
"@noble/hashes": "^1.8.0"
}
},
"packages/widget": {
"name": "@bitgate/widget",
"version": "0.1.0",
"dependencies": {
"@bitgate/core": "*",
"@bitgate/runtime": "*"
"@bitgate/runtime": "*",
"@bitgate/verify": "*"
}
}
}