mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: preserve trusted mint rekey transitions
This commit is contained in:
@@ -0,0 +1,211 @@
|
|||||||
|
// Pure trusted-mint registry transitions. Persistence serializes these
|
||||||
|
// operations, while this module keeps pinning and backup policy auditable.
|
||||||
|
|
||||||
|
import type {TrustedMint, TrustedMintNodeInfo, TrustKeyResult} from './trustedMints'
|
||||||
|
|
||||||
|
const PUBKEY_PATTERN = /^[0-9a-f]{66}$/
|
||||||
|
|
||||||
|
export const isValidMintPubkey = (value: string): boolean =>
|
||||||
|
PUBKEY_PATTERN.test(value.toLowerCase())
|
||||||
|
|
||||||
|
export type MintTransition<T> = {
|
||||||
|
readonly mints: TrustedMint[]
|
||||||
|
readonly result: T
|
||||||
|
readonly changed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MintKeyInput = {
|
||||||
|
readonly server: string
|
||||||
|
readonly mintPubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddMintInput = MintKeyInput & {
|
||||||
|
readonly nodeInfo?: TrustedMintNodeInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
const unchanged = <T>(mints: TrustedMint[], result: T): MintTransition<T> => ({
|
||||||
|
mints,
|
||||||
|
result,
|
||||||
|
changed: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const changed = <T>(mints: TrustedMint[], result: T): MintTransition<T> => ({
|
||||||
|
mints,
|
||||||
|
result,
|
||||||
|
changed: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const lockMint = (
|
||||||
|
mints: TrustedMint[],
|
||||||
|
input: MintKeyInput,
|
||||||
|
): MintTransition<TrustKeyResult> => {
|
||||||
|
const key = input.mintPubkey.trim().toLowerCase()
|
||||||
|
if (!input.server || !isValidMintPubkey(key)) {
|
||||||
|
return unchanged(mints, 'unchanged')
|
||||||
|
}
|
||||||
|
const existing = mints.find((mint) => mint.server === input.server)
|
||||||
|
if (!existing) {
|
||||||
|
return changed(
|
||||||
|
[
|
||||||
|
...mints,
|
||||||
|
{
|
||||||
|
server: input.server,
|
||||||
|
mintPubkey: key,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
locked: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'added',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (existing.mintPubkey === key) {
|
||||||
|
if (existing.locked && !existing.unconfirmed) {
|
||||||
|
return unchanged(mints, 'unchanged')
|
||||||
|
}
|
||||||
|
return changed(
|
||||||
|
mints.map((mint) =>
|
||||||
|
mint.server === input.server ? {...mint, locked: true, unconfirmed: undefined} : mint,
|
||||||
|
),
|
||||||
|
'unchanged',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (existing.pendingMintPubkey === key) {
|
||||||
|
return unchanged(mints, 'rekey-pending')
|
||||||
|
}
|
||||||
|
return changed(
|
||||||
|
mints.map((mint) => (mint.server === input.server ? {...mint, pendingMintPubkey: key} : mint)),
|
||||||
|
'rekey-pending',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const grandfatherMint = (
|
||||||
|
mints: TrustedMint[],
|
||||||
|
input: MintKeyInput,
|
||||||
|
): MintTransition<TrustKeyResult> => {
|
||||||
|
const key = input.mintPubkey.trim().toLowerCase()
|
||||||
|
if (!input.server || !isValidMintPubkey(key)) {
|
||||||
|
return unchanged(mints, 'unchanged')
|
||||||
|
}
|
||||||
|
const existing = mints.find((mint) => mint.server === input.server)
|
||||||
|
if (!existing) {
|
||||||
|
return changed(
|
||||||
|
[
|
||||||
|
...mints,
|
||||||
|
{
|
||||||
|
server: input.server,
|
||||||
|
mintPubkey: key,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
locked: false,
|
||||||
|
unconfirmed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'added',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (existing.mintPubkey === key) return unchanged(mints, 'unchanged')
|
||||||
|
if (existing.pendingMintPubkey === key) {
|
||||||
|
return unchanged(mints, 'rekey-pending')
|
||||||
|
}
|
||||||
|
return changed(
|
||||||
|
mints.map((mint) => (mint.server === input.server ? {...mint, pendingMintPubkey: key} : mint)),
|
||||||
|
'rekey-pending',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addMint = (
|
||||||
|
mints: TrustedMint[],
|
||||||
|
input: AddMintInput,
|
||||||
|
): MintTransition<TrustKeyResult> => {
|
||||||
|
const server = input.server.trim()
|
||||||
|
const key = input.mintPubkey.trim().toLowerCase()
|
||||||
|
if (!server) throw new Error('Enter a server.')
|
||||||
|
if (!isValidMintPubkey(key)) {
|
||||||
|
throw new Error('Signing key must be a 33-byte compressed pubkey (66 hex characters).')
|
||||||
|
}
|
||||||
|
const existing = mints.find((mint) => mint.server === server)
|
||||||
|
if (!existing) {
|
||||||
|
return changed(
|
||||||
|
[
|
||||||
|
...mints,
|
||||||
|
{
|
||||||
|
server,
|
||||||
|
mintPubkey: key,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
locked: false,
|
||||||
|
...input.nodeInfo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'added',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (existing.mintPubkey === key) {
|
||||||
|
return changed(
|
||||||
|
mints.map((mint) =>
|
||||||
|
mint.server === server ? {...mint, ...input.nodeInfo, unconfirmed: undefined} : mint,
|
||||||
|
),
|
||||||
|
'unchanged',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return changed(
|
||||||
|
mints.map((mint) =>
|
||||||
|
mint.server === server ? {...mint, pendingMintPubkey: key, ...input.nodeInfo} : mint,
|
||||||
|
),
|
||||||
|
'rekey-pending',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const confirmMintRekey = (mints: TrustedMint[], server: string): MintTransition<void> => {
|
||||||
|
const pending = mints.find((mint) => mint.server === server)?.pendingMintPubkey
|
||||||
|
if (!pending) return unchanged(mints, undefined)
|
||||||
|
return changed(
|
||||||
|
mints.map((mint) =>
|
||||||
|
mint.server === server
|
||||||
|
? {
|
||||||
|
...mint,
|
||||||
|
mintPubkey: pending,
|
||||||
|
pendingMintPubkey: undefined,
|
||||||
|
unconfirmed: undefined,
|
||||||
|
}
|
||||||
|
: mint,
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dismissMintRekey = (mints: TrustedMint[], server: string): MintTransition<void> => {
|
||||||
|
if (!mints.some((mint) => mint.server === server)) {
|
||||||
|
return unchanged(mints, undefined)
|
||||||
|
}
|
||||||
|
return changed(
|
||||||
|
mints.map((mint) => (mint.server === server ? {...mint, pendingMintPubkey: undefined} : mint)),
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cacheMintNodeInfo = (
|
||||||
|
mints: TrustedMint[],
|
||||||
|
server: string,
|
||||||
|
nodeInfo: TrustedMintNodeInfo,
|
||||||
|
): MintTransition<void> => {
|
||||||
|
if (!mints.some((mint) => mint.server === server)) {
|
||||||
|
return unchanged(mints, undefined)
|
||||||
|
}
|
||||||
|
return changed(
|
||||||
|
mints.map((mint) => (mint.server === server ? {...mint, ...nodeInfo} : mint)),
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeMint = (mints: TrustedMint[], server: string): MintTransition<void> => {
|
||||||
|
const existing = mints.find((mint) => mint.server === server)
|
||||||
|
if (!existing) return unchanged(mints, undefined)
|
||||||
|
if (existing.locked) {
|
||||||
|
throw new Error("Can't remove - you hold a bearer note from this mint.")
|
||||||
|
}
|
||||||
|
return changed(
|
||||||
|
mints.filter((mint) => mint.server !== server),
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearMints = (): MintTransition<void> => changed([], undefined)
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
|
|
||||||
import {beforeEach, describe, expect, it} from 'vitest'
|
import {beforeEach, describe, expect, it} from 'vitest'
|
||||||
|
|
||||||
|
import {linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||||
import type {TrustedMint} from './trustedMints'
|
import type {TrustedMint} from './trustedMints'
|
||||||
import {
|
import {
|
||||||
PUBLIC_MINTS,
|
PUBLIC_MINTS,
|
||||||
addTrustedMint,
|
addTrustedMint,
|
||||||
clearTrustedMints,
|
|
||||||
confirmTrustedMintRekey,
|
confirmTrustedMintRekey,
|
||||||
dismissTrustedMintRekey,
|
dismissTrustedMintRekey,
|
||||||
getTrustedMintPubkey,
|
getTrustedMintPubkey,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
lockTrustedMint,
|
lockTrustedMint,
|
||||||
mergeTrustedMints,
|
mergeTrustedMints,
|
||||||
readTrustedMints,
|
readTrustedMints,
|
||||||
removeTrustedMint
|
removeTrustedMint,
|
||||||
} from './trustedMints'
|
} from './trustedMints'
|
||||||
import {stubLocalStorage} from './test-utils'
|
import {stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
@@ -25,115 +25,123 @@ const KEY_A = '02' + 'aa'.repeat(32)
|
|||||||
const KEY_B = '03' + 'bb'.repeat(32)
|
const KEY_B = '03' + 'bb'.repeat(32)
|
||||||
const KEY_C = '02' + 'cc'.repeat(32)
|
const KEY_C = '02' + 'cc'.repeat(32)
|
||||||
const SERVER = 'mint.example'
|
const SERVER = 'mint.example'
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||||
|
const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
stubLocalStorage()
|
stubLocalStorage()
|
||||||
clearTrustedMints()
|
await saveLinkingKey(LINKING_KEY)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const onlyMint = (): TrustedMint => {
|
||||||
|
const mint = readTrustedMints(OWNER_ID)[0]
|
||||||
|
if (!mint) throw new Error('Expected one trusted mint.')
|
||||||
|
return mint
|
||||||
|
}
|
||||||
|
|
||||||
describe('pinning', () => {
|
describe('pinning', () => {
|
||||||
it('locks a mint the first time a bearer is held from it', () => {
|
it('locks a mint the first time a bearer is held from it', async () => {
|
||||||
expect(lockTrustedMint(SERVER, KEY_A)).toBe('added')
|
expect(await lockTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('added')
|
||||||
expect(isMintTrusted(SERVER)).toBe(true)
|
expect(isMintTrusted(SERVER, OWNER_ID)).toBe(true)
|
||||||
expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A)
|
expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A)
|
||||||
expect(readTrustedMints()[0]!.locked).toBe(true)
|
expect(onlyMint().locked).toBe(true)
|
||||||
// same key again: silent no-op
|
// same key again: silent no-op
|
||||||
expect(lockTrustedMint(SERVER, KEY_A)).toBe('unchanged')
|
expect(await lockTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('unchanged')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a malformed signing key without throwing', () => {
|
it('rejects a malformed signing key without throwing', async () => {
|
||||||
expect(lockTrustedMint(SERVER, 'not-a-key')).toBe('unchanged')
|
expect(await lockTrustedMint(SERVER, 'not-a-key', OWNER_ID)).toBe('unchanged')
|
||||||
expect(isMintTrusted(SERVER)).toBe(false)
|
expect(isMintTrusted(SERVER, OWNER_ID)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('rekey staging', () => {
|
describe('rekey staging', () => {
|
||||||
it('stages a differing advertised key for review, never auto-applies it', () => {
|
it('stages a differing advertised key for review, never auto-applies it', async () => {
|
||||||
lockTrustedMint(SERVER, KEY_A)
|
await lockTrustedMint(SERVER, KEY_A, OWNER_ID)
|
||||||
|
|
||||||
expect(lockTrustedMint(SERVER, KEY_B)).toBe('rekey-pending')
|
expect(await lockTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending')
|
||||||
const mint = readTrustedMints()[0]!
|
const mint = onlyMint()
|
||||||
// the staged candidate is visible, but the ORIGINAL pin is still
|
// the staged candidate is visible, but the ORIGINAL pin is still
|
||||||
// authoritative - this is the entire point of the staging model
|
// authoritative - this is the entire point of the staging model
|
||||||
expect(mint.pendingMintPubkey).toBe(KEY_B)
|
expect(mint.pendingMintPubkey).toBe(KEY_B)
|
||||||
expect(mint.mintPubkey).toBe(KEY_A)
|
expect(mint.mintPubkey).toBe(KEY_A)
|
||||||
expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A)
|
expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A)
|
||||||
|
|
||||||
// re-advertising the same pending key doesn't duplicate or escalate
|
// re-advertising the same pending key doesn't duplicate or escalate
|
||||||
expect(lockTrustedMint(SERVER, KEY_B)).toBe('rekey-pending')
|
expect(await lockTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending')
|
||||||
expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A)
|
expect(onlyMint().mintPubkey).toBe(KEY_A)
|
||||||
|
|
||||||
// and a THIRD key replaces the staged candidate, still not the pin
|
// and a THIRD key replaces the staged candidate, still not the pin
|
||||||
expect(lockTrustedMint(SERVER, KEY_C)).toBe('rekey-pending')
|
expect(await lockTrustedMint(SERVER, KEY_C, OWNER_ID)).toBe('rekey-pending')
|
||||||
expect(readTrustedMints()[0]!.pendingMintPubkey).toBe(KEY_C)
|
expect(onlyMint().pendingMintPubkey).toBe(KEY_C)
|
||||||
expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A)
|
expect(onlyMint().mintPubkey).toBe(KEY_A)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('promotes the staged key only on explicit holder confirmation', () => {
|
it('promotes the staged key only on explicit holder confirmation', async () => {
|
||||||
lockTrustedMint(SERVER, KEY_A)
|
await lockTrustedMint(SERVER, KEY_A, OWNER_ID)
|
||||||
lockTrustedMint(SERVER, KEY_B)
|
await lockTrustedMint(SERVER, KEY_B, OWNER_ID)
|
||||||
|
|
||||||
confirmTrustedMintRekey(SERVER)
|
await confirmTrustedMintRekey(SERVER, OWNER_ID)
|
||||||
const mint = readTrustedMints()[0]!
|
const mint = onlyMint()
|
||||||
expect(mint.mintPubkey).toBe(KEY_B)
|
expect(mint.mintPubkey).toBe(KEY_B)
|
||||||
expect(mint.pendingMintPubkey).toBeUndefined()
|
expect(mint.pendingMintPubkey).toBeUndefined()
|
||||||
expect(getTrustedMintPubkey(SERVER)).toBe(KEY_B)
|
expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_B)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drops the staged key on dismissal, keeping the original pin', () => {
|
it('drops the staged key on dismissal, keeping the original pin', async () => {
|
||||||
lockTrustedMint(SERVER, KEY_A)
|
await lockTrustedMint(SERVER, KEY_A, OWNER_ID)
|
||||||
lockTrustedMint(SERVER, KEY_B)
|
await lockTrustedMint(SERVER, KEY_B, OWNER_ID)
|
||||||
|
|
||||||
dismissTrustedMintRekey(SERVER)
|
await dismissTrustedMintRekey(SERVER, OWNER_ID)
|
||||||
const mint = readTrustedMints()[0]!
|
const mint = onlyMint()
|
||||||
expect(mint.pendingMintPubkey).toBeUndefined()
|
expect(mint.pendingMintPubkey).toBeUndefined()
|
||||||
expect(mint.mintPubkey).toBe(KEY_A)
|
expect(mint.mintPubkey).toBe(KEY_A)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stages a rekey even through unlock-time grandfathering', () => {
|
it('stages a rekey even through unlock-time grandfathering', async () => {
|
||||||
grandfatherTrustedMint(SERVER, KEY_A)
|
await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID)
|
||||||
expect(grandfatherTrustedMint(SERVER, KEY_B)).toBe('rekey-pending')
|
expect(await grandfatherTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending')
|
||||||
expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A)
|
expect(onlyMint().mintPubkey).toBe(KEY_A)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('grandfathering (storage-sourced claims)', () => {
|
describe('grandfathering (storage-sourced claims)', () => {
|
||||||
it('adds an unknown server unlocked and unconfirmed', () => {
|
it('adds an unknown server unlocked and unconfirmed', async () => {
|
||||||
expect(grandfatherTrustedMint(SERVER, KEY_A)).toBe('added')
|
expect(await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('added')
|
||||||
const mint = readTrustedMints()[0]!
|
const mint = onlyMint()
|
||||||
expect(mint.locked).toBe(false)
|
expect(mint.locked).toBe(false)
|
||||||
expect(mint.unconfirmed).toBe(true)
|
expect(mint.unconfirmed).toBe(true)
|
||||||
// unconfirmed pins stay out of offline signature verification
|
// unconfirmed pins stay out of offline signature verification
|
||||||
expect(getTrustedMintPubkey(SERVER)).toBeNull()
|
expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBeNull()
|
||||||
expect(isMintUnconfirmed(SERVER)).toBe(true)
|
expect(isMintUnconfirmed(SERVER, OWNER_ID)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is corroborated and locked by a live response advertising the same key', () => {
|
it('is corroborated and locked by a live response advertising the same key', async () => {
|
||||||
grandfatherTrustedMint(SERVER, KEY_A)
|
await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID)
|
||||||
expect(lockTrustedMint(SERVER, KEY_A)).toBe('unchanged')
|
expect(await lockTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('unchanged')
|
||||||
const mint = readTrustedMints()[0]!
|
const mint = onlyMint()
|
||||||
expect(mint.locked).toBe(true)
|
expect(mint.locked).toBe(true)
|
||||||
expect(mint.unconfirmed).toBeUndefined()
|
expect(mint.unconfirmed).toBeUndefined()
|
||||||
expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A)
|
expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('manual add and removal', () => {
|
describe('manual add and removal', () => {
|
||||||
it('validates input instead of silently no-oping', () => {
|
it('validates input instead of silently no-oping', async () => {
|
||||||
expect(() => addTrustedMint('', KEY_A)).toThrow()
|
await expect(addTrustedMint('', KEY_A, {ownerId: OWNER_ID})).rejects.toThrow()
|
||||||
expect(() => addTrustedMint(SERVER, 'junk')).toThrow()
|
await expect(addTrustedMint(SERVER, 'junk', {ownerId: OWNER_ID})).rejects.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refuses to remove a mint locked by a held bearer', () => {
|
it('refuses to remove a mint locked by a held bearer', async () => {
|
||||||
lockTrustedMint(SERVER, KEY_A)
|
await lockTrustedMint(SERVER, KEY_A, OWNER_ID)
|
||||||
expect(() => removeTrustedMint(SERVER)).toThrow(/bearer/)
|
await expect(removeTrustedMint(SERVER, OWNER_ID)).rejects.toThrow(/bearer/)
|
||||||
expect(isMintTrusted(SERVER)).toBe(true)
|
expect(isMintTrusted(SERVER, OWNER_ID)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes an unlocked mint', () => {
|
it('removes an unlocked mint', async () => {
|
||||||
addTrustedMint(SERVER, KEY_A)
|
await addTrustedMint(SERVER, KEY_A, {ownerId: OWNER_ID})
|
||||||
removeTrustedMint(SERVER)
|
await removeTrustedMint(SERVER, OWNER_ID)
|
||||||
expect(isMintTrusted(SERVER)).toBe(false)
|
expect(isMintTrusted(SERVER, OWNER_ID)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -145,12 +153,12 @@ describe('backup merge', () => {
|
|||||||
locked: true, // must never survive a merge from a file
|
locked: true, // must never survive a merge from a file
|
||||||
pendingMintPubkey: KEY_C, // must never survive either
|
pendingMintPubkey: KEY_C, // must never survive either
|
||||||
nodeAlias: 'Backup Mint',
|
nodeAlias: 'Backup Mint',
|
||||||
...overrides
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
it('merges unknown servers as unlocked, unconfirmed, and without staged keys', () => {
|
it('merges unknown servers as unlocked, unconfirmed, and without staged keys', async () => {
|
||||||
expect(mergeTrustedMints([fromFile()])).toBe(1)
|
expect(await mergeTrustedMints([fromFile()], OWNER_ID)).toBe(1)
|
||||||
const mint = readTrustedMints()[0]!
|
const mint = onlyMint()
|
||||||
expect(mint.server).toBe('backup-mint.example')
|
expect(mint.server).toBe('backup-mint.example')
|
||||||
expect(mint.mintPubkey).toBe(KEY_B)
|
expect(mint.mintPubkey).toBe(KEY_B)
|
||||||
expect(mint.locked).toBe(false)
|
expect(mint.locked).toBe(false)
|
||||||
@@ -159,25 +167,48 @@ describe('backup merge', () => {
|
|||||||
expect(mint.nodeAlias).toBe('Backup Mint')
|
expect(mint.nodeAlias).toBe('Backup Mint')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('never overwrites a server this device already knows', () => {
|
it('never overwrites a server this device already knows', async () => {
|
||||||
lockTrustedMint(SERVER, KEY_A)
|
await lockTrustedMint(SERVER, KEY_A, OWNER_ID)
|
||||||
const added = mergeTrustedMints([fromFile({server: SERVER, mintPubkey: KEY_B})])
|
const added = await mergeTrustedMints([fromFile({server: SERVER, mintPubkey: KEY_B})], OWNER_ID)
|
||||||
expect(added).toBe(0)
|
expect(added).toBe(0)
|
||||||
expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A)
|
expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('skips malformed entries', () => {
|
it('skips malformed entries', async () => {
|
||||||
// JSON round-trip: a backup file's entries are runtime data, not
|
// JSON round-trip: a backup file's entries are runtime data, not
|
||||||
// compile-time TrustedMints - the merge must filter, not trust
|
// compile-time TrustedMints - the merge must filter, not trust
|
||||||
const malformed: TrustedMint[] = JSON.parse(
|
const malformed: TrustedMint[] = JSON.parse(
|
||||||
JSON.stringify([
|
JSON.stringify([fromFile({mintPubkey: 'not-hex'}), fromFile({server: 42}), null]),
|
||||||
fromFile({mintPubkey: 'not-hex'}),
|
|
||||||
fromFile({server: 42}),
|
|
||||||
null
|
|
||||||
])
|
|
||||||
)
|
)
|
||||||
expect(mergeTrustedMints(malformed)).toBe(0)
|
expect(await mergeTrustedMints(malformed, OWNER_ID)).toBe(0)
|
||||||
expect(readTrustedMints()).toEqual([])
|
expect(readTrustedMints(OWNER_ID)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('security policy characterization', () => {
|
||||||
|
it('keeps local pins authoritative and requires explicit rekey confirmation', async () => {
|
||||||
|
await lockTrustedMint(SERVER, KEY_A, OWNER_ID)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await mergeTrustedMints(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
server: SERVER,
|
||||||
|
mintPubkey: KEY_C,
|
||||||
|
addedAt: 123,
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
OWNER_ID,
|
||||||
|
),
|
||||||
|
).toBe(0)
|
||||||
|
expect(await lockTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending')
|
||||||
|
expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A)
|
||||||
|
|
||||||
|
await confirmTrustedMintRekey(SERVER, OWNER_ID)
|
||||||
|
|
||||||
|
expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_B)
|
||||||
|
await expect(removeTrustedMint(SERVER, OWNER_ID)).rejects.toThrow(/bearer/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -188,7 +219,7 @@ describe('PUBLIC_MINTS', () => {
|
|||||||
'@lnurl.21mint.me',
|
'@lnurl.21mint.me',
|
||||||
'@mint.forgesworn.dev',
|
'@mint.forgesworn.dev',
|
||||||
'@lnurl.21linz.at',
|
'@lnurl.21linz.at',
|
||||||
'@minty.exe.xyz'
|
'@minty.exe.xyz',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+85
-235
@@ -1,10 +1,24 @@
|
|||||||
import type {MintAddressInfo} from 'lnurlcash-kit'
|
import type {MintAddressInfo} from 'lnurlcash-kit'
|
||||||
|
import {
|
||||||
// allow: SIZE_OK — one indivisible registry: every operation below reads
|
adoptLegacyStoredTrustedMints,
|
||||||
// and writes the same pinned-key cache through the same persist/notify
|
mutateStoredTrustedMints,
|
||||||
// path, and the file is a deliberate verbatim-behavior port of
|
onStoredTrustedMintsChange,
|
||||||
// lnurl-wallet's trustedMints.ts so the two wallets' pinning semantics
|
readOwnedTrustedMints,
|
||||||
// stay auditable side by side.
|
removeStoredTrustedMintsForOwner,
|
||||||
|
resetStoredTrustedMints,
|
||||||
|
} from './trustedMintsRepository'
|
||||||
|
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
|
||||||
|
import {
|
||||||
|
addMint,
|
||||||
|
cacheMintNodeInfo,
|
||||||
|
clearMints,
|
||||||
|
confirmMintRekey,
|
||||||
|
dismissMintRekey,
|
||||||
|
grandfatherMint,
|
||||||
|
lockMint,
|
||||||
|
removeMint,
|
||||||
|
} from './trustedMintTransitions'
|
||||||
|
import {mergeMints} from './trustedMintMerge'
|
||||||
|
|
||||||
// A mint's signing key (LUD-25 Offline verification's `mintPubkey`) - not a
|
// A mint's signing key (LUD-25 Offline verification's `mintPubkey`) - not a
|
||||||
// secret, just a public identity, so this is plain unencrypted localStorage,
|
// secret, just a public identity, so this is plain unencrypted localStorage,
|
||||||
@@ -74,7 +88,7 @@ export type TrustedMintNodeInfo = {
|
|||||||
// resolved, not from that endpoint's response.
|
// resolved, not from that endpoint's response.
|
||||||
export const mintAddressCacheInfo = (
|
export const mintAddressCacheInfo = (
|
||||||
info: MintAddressInfo | null,
|
info: MintAddressInfo | null,
|
||||||
username: string | null
|
username: string | null,
|
||||||
): TrustedMintNodeInfo | undefined => {
|
): TrustedMintNodeInfo | undefined => {
|
||||||
if (!info && !username) return undefined
|
if (!info && !username) return undefined
|
||||||
return {
|
return {
|
||||||
@@ -83,7 +97,7 @@ export const mintAddressCacheInfo = (
|
|||||||
nodeCapacityMsat: info?.nodeCapacityMsat,
|
nodeCapacityMsat: info?.nodeCapacityMsat,
|
||||||
nodeNumChannels: info?.nodeNumChannels,
|
nodeNumChannels: info?.nodeNumChannels,
|
||||||
nodeNumPeers: info?.nodeNumPeers,
|
nodeNumPeers: info?.nodeNumPeers,
|
||||||
username: username ?? undefined
|
username: username ?? undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,81 +113,36 @@ export const PUBLIC_MINTS = [
|
|||||||
'@lnurl.21mint.me',
|
'@lnurl.21mint.me',
|
||||||
'@mint.forgesworn.dev',
|
'@mint.forgesworn.dev',
|
||||||
'@lnurl.21linz.at',
|
'@lnurl.21linz.at',
|
||||||
'@minty.exe.xyz'
|
'@minty.exe.xyz',
|
||||||
]
|
]
|
||||||
|
|
||||||
const STORAGE_KEY = 'sattle_trusted_mints'
|
|
||||||
|
|
||||||
// 33-byte compressed secp256k1 pubkey, hex
|
|
||||||
const PUBKEY_PATTERN = /^[0-9a-f]{66}$/
|
|
||||||
|
|
||||||
const readStored = (): TrustedMint[] => {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!raw) return []
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(raw)
|
|
||||||
if (!Array.isArray(parsed)) return []
|
|
||||||
// shape-check every entry - this is the wallet's own persisted state
|
|
||||||
// (so locked/pendingMintPubkey/unconfirmed are all kept), but a
|
|
||||||
// tampered or corrupt record must not plant junk entries
|
|
||||||
return parsed.filter(
|
|
||||||
(m): m is TrustedMint =>
|
|
||||||
typeof m?.server === 'string' &&
|
|
||||||
typeof m?.mintPubkey === 'string' &&
|
|
||||||
PUBKEY_PATTERN.test(m.mintPubkey.toLowerCase()) &&
|
|
||||||
typeof m?.addedAt === 'number' &&
|
|
||||||
typeof m?.locked === 'boolean'
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lazily initialized on first access: importing this module must not touch
|
|
||||||
// localStorage (plain-Node test environments have none until stubbed)
|
|
||||||
let cache: TrustedMint[] | null = null
|
|
||||||
const readCache = (): TrustedMint[] => {
|
|
||||||
cache ??= readStored()
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
const listeners = new Set<(mints: TrustedMint[]) => void>()
|
|
||||||
|
|
||||||
// the Pinia mints store subscribes here to mirror the registry into
|
// the Pinia mints store subscribes here to mirror the registry into
|
||||||
// reactive state; returns the unsubscribe
|
// reactive state; returns the unsubscribe
|
||||||
export const onTrustedMintsChange = (
|
export const onTrustedMintsChange = (listener: () => void): (() => void) =>
|
||||||
listener: (mints: TrustedMint[]) => void
|
onStoredTrustedMintsChange(listener)
|
||||||
): (() => void) => {
|
|
||||||
listeners.add(listener)
|
|
||||||
return () => listeners.delete(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readTrustedMints = (): TrustedMint[] => readCache()
|
export const readTrustedMints = (ownerId?: string): TrustedMint[] => readOwnedTrustedMints(ownerId)
|
||||||
|
|
||||||
const persist = (mints: TrustedMint[]): void => {
|
export const isMintTrusted = (server: string, ownerId?: string): boolean =>
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(mints))
|
readTrustedMints(ownerId).some((mint) => mint.server === server)
|
||||||
cache = mints
|
|
||||||
for (const listener of listeners) listener(mints)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isMintTrusted = (server: string): boolean =>
|
export const getTrustedMintPubkey = (server: string, ownerId?: string): string | null =>
|
||||||
readCache().some(m => m.server === server)
|
readTrustedMints(ownerId).find((mint) => mint.server === server && !mint.unconfirmed)
|
||||||
|
?.mintPubkey ?? null
|
||||||
export const getTrustedMintPubkey = (server: string): string | null =>
|
|
||||||
readCache().find(m => m.server === server && !m.unconfirmed)?.mintPubkey ?? null
|
|
||||||
|
|
||||||
// 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
|
||||||
// uncorroborated
|
// uncorroborated
|
||||||
export const isMintUnconfirmed = (server: string): boolean =>
|
export const isMintUnconfirmed = (server: string, ownerId?: string): boolean =>
|
||||||
readCache().some(m => m.server === server && m.unconfirmed)
|
readTrustedMints(ownerId).some((mint) => mint.server === server && mint.unconfirmed)
|
||||||
|
|
||||||
// this mint's self-reported node color, for tinting its notes' background -
|
// this mint's self-reported node color, for tinting its notes' background -
|
||||||
// purely cosmetic. Mint-supplied, so it's only ever handed out as a plain
|
// purely cosmetic. Mint-supplied, so it's only ever handed out as a plain
|
||||||
// hex color - anything else (a style sink can take far more than colors) is
|
// hex color - anything else (a style sink can take far more than colors) is
|
||||||
// treated as absent
|
// treated as absent
|
||||||
export const getTrustedMintNodeColor = (server: string): string | null => {
|
export const getTrustedMintNodeColor = (server: string, ownerId?: string): string | null => {
|
||||||
const color = readCache().find(m => m.server === server)?.nodeColor
|
const color = readTrustedMints(ownerId).find((mint) => mint.server === server)?.nodeColor
|
||||||
return color && /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : null
|
return color && /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,8 +151,8 @@ export const getTrustedMintNodeColor = (server: string): string | null => {
|
|||||||
// guessing "mint@<server>" - null for a mint with no cached username
|
// guessing "mint@<server>" - null for a mint with no cached username
|
||||||
// (looked up as a bech32 LNURL, or trusted before this wallet learned to
|
// (looked up as a bech32 LNURL, or trusted before this wallet learned to
|
||||||
// remember one)
|
// remember one)
|
||||||
export const getTrustedMintAddress = (server: string): string | null => {
|
export const getTrustedMintAddress = (server: string, ownerId?: string): string | null => {
|
||||||
const username = readCache().find(m => m.server === server)?.username
|
const username = readTrustedMints(ownerId).find((mint) => mint.server === server)?.username
|
||||||
return username ? `${username}@${server}` : null
|
return username ? `${username}@${server}` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +162,14 @@ export const getTrustedMintAddress = (server: string): string | null => {
|
|||||||
// silently replacing it. Callers should surface that loudly.
|
// silently replacing it. Callers should surface that loudly.
|
||||||
export type TrustKeyResult = 'added' | 'unchanged' | 'rekey-pending'
|
export type TrustKeyResult = 'added' | 'unchanged' | 'rekey-pending'
|
||||||
|
|
||||||
|
export type TrustedMintMutationContext = {
|
||||||
|
readonly ownerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddTrustedMintContext = TrustedMintMutationContext & {
|
||||||
|
readonly nodeInfo?: TrustedMintNodeInfo
|
||||||
|
}
|
||||||
|
|
||||||
// Called whenever this wallet ends up holding (or already holds) a bearer
|
// Called whenever this wallet ends up holding (or already holds) a bearer
|
||||||
// from `server` - minting, receiving, splitting, merging all route through
|
// from `server` - minting, receiving, splitting, merging all route through
|
||||||
// the wallet store's addBearers/updateBearer, which is where this gets
|
// the wallet store's addBearers/updateBearer, which is where this gets
|
||||||
@@ -204,32 +181,10 @@ export type TrustKeyResult = 'added' | 'unchanged' | 'rekey-pending'
|
|||||||
// or dismiss (see confirmTrustedMintRekey).
|
// or dismiss (see confirmTrustedMintRekey).
|
||||||
export const lockTrustedMint = (
|
export const lockTrustedMint = (
|
||||||
server: string,
|
server: string,
|
||||||
mintPubkey: string
|
mintPubkey: string,
|
||||||
): TrustKeyResult => {
|
ownerId?: string,
|
||||||
const key = mintPubkey.trim().toLowerCase()
|
): Promise<TrustKeyResult> =>
|
||||||
if (!server || !PUBKEY_PATTERN.test(key)) return 'unchanged'
|
mutateStoredTrustedMints(ownerId, (mints) => lockMint(mints, {server, mintPubkey}))
|
||||||
const existing = readCache().find(m => m.server === server)
|
|
||||||
if (existing) {
|
|
||||||
if (existing.mintPubkey === key) {
|
|
||||||
if (existing.locked && !existing.unconfirmed) return 'unchanged'
|
|
||||||
// a match here is a live response from the server advertising this
|
|
||||||
// exact key - it corroborates an unconfirmed (file-sourced) pin
|
|
||||||
persist(
|
|
||||||
readCache().map(m =>
|
|
||||||
m.server === server ? {...m, locked: true, unconfirmed: undefined} : m
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 'unchanged'
|
|
||||||
}
|
|
||||||
if (existing.pendingMintPubkey === key) return 'rekey-pending'
|
|
||||||
persist(
|
|
||||||
readCache().map(m => (m.server === server ? {...m, pendingMintPubkey: key} : m))
|
|
||||||
)
|
|
||||||
return 'rekey-pending'
|
|
||||||
}
|
|
||||||
persist([...readCache(), {server, mintPubkey: key, addedAt: Date.now(), locked: true}])
|
|
||||||
return 'added'
|
|
||||||
}
|
|
||||||
|
|
||||||
// unlock-time grandfathering of the mints behind already-stored bearers -
|
// unlock-time grandfathering of the mints behind already-stored bearers -
|
||||||
// the key claims come from local storage, not a live response, so an
|
// the key claims come from local storage, not a live response, so an
|
||||||
@@ -240,25 +195,10 @@ export const lockTrustedMint = (
|
|||||||
// lockTrustedMint instead, which is what corroborates and re-locks.
|
// lockTrustedMint instead, which is what corroborates and re-locks.
|
||||||
export const grandfatherTrustedMint = (
|
export const grandfatherTrustedMint = (
|
||||||
server: string,
|
server: string,
|
||||||
mintPubkey: string
|
mintPubkey: string,
|
||||||
): TrustKeyResult => {
|
ownerId?: string,
|
||||||
const key = mintPubkey.trim().toLowerCase()
|
): Promise<TrustKeyResult> =>
|
||||||
if (!server || !PUBKEY_PATTERN.test(key)) return 'unchanged'
|
mutateStoredTrustedMints(ownerId, (mints) => grandfatherMint(mints, {server, mintPubkey}))
|
||||||
const existing = readCache().find(m => m.server === server)
|
|
||||||
if (existing) {
|
|
||||||
if (existing.mintPubkey === key) return 'unchanged'
|
|
||||||
if (existing.pendingMintPubkey === key) return 'rekey-pending'
|
|
||||||
persist(
|
|
||||||
readCache().map(m => (m.server === server ? {...m, pendingMintPubkey: key} : m))
|
|
||||||
)
|
|
||||||
return 'rekey-pending'
|
|
||||||
}
|
|
||||||
persist([
|
|
||||||
...readCache(),
|
|
||||||
{server, mintPubkey: key, addedAt: Date.now(), locked: false, unconfirmed: true}
|
|
||||||
])
|
|
||||||
return 'added'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manual add from the mints settings, or a user-confirmed first encounter -
|
// Manual add from the mints settings, or a user-confirmed first encounter -
|
||||||
// unlocked, since no bearer necessarily backs it yet. Validates and throws
|
// unlocked, since no bearer necessarily backs it yet. Validates and throws
|
||||||
@@ -270,81 +210,26 @@ export const grandfatherTrustedMint = (
|
|||||||
export const addTrustedMint = (
|
export const addTrustedMint = (
|
||||||
server: string,
|
server: string,
|
||||||
mintPubkey: string,
|
mintPubkey: string,
|
||||||
nodeInfo?: TrustedMintNodeInfo
|
context?: TrustedMintNodeInfo | AddTrustedMintContext,
|
||||||
): TrustKeyResult => {
|
): Promise<TrustKeyResult> => {
|
||||||
const trimmedServer = server.trim()
|
const ownerId = context && 'ownerId' in context ? context.ownerId : undefined
|
||||||
const key = mintPubkey.trim().toLowerCase()
|
const nodeInfo = context && 'ownerId' in context ? context.nodeInfo : context
|
||||||
if (!trimmedServer) {
|
return mutateStoredTrustedMints(ownerId, (mints) =>
|
||||||
throw new Error('Enter a server.')
|
addMint(mints, {server, mintPubkey, nodeInfo}),
|
||||||
}
|
|
||||||
if (!PUBKEY_PATTERN.test(key)) {
|
|
||||||
throw new Error(
|
|
||||||
'Signing key must be a 33-byte compressed pubkey (66 hex characters).'
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const existing = readCache().find(m => m.server === trimmedServer)
|
|
||||||
if (existing) {
|
|
||||||
if (existing.mintPubkey === key) {
|
|
||||||
// a match here is a live lookup corroborating the pin - it clears an
|
|
||||||
// unconfirmed (file-sourced) flag
|
|
||||||
persist(
|
|
||||||
readCache().map(m =>
|
|
||||||
m.server === trimmedServer
|
|
||||||
? {...m, ...nodeInfo, unconfirmed: undefined}
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 'unchanged'
|
|
||||||
}
|
|
||||||
persist(
|
|
||||||
readCache().map(m =>
|
|
||||||
m.server === trimmedServer
|
|
||||||
? {...m, pendingMintPubkey: key, ...nodeInfo}
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 'rekey-pending'
|
|
||||||
}
|
|
||||||
persist([
|
|
||||||
...readCache(),
|
|
||||||
{
|
|
||||||
server: trimmedServer,
|
|
||||||
mintPubkey: key,
|
|
||||||
addedAt: Date.now(),
|
|
||||||
locked: false,
|
|
||||||
...nodeInfo
|
|
||||||
}
|
|
||||||
])
|
|
||||||
return 'added'
|
|
||||||
}
|
|
||||||
|
|
||||||
// the holder confirms a mint's advertised new signing key - the pending key
|
// the holder confirms a mint's advertised new signing key - the pending key
|
||||||
// becomes the pinned one. Legitimate rotations (a mint moving to a new
|
// becomes the pinned one. Legitimate rotations (a mint moving to a new
|
||||||
// node) go through here; nothing else ever replaces a pin.
|
// node) go through here; nothing else ever replaces a pin.
|
||||||
export const confirmTrustedMintRekey = (server: string): void => {
|
export const confirmTrustedMintRekey = (server: string, ownerId?: string): Promise<void> =>
|
||||||
const existing = readCache().find(m => m.server === server)
|
mutateStoredTrustedMints(ownerId, (mints) => confirmMintRekey(mints, server))
|
||||||
if (!existing?.pendingMintPubkey) return
|
|
||||||
const pending = existing.pendingMintPubkey
|
|
||||||
persist(
|
|
||||||
readCache().map(m =>
|
|
||||||
m.server === server
|
|
||||||
? {...m, mintPubkey: pending, pendingMintPubkey: undefined, unconfirmed: undefined}
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// the holder rejects the advertised new key - the staged candidate is
|
// the holder rejects the advertised new key - the staged candidate is
|
||||||
// dropped, the original pin stays. Worth doing only when the change is
|
// dropped, the original pin stays. Worth doing only when the change is
|
||||||
// unexpected; the old key stays authoritative either way until confirmed.
|
// unexpected; the old key stays authoritative either way until confirmed.
|
||||||
export const dismissTrustedMintRekey = (server: string): void => {
|
export const dismissTrustedMintRekey = (server: string, ownerId?: string): Promise<void> =>
|
||||||
if (!readCache().some(m => m.server === server)) return
|
mutateStoredTrustedMints(ownerId, (mints) => dismissMintRekey(mints, server))
|
||||||
persist(
|
|
||||||
readCache().map(m =>
|
|
||||||
m.server === server ? {...m, pendingMintPubkey: undefined} : m
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshes just the cached display info for a server already in the list -
|
// refreshes just the cached display info for a server already in the list -
|
||||||
// never touches mintPubkey/addedAt/locked, and no-ops for a server that
|
// never touches mintPubkey/addedAt/locked, and no-ops for a server that
|
||||||
@@ -355,31 +240,34 @@ export const dismissTrustedMintRekey = (server: string): void => {
|
|||||||
// whatever was known the moment trust was first established.
|
// whatever was known the moment trust was first established.
|
||||||
export const cacheTrustedMintNodeInfo = (
|
export const cacheTrustedMintNodeInfo = (
|
||||||
server: string,
|
server: string,
|
||||||
nodeInfo: TrustedMintNodeInfo
|
nodeInfo: TrustedMintNodeInfo,
|
||||||
): void => {
|
ownerId?: string,
|
||||||
if (!readCache().some(m => m.server === server)) return
|
): Promise<void> =>
|
||||||
persist(readCache().map(m => (m.server === server ? {...m, ...nodeInfo} : m)))
|
mutateStoredTrustedMints(ownerId, (mints) => cacheMintNodeInfo(mints, server, nodeInfo))
|
||||||
}
|
|
||||||
|
|
||||||
// only succeeds for entries not backed by a held bearer - see
|
// only succeeds for entries not backed by a held bearer - see
|
||||||
// TrustedMint.locked
|
// TrustedMint.locked
|
||||||
export const removeTrustedMint = (server: string): void => {
|
export const removeTrustedMint = (server: string, ownerId?: string): Promise<void> =>
|
||||||
const entry = readCache().find(m => m.server === server)
|
mutateStoredTrustedMints(ownerId, (mints) => removeMint(mints, server))
|
||||||
if (!entry) return
|
|
||||||
if (entry.locked) {
|
|
||||||
throw new Error("Can't remove - you hold a bearer note from this mint.")
|
|
||||||
}
|
|
||||||
persist(readCache().filter(m => m.server !== server))
|
|
||||||
}
|
|
||||||
|
|
||||||
// wipes the whole registry - part of forgetting a wallet: nothing about a
|
// wipes the whole registry - part of forgetting a wallet: nothing about a
|
||||||
// wallet's mints (including otherwise-irremovable locked pins) should
|
// wallet's mints (including otherwise-irremovable locked pins) should
|
||||||
// linger on the device after it
|
// linger on the device after it
|
||||||
export const clearTrustedMints = (): void => {
|
export const clearTrustedMints = (ownerId?: string): Promise<void> =>
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
mutateStoredTrustedMints(ownerId, clearMints)
|
||||||
cache = []
|
|
||||||
for (const listener of listeners) listener([])
|
export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise<number> => {
|
||||||
|
const ownerId = linkingPubKeyHex(linkingKey)
|
||||||
|
if (savedKeyOwnerId() !== ownerId) {
|
||||||
|
throw new Error('Legacy trusted-mint migration requires a proven owner.')
|
||||||
}
|
}
|
||||||
|
return adoptLegacyStoredTrustedMints(ownerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeTrustedMintsForOwner = (ownerId: string): Promise<void> =>
|
||||||
|
removeStoredTrustedMintsForOwner(ownerId)
|
||||||
|
|
||||||
|
export const resetTrustedMintsForReplacement = (): Promise<void> => resetStoredTrustedMints()
|
||||||
|
|
||||||
// merges a backup's trusted mints in by server - a server already known on
|
// merges a backup's trusted mints in by server - a server already known on
|
||||||
// this device keeps its own current entry rather than being overwritten by
|
// this device keeps its own current entry rather than being overwritten by
|
||||||
@@ -391,43 +279,5 @@ export const clearTrustedMints = (): void => {
|
|||||||
// and every merged entry is marked `unconfirmed`, keeping it out of offline
|
// and every merged entry is marked `unconfirmed`, keeping it out of offline
|
||||||
// signature verification until a live response from that server advertises
|
// signature verification until a live response from that server advertises
|
||||||
// the same key (a crafted backup could otherwise forge "signed" badges)
|
// the same key (a crafted backup could otherwise forge "signed" badges)
|
||||||
export const mergeTrustedMints = (incoming: TrustedMint[]): number => {
|
export const mergeTrustedMints = (incoming: unknown[], ownerId?: string): Promise<number> =>
|
||||||
const knownServers = new Set(readCache().map(m => m.server))
|
mutateStoredTrustedMints(ownerId, (mints) => mergeMints(mints, incoming))
|
||||||
const merged = [...readCache()]
|
|
||||||
let added = 0
|
|
||||||
for (const mint of incoming) {
|
|
||||||
if (
|
|
||||||
typeof mint?.server !== 'string' ||
|
|
||||||
typeof mint?.mintPubkey !== 'string' ||
|
|
||||||
typeof mint?.addedAt !== 'number' ||
|
|
||||||
!PUBKEY_PATTERN.test(mint.mintPubkey.toLowerCase())
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (knownServers.has(mint.server)) continue
|
|
||||||
merged.push({
|
|
||||||
server: mint.server,
|
|
||||||
mintPubkey: mint.mintPubkey.toLowerCase(),
|
|
||||||
addedAt: mint.addedAt,
|
|
||||||
locked: false,
|
|
||||||
unconfirmed: true,
|
|
||||||
nodeAlias: typeof mint.nodeAlias === 'string' ? mint.nodeAlias : undefined,
|
|
||||||
nodeColor: typeof mint.nodeColor === 'string' ? mint.nodeColor : undefined,
|
|
||||||
nodeCapacityMsat:
|
|
||||||
typeof mint.nodeCapacityMsat === 'number'
|
|
||||||
? mint.nodeCapacityMsat
|
|
||||||
: undefined,
|
|
||||||
nodeNumChannels:
|
|
||||||
typeof mint.nodeNumChannels === 'number'
|
|
||||||
? mint.nodeNumChannels
|
|
||||||
: undefined,
|
|
||||||
nodeNumPeers:
|
|
||||||
typeof mint.nodeNumPeers === 'number' ? mint.nodeNumPeers : undefined,
|
|
||||||
username: typeof mint.username === 'string' ? mint.username : undefined
|
|
||||||
})
|
|
||||||
knownServers.add(mint.server)
|
|
||||||
added++
|
|
||||||
}
|
|
||||||
if (added > 0) persist(merged)
|
|
||||||
return added
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user