Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/canonical-p2pk-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@cashu/coco-core': patch
---

Derive P2PK public keys in canonical SEC1 compressed format while treating legacy and canonical
public key encodings as aliases across P2PK keyring operations.
13 changes: 7 additions & 6 deletions packages/core/api/KeyRingApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,26 @@ 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<Keypair> {
return this.keyRingService.addKeyPair(secretKey);
}

/**
* 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<void> {
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<Keypair | null> {
return this.keyRingService.getKeyPair(publicKey);
Expand Down
48 changes: 41 additions & 7 deletions packages/core/services/KeyRingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -89,15 +93,19 @@ export class KeyRingService {

async removeKeyPair(publicKey: string): Promise<void> {
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 });
}

async getKeyPair(publicKey: string): Promise<Keypair | null> {
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<Keypair | null> {
Expand All @@ -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.keyRingRepository.getPersistedKeyPair(publicKey, 'p2pk');
const keyPair = await this.findP2pkKeyPairByAlias(publicKey);
if (!keyPair) {
const publicKeyPreview = publicKey.substring(0, 8);
this.logger?.error('Key pair not found', { publicKey });
Expand All @@ -138,15 +146,41 @@ 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 {
return bytesToHex(secp256k1.getPublicKey(secretKey, true));
}

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

/**
* 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<Keypair | null> {
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.getPublicKeyHex(keyPair.secretKey) === publicKey ||
this.getLegacyPublicKeyHex(keyPair.secretKey) === publicKey
) {
return keyPair;
}
}

return null;
}
}
Loading
Loading