fix(auth): canonicalize verified Nostr pubkey + harden the link/login grade

Adversarial review (Identity Phase 1 flow) findings:

- Canonicalize the pubkey returned by verify_nip98 / verify_signed_action to
  lowercase hex. BIP-340 hex-decode is case-insensitive, so an UPPER-case-hex
  pubkey is a fully self-consistent, verifying event — but stored/looked-up
  verbatim it would let ONE key bind to two accounts via case variance (bypassing
  the UNIQUE index + 409) and yield non-canonical NIP-05 output. Now every
  downstream store/compare/lookup is on the canonical form. +KAT.
- nostr_link records a login event on success: a session-authed link (including a
  rotation that REPLACES an existing npub) is a security-relevant binding change
  and must not be silent.
- Tighten NIP98_LOGIN_MAX_AGE_SECS 60 -> 30s: a captured login token is a bearer
  credential within the window; match it to the state-change grade.
This commit is contained in:
jwinterm
2026-07-02 05:43:20 -04:00
parent 8d4f78a8a3
commit 1181b5938a
2 changed files with 67 additions and 5 deletions
+11 -3
View File
@@ -61,9 +61,10 @@ use crate::AppState;
// ── shared constants ────────────────────────────────────────────────────────
/// Replay window for a NIP-98 LOGIN token (seconds). Wide enough for client
/// clock skew, tight enough to bound replay.
const NIP98_LOGIN_MAX_AGE_SECS: i64 = 60;
/// Replay window for a NIP-98 LOGIN token (seconds). A captured login token is a
/// bearer credential within this window, so keep it tight — matched to the
/// state-change grade below. Still ample for real client clock skew.
const NIP98_LOGIN_MAX_AGE_SECS: i64 = 30;
/// Replay window for a NIP-98 STATE-CHANGE (signed action) token (seconds).
/// Deliberately tighter than login.
@@ -1393,6 +1394,13 @@ pub async fn nostr_link(
// 4. Persist. UNIQUE collision -> 409 CONFLICT (handled in set_nostr_pubkey).
state.db.set_nostr_pubkey(user_id, &pubkey).await?;
// Observability: linking (incl. a rotation that REPLACES an existing npub) is a
// security-relevant binding change, so record it in the login history — a
// session-authed rebind should never be silent.
let _ = state
.db
.record_login_event(Some(user_id), "btc", Platform::Nostr.as_str(), None, None)
.await;
info!(user_id = %user_id, "linked Nostr identity");
Ok(Json(NostrLinkResponse {
nostr_pubkey: pubkey,