Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion desktop/src/features/messages/ui/NewMessageScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,22 @@ export function NewMessageScreen() {
<div
className="group/to-field flex min-h-9 min-w-0 flex-1 cursor-text flex-wrap items-center gap-1.5 py-1"
data-testid="new-message-to-field"
onClick={() => {
onClick={(event) => {
// Portaled popovers (recipient inspection and its nested key
// copy) still bubble through React's tree to this handler,
// but their event targets are not DOM descendants of the
// field. Those clicks belong to the popover's own controls
// β€” they must not steal focus into the search input (which
// dismisses the popover via focus-outside) or reopen the
// picker. Only clicks physically within the recipient field
// focus its input.
const { currentTarget, target } = event;
if (
!(target instanceof Node) ||
!currentTarget.contains(target)
) {
return;
}
setIsRecipientPickerOpen(true);
searchInputRef.current?.focus({ preventScroll: true });
}}
Expand Down
10 changes: 8 additions & 2 deletions desktop/src/shared/lib/nostrUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/;
* anything else (does NOT throw β€” intended for live form validation).
*
* The input is trimmed first; surrounding whitespace from copy-paste is
* tolerated.
* tolerated. It is also case-normalized before matching and decoding β€”
* preexisting behavior β€” so a hex key in any casing resolves, and a
* mixed-case npub (invalid Bech32 as written) is accepted via its
* lowercased form. The identity payload itself stays strict: it must
* decode to exactly a 64-char hex identity key, because `npubEncode` also
* encodes degenerate short payloads (even `""`), which are never valid
* identities.
*/
export function parsePubkeyInput(input: string): string | null {
const trimmed = input.trim().toLowerCase();
Expand All @@ -41,7 +47,7 @@ export function parsePubkeyInput(input: string): string | null {
if (trimmed.startsWith("npub1")) {
try {
const decoded = decode(trimmed);
if (decoded.type === "npub") {
if (decoded.type === "npub" && HEX_PUBKEY_REGEX.test(decoded.data)) {
return decoded.data;
}
} catch {
Expand Down
17 changes: 17 additions & 0 deletions desktop/src/shared/lib/parsePubkeyInput.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ describe("parsePubkeyInput", () => {
assert.equal(parsePubkeyInput(NPUB), HEX);
});

it("normalizes a mixed-case npub to its canonical hex", () => {
// Preexisting behavior: user input is lowercased before decoding, so a
// mixed-case npub β€” invalid Bech32 as written β€” still resolves to the
// identity. canonicalNpub is the strict counterpart (see ../lib/pubkey.ts).
assert.equal(
parsePubkeyInput(`${NPUB.slice(0, 10)}${NPUB.slice(10).toUpperCase()}`),
HEX,
);
});

it("tolerates surrounding whitespace from copy-paste", () => {
assert.equal(parsePubkeyInput(` ${NPUB}\n`), HEX);
assert.equal(parsePubkeyInput(` ${HEX} `), HEX);
Expand All @@ -42,6 +52,13 @@ describe("parsePubkeyInput", () => {
assert.equal(parsePubkeyInput(`${HEX}0`), null);
});

it("rejects degenerate npubs whose payload is not a 64-char identity", () => {
// `npubEncode` happily encodes short payloads with valid checksums β€”
// those are not identity keys and must never bind as one.
assert.equal(parsePubkeyInput("npub1m6kmamcvty5gd"), null);
assert.equal(parsePubkeyInput("npub106246s"), null);
});

it("rejects non-hex non-npub input", () => {
assert.equal(parsePubkeyInput(""), null);
assert.equal(parsePubkeyInput("alice"), null);
Expand Down
69 changes: 68 additions & 1 deletion desktop/src/shared/lib/pubkey.test.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";

import { normalizePubkey, truncatePubkey } from "./pubkey.ts";
import {
canonicalNpub,
normalizePubkey,
truncateNpub,
truncatePubkey,
} from "./pubkey.ts";

const PUBKEY =
"44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435";
const PUBKEY_NPUB =
"npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6";
const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328";
const HEX_NPUB =
"npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60";

test("truncates to the canonical 8+4 form with unicode ellipsis", () => {
assert.equal(truncatePubkey(PUBKEY), "44b8e82b…0435");
Expand All @@ -18,3 +28,60 @@ test("returns short strings unchanged", () => {
test("normalizePubkey trims and lowercases", () => {
assert.equal(normalizePubkey(" ABCDEF "), "abcdef");
});

test("truncateNpub compacts the hex pubkey's npub, not its hex form", () => {
assert.equal(truncateNpub(PUBKEY), "npub1gju…9xj6");
assert.equal(truncateNpub(HEX), "npub1a2d…yp60");
assert.equal(truncateNpub(HEX.toUpperCase()), "npub1a2d…yp60");
});

test("truncateNpub accepts already-npub strings", () => {
assert.equal(truncateNpub(PUBKEY_NPUB), "npub1gju…9xj6");
assert.equal(truncateNpub(` ${HEX_NPUB} `), "npub1a2d…yp60");
// All-uppercase Bech32 is a valid identity per the parser; render the
// canonical form, never the neutral label.
assert.equal(truncateNpub(HEX_NPUB.toUpperCase()), "npub1a2d…yp60");
});

test("truncateNpub renders the neutral label for invalid identities", () => {
// Never the raw hex/input fallback: a wrong-length or non-hex string is not
// a displayable identity.
assert.equal(truncateNpub(""), "Unavailable");
assert.equal(truncateNpub("not a pubkey"), "Unavailable");
assert.equal(truncateNpub(`${HEX.slice(0, 63)}`), "Unavailable");
assert.equal(truncateNpub(`z${HEX.slice(1)}`), "Unavailable");
// Corrupted npub checksum is not a valid identity either.
assert.equal(truncateNpub(`${HEX_NPUB.slice(0, -1)}q`), "Unavailable");
// Other bech32 entities are not pubkeys.
assert.equal(
truncateNpub(
"nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
),
"Unavailable",
);
});

test("canonicalNpub returns the full npub for valid identities only", () => {
assert.equal(canonicalNpub(HEX), HEX_NPUB);
assert.equal(canonicalNpub(HEX.toUpperCase()), HEX_NPUB);
assert.equal(canonicalNpub(HEX_NPUB), HEX_NPUB);
// All-uppercase Bech32 is valid and returns the canonical lowercase npub
// (parser agreement); a mixed-case npub is invalid Bech32.
assert.equal(canonicalNpub(HEX_NPUB.toUpperCase()), HEX_NPUB);
assert.equal(
canonicalNpub(
`${HEX_NPUB.slice(0, 10)}${HEX_NPUB.slice(10).toUpperCase()}`,
),
null,
);
// Strict identity keys only β€” short/degenerate payloads never encode.
assert.equal(canonicalNpub(""), null);
assert.equal(canonicalNpub("deadbeef"), null);
assert.equal(canonicalNpub(`${HEX.slice(0, 63)}`), null);
// Checksum-valid short npubs are degenerate payloads too (8-char and
// empty) β€” `npubEncode` would happily re-encode them, so never bind them.
assert.equal(canonicalNpub("npub1m6kmamcvty5gd"), null);
assert.equal(canonicalNpub("npub106246s"), null);
// Corrupted checksum never binds as the identity it resembles.
assert.equal(canonicalNpub(`${HEX_NPUB.slice(0, -2)}qq`), null);
});
62 changes: 61 additions & 1 deletion desktop/src/shared/lib/pubkey.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { decode, npubEncode } from "nostr-tools/nip19";

import { safeNpub } from "./nostrUtils";

/**
* Canonical pubkey normalisation.
*
Expand All @@ -8,18 +12,74 @@ 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 pubkey: `abcd1234…wxyz`.
* 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);
}
118 changes: 118 additions & 0 deletions desktop/src/shared/ui/PubKey.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Widget-boundary coverage for the shared <PubKey> identity gate.
*
* Codec vectors and the exact compact/neutral strings live in
* ../lib/pubkey.test.mjs. This suite pins what static rendering shows: the
* rendered text per variant, and that an unencodable identity β€” including
* degenerate-length hex and short-payload npubs whose npubEncode outputs
* carry valid checksums β€” renders the neutral label with no copy affordance,
* never a fake npub. The clipboard write behind the copy affordance and the
* popover the widget opens are real-bridge interactions owned by the E2E
* regressions: the full variant's copy is pinned by the new-DM recipient
* verification flow (tests/e2e/pubkey-display-screenshots.spec.ts) and the
* compact variant's by the agent-access owner hint
* (tests/e2e/agent-access-warning.spec.ts); both drive CopyRow through the
* mock bridge into the actual browser clipboard.
*/
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";

import { JSDOM } from "jsdom";

const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});

before(() => {
Object.assign(globalThis, {
document: dom.window.document,
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
Node: dom.window.Node,
ResizeObserver: class {
disconnect() {}
observe() {}
unobserve() {}
},
window: dom.window,
});
});

afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
});

after(() => dom.window.close());

const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328";
const NPUB = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60";
const COMPACT_NPUB = "npub1a2d…yp60";

async function renderPubKey(props) {
const React = await import("react");
const { render, within } = await import("@testing-library/react");
const { PubKey } = await import("./PubKey.tsx");
const view = render(React.createElement(PubKey, props));
// render()'s bound queries search the whole body; scope to this render so
// earlier mounts (cleaned up only per test) stay invisible.
return { ...within(view.container), container: view.container };
}

test("compact PubKey renders the truncated npub, never the hex", async () => {
const trigger = await renderPubKey({ pubkey: HEX });
assert.equal(
trigger.getByRole("button", { name: "Show full public key" }).textContent,
COMPACT_NPUB,
);
assert.equal(trigger.queryByText(HEX), null);

// A parent row that owns the interaction gets the same text, not a button.
const text = await renderPubKey({ interactive: false, pubkey: HEX });
assert.equal(text.getByText(COMPACT_NPUB).tagName, "SPAN");
assert.equal(text.queryByRole("button"), null);
assert.equal(text.queryByText(HEX), null);

// An all-uppercase Bech32 npub is a valid identity (parsePubkeyInput
// accepts it); the gate must render its canonical compact form, not the
// neutral label.
const upper = await renderPubKey({ pubkey: NPUB.toUpperCase() });
assert.equal(
upper.getByRole("button", { name: "Show full public key" }).textContent,
COMPACT_NPUB,
);
assert.equal(upper.queryByText("Unavailable"), null);
});

test("full PubKey renders the complete npub with a copy affordance", async () => {
const view = await renderPubKey({ pubkey: HEX, variant: "full" });
assert.equal(view.getByText(NPUB).textContent, NPUB);
assert.equal(
view.getByRole("button", { name: "Copy public key" }).tagName,
"BUTTON",
);
assert.equal(view.queryByText(HEX), null);
});

test("unencodable keys render Unavailable with no copy affordance", async () => {
// "zz" cannot decode; "deadbeef" is a degenerate-length hex that npubEncode
// would happily turn into a checksum-valid fake npub; npub1m6kmamcvty5gd
// and npub106246s decode fine but are checksum-valid short-payload npubs
// (8-char and empty identity payloads). All four would masquerade as
// displayable identities β€” the gate refuses every one.
for (const pubkey of [
"zz",
"deadbeef",
"npub1m6kmamcvty5gd",
"npub106246s",
]) {
for (const variant of [undefined, "full"]) {
const view = await renderPubKey({ pubkey, variant });
const label = `${pubkey} ${variant ?? "compact"}`;
assert.equal(view.getByText("Unavailable").tagName, "SPAN", label);
assert.equal(view.queryByRole("button"), null, label);
assert.equal(view.container.textContent?.includes("npub1"), false, label);
}
}
});
Loading
Loading