From aa0829484fccd89f2c2a6fc2fef863bf5f820794 Mon Sep 17 00:00:00 2001 From: lescuer97 Date: Fri, 20 Mar 2026 21:11:14 +0100 Subject: [PATCH 1/4] add test --- packages/core/services/KeyRingService.ts | 6 +- .../core/test/unit/KeyRingService.test.ts | 70 +++++++++++++++++-- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/core/services/KeyRingService.ts b/packages/core/services/KeyRingService.ts index b7a7e9cde..d777d43b7 100644 --- a/packages/core/services/KeyRingService.ts +++ b/packages/core/services/KeyRingService.ts @@ -138,12 +138,10 @@ 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 getCompressedPublicKeyHex(secretKey: Uint8Array): string { diff --git a/packages/core/test/unit/KeyRingService.test.ts b/packages/core/test/unit/KeyRingService.test.ts index 8c8a04d42..afb295527 100644 --- a/packages/core/test/unit/KeyRingService.test.ts +++ b/packages/core/test/unit/KeyRingService.test.ts @@ -13,6 +13,25 @@ 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); +} + describe('KeyRingService', () => { let repo: MemoryKeyRingRepository; let seedService: SeedService; @@ -29,8 +48,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 @@ -98,6 +117,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(); @@ -192,8 +255,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(); From 940f41d9a44065deda0be4e6f2ae18100d9ad060 Mon Sep 17 00:00:00 2001 From: lescuer97 Date: Tue, 24 Mar 2026 22:55:24 +0100 Subject: [PATCH 2/4] add compatibility for the older keysets --- packages/core/services/KeyRingService.ts | 22 +++++- .../core/test/unit/KeyRingService.test.ts | 67 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/core/services/KeyRingService.ts b/packages/core/services/KeyRingService.ts index d777d43b7..debc0abac 100644 --- a/packages/core/services/KeyRingService.ts +++ b/packages/core/services/KeyRingService.ts @@ -120,7 +120,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, 'p2pk'); + const keyPair = await this.findSigningKeyPair(publicKey); if (!keyPair) { const publicKeyPreview = publicKey.substring(0, 8); this.logger?.error('Key pair not found', { publicKey }); @@ -147,4 +147,24 @@ export class KeyRingService { private getCompressedPublicKeyHex(secretKey: Uint8Array): string { return bytesToHex(secp256k1.getPublicKey(secretKey, true)); } + + private getLegacyPublicKeyHex(secretKey: Uint8Array): string { + return '02' + bytesToHex(schnorr.getPublicKey(secretKey)); + } + + private async findSigningKeyPair(publicKey: string): Promise { + const directMatch = await this.keyRingRepository.getPersistedKeyPair(publicKey, 'p2pk'); + if (directMatch) { + return directMatch; + } + + const persistedKeyPairs = await this.keyRingRepository.getAllPersistedKeyPairs('p2pk'); + 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 afb295527..b6cc6a52d 100644 --- a/packages/core/test/unit/KeyRingService.test.ts +++ b/packages/core/test/unit/KeyRingService.test.ts @@ -32,6 +32,10 @@ async function mnemonicToSeedNormalized(mnemonic: string, passphrase: string): P return new Uint8Array(seed); } +function getLegacyPublicKeyHex(secretKey: Uint8Array): string { + return '02' + bytesToHex(schnorr.getPublicKey(secretKey)); +} + describe('KeyRingService', () => { let repo: MemoryKeyRingRepository; let seedService: SeedService; @@ -493,6 +497,52 @@ 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: Amount.from(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: Amount.from(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', @@ -523,6 +573,23 @@ describe('KeyRingService', () => { ); }); + it('does not match unrelated legacy public keys', async () => { + await service.generateNewKeyPair({ dumpSecretKey: true }); + + const proof: Proof = { + id: 'keyset123', + amount: Amount.from(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(); From e37340b16b88f0dc5edcd0da695eb51d05a76e92 Mon Sep 17 00:00:00 2001 From: Egge Date: Thu, 9 Jul 2026 23:18:27 +0200 Subject: [PATCH 3/4] chore(core): add P2PK key changeset --- .changeset/canonical-p2pk-keys.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/canonical-p2pk-keys.md diff --git a/.changeset/canonical-p2pk-keys.md b/.changeset/canonical-p2pk-keys.md new file mode 100644 index 000000000..471888deb --- /dev/null +++ b/.changeset/canonical-p2pk-keys.md @@ -0,0 +1,6 @@ +--- +'@cashu/coco-core': patch +--- + +Derive P2PK public keys in canonical SEC1 compressed format while retaining proof-signing +compatibility with legacy public key aliases. From 70082b0773d993e7f36fffa268ff2bd2748c0e5c Mon Sep 17 00:00:00 2001 From: Egge Date: Fri, 10 Jul 2026 06:14:10 +0200 Subject: [PATCH 4/4] fix(core): resolve legacy P2PK key aliases --- .changeset/canonical-p2pk-keys.md | 4 +- packages/core/api/KeyRingApi.ts | 13 +-- packages/core/services/KeyRingService.ts | 26 ++++-- .../core/test/unit/KeyRingService.test.ts | 80 +++++++++++++++++++ packages/docs/pages/keyring.md | 12 ++- 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/.changeset/canonical-p2pk-keys.md b/.changeset/canonical-p2pk-keys.md index 471888deb..a992dbed9 100644 --- a/.changeset/canonical-p2pk-keys.md +++ b/.changeset/canonical-p2pk-keys.md @@ -2,5 +2,5 @@ '@cashu/coco-core': patch --- -Derive P2PK public keys in canonical SEC1 compressed format while retaining proof-signing -compatibility with legacy public key aliases. +Derive P2PK public keys in canonical SEC1 compressed format while treating legacy and canonical +public key encodings as aliases across P2PK keyring operations. diff --git a/packages/core/api/KeyRingApi.ts b/packages/core/api/KeyRingApi.ts index 95467fcb5..fb722e4d0 100644 --- a/packages/core/api/KeyRingApi.ts +++ b/packages/core/api/KeyRingApi.ts @@ -22,7 +22,8 @@ export class KeyRingApi { } /** - * Adds an existing keypair to the keyring using a secret key. + * Adds an existing keypair using its canonical compressed public key. If the same secret is + * already stored under coco's legacy public key encoding, returns that existing keypair. * @param secretKey - The 32-byte secret key as Uint8Array */ async addKeyPair(secretKey: Uint8Array): Promise { @@ -30,17 +31,17 @@ export class KeyRingApi { } /** - * Removes a keypair from the keyring. - * @param publicKey - The public key (hex string) of the keypair to remove + * Removes a keypair from the keyring using its canonical or legacy public key encoding. + * @param publicKey - A canonical or legacy public key hex string for the keypair to remove */ async removeKeyPair(publicKey: string): Promise { return this.keyRingService.removeKeyPair(publicKey); } /** - * Retrieves a specific keypair by its public key. - * @param publicKey - The public key (hex string) to look up - * @returns The keypair if found, null otherwise + * Retrieves a specific keypair using its canonical or legacy public key encoding. + * @param publicKey - A canonical or legacy public key hex string to look up + * @returns The persisted keypair if found, preserving its stored public key encoding */ async getKeyPair(publicKey: string): Promise { return this.keyRingService.getKeyPair(publicKey); diff --git a/packages/core/services/KeyRingService.ts b/packages/core/services/KeyRingService.ts index debc0abac..10f9f4e3a 100644 --- a/packages/core/services/KeyRingService.ts +++ b/packages/core/services/KeyRingService.ts @@ -78,6 +78,10 @@ export class KeyRingService { throw new Error('Secret key must be exactly 32 bytes'); } const publicKeyHex = this.getPublicKeyHex(secretKey); + const existingKeyPair = await this.findP2pkKeyPairByAlias(publicKeyHex); + if (existingKeyPair) { + return existingKeyPair; + } await this.keyRingRepository.setPersistedKeyPair({ publicKeyHex, secretKey, @@ -89,7 +93,11 @@ export class KeyRingService { async removeKeyPair(publicKey: string): Promise { this.logger?.debug('Removing key pair', { publicKey }); - await this.keyRingRepository.deletePersistedKeyPair(publicKey, 'p2pk'); + const keyPair = await this.findP2pkKeyPairByAlias(publicKey); + if (!keyPair) { + return; + } + await this.keyRingRepository.deletePersistedKeyPair(keyPair.publicKeyHex, 'p2pk'); this.logger?.debug('Key pair removed', { publicKey }); } @@ -97,7 +105,7 @@ export class KeyRingService { if (!publicKey || typeof publicKey !== 'string') { throw new Error('Public key is required and must be a string'); } - return this.keyRingRepository.getPersistedKeyPair(publicKey, 'p2pk'); + return this.findP2pkKeyPairByAlias(publicKey); } async getMintQuoteKeyPair(publicKey: string): Promise { @@ -120,7 +128,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.findSigningKeyPair(publicKey); + const keyPair = await this.findP2pkKeyPairByAlias(publicKey); if (!keyPair) { const publicKeyPreview = publicKey.substring(0, 8); this.logger?.error('Key pair not found', { publicKey }); @@ -152,7 +160,12 @@ export class KeyRingService { return '02' + bytesToHex(schnorr.getPublicKey(secretKey)); } - private async findSigningKeyPair(publicKey: string): Promise { + /** + * Resolves the canonical SEC1 encoding and coco's legacy even-Y encoding as aliases. + * Existing rows keep their stored identity; the fallback scans only P2PK keys so NUT-20 keys + * cannot satisfy a P2PK lookup. + */ + private async findP2pkKeyPairByAlias(publicKey: string): Promise { const directMatch = await this.keyRingRepository.getPersistedKeyPair(publicKey, 'p2pk'); if (directMatch) { return directMatch; @@ -160,7 +173,10 @@ export class KeyRingService { const persistedKeyPairs = await this.keyRingRepository.getAllPersistedKeyPairs('p2pk'); for (const keyPair of persistedKeyPairs) { - if (this.getLegacyPublicKeyHex(keyPair.secretKey) === publicKey) { + if ( + this.getPublicKeyHex(keyPair.secretKey) === publicKey || + this.getLegacyPublicKeyHex(keyPair.secretKey) === publicKey + ) { return keyPair; } } diff --git a/packages/core/test/unit/KeyRingService.test.ts b/packages/core/test/unit/KeyRingService.test.ts index b6cc6a52d..ac90d226d 100644 --- a/packages/core/test/unit/KeyRingService.test.ts +++ b/packages/core/test/unit/KeyRingService.test.ts @@ -36,6 +36,12 @@ function getLegacyPublicKeyHex(secretKey: Uint8Array): string { return '02' + bytesToHex(schnorr.getPublicKey(secretKey)); } +const ODD_Y_SECRET_KEY = Uint8Array.from([...new Uint8Array(31), 6]); +const ODD_Y_CANONICAL_PUBLIC_KEY = + '03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556'; +const ODD_Y_LEGACY_PUBLIC_KEY = + '02fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556'; + describe('KeyRingService', () => { let repo: MemoryKeyRingRepository; let seedService: SeedService; @@ -276,6 +282,21 @@ describe('KeyRingService', () => { expect(stored?.derivationIndex).toBeUndefined(); }); + it('does not duplicate a persisted legacy odd-Y keypair when importing its secret', async () => { + await repo.setPersistedKeyPair({ + publicKeyHex: ODD_Y_LEGACY_PUBLIC_KEY, + secretKey: ODD_Y_SECRET_KEY, + derivationIndex: 7, + purpose: 'p2pk', + }); + + const imported = await service.addKeyPair(ODD_Y_SECRET_KEY); + + expect(imported.publicKeyHex).toBe(ODD_Y_LEGACY_PUBLIC_KEY); + expect(imported.derivationIndex).toBe(7); + expect(await service.getAllKeyPairs()).toHaveLength(1); + }); + it('rejects secret key that is not 32 bytes', async () => { const invalidKey = new Uint8Array(31); // Wrong length @@ -309,6 +330,26 @@ describe('KeyRingService', () => { expect(stored).toBeNull(); }); + it('removes a canonical odd-Y keypair by its legacy public key alias', async () => { + await service.addKeyPair(ODD_Y_SECRET_KEY); + + await service.removeKeyPair(ODD_Y_LEGACY_PUBLIC_KEY); + + expect(await service.getKeyPair(ODD_Y_CANONICAL_PUBLIC_KEY)).toBeNull(); + }); + + it('removes a persisted legacy odd-Y keypair by its canonical public key alias', async () => { + await repo.setPersistedKeyPair({ + publicKeyHex: ODD_Y_LEGACY_PUBLIC_KEY, + secretKey: ODD_Y_SECRET_KEY, + purpose: 'p2pk', + }); + + await service.removeKeyPair(ODD_Y_CANONICAL_PUBLIC_KEY); + + expect(await repo.getPersistedKeyPair(ODD_Y_LEGACY_PUBLIC_KEY, 'p2pk')).toBeNull(); + }); + it('does not throw when removing non-existent key', async () => { // Should complete without throwing await service.removeKeyPair( @@ -329,6 +370,26 @@ describe('KeyRingService', () => { expect(bytesToHex(retrieved!.secretKey)).toBe(bytesToHex(generated.secretKey)); }); + it('retrieves a canonical odd-Y keypair by its legacy public key alias', async () => { + await service.addKeyPair(ODD_Y_SECRET_KEY); + + const retrieved = await service.getKeyPair(ODD_Y_LEGACY_PUBLIC_KEY); + + expect(retrieved?.publicKeyHex).toBe(ODD_Y_CANONICAL_PUBLIC_KEY); + }); + + it('retrieves a persisted legacy odd-Y keypair by its canonical public key alias', async () => { + await repo.setPersistedKeyPair({ + publicKeyHex: ODD_Y_LEGACY_PUBLIC_KEY, + secretKey: ODD_Y_SECRET_KEY, + purpose: 'p2pk', + }); + + const retrieved = await service.getKeyPair(ODD_Y_CANONICAL_PUBLIC_KEY); + + expect(retrieved?.publicKeyHex).toBe(ODD_Y_LEGACY_PUBLIC_KEY); + }); + it('returns null for non-existent key', async () => { const result = await service.getKeyPair( '0000000000000000000000000000000000000000000000000000000000000000', @@ -543,6 +604,25 @@ describe('KeyRingService', () => { expect(witness.signatures).toHaveLength(1); }); + it('signs a canonical alias using a persisted legacy odd-Y keypair', async () => { + await repo.setPersistedKeyPair({ + publicKeyHex: ODD_Y_LEGACY_PUBLIC_KEY, + secretKey: ODD_Y_SECRET_KEY, + purpose: 'p2pk', + }); + const proof: Proof = { + id: 'keyset123', + amount: Amount.from(64), + secret: 'canonical-alias-secret', + C: '0000000000000000000000000000000000000000000000000000000000000000', + }; + + const signed = await service.signProof(proof, ODD_Y_CANONICAL_PUBLIC_KEY); + 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', diff --git a/packages/docs/pages/keyring.md b/packages/docs/pages/keyring.md index 004b258c4..e83c8cb96 100644 --- a/packages/docs/pages/keyring.md +++ b/packages/docs/pages/keyring.md @@ -30,6 +30,9 @@ console.log('Public key:', keypair.publicKeyHex); console.log('Secret key:', keypair.secretKey); // Uint8Array(32) ``` +Generated P2PK public keys use canonical SEC1 compressed encoding, including the key's actual +`02` or `03` parity prefix. This matches CDK and the deterministic P2PK derivation vectors. + ::: warning The secret key is sensitive cryptographic material. When setting `dumpSecretKey: true`, ensure you handle the key securely and clear it from memory when no longer needed. ::: @@ -45,10 +48,15 @@ const keypair = await coco.keyring.addKeyPair(secretKey); console.log('Imported public key:', keypair.publicKeyHex); ``` +For compatibility with earlier coco versions, the keyring treats the legacy always-`02` encoding +and the canonical compressed encoding as aliases for the same P2PK key. Existing persisted keys +keep their stored public key encoding and derivation metadata; imports do not create a duplicate +row for the other alias. + ### Retrieving Keypairs ```ts -// Get a specific keypair by public key +// Get a specific keypair by its canonical or legacy public key encoding const keypair = await coco.keyring.getKeyPair(publicKeyHex); if (keypair) { console.log('Found keypair:', keypair.publicKeyHex); @@ -65,7 +73,7 @@ console.log(`You have ${allKeypairs.length} keypairs`); ### Removing Keypairs ```ts -// Remove a keypair by public key +// Remove a keypair by its canonical or legacy public key encoding await coco.keyring.removeKeyPair(publicKeyHex); ```