diff --git a/sdk/src/encoding.ts b/sdk/src/encoding.ts index f39d052..1101be5 100644 --- a/sdk/src/encoding.ts +++ b/sdk/src/encoding.ts @@ -1,4 +1,4 @@ -import { createHash } from 'crypto'; +import { createHash } from './hash'; // BN254 scalar field prime // r = 21888242871839275222246405745257275088548364400416034343698204186575808495617 diff --git a/sdk/src/hash.ts b/sdk/src/hash.ts new file mode 100644 index 0000000..b55bd54 --- /dev/null +++ b/sdk/src/hash.ts @@ -0,0 +1,121 @@ +/** + * Browser-safe hash functions. + * + * Provides SHA-256 and other common hashes that work across environments: + * - Node.js (native crypto module) + * - Browsers (SubtleCrypto) + * + * NOTE: For production use with ZK circuits, you should use a dedicated + * Poseidon hash implementation compatible with your proving system. + */ + +import { detectEnv, RuntimeEnv } from './random'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A hash function that takes arbitrary bytes and returns a fixed-size digest. + */ +export interface HashFunction { + /** + * Compute the hash of the input data. + */ + update(data: Buffer): this; + + /** + * Finalize and return the digest. + */ + digest(): Buffer; +} + +// --------------------------------------------------------------------------- +// Hash implementations +// --------------------------------------------------------------------------- + +/** + * Node.js SHA-256 implementation. + */ +export class NodeSha256 implements HashFunction { + private readonly hash: any; + + constructor() { + const { createHash } = require('crypto'); + this.hash = createHash('sha256'); + } + + update(data: Buffer): this { + this.hash.update(data); + return this; + } + + digest(): Buffer { + return this.hash.digest(); + } +} + +/** + * Web Crypto SHA-256 implementation. + * Note: This is async - you must await the digest promise. + */ +export class WebCryptoSha256 { + private chunks: Buffer[] = []; + + update(data: Buffer): this { + this.chunks.push(data); + return this; + } + + /** + * WARNING: This returns a Promise, not a Buffer! + * If you need a sync API, use Node.js or a pure-JS SHA-256 implementation. + */ + async digest(): Promise { + const data = Buffer.concat(this.chunks); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return Buffer.from(new Uint8Array(hashBuffer)); + } +} + +// --------------------------------------------------------------------------- +// Convenience API - SHA-256 (Node.js only for now due to async) +// --------------------------------------------------------------------------- + +/** + * Create a SHA-256 hash context. + * NOTE: In browsers, this will throw - use a pure JS implementation or SubtleCrypto directly. + */ +export function createHash(algorithm: 'sha256'): HashFunction { + if (algorithm !== 'sha256') { + throw new Error(`Unsupported hash algorithm: ${algorithm}. Only 'sha256' is available.`); + } + + const env = detectEnv(); + + switch (env) { + case 'node': + return new NodeSha256(); + + default: + throw new Error( + `Synchronous SHA-256 is not available in environment '${env}'. ` + + `In browsers, use crypto.subtle.digest('SHA-256', data) which is async, ` + + `or use a pure-JS SHA-256 implementation.` + ); + } +} + +/** + * Compute SHA-256 hash of data in one call. + */ +export function sha256(data: Buffer): Buffer { + return createHash('sha256').update(data).digest(); +} + +export default { + createHash, + sha256, + NodeSha256, + WebCryptoSha256, +}; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 3687fc9..fbe0bc0 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -2,10 +2,12 @@ export * from './backends'; export * from './benchmark'; export * from './encoding'; export * from './errors'; +export * from './hash'; export * from './merkle'; export * from './note'; export * from './proof'; export * from './gas'; +export * from './random'; export * from './stealth'; export * from './withdraw'; export { diff --git a/sdk/src/note.ts b/sdk/src/note.ts index 613ccbd..82b4430 100644 --- a/sdk/src/note.ts +++ b/sdk/src/note.ts @@ -1,4 +1,5 @@ -import { createHash, randomBytes } from 'crypto'; +import { createHash } from './hash'; +import { randomBytes } from './random'; // --------------------------------------------------------------------------- // Backup format constants diff --git a/sdk/src/random.ts b/sdk/src/random.ts new file mode 100644 index 0000000..5afa335 --- /dev/null +++ b/sdk/src/random.ts @@ -0,0 +1,193 @@ +/** + * Browser-safe cryptographically secure random number generation. + * + * Provides environment detection and graceful fallbacks for: + * - Node.js (native crypto module) + * - Browsers (Web Crypto API) + * - Cloudflare Workers / Deno (Web Crypto API) + * - Other runtimes (throws helpful error with instructions) + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A secure random source that can generate cryptographically safe bytes. + */ +export interface RandomSource { + /** + * Generate `n` cryptographically secure random bytes. + */ + randomBytes(n: number): Buffer; +} + +// --------------------------------------------------------------------------- +// Environment detection +// --------------------------------------------------------------------------- + +/** + * Detected execution environment. + */ +export type RuntimeEnv = + | 'node' // Node.js + | 'browser' // Web browser + | 'worker' // Web Worker / Cloudflare Worker + | 'deno' // Deno + | 'unknown'; // ¯\_(ツ)_/¯ + +/** + * Detect the current execution environment. + */ +export function detectEnv(): RuntimeEnv { + if (typeof process !== 'undefined' && process.versions?.node) { + return 'node'; + } + + if (typeof self !== 'undefined' && self.crypto) { + // Check for Cloudflare Worker or Web Worker + if (typeof (self as any).addEventListener !== 'undefined' && !self.document) { + return 'worker'; + } + return 'browser'; + } + + if (typeof (globalThis as any).Deno !== 'undefined') { + return 'deno'; + } + + return 'unknown'; +} + +// --------------------------------------------------------------------------- +// Random source implementations +// --------------------------------------------------------------------------- + +/** + * Node.js random source using built-in crypto module. + */ +export class NodeRandomSource implements RandomSource { + private readonly rb: (n: number) => Buffer; + + constructor() { + // Lazy-require to avoid breaking browser bundlers + const { randomBytes } = require('crypto'); + this.rb = randomBytes; + } + + randomBytes(n: number): Buffer { + return this.rb(n); + } +} + +/** + * Web Crypto API random source (works in browsers, Deno, and Cloudflare Workers). + */ +export class WebCryptoRandomSource implements RandomSource { + private readonly crypto: Crypto; + + constructor(cryptoImpl?: Crypto) { + this.crypto = cryptoImpl || self.crypto; + if (!this.crypto?.getRandomValues) { + throw new Error( + 'Web Crypto API is not available in this environment. ' + + 'You may need to use a Node.js polyfill or provide a custom RandomSource.' + ); + } + } + + randomBytes(n: number): Buffer { + const arr = new Uint8Array(n); + this.crypto.getRandomValues(arr); + return Buffer.from(arr); + } +} + +/** + * Random source that always throws. + * Used as the default fallback when no secure RNG is available. + */ +export class ThrowingRandomSource implements RandomSource { + constructor(public readonly env: RuntimeEnv) {} + + randomBytes(n: number): Buffer { + throw new Error( + `No cryptographically secure random source available in detected environment '${this.env}'. ` + + `Please provide a custom RandomSource implementation for this runtime. ` + + `In Node.js, ensure you can 'require("crypto")'. ` + + `In browsers, ensure you're running in a secure context (HTTPS or localhost).` + ); + } +} + +// --------------------------------------------------------------------------- +// Default source auto-selection +// --------------------------------------------------------------------------- + +let defaultSource: RandomSource | undefined; + +/** + * Get the default random source for this environment. + * The source is lazily detected on first call and cached. + */ +export function getDefaultRandomSource(): RandomSource { + if (defaultSource) { + return defaultSource; + } + + const env = detectEnv(); + + switch (env) { + case 'node': + defaultSource = new NodeRandomSource(); + break; + + case 'browser': + case 'worker': + case 'deno': + defaultSource = new WebCryptoRandomSource(); + break; + + default: + defaultSource = new ThrowingRandomSource(env); + } + + return defaultSource; +} + +/** + * Override the default random source. + * Useful for: + * - Testing with deterministic mocks + * - Using an HSM or hardware RNG + * - Unsupported runtimes + */ +export function setDefaultRandomSource(source: RandomSource): void { + defaultSource = source; +} + +/** + * Clear the cached default source, forcing re-detection on next use. + */ +export function clearDefaultRandomSource(): void { + defaultSource = undefined; +} + +/** + * Generate random bytes using the default source. + * Convenience export for callers. + */ +export function randomBytes(n: number): Buffer { + return getDefaultRandomSource().randomBytes(n); +} + +export default { + randomBytes, + getDefaultRandomSource, + setDefaultRandomSource, + clearDefaultRandomSource, + detectEnv, + NodeRandomSource, + WebCryptoRandomSource, + ThrowingRandomSource, +}; diff --git a/sdk/src/stealth.ts b/sdk/src/stealth.ts index fe95e15..cd17b56 100644 --- a/sdk/src/stealth.ts +++ b/sdk/src/stealth.ts @@ -1,5 +1,6 @@ import * as elliptic from 'elliptic'; -import { randomBytes, createHash } from 'crypto'; +import { randomBytes } from './random'; +import { createHash } from './hash'; const ed25519 = new elliptic.eddsa('ed25519'); diff --git a/sdk/test/hash.test.ts b/sdk/test/hash.test.ts new file mode 100644 index 0000000..fddd6b5 --- /dev/null +++ b/sdk/test/hash.test.ts @@ -0,0 +1,57 @@ +import { createHash, sha256, NodeSha256 } from '../src/hash'; + +describe('hash module', () => { + describe('NodeSha256', () => { + it('computes correct SHA-256 hash', () => { + const hash = new NodeSha256(); + hash.update(Buffer.from('hello world')); + const digest = hash.digest(); + + // Known SHA-256 hash of "hello world" + const expected = Buffer.from( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + 'hex' + ); + + expect(digest.equals(expected)).toBe(true); + }); + + it('supports chained updates', () => { + const hash = new NodeSha256(); + hash.update(Buffer.from('hello')); + hash.update(Buffer.from(' ')); + hash.update(Buffer.from('world')); + const digest = hash.digest(); + + const expected = Buffer.from( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + 'hex' + ); + + expect(digest.equals(expected)).toBe(true); + }); + }); + + describe('createHash convenience', () => { + it('creates a SHA-256 hash instance', () => { + const hash = createHash('sha256'); + expect(hash).toBeInstanceOf(NodeSha256); + }); + + it('throws for unsupported algorithms', () => { + // @ts-ignore - intentional bad value + expect(() => createHash('md5')).toThrow(/Unsupported hash algorithm/); + }); + }); + + describe('sha256 convenience', () => { + it('hashes data in one call', () => { + const result = sha256(Buffer.from('hello world')); + const expected = Buffer.from( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + 'hex' + ); + expect(result.equals(expected)).toBe(true); + }); + }); +}); diff --git a/sdk/test/random.test.ts b/sdk/test/random.test.ts new file mode 100644 index 0000000..bb3894e --- /dev/null +++ b/sdk/test/random.test.ts @@ -0,0 +1,97 @@ +import { + randomBytes, + detectEnv, + getDefaultRandomSource, + setDefaultRandomSource, + clearDefaultRandomSource, + NodeRandomSource, + WebCryptoRandomSource, + ThrowingRandomSource, + RandomSource, +} from '../src/random'; + +describe('random module', () => { + beforeEach(() => { + clearDefaultRandomSource(); + }); + + describe('detectEnv', () => { + it('detects Node.js environment', () => { + expect(detectEnv()).toBe('node'); + }); + }); + + describe('NodeRandomSource', () => { + it('generates random bytes of correct length', () => { + const source = new NodeRandomSource(); + const bytes = source.randomBytes(32); + expect(bytes.length).toBe(32); + expect(Buffer.isBuffer(bytes)).toBe(true); + }); + + it('generates different bytes each call', () => { + const source = new NodeRandomSource(); + const b1 = source.randomBytes(32); + const b2 = source.randomBytes(32); + expect(b1.equals(b2)).toBe(false); + }); + }); + + describe('randomBytes convenience function', () => { + it('generates random bytes using the default source', () => { + const bytes = randomBytes(16); + expect(bytes.length).toBe(16); + expect(Buffer.isBuffer(bytes)).toBe(true); + }); + }); + + describe('default source management', () => { + it('allows overriding the default source', () => { + const mock: RandomSource = { + randomBytes: jest.fn(() => Buffer.alloc(32)), + }; + + setDefaultRandomSource(mock); + const result = randomBytes(32); + + expect(mock.randomBytes).toHaveBeenCalledWith(32); + expect(result.length).toBe(32); + }); + + it('clears the cached default source', () => { + const source1 = getDefaultRandomSource(); + clearDefaultRandomSource(); + const source2 = getDefaultRandomSource(); + expect(source1).not.toBe(source2); + }); + }); + + describe('ThrowingRandomSource', () => { + it('throws with helpful error message', () => { + const source = new ThrowingRandomSource('unknown'); + expect(() => source.randomBytes(32)).toThrow(/No cryptographically secure random source/); + expect(() => source.randomBytes(32)).toThrow(/unknown/); + }); + }); + + describe('WebCryptoRandomSource', () => { + it('can be constructed with mock crypto impl', () => { + const mockCrypto = { + getRandomValues: (arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) { + arr[i] = i % 256; + } + return arr; + }, + }; + + const source = new WebCryptoRandomSource(mockCrypto as any); + const bytes = source.randomBytes(5); + + expect(bytes.length).toBe(5); + expect(bytes[0]).toBe(0); + expect(bytes[1]).toBe(1); + expect(bytes[2]).toBe(2); + }); + }); +});