Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 4 additions & 2 deletions desktop/src/shared/lib/nostrUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ 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. The npub payload must decode to exactly a 64-char hex identity
* key: `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 +43,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
7 changes: 7 additions & 0 deletions desktop/src/shared/lib/parsePubkeyInput.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,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
64 changes: 63 additions & 1 deletion desktop/src/shared/lib/pubkey.test.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";

import { normalizePubkey, truncatePubkey } from "./pubkey.ts";
import {
canonicalNpub,
normalizePubkey,
truncateNpub,
truncatePubkey,
UNAVAILABLE_KEY_LABEL,
} 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 +29,54 @@ 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");
});

test("truncateNpub accepts already-npub strings", () => {
assert.equal(truncateNpub(PUBKEY_NPUB), "npub1gju…9xj6");
assert.equal(truncateNpub(` ${HEX_NPUB} `), "npub1a2d…yp60");
});

test("truncateNpub accepts uppercase hex", () => {
assert.equal(truncateNpub(HEX.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_KEY_LABEL);
assert.equal(truncateNpub("not a pubkey"), UNAVAILABLE_KEY_LABEL);
assert.equal(truncateNpub(`${HEX.slice(0, 63)}`), UNAVAILABLE_KEY_LABEL);
assert.equal(truncateNpub(`z${HEX.slice(1)}`), UNAVAILABLE_KEY_LABEL);
// Corrupted npub checksum is not a valid identity either.
assert.equal(
truncateNpub(`${HEX_NPUB.slice(0, -1)}q`),
UNAVAILABLE_KEY_LABEL,
);
// Other bech32 entities are not pubkeys.
assert.equal(
truncateNpub(
"nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
),
UNAVAILABLE_KEY_LABEL,
);
});

test("UNAVAILABLE_KEY_LABEL stays the existing neutral vocabulary", () => {
assert.equal(UNAVAILABLE_KEY_LABEL, "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);
// 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);
// Corrupted checksum never binds as the identity it resembles.
assert.equal(canonicalNpub(`${HEX_NPUB.slice(0, -2)}qq`), null);
});
53 changes: 52 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,65 @@ 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-`npub1…` 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.
*/
export function canonicalNpub(pubkey: string): string | null {
const trimmed = pubkey.trim();
if (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);
}
186 changes: 186 additions & 0 deletions desktop/src/shared/ui/PubKey.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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, {
CustomEvent: dom.window.CustomEvent,
document: dom.window.document,
Element: dom.window.Element,
Event: dom.window.Event,
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
Node: dom.window.Node,
NodeFilter: dom.window.NodeFilter,
ResizeObserver: class {
disconnect() {}
observe() {}
unobserve() {}
},
window: dom.window,
});
// Copy remaining DOM-level globals that Radix Popover focus machinery
// references without a `window.` prefix (NodeFilter, HTMLInputElement,
// …). Bulk-copy follows the HarnessCatalogDialog.acpForcedGate test pattern
// so new Radix internals cannot reintroduce per-global whack-a-mole.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (
!(key in globalThis) &&
(key.startsWith("HTML") ||
key.startsWith("SVG") ||
key.startsWith("CSS") ||
[
"Node",
"NodeFilter",
"NodeList",
"NamedNodeMap",
"Event",
"CustomEvent",
"MouseEvent",
"KeyboardEvent",
"FocusEvent",
"InputEvent",
"PointerEvent",
"EventTarget",
"Text",
"DocumentFragment",
"Range",
"Selection",
].includes(key))
) {
const val = dom.window[key];
if (val !== undefined) globalThis[key] = val;
}
}
});

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";

test("compact PubKey renders the truncated npub, never the hex", async () => {
const React = await import("react");
const { render } = await import("@testing-library/react");
const { PubKey } = await import("./PubKey.tsx");

const view = render(React.createElement(PubKey, { pubkey: HEX }));

const trigger = view.getByRole("button", { name: "Show full public key" });
assert.equal(trigger.textContent, COMPACT_NPUB);
assert.equal(view.queryByText(HEX), null);
});

test("non-interactive compact PubKey renders the truncated npub as text", async () => {
const React = await import("react");
const { render } = await import("@testing-library/react");
const { PubKey } = await import("./PubKey.tsx");

const view = render(
React.createElement(PubKey, { interactive: false, pubkey: HEX }),
);

assert.equal(view.getByText(COMPACT_NPUB).tagName, "SPAN");
assert.equal(view.queryByRole("button"), null);
assert.equal(view.queryByText(HEX), null);
});

test("full PubKey renders the complete npub with an npub-only copy popover", async () => {
const React = await import("react");
const { fireEvent, render, within } = await import("@testing-library/react");
const { PubKey } = await import("./PubKey.tsx");

const view = render(
React.createElement(PubKey, { pubkey: HEX, variant: "full" }),
);

assert.equal(within(view.container).getByText(NPUB).textContent, NPUB);
assert.equal(view.queryByText(HEX), null);

fireEvent.click(
within(view.container).getByRole("button", { name: "Copy public key" }),
);
// The copy popover offers the full canonical npub only β€” no hex row.
assert.equal(view.getAllByText(NPUB).length, 2);
assert.equal(
view.getByRole("button", { name: "Copy npub" }).tagName,
"BUTTON",
);
assert.equal(view.queryByRole("button", { name: "Copy hex" }), null);
assert.equal(view.queryByText("hex"), null);
});

test("invalid keys render Unavailable with no copy affordance", async () => {
const React = await import("react");
const { render, within } = await import("@testing-library/react");
const { PubKey } = await import("./PubKey.tsx");

const compact = render(React.createElement(PubKey, { pubkey: "zz" }));
assert.equal(
within(compact.container).getByText("Unavailable").tagName,
"SPAN",
);
assert.equal(
within(compact.container).queryByRole("button", {
name: "Show full public key",
}),
null,
);

const full = render(
React.createElement(PubKey, { pubkey: "zz", variant: "full" }),
);
assert.equal(within(full.container).getByText("Unavailable").tagName, "SPAN");
// No copy action for an identity that cannot be encoded.
assert.equal(
within(full.container).queryByRole("button", { name: "Copy public key" }),
null,
);
});

test("degenerate-length hex keys render Unavailable, never a fake npub", async () => {
const React = await import("react");
const { render, within } = await import("@testing-library/react");
const { PubKey } = await import("./PubKey.tsx");

// `npubEncode` happily encodes an 8-char hex into a valid-checksum npub β€”
// that is not an identity key, so no surface may show or copy it.
const short = render(React.createElement(PubKey, { pubkey: "deadbeef" }));
assert.equal(
within(short.container).getByText("Unavailable").tagName,
"SPAN",
);
assert.equal(
within(short.container).queryByRole("button", {
name: "Show full public key",
}),
null,
);

const shortFull = render(
React.createElement(PubKey, { pubkey: "deadbeef", variant: "full" }),
);
assert.equal(
within(shortFull.container).getByText("Unavailable").tagName,
"SPAN",
);
assert.equal(
within(shortFull.container).queryByRole("button", {
name: "Copy public key",
}),
null,
);
assert.equal(shortFull.container.textContent?.includes("npub1"), false);
});
Loading
Loading