Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 22 additions & 13 deletions src/engine.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) };
}
Expand All @@ -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.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -298,15 +299,21 @@ 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));
}

/** 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));
Expand 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;
}
Expand Down
5 changes: 4 additions & 1 deletion tests/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
89 changes: 89 additions & 0 deletions tests/primitive-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading