diff --git a/src/lnurlcash/trustedMintTransitions.ts b/src/lnurlcash/trustedMintTransitions.ts index ef3287f..328ce2f 100644 --- a/src/lnurlcash/trustedMintTransitions.ts +++ b/src/lnurlcash/trustedMintTransitions.ts @@ -164,6 +164,10 @@ export const confirmMintRekey = (mints: TrustedMint[], server: string): MintTran ...mint, mintPubkey: pending, pendingMintPubkey: undefined, + // the retiring pin stays verifiable for the notes it already + // signed (landed-mutation verification accepts current OR + // previous); it is never a target for new trust decisions + previousMintPubkey: mint.mintPubkey, unconfirmed: undefined, } : mint, diff --git a/src/lnurlcash/trustedMints.test.ts b/src/lnurlcash/trustedMints.test.ts index 33611f3..a87f93c 100644 --- a/src/lnurlcash/trustedMints.test.ts +++ b/src/lnurlcash/trustedMints.test.ts @@ -11,6 +11,7 @@ import { confirmTrustedMintRekey, dismissTrustedMintRekey, getTrustedMintPubkey, + getTrustedMintVerificationKeys, grandfatherTrustedMint, isMintTrusted, isMintUnconfirmed, @@ -88,6 +89,29 @@ describe('rekey staging', () => { expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_B) }) + it('keeps the retiring key verifiable as previous after a confirmed rekey', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([KEY_A]) + + await lockTrustedMint(SERVER, KEY_B, OWNER_ID) + // while the rekey is only STAGED, the candidate is not a verification key + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([KEY_A]) + + await confirmTrustedMintRekey(SERVER, OWNER_ID) + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([KEY_B, KEY_A]) + + // dismissing a later staged candidate keeps the previous key intact + await lockTrustedMint(SERVER, KEY_C, OWNER_ID) + await dismissTrustedMintRekey(SERVER, OWNER_ID) + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([KEY_B, KEY_A]) + }) + + it('excludes unconfirmed pins from verification keys', async () => { + await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID) + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([]) + expect(getTrustedMintVerificationKeys('unknown.example', OWNER_ID)).toEqual([]) + }) + it('drops the staged key on dismissal, keeping the original pin', async () => { await lockTrustedMint(SERVER, KEY_A, OWNER_ID) await lockTrustedMint(SERVER, KEY_B, OWNER_ID) diff --git a/src/lnurlcash/trustedMints.ts b/src/lnurlcash/trustedMints.ts index 3358353..fa4fec5 100644 --- a/src/lnurlcash/trustedMints.ts +++ b/src/lnurlcash/trustedMints.ts @@ -49,6 +49,14 @@ export type TrustedMint = { // would defeat the entire pinning model (a compromised mint could sign // unbacked notes that then show the "signed" badge). pendingMintPubkey?: string + // the key this mint signed under immediately BEFORE the holder confirmed + // the current pin (set by confirmTrustedMintRekey): notes issued just + // before a legitimate rotation stay verifiable against it. Never imported + // from a backup (a crafted file could otherwise plant a forgeable + // "previous" key, exactly like pendingMintPubkey) and never staged from + // an advertisement - only a confirmed rekey creates it, from the key that + // was actually pinned. + previousMintPubkey?: string // best-effort node identity/capacity, cached from the mint-address // discovery endpoint (see the kit's fetchMintAddress) purely for display - // absent for a mint that doesn't support it, or one trusted before this @@ -132,6 +140,20 @@ export const getTrustedMintPubkey = (server: string, ownerId?: string): string | readTrustedMints(ownerId).find((mint) => mint.server === server && !mint.unconfirmed) ?.mintPubkey ?? null +// the keys a landed mutation signature may be checked against for this +// server: the pinned current key plus the previous one (kept across a +// confirmed rekey so the old key's last notes still verify). Unconfirmed +// pins contribute nothing - see TrustedMint.unconfirmed. +export const getTrustedMintVerificationKeys = (server: string, ownerId?: string): string[] => { + const mint = readTrustedMints(ownerId).find( + (entry) => entry.server === server && !entry.unconfirmed, + ) + if (!mint) return [] + return mint.previousMintPubkey + ? [mint.mintPubkey, mint.previousMintPubkey] + : [mint.mintPubkey] +} + // true when a server has a pin that came from a file/storage rather than a // live response (see TrustedMint.unconfirmed) - callers should treat a // bearer's own cached mintPubkey for such a server as equally diff --git a/src/lnurlcash/trustedMintsRegistry.ts b/src/lnurlcash/trustedMintsRegistry.ts index 99988ef..1acb4aa 100644 --- a/src/lnurlcash/trustedMintsRegistry.ts +++ b/src/lnurlcash/trustedMintsRegistry.ts @@ -39,6 +39,9 @@ const isTrustedMint = (value: unknown): value is TrustedMint => { (value.pendingMintPubkey === undefined || (typeof value.pendingMintPubkey === 'string' && isValidMintPubkey(value.pendingMintPubkey))) && + (value.previousMintPubkey === undefined || + (typeof value.previousMintPubkey === 'string' && + isValidMintPubkey(value.previousMintPubkey))) && isOptionalString(value.nodeAlias) && isOptionalString(value.nodeColor) && isOptionalNumber(value.nodeCapacityMsat) &&