diff --git a/packages/js-evo-sdk/src/documents/facade.ts b/packages/js-evo-sdk/src/documents/facade.ts index b5061e4c5b4..db0a5552632 100644 --- a/packages/js-evo-sdk/src/documents/facade.ts +++ b/packages/js-evo-sdk/src/documents/facade.ts @@ -1,4 +1,4 @@ -import { asJsonString } from '../util.js'; +import { asJsonString, generateEntropy } from '../util.js'; import type { EvoSDK } from '../sdk.js'; export class DocumentsFacade { @@ -72,10 +72,12 @@ export class DocumentsFacade { type: string; ownerId: string; data: unknown; - entropyHex: string; + entropyHex?: string; // Now optional - will auto-generate if not provided privateKeyWif: string; }): Promise { - const { contractId, type, ownerId, data, entropyHex, privateKeyWif } = args; + const { contractId, type, ownerId, data, privateKeyWif } = args; + // Auto-generate entropy if not provided + const entropyHex = args.entropyHex ?? generateEntropy(); const w = await this.sdk.getWasmSdkConnected(); return w.documentCreate( contractId, diff --git a/packages/js-evo-sdk/src/util.ts b/packages/js-evo-sdk/src/util.ts index 36d5f27d2fb..9bd7552afc8 100644 --- a/packages/js-evo-sdk/src/util.ts +++ b/packages/js-evo-sdk/src/util.ts @@ -3,3 +3,34 @@ export function asJsonString(value: unknown): string | undefined { if (typeof value === 'string') return value; return JSON.stringify(value); } + +/** + * Generate 32 bytes of cryptographically secure random entropy as a hex string. + * Works in both Node.js and browser environments. + * + * @returns A 64-character hex string representing 32 bytes of entropy + * @throws Error if no secure random source is available + */ +export function generateEntropy(): string { + // Node.js environment + if (typeof globalThis !== 'undefined' && globalThis.crypto && 'randomBytes' in globalThis.crypto) { + // @ts-ignore - Node.js crypto.randomBytes exists but may not be in types + return globalThis.crypto.randomBytes(32).toString('hex'); + } + + // Browser environment or Node.js with Web Crypto API + if (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.getRandomValues) { + const buffer = new Uint8Array(32); + globalThis.crypto.getRandomValues(buffer); + return Array.from(buffer).map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + // Fallback for older environments + if (typeof window !== 'undefined' && window.crypto && window.crypto.getRandomValues) { + const buffer = new Uint8Array(32); + window.crypto.getRandomValues(buffer); + return Array.from(buffer).map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + throw new Error('No secure random source available. This environment does not support crypto.randomBytes or crypto.getRandomValues.'); +} diff --git a/packages/js-evo-sdk/tests/unit/facades/documents.spec.mjs b/packages/js-evo-sdk/tests/unit/facades/documents.spec.mjs index 1943f2d00ca..236fff41a55 100644 --- a/packages/js-evo-sdk/tests/unit/facades/documents.spec.mjs +++ b/packages/js-evo-sdk/tests/unit/facades/documents.spec.mjs @@ -53,7 +53,7 @@ describe('DocumentsFacade', () => { expect(wasmSdk.getDocumentWithProofInfo).to.be.calledOnceWithExactly('c', 't', 'id'); }); - it('create() calls wasmSdk.documentCreate with JSON data', async () => { + it('create() calls wasmSdk.documentCreate with JSON data and provided entropy', async () => { const data = { foo: 'bar' }; await client.documents.create({ contractId: 'c', @@ -66,6 +66,36 @@ describe('DocumentsFacade', () => { expect(wasmSdk.documentCreate).to.be.calledOnceWithExactly('c', 't', 'o', JSON.stringify(data), 'ee', 'wif'); }); + it('create() auto-generates entropy when not provided', async () => { + const data = { foo: 'bar' }; + await client.documents.create({ + contractId: 'c', + type: 't', + ownerId: 'o', + data, + // No entropyHex provided - should auto-generate + privateKeyWif: 'wif', + }); + + // Check that documentCreate was called + expect(wasmSdk.documentCreate).to.be.calledOnce(); + const [ + contractId, type, ownerId, jsonData, entropy, wif, + ] = wasmSdk.documentCreate.firstCall.args; + + // Verify all params except entropy + expect(contractId).to.equal('c'); + expect(type).to.equal('t'); + expect(ownerId).to.equal('o'); + expect(jsonData).to.equal(JSON.stringify(data)); + expect(wif).to.equal('wif'); + + // Verify that entropy was auto-generated (should be 64 hex chars = 32 bytes) + expect(entropy).to.be.a('string'); + expect(entropy).to.match(/^[0-9a-f]{64}$/i); + expect(entropy.length).to.equal(64); + }); + it('replace() calls wasmSdk.documentReplace with BigInt revision', async () => { await client.documents.replace({ contractId: 'c', diff --git a/packages/js-evo-sdk/tests/unit/util.spec.mjs b/packages/js-evo-sdk/tests/unit/util.spec.mjs new file mode 100644 index 00000000000..a366cee7701 --- /dev/null +++ b/packages/js-evo-sdk/tests/unit/util.spec.mjs @@ -0,0 +1,77 @@ +import { asJsonString, generateEntropy } from '../../dist/util.js'; + +describe('Util Functions', () => { + describe('asJsonString', () => { + it('returns undefined for null', () => { + expect(asJsonString(null)).to.be.undefined(); + }); + + it('returns undefined for undefined', () => { + expect(asJsonString(undefined)).to.be.undefined(); + }); + + it('returns string as-is', () => { + expect(asJsonString('hello')).to.equal('hello'); + }); + + it('converts objects to JSON string', () => { + const obj = { foo: 'bar', num: 42 }; + expect(asJsonString(obj)).to.equal(JSON.stringify(obj)); + }); + + it('converts arrays to JSON string', () => { + const arr = [1, 2, 'three']; + expect(asJsonString(arr)).to.equal(JSON.stringify(arr)); + }); + }); + + describe('generateEntropy', () => { + it('generates a 64-character hex string', () => { + const entropy = generateEntropy(); + expect(entropy).to.be.a('string'); + expect(entropy.length).to.equal(64); + }); + + it('generates valid hexadecimal', () => { + const entropy = generateEntropy(); + expect(entropy).to.match(/^[0-9a-f]{64}$/i); + }); + + it('generates different values each time', () => { + const entropy1 = generateEntropy(); + const entropy2 = generateEntropy(); + const entropy3 = generateEntropy(); + + // Should be different (extremely unlikely to be the same) + expect(entropy1).to.not.equal(entropy2); + expect(entropy2).to.not.equal(entropy3); + expect(entropy1).to.not.equal(entropy3); + }); + + it('returns exactly 32 bytes when decoded', () => { + const entropy = generateEntropy(); + // Convert hex string to bytes + const bytes = []; + for (let i = 0; i < entropy.length; i += 2) { + bytes.push(parseInt(entropy.substring(i, 2), 16)); + } + expect(bytes.length).to.equal(32); + }); + + it('generates values with good distribution', () => { + // Generate multiple samples and check that we get a variety of hex digits + const samples = []; + for (let i = 0; i < 10; i += 1) { + samples.push(generateEntropy()); + } + + // Check that we see various hex digits (not all zeros or all ones) + const allChars = samples.join(''); + const uniqueChars = new Set(allChars).size; + + // We should see most of the 16 possible hex digits (0-9, a-f) + // With 640 characters (10 * 64), we expect to see all 16 + expect(uniqueChars).to.be.at.least(10); + }); + }); +});