diff --git a/README.md b/README.md index 5d15bf8..b42f88f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ ## What It Is -An interactive model of **Bitcoin Script** — the tiny, stack-based language that defines who is allowed to spend each bitcoin — using **real cryptography in the browser**. The page composes the audited [`@noble/secp256k1`](https://github.com/paulmillr/noble-secp256k1) and [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) libraries to build a genuine **Pay-to-Public-Key-Hash (P2PKH)** spend end to end: a secp256k1 key pair, a HASH160 (SHA-256 → RIPEMD-160) commitment in the locking script, a real **legacy SIGHASH_ALL** preimage over a concrete one-input/one-output transaction, an **ECDSA** signature (low-S, DER-encoded) in the unlocking script, and a hand-rolled stack interpreter that executes `OP_DUP · OP_HASH160 · OP_EQUALVERIFY · OP_CHECKSIG` one step at a time. Nothing is faked: the signature math, the hashes, and the sighash are all the real thing, validated at boot against the textbook privkey = 1 key whose HASH160 is `751e76e8199196d454941c45d1b3a323f1433bd6` (address `1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH`). Keys are generated per session in memory and never persisted — this is **for education only**. +An interactive model of **Bitcoin Script** — the tiny, stack-based language that defines who is allowed to spend each bitcoin — using **real cryptography in the browser**. The page composes [`@noble/secp256k1`](https://github.com/paulmillr/noble-secp256k1) and [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) libraries to build a genuine **Pay-to-Public-Key-Hash (P2PKH)** spend end to end: a secp256k1 key pair, a HASH160 (SHA-256 → RIPEMD-160) commitment in the locking script, a real **legacy SIGHASH_ALL** preimage over a concrete one-input/one-output transaction, an **ECDSA** signature (low-S, DER-encoded) in the unlocking script, and a hand-rolled stack interpreter that executes `OP_DUP · OP_HASH160 · OP_EQUALVERIFY · OP_CHECKSIG` one step at a time. Nothing is faked: the signature math, the hashes, and the sighash are all the real thing, validated at boot against the textbook privkey = 1 key whose HASH160 is `751e76e8199196d454941c45d1b3a323f1433bd6` (address `1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH`). Keys are generated per session in memory and never persisted — this is **for education only**. + +The lockfile selects `@noble/secp256k1` 3.2.0 and `@noble/hashes` 2.4.0. The secp256k1 publisher [states that the current rewrite has not been independently audited](https://github.com/paulmillr/noble-secp256k1/blob/04c95fb63085e862a14616ce5b86549fea56191c/README.md#security); its v1 audit does not establish an audit of this locked version. Signing and verification use `prehash: false` because the lab already computes the Bitcoin double-SHA256 digest. Compact 64-byte SDK signatures pass through the existing Bitcoin DER wrapper and SIGHASH_ALL byte. Regression tests independently verify those signatures with Node/OpenSSL and reject the extra-hash case. ## When to Use It @@ -35,6 +37,8 @@ A coin is locked to an owner's `pubKeyHash`. Pick one of four spend scenarios ## How to Run Locally +Use Node.js 24, matching the application CI runtime. + ```bash git clone https://github.com/systemslibrarian/crypto-lab-bitcoin-script cd crypto-lab-bitcoin-script diff --git a/package-lock.json b/package-lock.json index 5e4f48f..e0e3d83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,8 @@ "name": "crypto-lab-bitcoin-script", "version": "0.1.0", "dependencies": { - "@noble/hashes": "^1.5.0", - "@noble/secp256k1": "^2.3.0" + "@noble/hashes": "^2.4.0", + "@noble/secp256k1": "^3.2.0" }, "devDependencies": { "@axe-core/playwright": "^4.13.0", @@ -278,21 +278,21 @@ } }, "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==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/secp256k1": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.3.0.tgz", - "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.2.0.tgz", + "integrity": "sha512-Z3ZAWOTxJ0EuTuZTi7Y69iK7GgrLHh8sgm45lVDhg/b3Nk1TpAiKhick2KkZisHuupeepSkyIydN/J459SdX1w==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" diff --git a/package.json b/package.json index 44b7b8e..bec314c 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "test:a11y": "playwright test" }, "dependencies": { - "@noble/hashes": "^1.5.0", - "@noble/secp256k1": "^2.3.0" + "@noble/hashes": "^2.4.0", + "@noble/secp256k1": "^3.2.0" }, "devDependencies": { "@axe-core/playwright": "^4.13.0", diff --git a/src/engine.ts b/src/engine.ts index fb459ad..0d5b683 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,4 +1,4 @@ -// engine.ts — the real cryptography behind the demo, built on the audited +// engine.ts — the real cryptography behind the demo, built on the // @noble libraries. Covers: secp256k1 keys, HASH160 (RIPEMD160∘SHA256), DER // signature encoding/decoding/inspection, a real legacy SIGHASH_ALL preimage // (exposed as labelled segments) over a concrete one-input/one-output @@ -11,16 +11,17 @@ // per session in memory and never persisted; do not guard real funds. import * as secp from '@noble/secp256k1'; -import { sha256 } from '@noble/hashes/sha2'; -import { ripemd160 } from '@noble/hashes/legacy'; -import { hmac } from '@noble/hashes/hmac'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { ripemd160 } from '@noble/hashes/legacy.js'; +import { hmac } from '@noble/hashes/hmac.js'; -// @noble/secp256k1 v2 needs a synchronous HMAC-SHA256 wired up for RFC-6979 +// @noble/secp256k1 v3 needs a synchronous HMAC-SHA256 wired up for RFC-6979 // deterministic ECDSA (otherwise only the async signer is available). -secp.etc.hmacSha256Sync = (key: Uint8Array, ...msgs: Uint8Array[]): Uint8Array => - hmac(sha256, key, secp.etc.concatBytes(...msgs)); +secp.hashes.hmacSha256 = (key: Uint8Array, msg: Uint8Array): Uint8Array => + hmac(sha256, key, msg); +secp.hashes.sha256 = sha256; -const N = secp.CURVE.n; +const N = secp.Point.CURVE().n; const HALF_N = N / 2n; // --------------------------------------------------------------------------- @@ -139,7 +140,7 @@ export interface KeyPair { } export function makeKeyPair(priv?: Uint8Array): KeyPair { - const sk = priv ?? secp.utils.randomPrivateKey(); + const sk = priv ?? secp.utils.randomSecretKey(); const pub = secp.getPublicKey(sk, true); return { priv: sk, pub, pubKeyHash: hash160(pub) }; } @@ -154,7 +155,7 @@ export function parseCompressedPubKey(bytes: Uint8Array): Uint8Array { } // --------------------------------------------------------------------------- -// DER encoding/decoding (BIP-66 shape). @noble v2 only exposes compact (r||s), +// DER encoding/decoding (BIP-66 shape). The SDK exposes compact (r||s), // so the Bitcoin-flavoured DER wrapper is built here — and it is exactly the // part a student wants to see, not hide in a library. // --------------------------------------------------------------------------- @@ -298,7 +299,11 @@ export function legacySighash(ctx: SpendContext): Uint8Array { /** ECDSA-sign a 32-byte sighash, low-S, and return the Bitcoin scriptSig push: * DER(r,s) || hashType. */ export function signSighash(sighash: Uint8Array, priv: Uint8Array): Uint8Array { - const sig = secp.sign(sighash, priv); // RFC-6979 deterministic, low-S by default + // legacySighash already double-hashes the preimage; v3's default prehash + // would hash it a third time. Keep the compact SDK form inside our DER wrapper. + const sig = secp.Signature.fromBytes( + secp.sign(sighash, priv, { prehash: false, format: 'compact' }), 'compact', + ); // RFC-6979 deterministic, low-S by default const der = rsToDER(bigintTo32be(sig.r), bigintTo32be(sig.s)); return concat(der, Uint8Array.of(SIGHASH_ALL)); } @@ -306,7 +311,9 @@ export function signSighash(sighash: Uint8Array, priv: Uint8Array): Uint8Array { /** Like signSighash but flips s to n−s, producing a mathematically valid but * non-standard HIGH-S signature (BIP-146 policy rejects it). For edge cases. */ export function signSighashHighS(sighash: Uint8Array, priv: Uint8Array): Uint8Array { - const sig = secp.sign(sighash, priv); + const sig = secp.Signature.fromBytes( + secp.sign(sighash, priv, { prehash: false, format: 'compact' }), 'compact', + ); const highS = N - sig.s; const der = rsToDER(bigintTo32be(sig.r), bigintTo32be(highS)); return concat(der, Uint8Array.of(SIGHASH_ALL)); @@ -327,7 +334,9 @@ export function checkSig( const der = sigWithType.slice(0, sigWithType.length - 1); // drop hash-type byte const { r, s } = derToRS(der); const sig = new secp.Signature(r, s); // throws if r or s ∉ [1, n) - return secp.verify(sig.toCompactRawBytes(), sighash, pub, { lowS }); + return secp.verify(sig.toBytes('compact'), sighash, pub, { + lowS, prehash: false, format: 'compact', + }); } catch { return false; } diff --git a/tests/engine.test.ts b/tests/engine.test.ts index caf0ec6..3916e08 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -46,7 +46,10 @@ describe('engine: real Bitcoin known-answers', () => { describe('DER encoding round-trips', () => { it('encodes and decodes an (r, s) pair', () => { const kp = makeKeyPair(); - const sig = secp.sign(new Uint8Array(32).fill(7), kp.priv); + const sig = secp.Signature.fromBytes( + secp.sign(new Uint8Array(32).fill(7), kp.priv, { prehash: false, format: 'compact' }), + 'compact', + ); const r = hexToBytes(sig.r.toString(16).padStart(64, '0')); const s = hexToBytes(sig.s.toString(16).padStart(64, '0')); const der = rsToDER(r, s); diff --git a/tests/primitive-compatibility.test.ts b/tests/primitive-compatibility.test.ts new file mode 100644 index 0000000..0cfcaea --- /dev/null +++ b/tests/primitive-compatibility.test.ts @@ -0,0 +1,89 @@ +import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + checkSig, concat, CURVE_N, derToRS, hexToBytes, inspectSignature, legacySighash, + makeKeyPair, rsToDER, secp, SIGHASH_ALL, sighashPreimage, signSighash, + signSighashHighS, type SpendContext, +} from '../src/engine'; +import { p2pkhScriptPubKey, serializeScript } from '../src/script'; + +const secret = hexToBytes('00'.repeat(31) + '01'); +const owner = makeKeyPair(secret); +const context: SpendContext = { + version: 1, prevTxid: new Uint8Array(32), vout: 0, sequence: 0xffffffff, + subscript: serializeScript(p2pkhScriptPubKey(owner.pubKeyHash)), outValue: 1000n, + outScript: serializeScript(p2pkhScriptPubKey(owner.pubKeyHash)), locktime: 0, +}; +const preimage = sighashPreimage(context); +const sighash = legacySighash(context); +// OpenSSL hashes this once more during ECDSA, independently checking Bitcoin's +// double-SHA256. Feeding the final sighash instead is a deliberate triple-hash control. +const firstHash = createHash('sha256').update(preimage).digest(); +// SEC 1 ecPublicKey / secp256k1 SubjectPublicKeyInfo with a compressed SEC 1 key. +const publicKey = createPublicKey({ + key: Buffer.concat([Buffer.from('3036301006072a8648ce3d020106052b8104000a032200', 'hex'), owner.pub]), + format: 'der', type: 'spki', +}); +const affine = secp.Point.fromBytes(owner.pub).toAffine(); +const b64 = (n: bigint) => Buffer.from(n.toString(16).padStart(64, '0'), 'hex').toString('base64url'); +const privateKey = createPrivateKey({ + key: { kty: 'EC', crv: 'secp256k1', x: b64(affine.x), y: b64(affine.y), + d: Buffer.from(secret).toString('base64url') }, format: 'jwk', +}); +const scalarBytes = (n: bigint) => hexToBytes(n.toString(16).padStart(64, '0')); +const withType = (der: Uint8Array) => concat(der, Uint8Array.of(SIGHASH_ALL)); + +describe('Noble 3 Bitcoin signature compatibility with native OpenSSL', () => { + it('signs the already double-hashed transaction, without adding a third hash', () => { + const wire = signSighash(sighash, secret); + const der = wire.slice(0, -1); + expect(wire[wire.length - 1]).toBe(SIGHASH_ALL); + expect(verify('sha256', firstHash, publicKey, der)).toBe(true); + expect(verify('sha256', sighash, publicKey, der)).toBe(false); + const changed = Buffer.from(firstHash); changed[0] ^= 1; + expect(verify('sha256', changed, publicKey, der)).toBe(false); + expect(inspectSignature(wire).lowS).toBe(true); + }); + + it('verifies a native signature on the double-hashed preimage and rejects a different transaction', () => { + const der = sign('sha256', firstHash, privateKey); + const wire = withType(der); + expect(checkSig(wire, owner.pub, sighash, false)).toBe(true); + expect(checkSig(wire, owner.pub, sighash)).toBe(inspectSignature(wire).lowS); + expect(checkSig(wire, owner.pub, legacySighash({ ...context, outValue: 1001n }), false)).toBe(false); + }); + + it('retains high-S mathematical validity while the default policy rejects it', () => { + const wire = signSighashHighS(sighash, secret); + expect(verify('sha256', firstHash, publicKey, wire.slice(0, -1))).toBe(true); + expect(inspectSignature(wire).lowS).toBe(false); + expect(checkSig(wire, owner.pub, sighash)).toBe(false); + expect(checkSig(wire, owner.pub, sighash, false)).toBe(true); + }); + + it('detects the SDK default-prehash trap in both signing and verification', () => { + const extraHashed = secp.Signature.fromBytes(secp.sign(sighash, secret), 'compact'); + const wrongWire = withType(rsToDER(scalarBytes(extraHashed.r), scalarBytes(extraHashed.s))); + expect(verify('sha256', firstHash, publicKey, wrongWire.slice(0, -1))).toBe(false); + expect(checkSig(wrongWire, owner.pub, sighash)).toBe(false); + const { r, s } = derToRS(signSighash(sighash, secret).slice(0, -1)); + const compact = new secp.Signature(r, s).toBytes('compact'); + expect(compact.length).toBe(64); + expect(secp.verify(compact, sighash, owner.pub)).toBe(false); + expect(secp.verify(compact, sighash, owner.pub, { prehash: false, format: 'compact' })).toBe(true); + }); + + it('keeps malformed keys and zero/out-of-range signature scalars fail-closed', () => { + const wire = signSighash(sighash, secret); + expect(checkSig(wire, new Uint8Array(33), sighash)).toBe(false); + expect(checkSig(wire, owner.pub.slice(0, 32), sighash)).toBe(false); + for (const [r, s] of [[0n, 1n], [1n, 0n], [CURVE_N, 1n], [1n, CURVE_N]]) { + expect(checkSig(withType(rsToDER(scalarBytes(r), scalarBytes(s))), owner.pub, sighash, false)).toBe(false); + } + const first = makeKeyPair(); const second = makeKeyPair(); + expect(secp.utils.isValidSecretKey(first.priv)).toBe(true); + expect(first.pub.length).toBe(33); + expect(first.priv).not.toEqual(second.priv); + expect(checkSig(wire, second.pub, sighash)).toBe(false); + }); +});