feat(trust): keep previous mint signing key verifiable

This commit is contained in:
2026-09-05 10:22:24 +02:00
parent 7b7d4edaf3
commit a30c4bb2cc
4 changed files with 53 additions and 0 deletions
+4
View File
@@ -164,6 +164,10 @@ export const confirmMintRekey = (mints: TrustedMint[], server: string): MintTran
...mint, ...mint,
mintPubkey: pending, mintPubkey: pending,
pendingMintPubkey: undefined, 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, unconfirmed: undefined,
} }
: mint, : mint,
+24
View File
@@ -11,6 +11,7 @@ import {
confirmTrustedMintRekey, confirmTrustedMintRekey,
dismissTrustedMintRekey, dismissTrustedMintRekey,
getTrustedMintPubkey, getTrustedMintPubkey,
getTrustedMintVerificationKeys,
grandfatherTrustedMint, grandfatherTrustedMint,
isMintTrusted, isMintTrusted,
isMintUnconfirmed, isMintUnconfirmed,
@@ -88,6 +89,29 @@ describe('rekey staging', () => {
expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_B) 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 () => { it('drops the staged key on dismissal, keeping the original pin', async () => {
await lockTrustedMint(SERVER, KEY_A, OWNER_ID) await lockTrustedMint(SERVER, KEY_A, OWNER_ID)
await lockTrustedMint(SERVER, KEY_B, OWNER_ID) await lockTrustedMint(SERVER, KEY_B, OWNER_ID)
+22
View File
@@ -49,6 +49,14 @@ export type TrustedMint = {
// would defeat the entire pinning model (a compromised mint could sign // would defeat the entire pinning model (a compromised mint could sign
// unbacked notes that then show the "signed" badge). // unbacked notes that then show the "signed" badge).
pendingMintPubkey?: string 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 // best-effort node identity/capacity, cached from the mint-address
// discovery endpoint (see the kit's fetchMintAddress) purely for display - // discovery endpoint (see the kit's fetchMintAddress) purely for display -
// absent for a mint that doesn't support it, or one trusted before this // 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) readTrustedMints(ownerId).find((mint) => mint.server === server && !mint.unconfirmed)
?.mintPubkey ?? null ?.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 // 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 // live response (see TrustedMint.unconfirmed) - callers should treat a
// bearer's own cached mintPubkey for such a server as equally // bearer's own cached mintPubkey for such a server as equally
+3
View File
@@ -39,6 +39,9 @@ const isTrustedMint = (value: unknown): value is TrustedMint => {
(value.pendingMintPubkey === undefined || (value.pendingMintPubkey === undefined ||
(typeof value.pendingMintPubkey === 'string' && (typeof value.pendingMintPubkey === 'string' &&
isValidMintPubkey(value.pendingMintPubkey))) && isValidMintPubkey(value.pendingMintPubkey))) &&
(value.previousMintPubkey === undefined ||
(typeof value.previousMintPubkey === 'string' &&
isValidMintPubkey(value.previousMintPubkey))) &&
isOptionalString(value.nodeAlias) && isOptionalString(value.nodeAlias) &&
isOptionalString(value.nodeColor) && isOptionalString(value.nodeColor) &&
isOptionalNumber(value.nodeCapacityMsat) && isOptionalNumber(value.nodeCapacityMsat) &&