Skip to content
Open
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
24 changes: 12 additions & 12 deletions bun.lock

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

30 changes: 24 additions & 6 deletions packages/core/services/KeyRingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand All @@ -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);
Comment on lines +107 to +108
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the legacy public-key ID for restored P2PK keys

publicKeyHex is the repository key that generateNewKeyPair() stores and that signProof()/getKeyPair() later look up by exact string. Changing it from the old '02' + schnorr.getPublicKey(secretKey) form to secp256k1.getPublicKey(secretKey, true) changes the identifier for every key whose secp256k1 point has odd Y (the new tests show several 03... cases). That means a wallet restored from seed on this release will re-derive and persist a different publicKeyHex than previous releases, so previously shared or minted P2PK proofs locked to the legacy value can no longer be matched after a DB wipe/restore, breaking recovery of existing funds unless there is a migration or backward-compatible aliasing layer.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some code changes. can you rereview?

}

private getLegacyPublicKeyHex(secretKey: Uint8Array): string {
return '02' + bytesToHex(schnorr.getPublicKey(secretKey));
}

private async findSigningKeyPair(publicKey: string): Promise<Keypair | null> {
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;
}
}
139 changes: 134 additions & 5 deletions packages/core/test/unit/KeyRingService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,6 +12,32 @@ for (let i = 0; i < 64; i++) {
MOCK_SEED[i] = i;
}

async function mnemonicToSeedNormalized(
mnemonic: string,
passphrase: string,
): Promise<Uint8Array> {
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;
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();

Expand Down