diff --git a/bun.lock b/bun.lock index ef79d7e3..cac5aae1 100644 --- a/bun.lock +++ b/bun.lock @@ -16,22 +16,22 @@ }, "packages/adapter-tests": { "name": "coco-cashu-adapter-tests", - "version": "1.1.2-rc.47", + "version": "1.1.2-rc.48", "dependencies": { "fake-bolt11": "^0.1.0", }, "devDependencies": { - "coco-cashu-core": "1.1.2-rc.47", + "coco-cashu-core": "1.1.2-rc.48", }, "peerDependencies": { "@cashu/cashu-ts": "^3.3.0", - "coco-cashu-core": "^1.1.2-rc.47", + "coco-cashu-core": "^1.1.2-rc.48", "typescript": "^5", }, }, "packages/core": { "name": "coco-cashu-core", - "version": "1.1.2-rc.47", + "version": "1.1.2-rc.48", "dependencies": { "@cashu/cashu-ts": "^3.5.0", "@noble/curves": "^2.0.1", @@ -39,7 +39,7 @@ "@scure/bip32": "^2.0.1", }, "devDependencies": { - "coco-cashu-adapter-tests": "1.1.2-rc.47", + "coco-cashu-adapter-tests": "1.1.2-rc.48", "typescript-eslint": "^8.40.0", }, "peerDependencies": { @@ -54,7 +54,7 @@ }, "packages/expo-sqlite": { "name": "coco-cashu-expo-sqlite", - "version": "1.1.2-rc.47", + "version": "1.1.2-rc.48", "devDependencies": { "coco-cashu-adapter-tests": "1.1.2-rc.47", }, @@ -66,7 +66,7 @@ }, "packages/indexeddb": { "name": "coco-cashu-indexeddb", - "version": "1.1.2-rc.47", + "version": "1.1.2-rc.48", "dependencies": { "dexie": "^4.0.8", }, @@ -83,14 +83,14 @@ }, "packages/react": { "name": "coco-cashu-react", - "version": "1.1.2-rc.47", + "version": "1.1.2-rc.48", "devDependencies": { "@eslint/js": "^9.33.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", "ajv": "^8.17.1", - "coco-cashu-core": "1.1.2-rc.47", + "coco-cashu-core": "1.1.2-rc.48", "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", @@ -101,13 +101,13 @@ "vite-plugin-dts": "^4.5.4", }, "peerDependencies": { - "coco-cashu-core": "1.1.2-rc.47", + "coco-cashu-core": "1.1.2-rc.48", "react": "^19", }, }, "packages/sqlite-bun": { "name": "coco-cashu-sqlite-bun", - "version": "1.1.2-rc.47", + "version": "1.1.2-rc.48", "devDependencies": { "coco-cashu-adapter-tests": "1.1.2-rc.47", }, @@ -118,7 +118,7 @@ }, "packages/sqlite3": { "name": "coco-cashu-sqlite3", - "version": "1.1.2-rc.47", + "version": "1.1.2-rc.48", "dependencies": { "better-sqlite3": "^12.6.2", }, diff --git a/packages/core/services/KeyRingService.ts b/packages/core/services/KeyRingService.ts index bc9c54ca..e399462e 100644 --- a/packages/core/services/KeyRingService.ts +++ b/packages/core/services/KeyRingService.ts @@ -2,7 +2,7 @@ import type { Proof } from '@cashu/cashu-ts'; import type { Logger } from '@core/logging'; import type { KeyRingRepository } from '@core/repositories'; import type { Keypair } from '@core/models/Keypair'; -import { schnorr } from '@noble/curves/secp256k1.js'; +import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js'; import { bytesToHex } from '@noble/curves/utils.js'; import { sha256 } from '@noble/hashes/sha2.js'; import type { SeedService } from '@core/services/SeedService.ts'; @@ -84,7 +84,7 @@ export class KeyRingService { if (!proof.secret || typeof proof.secret !== 'string') { throw new Error('Proof secret is required and must be a string'); } - const keyPair = await this.keyRingRepository.getPersistedKeyPair(publicKey); + const keyPair = await this.findSigningKeyPair(publicKey); if (!keyPair) { const publicKeyPreview = publicKey.substring(0, 8); this.logger?.error('Key pair not found', { publicKey }); @@ -102,11 +102,29 @@ export class KeyRingService { /** * Converts a secret key to its corresponding public key in SEC1 compressed format. - * Note: schnorr.getPublicKey() returns a 32-byte x-only public key (BIP340). - * We prepend '02' to create a 33-byte SEC1 compressed format as expected by Cashu. */ private getPublicKeyHex(secretKey: Uint8Array): string { - const publicKey = schnorr.getPublicKey(secretKey); - return '02' + bytesToHex(publicKey); + const publicKey = secp256k1.getPublicKey(secretKey, true); + return bytesToHex(publicKey); + } + + private getLegacyPublicKeyHex(secretKey: Uint8Array): string { + return '02' + bytesToHex(schnorr.getPublicKey(secretKey)); + } + + private async findSigningKeyPair(publicKey: string): Promise { + const directMatch = await this.keyRingRepository.getPersistedKeyPair(publicKey); + if (directMatch) { + return directMatch; + } + + const persistedKeyPairs = await this.keyRingRepository.getAllPersistedKeyPairs(); + for (const keyPair of persistedKeyPairs) { + if (this.getLegacyPublicKeyHex(keyPair.secretKey) === publicKey) { + return keyPair; + } + } + + return null; } } diff --git a/packages/core/test/unit/KeyRingService.test.ts b/packages/core/test/unit/KeyRingService.test.ts index a57366bb..a774706b 100644 --- a/packages/core/test/unit/KeyRingService.test.ts +++ b/packages/core/test/unit/KeyRingService.test.ts @@ -3,7 +3,7 @@ import { KeyRingService } from '../../services/KeyRingService.ts'; import { SeedService } from '../../services/SeedService.ts'; import { MemoryKeyRingRepository } from '../../repositories/memory/MemoryKeyRingRepository.ts'; import { bytesToHex } from '@noble/curves/utils.js'; -import { schnorr } from '@noble/curves/secp256k1.js'; +import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js'; import type { Proof } from '@cashu/cashu-ts'; // Mock seed for deterministic testing @@ -12,6 +12,32 @@ for (let i = 0; i < 64; i++) { MOCK_SEED[i] = i; } +async function mnemonicToSeedNormalized( + mnemonic: string, + passphrase: string, +): Promise { + const encoder = new TextEncoder(); + const password = encoder.encode(mnemonic.normalize('NFKD')); + const salt = encoder.encode(`mnemonic${passphrase.normalize('NFKD')}`); + const key = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits']); + const seed = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + hash: 'SHA-512', + salt, + iterations: 2048, + }, + key, + 512, + ); + + return new Uint8Array(seed); +} + +function getLegacyPublicKeyHex(secretKey: Uint8Array): string { + return '02' + bytesToHex(schnorr.getPublicKey(secretKey)); +} + describe('KeyRingService', () => { let repo: MemoryKeyRingRepository; let seedService: SeedService; @@ -28,8 +54,8 @@ describe('KeyRingService', () => { const result = await service.generateNewKeyPair(); expect(result.publicKeyHex).toBeDefined(); - expect(result.publicKeyHex.length).toBe(66); // 32 bytes * 2 for hex + '02' prefix - expect(result.publicKeyHex.startsWith('02')).toBe(true); + expect(result.publicKeyHex.length).toBe(66); + expect(['02', '03']).toContain(result.publicKeyHex.slice(0, 2)); expect('secretKey' in result).toBe(false); // Verify it was stored in the repository @@ -97,6 +123,50 @@ describe('KeyRingService', () => { expect(bytesToHex(kp1.secretKey)).toBe(bytesToHex(kp2.secretKey)); }); + it('P2PK derivation test vectors', async () => { + const seed = await mnemonicToSeedNormalized( + 'half depart obvious quality work element tank gorilla view sugar picture humble', + '', + ); + const vectorRepo = new MemoryKeyRingRepository(); + const vectorSeedService = new SeedService(async () => seed); + const vectorService = new KeyRingService(vectorRepo, vectorSeedService); + + const kp0 = await vectorService.generateNewKeyPair(); + const kp1 = await vectorService.generateNewKeyPair(); + const kp2 = await vectorService.generateNewKeyPair(); + const kp3 = await vectorService.generateNewKeyPair(); + const kp4 = await vectorService.generateNewKeyPair(); + + expect(kp0.publicKeyHex).toBe( + '021693d45f4fdf610ae641fedb0944fb460fbb8264f21c19d2626c3da755fcbbcb', + ); + expect(kp1.publicKeyHex).toBe( + '0395461ab678058c0ed6aa39f38dda490eaa163e9ad27070b23ec3d06b41e07535', + ); + expect(kp2.publicKeyHex).toBe( + '02a05e4e593a633e9b4405f01c9632c8afde24cb613017a1aee56fd76291ad26d1', + ); + expect(kp3.publicKeyHex).toBe( + '033addea25c3873b93d67d536c61c9d9c993f6efd8b9dfa657951b66b5001e51dd', + ); + expect(kp4.publicKeyHex).toBe( + '03c964bdf42fc82b6c574615746eeca37527a24f1fdfc1b34a732c53843b5744a5', + ); + + const stored0 = await vectorRepo.getPersistedKeyPair(kp0.publicKeyHex); + const stored1 = await vectorRepo.getPersistedKeyPair(kp1.publicKeyHex); + const stored2 = await vectorRepo.getPersistedKeyPair(kp2.publicKeyHex); + const stored3 = await vectorRepo.getPersistedKeyPair(kp3.publicKeyHex); + const stored4 = await vectorRepo.getPersistedKeyPair(kp4.publicKeyHex); + + expect(stored0?.derivationIndex).toBe(0); + expect(stored1?.derivationIndex).toBe(1); + expect(stored2?.derivationIndex).toBe(2); + expect(stored3?.derivationIndex).toBe(3); + expect(stored4?.derivationIndex).toBe(4); + }); + it('continues derivation index after imported keys', async () => { // Generate first key (index 0) const derived1 = await service.generateNewKeyPair(); @@ -162,8 +232,7 @@ describe('KeyRingService', () => { const secretKey = schnorr.utils.randomSecretKey(); const result = await service.addKeyPair(secretKey); - // The public key should have '02' prefix for compressed format - const publicKeyHex = '02' + bytesToHex(schnorr.getPublicKey(secretKey)); + const publicKeyHex = bytesToHex(secp256k1.getPublicKey(secretKey, true)); const stored = await repo.getPersistedKeyPair(publicKeyHex); expect(stored).not.toBeNull(); @@ -401,6 +470,49 @@ describe('KeyRingService', () => { expect(signatureBytes.length).toBe(64); }); + it('signs proofs locked to the legacy public key alias', async () => { + const kp = await service.generateNewKeyPair({ dumpSecretKey: true }); + const legacyPublicKeyHex = getLegacyPublicKeyHex(kp.secretKey); + + const proof: Proof = { + id: 'keyset123', + amount: 64, + secret: 'legacy-secret', + C: '0000000000000000000000000000000000000000000000000000000000000000', + }; + + const signed = await service.signProof(proof, legacyPublicKeyHex); + const witness = JSON.parse(signed.witness as string); + + expect(witness.signatures).toHaveLength(1); + }); + + it('signs legacy aliases for keys with odd-Y compressed public keys', async () => { + const seed = await mnemonicToSeedNormalized( + 'half depart obvious quality work element tank gorilla view sugar picture humble', + '', + ); + const vectorRepo = new MemoryKeyRingRepository(); + const vectorSeedService = new SeedService(async () => seed); + const vectorService = new KeyRingService(vectorRepo, vectorSeedService); + + await vectorService.generateNewKeyPair(); + const oddYKeyPair = await vectorService.generateNewKeyPair({ dumpSecretKey: true }); + expect(oddYKeyPair.publicKeyHex.startsWith('03')).toBe(true); + + const proof: Proof = { + id: 'keyset123', + amount: 64, + secret: 'odd-y-legacy-secret', + C: '0000000000000000000000000000000000000000000000000000000000000000', + }; + + const signed = await vectorService.signProof(proof, getLegacyPublicKeyHex(oddYKeyPair.secretKey)); + const witness = JSON.parse(signed.witness as string); + + expect(witness.signatures).toHaveLength(1); + }); + it('throws when keypair not found', async () => { const proof: Proof = { id: 'keyset123', @@ -431,6 +543,23 @@ describe('KeyRingService', () => { ); }); + it('does not match unrelated legacy public keys', async () => { + await service.generateNewKeyPair({ dumpSecretKey: true }); + + const proof: Proof = { + id: 'keyset123', + amount: 64, + secret: 'my-secret-string', + C: '0000000000000000000000000000000000000000000000000000000000000000', + }; + + const fakeLegacyPublicKey = '02' + '11'.repeat(32); + + await expect(service.signProof(proof, fakeLegacyPublicKey)).rejects.toThrow( + /Key pair not found for public key/, + ); + }); + it('signs different proofs with different signatures', async () => { const kp = await service.generateNewKeyPair();