diff --git a/CHANGELOG.md b/CHANGELOG.md
index 047aaaa..68cd2fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,12 @@ holder is the one who decides.
offer. The difference between the two errors is whether there is a decision
to make at all.
- `KeyRotationError` is exported alongside the other wallet errors.
+- The web wallet answers it with its own refusal card, alongside the one for a
+ bad signature. Without it the browser had a refusal it could not show and no
+ way to accept: the CLI's flag has no equivalent behind glass, so the card
+ carries the decision. Two taps, like the signature override, because the
+ honest reason to accept is that you checked the key somewhere other than the
+ mint that is asking.
- Graded against `lnurlcash-conformance` 0.7.0, whose `cash-derivation.json`
is the vector for the `m/139'` scheme this wallet now mints under.
diff --git a/web/src/main.ts b/web/src/main.ts
index e80af09..2b1a909 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -33,7 +33,14 @@ import {
toBech32Lnurl,
verifyNoteSignature
} from 'lnurlcash-kit'
-import {Wallet, BadSignatureError, WalletUsageError, InsufficientFundsError, PinMismatchError} from '../../src/wallet.ts'
+import {
+ Wallet,
+ BadSignatureError,
+ KeyRotationError,
+ WalletUsageError,
+ InsufficientFundsError,
+ PinMismatchError
+} from '../../src/wallet.ts'
import type {CheckReport, OfflineHandover} from '../../src/wallet.ts'
import {tryDecodeBolt11} from 'farrier-kit/bolt11'
import {exportBackup, importBackup} from '../../src/backup.ts'
@@ -2033,11 +2040,13 @@ const viewReceive = (prefill?: string): void => {
const refusal = el('
')
body.insertBefore(refusal, receiveButton)
- const take = async (acceptBadSignature: boolean): Promise => {
+ const take = async (
+ options: {acceptBadSignature?: boolean; approveKeyRotation?: boolean} = {}
+ ): Promise => {
try {
const result = offlineMode
? await w.receiveOffline(input.value.trim())
- : await w.receive(input.value.trim(), {acceptBadSignature})
+ : await w.receive(input.value.trim(), options)
result.warnings.forEach(warning => toast(warning, 'err'))
// Safely in the store and rotated: only now is it safe to forget
// the incoming claim.
@@ -2047,6 +2056,15 @@ const viewReceive = (prefill?: string): void => {
toast(`Received ${sats(result.note.amountMsat)} sat`, 'ok')
setTimeout(viewHome, 650)
} catch (err) {
+ // Two different refusals, and the difference is whether there is a
+ // decision to make. A signature that does not verify is a fact about
+ // this note; a mint presenting a new signing key is a question about
+ // the mint, and LUD-25 requires the holder to answer it rather than
+ // the wallet.
+ if (err instanceof KeyRotationError) {
+ showKeyRotation(err.message)
+ return
+ }
if (!(err instanceof BadSignatureError)) throw err
showRefusal(err.message)
}
@@ -2083,13 +2101,45 @@ const viewReceive = (prefill?: string): void => {
label.textContent = 'Tap again to take a note that failed its check'
return
}
- void busy(accept as HTMLButtonElement, () => take(true))
+ void busy(accept as HTMLButtonElement, () => take({acceptBadSignature: true}))
+ })
+ card.append(accept)
+ refusal.append(card)
+ }
+
+ // The mint has changed the key its notes are signed by, and publishes the
+ // old one as retired. That is what a mint rotating properly looks like -
+ // and it is also what a mint somebody else now runs would publish, because
+ // whoever controls the host controls what it publishes. The wallet cannot
+ // tell those apart, so it does not try: it says both, and the holder
+ // decides. Two taps, like the signature override, because the honest
+ // reason to accept is that you checked the key somewhere other than here.
+ const showKeyRotation = (reason: string): void => {
+ refusal.replaceChildren()
+ const card = el(`
+
This mint has a new signing key
+
+
A mint that changes its key and says so looks exactly like a mint somebody else has taken over, because the same server publishes both claims. Nothing here can tell them apart. Check the new key against the operator by some route that is not this mint - their Nostr account, their site, a person - before you accept it.
+
Notes you already hold keep working either way: the old key is kept, so they still verify.
+
`)
+ // the reason names a host and comes off the wire: text, never markup
+ card.querySelector('[data-reason]')!.textContent = `${reason}.`
+ const accept = el(``)
+ const label = accept.querySelector('span')!
+ let armed = false
+ accept.addEventListener('click', () => {
+ if (!armed) {
+ armed = true
+ label.textContent = 'Tap again to trust this mint\u2019s new key'
+ return
+ }
+ void busy(accept as HTMLButtonElement, () => take({approveKeyRotation: true}))
})
card.append(accept)
refusal.append(card)
}
- receiveButton.addEventListener('click', () => busy(receiveButton, () => take(false)))
+ receiveButton.addEventListener('click', () => busy(receiveButton, () => take()))
return view
})
}