Files
buzz/desktop/src/shared/lib/pubkey.ts
Logan Johnson bfc3848558 fix(desktop): shared npub identity foundation (canonicalNpub, PubKey gate, strict parser) (#7488)
🤖
## Summary

Identity keys in the desktop app are displayed as raw 64-character hex.
A person's key shows up as something like `953d3363…` — unreadable,
impossible to recognize as the same identity on another screen, and a
hazard when copied by hand. Nostr (the protocol Buzz runs on) has a
human-readable spelling for identity keys — the `npub1…` form — but the
desktop app did not use it consistently.

This is the foundation of the desktop npub changes: it adds the shared
pieces every identity surface builds on, and two follow-up slices stack
directly on this branch — #7489 converts the identity controls (profile,
settings, allowlist, workflow key fields) and #7495 converts the
everyday display surfaces (mentions, member lists, sidebar, and other
name fallbacks).

After this change:

- The shared identity widget shows the compact npub form —
`npub1j57...fjmv` — instead of a hex prefix, everywhere it renders (for
example the owned-agent public-key row on a profile). Copying it puts
the full npub on the clipboard.
- Copy is a real interaction, verified end-to-end: both popover variants
put the exact canonical npub on the actual clipboard — never the raw hex
the popover also lists, never a truncation — and a portaled popover's
clicks no longer steal focus from the new-DM To-field mid-copy. Pointer
copy, a natural Space-then-Enter path, and inner/outer Escape are
covered.
- Anything that isn't a valid identity key fails neutrally: short or
corrupt values — including degenerate values that technically encode to
a checksum-valid npub but aren't real identity keys — show "Unavailable"
with no copy button, instead of a misleading value.
- Both valid npub spellings display: all-lowercase `npub1…` and
all-uppercase `NPUB1…` (Bech32, npub's encoding, permits either casing)
both render the same canonical lowercase npub. Mixed case is rejected by
the display path as written — `canonicalNpub` and the widget don't
case-normalize input — while input parsing (`parsePubkeyInput`) keeps
its trim-and-lowercase normalization and accepts mixed-case npubs; both
paths require the decoded payload to be exactly a 64-character identity
key.
- Identity-key input is strict on payload: an npub whose decoded payload
isn't exactly a 64-character identity key is rejected, matching the
validation the app's Rust side already applies to agent allowlists.

Intentional scope boundary: only surfaces that render through the shared
widget change here. Outer profile copy, settings identity cards, the
respond-to allowlist, and workflow key fields still show hex — they move
to npub in the controls follow-up (#7489). Nothing else changes identity
representation: display names, private keys, event IDs, and the hex the
app stores, sends, and matches internally are untouched; only the
user-facing spelling of an identity key changes.

## Details

- `desktop/src/shared/lib/pubkey.ts` — `canonicalNpub()`: strict
canonical full-npub helper (64-char hex in any case, or a
checksum-validated npub, returns the canonical npub; anything else
returns `null`); `truncateNpub()`: the compact display form; existing
exports unchanged.
- `desktop/src/shared/ui/PubKey.tsx` — the shared widget's identity gate
validates through `canonicalNpub`; the popover copies the npub only.
- `desktop/src/shared/lib/nostrUtils.ts` — `parsePubkeyInput` rejects
npubs whose payload is not exactly a 64-character identity key.
- `desktop/src/features/messages/ui/NewMessageScreen.tsx` — the To-field
focuses its search input only for clicks that land inside the field
itself, so portaled recipient popovers keep their focus while open (a
popover click previously dismissed it mid-copy).
- Unit suites cover the helper, widget, and parser (including the
degenerate-encode and uppercase regressions); the e2e specs that render
these rows assert the npub display.

### Related issue

- Fixes: N/A. Searched existing issues/PRs for npub identity display —
no existing match.
- Stack: #7489 is based on this branch and builds on these primitives;
it does not stand alone on main.

### Testing

At head `b3310c248` (base: main `44316ff72`; 12 files, +440/−39):

- Focused unit suites (pubkey, PubKey, parsePubkeyInput): 20/20 green;
mutation-checked — removing the decoded-length predicate fails the
short/empty checksum-valid-npub assertions in `canonicalNpub` and the
widget, and a wrong-identity clipboard value fails the new copy
assertions.
- `pnpm typecheck` and `pnpm check`: pass; full desktop unit suite
6459/6459 at this exact head.
- Targeted e2e at this exact head: 8/8 across the two specs that own the
clipboard flows — `agent-access-warning.spec.ts` (compact variant,
agent-access owner hint) and `pubkey-display-screenshots.spec.ts` (full
variant, new-DM recipient verification: pointer copy, popover surviving
the copy, inner/outer Escape, Space-then-Enter).
- No Rust-side or build files change in this PR, so those results are
unaffected.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 10:32:26 -04:00

86 lines
3.3 KiB
TypeScript

import { decode, npubEncode } from "nostr-tools/nip19";
import { safeNpub } from "./nostrUtils";
/**
* Canonical pubkey normalisation.
*
* Hex pubkeys are case-insensitive, but callers compare them with `===`.
* Trimming guards against stray whitespace from user input or tag parsing.
*/
export function normalizePubkey(pubkey: string): string {
return pubkey.trim().toLowerCase();
}
/** Neutral identity label for keys that cannot be encoded for display. */
export const UNAVAILABLE_KEY_LABEL = "Unavailable";
const HEX_64_REGEX = /^[0-9a-f]{64}$/;
/**
* The ONE canonical compact display form for a hex string: `abcd1234…wxyz`.
*
* A truncated pubkey is a recognition aid, never an identity proof — vanity
* grinders forge short prefixes cheaply. Surfaces where the user makes a
* trust decision must show the full npub (see `<PubKey variant="full">`).
* Do not hand-roll `pubkey.slice(…)` display forms; `check-pubkey-truncation`
* fails the build if one sneaks in outside this module.
*
* Identity (pubkey) surfaces should use `truncateNpub` instead; this hex form
* remains canonical for non-identity identifiers — event and blob IDs.
*/
export function truncatePubkey(pubkey: string): string {
if (pubkey.length <= 12) {
return pubkey;
}
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
}
/**
* Canonical full npub for an identity key: a 64-char hex pubkey (any
* case) or an already-npub string (checksum-validated) returns the
* canonical npub; anything else returns null. Strict 64-char identity keys
* only — `npubEncode` happily encodes short/degenerate payloads (even `""`),
* which are not displayable identities.
*
* Bech32 casing is strict on the input as written: a lowercase `npub1…`
* or an all-uppercase `NPUB1…` (both valid Bech32) returns the canonical
* lowercase npub, while a mixed-case npub is invalid Bech32 and returns
* null — `decode` enforces the all-lower/all-upper rule. This is
* intentionally stricter than the parser (`parsePubkeyInput`), which
* normalizes user input before decoding and so also accepts mixed-case
* npubs; the two agree that the payload must be a 64-hex identity key and
* that both valid casings above are acceptable input.
*/
export function canonicalNpub(pubkey: string): string | null {
const trimmed = pubkey.trim();
if (trimmed.startsWith("npub1") || trimmed.startsWith("NPUB1")) {
try {
const decoded = decode(trimmed);
if (decoded.type !== "npub" || !HEX_64_REGEX.test(decoded.data)) {
return null;
}
return npubEncode(decoded.data);
} catch {
return null;
}
}
const normalized = normalizePubkey(trimmed);
return HEX_64_REGEX.test(normalized) ? safeNpub(normalized) : null;
}
/**
* The ONE canonical compact identity display for a pubkey: `npub1abcd…wxyz`
* (first 8 + last 4 of the FULL npub).
*
* Identity surfaces render this form so a displayed prefix is always npub-
* shaped; the underlying hex never leaks as the identity display. A
* truncated key is a recognition aid, never an identity proof — trust
* decisions use `<PubKey variant="full">` or the full npub directly. Invalid
* keys render `UNAVAILABLE_KEY_LABEL`, never raw hex or raw input.
*/
export function truncateNpub(pubkey: string): string {
const npub = canonicalNpub(pubkey);
return npub === null ? UNAVAILABLE_KEY_LABEL : truncatePubkey(npub);
}