mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Audit response: fix BL-16 through BL-20 and the low findings, same day
BL-16 (high): a remote signer's auth_url is attacker-controlled input that became a clickable href. sanitizeAuthUrl in core now admits only https (or loopback http) -- javascript:/data:/blob:/custom schemes are dropped without consuming the surfacing slot -- and the widget link shows the hostname. Hostile-scheme tests at both layers. BL-17: the post-creation PRF fallback assertion is pinned via allowCredentials to the credential that was just created, so a multi- passkey account sheet can no longer silently bind an older passkey's account. BL-18: queryOnce's timeout path sent CLOSE before settling, unguarded -- a relay dying mid-query stranded the promise and everything queued behind the worker's serial queue. The send is guarded; settling is unconditional. MockRelay.close() now terminates live sockets (it previously waited on them forever), and a suppressEose switch models the silent relay. BL-19: relays are no longer trusted to honor limits. Buffered queries enforce filter.limit locally, drop replayed duplicate ids, and cap at 1000 events; a 20-copy replay now yields one event. BL-20: the .semgrep.yml try/catch fallback pattern never parsed, so the entropy rules silently did not run. Rewritten in parseable form and verified under the pinned Semgrep 1.171.0: fires on hostile code, exits 0 on the clean tree. Lows: NIP-07 and NIP-46 verify returned signed events (signature, pinned session identity, field equality with the request); Nip46Client.close() wipes the client and conversation keys (§11.10); passkey labels carry a short suffix so multiple accounts are distinguishable; postcss bumped to 8.5.25 (GHSA-fxqj-rqcc-2cmp); GitHub Actions pinned to commit SHAs. Deferred as interop (recorded in TODO): NIP-46 protocol logout and switch_relays. 12 new adversarial tests; 256 total. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
36cd3eb853
commit
f0b5f40fd4
15 files changed
+409
-40
No files matched your search
@@ -14,10 +14,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
mkdir -p security-artifacts
|
mkdir -p security-artifacts
|
||||||
npm sbom --sbom-format cyclonedx > security-artifacts/sbom.cdx.json
|
npm sbom --sbom-format cyclonedx > security-artifacts/sbom.cdx.json
|
||||||
find packages/widget/dist packages/demo/dist -type f -print0 | sort -z | xargs -0 sha256sum > security-artifacts/SHA256SUMS
|
find packages/widget/dist packages/demo/dist -type f -print0 | sort -z | xargs -0 sha256sum > security-artifacts/SHA256SUMS
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
with:
|
with:
|
||||||
name: bitlogin-security-artifacts
|
name: bitlogin-security-artifacts
|
||||||
path: security-artifacts/
|
path: security-artifacts/
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -51,6 +51,6 @@ jobs:
|
|||||||
- name: Enforce local randomness rules (entropy audit §13)
|
- name: Enforce local randomness rules (entropy audit §13)
|
||||||
run: semgrep scan --config .semgrep.yml --error --timeout 0 packages
|
run: semgrep scan --config .semgrep.yml --error --timeout 0 packages
|
||||||
- name: Scan repository history for secrets
|
- name: Scan repository history for secrets
|
||||||
uses: gitleaks/gitleaks-action@v2
|
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+8
-1
@@ -36,7 +36,14 @@ rules:
|
|||||||
- pattern: $C.getRandomValues(...) ?? $FALLBACK
|
- pattern: $C.getRandomValues(...) ?? $FALLBACK
|
||||||
- pattern: $C.getRandomValues(...) || $FALLBACK
|
- pattern: $C.getRandomValues(...) || $FALLBACK
|
||||||
- pattern: |
|
- pattern: |
|
||||||
try { ... $C.getRandomValues(...) ... } catch(...) { ... return $FALLBACK; ... }
|
try {
|
||||||
|
...
|
||||||
|
$C.getRandomValues(...)
|
||||||
|
...
|
||||||
|
} catch ($E) {
|
||||||
|
...
|
||||||
|
return $FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
- id: bitlogin-no-time-or-id-seeded-secrets
|
- id: bitlogin-no-time-or-id-seeded-secrets
|
||||||
languages: [typescript, javascript]
|
languages: [typescript, javascript]
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ site with its own vendored copy (see "Updating your integration" in
|
|||||||
sitting on an older vendored copy is missing a fix or behavior change it
|
sitting on an older vendored copy is missing a fix or behavior change it
|
||||||
would plausibly want. Newest first.
|
would plausibly want. Newest first.
|
||||||
|
|
||||||
|
## 2026-08-04 (security-audit response, same day)
|
||||||
|
|
||||||
|
- **External signers are now verified, not trusted.** NIP-07 and NIP-46
|
||||||
|
`signEvent` results are checked for a valid signature, the session's
|
||||||
|
identity, and field-for-field equality with the requested event; NIP-46
|
||||||
|
`auth_url` is surfaced only as a validated https URL with its hostname
|
||||||
|
shown. Relay queries enforce `filter.limit` locally, drop replayed
|
||||||
|
duplicates, cap buffering, and can no longer be stranded by a relay dying
|
||||||
|
mid-query. Passkey PRF fallback assertions are pinned to the
|
||||||
|
just-created credential. If your integration relied on an extension
|
||||||
|
returning altered events, that now errors — by design.
|
||||||
|
|
||||||
## 2026-08-04
|
## 2026-08-04
|
||||||
|
|
||||||
- **CSP fix every self-hosting site needs: add `'wasm-unsafe-eval'` to
|
- **CSP fix every self-hosting site needs: add `'wasm-unsafe-eval'` to
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ broken since 2026-07-31), the service-worker `cache: "reload"` precache
|
|||||||
fix, `window.bitlogin.activeMethod()/activeSession()`, and welcome/sign-in
|
fix, `window.bitlogin.activeMethod()/activeSession()`, and welcome/sign-in
|
||||||
UI polish.
|
UI polish.
|
||||||
|
|
||||||
|
## Security audit response (2026-08-04, same day)
|
||||||
|
|
||||||
|
All confirmed findings fixed and regression-tested: BL-16 (auth_url now
|
||||||
|
https-only via `sanitizeAuthUrl`, hostname shown on the link, hostile-scheme
|
||||||
|
tests), BL-17 (post-creation PRF fallback pinned via `allowCredentials` to
|
||||||
|
the created credential), BL-18 (dead-socket CLOSE can no longer strand a
|
||||||
|
query promise), BL-19 (local `filter.limit` enforcement + duplicate-id drop
|
||||||
|
+ 1000-event ceiling per buffered query), BL-20 (.semgrep.yml try/catch
|
||||||
|
pattern was unparseable — fixed and verified firing on hostile code under
|
||||||
|
the pinned Semgrep 1.171.0). Lows: NIP-07 and NIP-46 now verify returned
|
||||||
|
signed events against the request (signature, identity, field equality);
|
||||||
|
`Nip46Client.close()` wipes the client and conversation keys; passkey
|
||||||
|
labels are distinguishable; postcss ≥8.5.25; Actions pinned to commit SHAs.
|
||||||
|
|
||||||
|
Deferred from the audit, non-security interop items: NIP-46 protocol-level
|
||||||
|
logout on teardown (no stable method name across deployed signers) and the
|
||||||
|
recommended `switch_relays` request.
|
||||||
|
|
||||||
Owner action items:
|
Owner action items:
|
||||||
|
|
||||||
1. **Live-fire the NIP-46 QR with Amber** on bitlogin.network (flow was
|
1. **Live-fire the NIP-46 QR with Amber** on bitlogin.network (flow was
|
||||||
|
|||||||
Generated
+3
-3
@@ -1542,9 +1542,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.20",
|
"version": "8.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||||
"integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==",
|
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
Nip46Client,
|
Nip46Client,
|
||||||
Nip46ErrorResponse,
|
Nip46ErrorResponse,
|
||||||
Nip46RequestTimeoutError,
|
Nip46RequestTimeoutError,
|
||||||
parseBunkerUri
|
parseBunkerUri,
|
||||||
|
sanitizeAuthUrl
|
||||||
} from "./nip46.js";
|
} from "./nip46.js";
|
||||||
import { getConversationKey, nip44Decrypt, nip44Encrypt } from "../crypto/nip44.js";
|
import { getConversationKey, nip44Decrypt, nip44Encrypt } from "../crypto/nip44.js";
|
||||||
import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js";
|
import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js";
|
||||||
@@ -60,6 +61,32 @@ describe("buildNostrconnectUri", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sanitizeAuthUrl", () => {
|
||||||
|
it("passes https and loopback-http, normalized", () => {
|
||||||
|
expect(sanitizeAuthUrl("https://approve.example/req?id=1")).toBe("https://approve.example/req?id=1");
|
||||||
|
expect(sanitizeAuthUrl(" https://approve.example ")).toBe("https://approve.example/");
|
||||||
|
expect(sanitizeAuthUrl("http://localhost:8080/ok")).toBe("http://localhost:8080/ok");
|
||||||
|
expect(sanitizeAuthUrl("http://127.0.0.1/ok")).toBe("http://127.0.0.1/ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects every active or non-https scheme and garbage", () => {
|
||||||
|
// eslint-disable-next-line no-script-url -- hostile input under test
|
||||||
|
for (const hostile of [
|
||||||
|
"javascript:alert(document.domain)",
|
||||||
|
"data:text/html,<script>alert(1)</script>",
|
||||||
|
"blob:https://x.example/abc",
|
||||||
|
"vbscript:msgbox(1)",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"http://evil.example/not-loopback",
|
||||||
|
"nostr:nevent1...",
|
||||||
|
"//protocol-relative.example",
|
||||||
|
"not a url"
|
||||||
|
]) {
|
||||||
|
expect(sanitizeAuthUrl(hostile)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A minimal in-test remote signer: listens for kind-24133 requests addressed
|
* A minimal in-test remote signer: listens for kind-24133 requests addressed
|
||||||
* to its pubkey, decrypts them per client, and answers per `handle`.
|
* to its pubkey, decrypts them per client, and answers per `handle`.
|
||||||
@@ -233,6 +260,62 @@ describe("Nip46Client against a fake bunker", () => {
|
|||||||
expect(authUrls).toEqual(["https://approve.example/req"]);
|
expect(authUrls).toEqual(["https://approve.example/req"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops a hostile-scheme auth_url without consuming the surfacing slot", async () => {
|
||||||
|
bunker = new FakeBunker(relay.url);
|
||||||
|
const inner = bunker.handle.bind(bunker);
|
||||||
|
bunker.handle = (request) => {
|
||||||
|
if (request.method === "get_public_key") {
|
||||||
|
return [
|
||||||
|
// eslint-disable-next-line no-script-url -- hostile input under test
|
||||||
|
{ id: request.id, result: "auth_url", error: "javascript:alert(document.domain)" },
|
||||||
|
{ id: request.id, result: "auth_url", error: "https://approve.example/real" },
|
||||||
|
...inner(request)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return inner(request);
|
||||||
|
};
|
||||||
|
await bunker.start();
|
||||||
|
|
||||||
|
const authUrls: string[] = [];
|
||||||
|
client = new Nip46Client({
|
||||||
|
clientSecretKey: generatePrivateKey(),
|
||||||
|
pointer: { signerPubkey: bunker.signerPubkey, relayUrls: [relay.url] },
|
||||||
|
onAuthUrl: (url) => authUrls.push(url),
|
||||||
|
requestTimeoutMs: 5000
|
||||||
|
});
|
||||||
|
expect(await client.getUserPublicKey()).toBe(bunker.userPubkey);
|
||||||
|
// The javascript: URL never surfaced; the later https one did.
|
||||||
|
expect(authUrls).toEqual(["https://approve.example/real"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a signed event whose fields differ from the request", async () => {
|
||||||
|
bunker = new FakeBunker(relay.url);
|
||||||
|
const inner = bunker.handle.bind(bunker);
|
||||||
|
bunker.handle = (request) => {
|
||||||
|
if (request.method === "sign_event") {
|
||||||
|
const unsigned = JSON.parse(request.params[0]!) as {
|
||||||
|
kind: number;
|
||||||
|
content: string;
|
||||||
|
tags: string[][];
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
const signed = signNostrEvent(
|
||||||
|
{ ...unsigned, pubkey: bunker.userPubkey, content: "tampered" },
|
||||||
|
bunker.userKey
|
||||||
|
);
|
||||||
|
return [{ id: request.id, result: JSON.stringify(signed) }];
|
||||||
|
}
|
||||||
|
return inner(request);
|
||||||
|
};
|
||||||
|
await bunker.start();
|
||||||
|
const c = makeClient();
|
||||||
|
await c.connect();
|
||||||
|
await c.getUserPublicKey();
|
||||||
|
await expect(
|
||||||
|
c.signEvent({ kind: 1, content: "original", tags: [], created_at: 1_700_000_000 })
|
||||||
|
).rejects.toThrow(/different event/);
|
||||||
|
});
|
||||||
|
|
||||||
it("times out with a typed error when the signer never answers", async () => {
|
it("times out with a typed error when the signer never answers", async () => {
|
||||||
bunker = new FakeBunker(relay.url);
|
bunker = new FakeBunker(relay.url);
|
||||||
bunker.handle = () => []; // listens, never replies
|
bunker.handle = () => []; // listens, never replies
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { getConversationKey, nip44Encrypt, nip44Decrypt } from "../crypto/nip44.
|
|||||||
import { getPublicKeyHex } from "../crypto/secp256k1.js";
|
import { getPublicKeyHex } from "../crypto/secp256k1.js";
|
||||||
import { randomBytes } from "../crypto/random.js";
|
import { randomBytes } from "../crypto/random.js";
|
||||||
import { bytesToHex } from "../crypto/encoding.js";
|
import { bytesToHex } from "../crypto/encoding.js";
|
||||||
|
import { wipe } from "../crypto/memory.js";
|
||||||
|
|
||||||
const HEX64 = /^[0-9a-f]{64}$/u;
|
const HEX64 = /^[0-9a-f]{64}$/u;
|
||||||
|
|
||||||
@@ -56,6 +57,26 @@ export class Nip46ErrorResponse extends Error {
|
|||||||
override name = "Nip46ErrorResponse";
|
override name = "Nip46ErrorResponse";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a signer-supplied auth_url before it may be shown as a link.
|
||||||
|
* Only https (or http to loopback, for local development signers) survives;
|
||||||
|
* anything else -- javascript:, data:, blob:, custom schemes, unparseable
|
||||||
|
* strings -- returns null and is never surfaced.
|
||||||
|
*/
|
||||||
|
export function sanitizeAuthUrl(candidate: string): string | null {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(candidate.trim());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||||
|
if (url.protocol === "https:" || (url.protocol === "http:" && isLoopback)) {
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseBunkerUri(uri: string): BunkerPointer {
|
export function parseBunkerUri(uri: string): BunkerPointer {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
@@ -197,10 +218,18 @@ export class Nip46Client {
|
|||||||
if (!request) return; // answered already by a faster relay, or stale
|
if (!request) return; // answered already by a faster relay, or stale
|
||||||
if (payload.result === "auth_url") {
|
if (payload.result === "auth_url") {
|
||||||
// Interim, not an answer: the signer wants interactive approval at the
|
// Interim, not an answer: the signer wants interactive approval at the
|
||||||
// URL (carried in `error`, per NIP-46). Surface once, keep waiting.
|
// URL (carried in `error`, per NIP-46). The URL is REMOTE-SIGNER
|
||||||
if (!request.authUrlSeen) {
|
// CONTROLLED input that UIs render as a clickable link, so only a
|
||||||
request.authUrlSeen = true;
|
// validated https URL is ever surfaced (sanitizeAuthUrl) -- a hostile
|
||||||
if (typeof payload.error === "string" && payload.error) this.onAuthUrl?.(payload.error);
|
// signer must not be able to hand the page a javascript:/data: href.
|
||||||
|
// An invalid URL is dropped without consuming the one surfacing slot,
|
||||||
|
// so a subsequent well-formed auth_url can still arrive.
|
||||||
|
if (!request.authUrlSeen && typeof payload.error === "string" && payload.error) {
|
||||||
|
const authUrl = sanitizeAuthUrl(payload.error);
|
||||||
|
if (authUrl) {
|
||||||
|
request.authUrlSeen = true;
|
||||||
|
this.onAuthUrl?.(authUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -289,6 +318,17 @@ export class Nip46Client {
|
|||||||
if (this.userPubkeyKnown && event.pubkey !== this.userPubkeyKnown) {
|
if (this.userPubkeyKnown && event.pubkey !== this.userPubkeyKnown) {
|
||||||
throw new Error("The signer returned an event signed by a different identity.");
|
throw new Error("The signer returned an event signed by a different identity.");
|
||||||
}
|
}
|
||||||
|
// Signature and identity are necessary but not sufficient: the signer
|
||||||
|
// could sign a DIFFERENT event than the one requested. Enforce field
|
||||||
|
// equality so callers get exactly what they asked to have signed.
|
||||||
|
if (
|
||||||
|
event.kind !== unsigned.kind ||
|
||||||
|
event.content !== unsigned.content ||
|
||||||
|
event.created_at !== unsigned.created_at ||
|
||||||
|
JSON.stringify(event.tags) !== JSON.stringify(unsigned.tags)
|
||||||
|
) {
|
||||||
|
throw new Error("The signer returned a different event than the one requested.");
|
||||||
|
}
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +358,10 @@ export class Nip46Client {
|
|||||||
request.reject(new Error("The signer connection was closed."));
|
request.reject(new Error("The signer connection was closed."));
|
||||||
}
|
}
|
||||||
this.pending.clear();
|
this.pending.clear();
|
||||||
|
// Shortest-practical-lifetime handling (§11.10): the ephemeral client
|
||||||
|
// key and the conversation key derived from it die with the session.
|
||||||
|
wipe(this.clientSecretKey);
|
||||||
|
wipe(this.conversationKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ export interface RelayConnectionOptions {
|
|||||||
connectTimeoutMs?: number;
|
connectTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hard ceiling on events buffered per query, regardless of the requested
|
||||||
|
* limit -- the local backstop against an adversarial relay spraying events
|
||||||
|
* before EOSE. Far above any legitimate BitLogin query (capsules and lists
|
||||||
|
* are single-digit result sets). */
|
||||||
|
const MAX_BUFFERED_EVENTS = 1000;
|
||||||
|
|
||||||
export class RelayConnection {
|
export class RelayConnection {
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
private ws: WebSocketLike | null = null;
|
private ws: WebSocketLike | null = null;
|
||||||
@@ -58,6 +64,8 @@ export class RelayConnection {
|
|||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
events: NostrEvent[];
|
events: NostrEvent[];
|
||||||
|
/** Ids already buffered, for replay dedup (buffered queries only). */
|
||||||
|
seenIds: Set<string>;
|
||||||
filter: NostrFilter;
|
filter: NostrFilter;
|
||||||
onEose: () => void;
|
onEose: () => void;
|
||||||
/** Present on live subscriptions (subscribeLive): each verified,
|
/** Present on live subscriptions (subscribeLive): each verified,
|
||||||
@@ -151,8 +159,18 @@ export class RelayConnection {
|
|||||||
verifyNostrEvent(event) &&
|
verifyNostrEvent(event) &&
|
||||||
matchesNostrFilter(event, sub.filter)
|
matchesNostrFilter(event, sub.filter)
|
||||||
) {
|
) {
|
||||||
if (sub.onEvent) sub.onEvent(event);
|
if (sub.onEvent) {
|
||||||
else sub.events.push(event);
|
sub.onEvent(event);
|
||||||
|
} else {
|
||||||
|
// Relays are untrusted: enforce the requested limit and a global
|
||||||
|
// ceiling LOCALLY, and drop replayed duplicates, so an adversarial
|
||||||
|
// relay cannot amplify memory/CPU by spraying copies before EOSE.
|
||||||
|
if (sub.seenIds.has(event.id)) return;
|
||||||
|
const cap = Math.min(sub.filter.limit ?? MAX_BUFFERED_EVENTS, MAX_BUFFERED_EVENTS);
|
||||||
|
if (sub.events.length >= cap) return;
|
||||||
|
sub.seenIds.add(event.id);
|
||||||
|
sub.events.push(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,7 +254,7 @@ export class RelayConnection {
|
|||||||
): Promise<{ close: () => void }> {
|
): Promise<{ close: () => void }> {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
const subId = bytesToHex(randomBytes(8));
|
const subId = bytesToHex(randomBytes(8));
|
||||||
this.subs.set(subId, { events: [], filter, onEose: () => {}, onEvent });
|
this.subs.set(subId, { events: [], seenIds: new Set(), filter, onEose: () => {}, onEvent });
|
||||||
this.send(["REQ", subId, filter]);
|
this.send(["REQ", subId, filter]);
|
||||||
return {
|
return {
|
||||||
close: () => {
|
close: () => {
|
||||||
@@ -266,7 +284,14 @@ export class RelayConnection {
|
|||||||
const settle = (fail?: Error) => {
|
const settle = (fail?: Error) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
this.subs.delete(subId);
|
this.subs.delete(subId);
|
||||||
this.send(["CLOSE", subId]);
|
try {
|
||||||
|
this.send(["CLOSE", subId]);
|
||||||
|
} catch {
|
||||||
|
// The socket died mid-query. The relay-side subscription died with
|
||||||
|
// it; what matters is settling the promise below REGARDLESS -- an
|
||||||
|
// unreliable relay must not strand a query (and with it everything
|
||||||
|
// queued behind the worker's serial queue) until reload.
|
||||||
|
}
|
||||||
if (fail) reject(fail);
|
if (fail) reject(fail);
|
||||||
else resolve(events);
|
else resolve(events);
|
||||||
};
|
};
|
||||||
@@ -286,7 +311,7 @@ export class RelayConnection {
|
|||||||
),
|
),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
this.subs.set(subId, { events, filter, onEose: () => settle() });
|
this.subs.set(subId, { events, seenIds: new Set(), filter, onEose: () => settle() });
|
||||||
this.send(["REQ", subId, filter]);
|
this.send(["REQ", subId, filter]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/** Adversarial-relay hardening (audit BL-18, BL-19): a malicious or flaky
|
||||||
|
* relay must neither strand a query promise nor amplify memory before EOSE. */
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { MockRelay } from "../test-support/mockRelay.js";
|
||||||
|
import { RelayConnection } from "./relay.js";
|
||||||
|
import { signNostrEvent } from "./event.js";
|
||||||
|
import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js";
|
||||||
|
|
||||||
|
describe("relay hardening", () => {
|
||||||
|
let relay: MockRelay;
|
||||||
|
let conn: RelayConnection;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
relay = await MockRelay.start();
|
||||||
|
conn = new RelayConnection(relay.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
conn.close();
|
||||||
|
await relay.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function signedNote(key: Uint8Array, content: string, created_at = 1_700_000_000) {
|
||||||
|
return signNostrEvent(
|
||||||
|
{ pubkey: getPublicKeyHex(key), created_at, kind: 1, tags: [], content },
|
||||||
|
key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("BL-18: a relay that dies mid-query still settles the promise", async () => {
|
||||||
|
await conn.connect();
|
||||||
|
// Subscription opens against a relay that never answers (no EOSE), and
|
||||||
|
// the relay then goes away entirely before the client's timeout.
|
||||||
|
relay.suppressEose = true;
|
||||||
|
const key = generatePrivateKey();
|
||||||
|
const pending = conn.queryOnce({ kinds: [1], authors: [getPublicKeyHex(key)] }, 400);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50)); // let REQ hit the wire
|
||||||
|
await relay.close();
|
||||||
|
// Before the fix, the timeout path threw sending CLOSE on the dead
|
||||||
|
// socket and the promise never settled -- wedging everything behind the
|
||||||
|
// worker's serial queue. Now it must reject, promptly.
|
||||||
|
await expect(pending).rejects.toThrow(/did not answer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BL-19: replayed duplicates are dropped and filter.limit is enforced locally", async () => {
|
||||||
|
const key = generatePrivateKey();
|
||||||
|
const note = signedNote(key, "hello");
|
||||||
|
// A hostile relay spraying 20 copies of the same valid, filter-matching
|
||||||
|
// event before EOSE.
|
||||||
|
relay.unsolicitedQueryEvents = Array.from({ length: 20 }, () => note);
|
||||||
|
const limited = await conn.queryOnce(
|
||||||
|
{ kinds: [1], authors: [getPublicKeyHex(key)], limit: 1 },
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
expect(limited).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BL-19: duplicates collapse even without an explicit limit", async () => {
|
||||||
|
const key = generatePrivateKey();
|
||||||
|
const noteA = signedNote(key, "a");
|
||||||
|
const noteB = signedNote(key, "b", 1_700_000_001);
|
||||||
|
relay.unsolicitedQueryEvents = [noteA, noteA, noteB, noteA, noteB];
|
||||||
|
const events = await conn.queryOnce({ kinds: [1], authors: [getPublicKeyHex(key)] }, 3000);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(new Set(events.map((e) => e.id)).size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BL-19: distinct events beyond the requested limit are not buffered", async () => {
|
||||||
|
const key = generatePrivateKey();
|
||||||
|
relay.unsolicitedQueryEvents = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
signedNote(key, `n${i}`, 1_700_000_000 + i)
|
||||||
|
);
|
||||||
|
const events = await conn.queryOnce(
|
||||||
|
{ kinds: [1], authors: [getPublicKeyHex(key)], limit: 3 },
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,10 @@ export class MockRelay {
|
|||||||
/** Frames injected before normal query results, even when they do not match
|
/** Frames injected before normal query results, even when they do not match
|
||||||
* the requested filter. Models a malicious or buggy relay. */
|
* the requested filter. Models a malicious or buggy relay. */
|
||||||
public unsolicitedQueryEvents: unknown[] = [];
|
public unsolicitedQueryEvents: unknown[] = [];
|
||||||
|
/** When true, REQ subscriptions never receive EOSE -- models a relay that
|
||||||
|
* goes silent (or dies) mid-query, leaving the client's timeout as the
|
||||||
|
* only way out. */
|
||||||
|
public suppressEose = false;
|
||||||
/** Addressable-event d tags whose publishes are refused. Lets ordering tests
|
/** Addressable-event d tags whose publishes are refused. Lets ordering tests
|
||||||
* reject recovery writes while still accepting credential writes (or vice
|
* reject recovery writes while still accepting credential writes (or vice
|
||||||
* versa), independently of the signing identity. */
|
* versa), independently of the signing identity. */
|
||||||
@@ -101,7 +105,7 @@ export class MockRelay {
|
|||||||
const matches = this.query(filters[0] ?? {});
|
const matches = this.query(filters[0] ?? {});
|
||||||
for (const event of this.unsolicitedQueryEvents) socket.send(JSON.stringify(["EVENT", subId, event]));
|
for (const event of this.unsolicitedQueryEvents) socket.send(JSON.stringify(["EVENT", subId, event]));
|
||||||
for (const event of matches) socket.send(JSON.stringify(["EVENT", subId, event]));
|
for (const event of matches) socket.send(JSON.stringify(["EVENT", subId, event]));
|
||||||
socket.send(JSON.stringify(["EOSE", subId]));
|
if (!this.suppressEose) socket.send(JSON.stringify(["EOSE", subId]));
|
||||||
let subsForSocket = this.liveSubs.get(socket);
|
let subsForSocket = this.liveSubs.get(socket);
|
||||||
if (!subsForSocket) {
|
if (!subsForSocket) {
|
||||||
subsForSocket = new Map();
|
subsForSocket = new Map();
|
||||||
@@ -176,6 +180,10 @@ export class MockRelay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
|
// Terminate live sockets first: wss.close() only stops listening and its
|
||||||
|
// callback waits for every client to leave -- a test closing the relay
|
||||||
|
// mid-query (the BL-18 scenario) would otherwise hang here forever.
|
||||||
|
for (const socket of this.wss.clients) socket.terminate();
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.wss.close(() => resolve());
|
this.wss.close(() => resolve());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1045,8 +1045,13 @@ export class BitLoginAuthElement extends HTMLElement {
|
|||||||
let prf = extractPrfOutput(credential);
|
let prf = extractPrfOutput(credential);
|
||||||
if (!prf && kind === "create") {
|
if (!prf && kind === "create") {
|
||||||
// Some authenticators only reveal PRF results during assertion; ask
|
// Some authenticators only reveal PRF results during assertion; ask
|
||||||
// for one immediately against the passkey that was just created.
|
// for one immediately, pinned via allowCredentials to EXACTLY the
|
||||||
const assertion = (await navigator.credentials.get(buildPasskeyGetOptions())) as PublicKeyCredential | null;
|
// passkey that was just created -- an unrestricted discovery here
|
||||||
|
// would let the sheet offer an older passkey and silently bind a
|
||||||
|
// different account (audit BL-17).
|
||||||
|
const assertion = (await navigator.credentials.get(
|
||||||
|
buildPasskeyGetOptions(undefined, credential.rawId)
|
||||||
|
)) as PublicKeyCredential | null;
|
||||||
prf = assertion ? extractPrfOutput(assertion) : null;
|
prf = assertion ? extractPrfOutput(assertion) : null;
|
||||||
}
|
}
|
||||||
if (!prf) {
|
if (!prf) {
|
||||||
@@ -1717,10 +1722,19 @@ export class BitLoginAuthElement extends HTMLElement {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
case "bunker-connect": {
|
case "bunker-connect": {
|
||||||
const authNotice = this.bunkerAuthUrl
|
// The auth URL is validated https-only in core (sanitizeAuthUrl);
|
||||||
? `<div class="notice info">Your signer asks you to approve this connection first.
|
// parse defensively anyway and show the HOSTNAME so the user sees
|
||||||
<a href="${escapeHtml(this.bunkerAuthUrl)}" target="_blank" rel="noopener noreferrer">Open the approval page</a>, then return here.</div>`
|
// where the signer is sending them before they click.
|
||||||
: "";
|
let authNotice = "";
|
||||||
|
if (this.bunkerAuthUrl) {
|
||||||
|
try {
|
||||||
|
const authHost = new URL(this.bunkerAuthUrl).hostname;
|
||||||
|
authNotice = `<div class="notice info">Your signer asks you to approve this connection first.
|
||||||
|
<a href="${escapeHtml(this.bunkerAuthUrl)}" target="_blank" rel="noopener noreferrer">Open the approval page at ${escapeHtml(authHost)}</a>, then return here.</div>`;
|
||||||
|
} catch {
|
||||||
|
authNotice = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
const qrBlock = this.bunkerConnectUri
|
const qrBlock = this.bunkerConnectUri
|
||||||
? `<div class="qr-wrap" aria-live="polite">${renderQrSvg(this.bunkerConnectUri, "Nostr Connect QR code")}</div>
|
? `<div class="qr-wrap" aria-live="polite">${renderQrSvg(this.bunkerConnectUri, "Nostr Connect QR code")}</div>
|
||||||
<button class="secondary" type="button" data-action="copy-bunker-uri">Copy connection code</button>
|
<button class="secondary" type="button" data-action="copy-bunker-uri">Copy connection code</button>
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ describe("ceremony options", () => {
|
|||||||
expect(buildPasskeyGetOptions("site.example").publicKey!.rpId).toBe("site.example");
|
expect(buildPasskeyGetOptions("site.example").publicKey!.rpId).toBe("site.example");
|
||||||
expect(buildPasskeyCreateOptions("BitLogin", "site.example").publicKey!.rp.id).toBe("site.example");
|
expect(buildPasskeyCreateOptions("BitLogin", "site.example").publicKey!.rp.id).toBe("site.example");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pins the assertion to one credential when given (audit BL-17)", () => {
|
||||||
|
// The post-creation PRF fallback MUST scope discovery to the passkey
|
||||||
|
// just created; an open sheet could bind an older passkey's account.
|
||||||
|
const rawId = new Uint8Array(16).fill(3).buffer;
|
||||||
|
const options = buildPasskeyGetOptions(undefined, rawId).publicKey!;
|
||||||
|
expect(options.allowCredentials).toEqual([{ type: "public-key", id: rawId }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels each created passkey distinguishably (audit follow-up)", () => {
|
||||||
|
const a = buildPasskeyCreateOptions("BitLogin").publicKey!;
|
||||||
|
const b = buildPasskeyCreateOptions("BitLogin").publicKey!;
|
||||||
|
expect(a.user.name).toMatch(/^BitLogin \([0-9a-f]{4}\)$/u);
|
||||||
|
expect(a.user.displayName).toBe(a.user.name);
|
||||||
|
expect(a.user.name).not.toBe(b.user.name);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractPrfOutput", () => {
|
describe("extractPrfOutput", () => {
|
||||||
|
|||||||
@@ -59,8 +59,14 @@ export function passkeySupported(w: { PublicKeyCredential?: unknown } = window):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Options for the sign-in ceremony: discoverable credential, user
|
/** Options for the sign-in ceremony: discoverable credential, user
|
||||||
* verification required, PRF evaluated in the same step. */
|
* verification required, PRF evaluated in the same step.
|
||||||
export function buildPasskeyGetOptions(rpId?: string): CredentialRequestOptions {
|
*
|
||||||
|
* `allowCredentialId` pins the assertion to ONE specific credential. It is
|
||||||
|
* required for the post-creation PRF fallback: an unrestricted discovery
|
||||||
|
* there would let the browser's account sheet offer OLDER passkeys, and a
|
||||||
|
* user picking one would silently bind a different account than the passkey
|
||||||
|
* they just created (audit BL-17). */
|
||||||
|
export function buildPasskeyGetOptions(rpId?: string, allowCredentialId?: ArrayBuffer): CredentialRequestOptions {
|
||||||
return {
|
return {
|
||||||
publicKey: {
|
publicKey: {
|
||||||
// The challenge protects sign-count/liveness in server-verified
|
// The challenge protects sign-count/liveness in server-verified
|
||||||
@@ -68,6 +74,9 @@ export function buildPasskeyGetOptions(rpId?: string): CredentialRequestOptions
|
|||||||
// is whether the derived credential opens the capsule. Random anyway.
|
// is whether the derived credential opens the capsule. Random anyway.
|
||||||
challenge: asBufferSource(randomBytes(32)),
|
challenge: asBufferSource(randomBytes(32)),
|
||||||
...(rpId ? { rpId } : {}),
|
...(rpId ? { rpId } : {}),
|
||||||
|
...(allowCredentialId
|
||||||
|
? { allowCredentials: [{ type: "public-key" as const, id: allowCredentialId }] }
|
||||||
|
: {}),
|
||||||
userVerification: "required",
|
userVerification: "required",
|
||||||
extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } } as AuthenticationExtensionsClientInputs
|
extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } } as AuthenticationExtensionsClientInputs
|
||||||
}
|
}
|
||||||
@@ -77,6 +86,10 @@ export function buildPasskeyGetOptions(rpId?: string): CredentialRequestOptions
|
|||||||
/** Options for creating the passkey. Discoverable + user-verified, so later
|
/** Options for creating the passkey. Discoverable + user-verified, so later
|
||||||
* sign-ins need no username at all. */
|
* sign-ins need no username at all. */
|
||||||
export function buildPasskeyCreateOptions(rpName: string, rpId?: string): CredentialCreationOptions {
|
export function buildPasskeyCreateOptions(rpName: string, rpId?: string): CredentialCreationOptions {
|
||||||
|
// A short suffix so several BitLogin passkeys in one password manager are
|
||||||
|
// tellable apart (audit follow-up: identical names made multiple
|
||||||
|
// passkey-derived accounts indistinguishable in the credential sheet).
|
||||||
|
const label = `${rpName} (${bytesToHex(randomBytes(2))})`;
|
||||||
return {
|
return {
|
||||||
publicKey: {
|
publicKey: {
|
||||||
challenge: asBufferSource(randomBytes(32)),
|
challenge: asBufferSource(randomBytes(32)),
|
||||||
@@ -85,8 +98,8 @@ export function buildPasskeyCreateOptions(rpName: string, rpId?: string): Creden
|
|||||||
// Opaque handle; the account identity is DERIVED from the PRF, not
|
// Opaque handle; the account identity is DERIVED from the PRF, not
|
||||||
// from this. Random so re-creates don't silently overwrite.
|
// from this. Random so re-creates don't silently overwrite.
|
||||||
id: asBufferSource(randomBytes(16)),
|
id: asBufferSource(randomBytes(16)),
|
||||||
name: rpName,
|
name: label,
|
||||||
displayName: rpName
|
displayName: label
|
||||||
},
|
},
|
||||||
pubKeyCredParams: [
|
pubKeyCredParams: [
|
||||||
{ type: "public-key", alg: -7 }, // ES256
|
{ type: "public-key", alg: -7 }, // ES256
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { signNostrEvent } from "@bitlogin/core/nostr";
|
||||||
|
import { generatePrivateKey, getPublicKeyHex } from "@bitlogin/core/crypto";
|
||||||
import { detectForeignNip07Provider, Nip07Signer, type ForeignNip07Provider } from "./nip07.js";
|
import { detectForeignNip07Provider, Nip07Signer, type ForeignNip07Provider } from "./nip07.js";
|
||||||
import { SignerTimeoutError, SignerUnsupportedError } from "./types.js";
|
import { SignerTimeoutError, SignerUnsupportedError } from "./types.js";
|
||||||
|
|
||||||
const PUBKEY = "a".repeat(64);
|
// A real key: the signer now VERIFIES what the "extension" returns, so the
|
||||||
|
// fake must produce genuinely signed events.
|
||||||
|
const EXTENSION_KEY = generatePrivateKey();
|
||||||
|
const PUBKEY = getPublicKeyHex(EXTENSION_KEY);
|
||||||
|
|
||||||
function fullProvider(overrides: Partial<ForeignNip07Provider> = {}): ForeignNip07Provider {
|
function fullProvider(overrides: Partial<ForeignNip07Provider> = {}): ForeignNip07Provider {
|
||||||
return {
|
return {
|
||||||
getPublicKey: async () => PUBKEY,
|
getPublicKey: async () => PUBKEY,
|
||||||
signEvent: async (event) => ({
|
signEvent: async (event) => signNostrEvent({ ...event, pubkey: PUBKEY }, EXTENSION_KEY),
|
||||||
...event,
|
|
||||||
id: "e".repeat(64),
|
|
||||||
pubkey: PUBKEY,
|
|
||||||
sig: "f".repeat(128)
|
|
||||||
}),
|
|
||||||
getRelays: async () => ({}),
|
getRelays: async () => ({}),
|
||||||
nip44: {
|
nip44: {
|
||||||
encrypt: async (peer, plaintext) => `nip44(${peer.slice(0, 4)},${plaintext})`,
|
encrypt: async (peer, plaintext) => `nip44(${peer.slice(0, 4)},${plaintext})`,
|
||||||
@@ -90,7 +90,7 @@ describe("Nip07Signer delegation", () => {
|
|||||||
fullProvider({
|
fullProvider({
|
||||||
signEvent: async (event) => {
|
signEvent: async (event) => {
|
||||||
seen = event;
|
seen = event;
|
||||||
return { ...event, id: "e".repeat(64), pubkey: PUBKEY, sig: "f".repeat(128) };
|
return signNostrEvent({ ...event, pubkey: PUBKEY }, EXTENSION_KEY);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -103,6 +103,32 @@ describe("Nip07Signer delegation", () => {
|
|||||||
expect(seen!.created_at).toBe(1234);
|
expect(seen!.created_at).toBe(1234);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a returned event whose fields differ from the request", async () => {
|
||||||
|
const signer = new Nip07Signer(
|
||||||
|
fullProvider({
|
||||||
|
signEvent: async (event) =>
|
||||||
|
signNostrEvent({ ...event, pubkey: PUBKEY, content: "tampered" }, EXTENSION_KEY)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await expect(signer.signEvent({ kind: 1, content: "original" })).rejects.toThrow(
|
||||||
|
/different event/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an event signed by a different identity than the session's", async () => {
|
||||||
|
const otherKey = generatePrivateKey();
|
||||||
|
const signer = new Nip07Signer(
|
||||||
|
fullProvider({
|
||||||
|
signEvent: async (event) =>
|
||||||
|
signNostrEvent({ ...event, pubkey: getPublicKeyHex(otherKey) }, otherKey)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await signer.getPublicKey(); // pins the session identity
|
||||||
|
await expect(signer.signEvent({ kind: 1, content: "x" })).rejects.toThrow(
|
||||||
|
/different identity/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("passes nip44/nip04 calls through and surfaces the extension's own rejections", async () => {
|
it("passes nip44/nip04 calls through and surfaces the extension's own rejections", async () => {
|
||||||
const signer = new Nip07Signer(fullProvider());
|
const signer = new Nip07Signer(fullProvider());
|
||||||
expect(await signer.nip44Encrypt(PUBKEY, "hi")).toBe(`nip44(${PUBKEY.slice(0, 4)},hi)`);
|
expect(await signer.nip44Encrypt(PUBKEY, "hi")).toBe(`nip44(${PUBKEY.slice(0, 4)},hi)`);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* owns that slot, and fighting it (claimSigner) is reserved for BitLogin-account
|
* owns that slot, and fighting it (claimSigner) is reserved for BitLogin-account
|
||||||
* sessions where the user explicitly chose BitLogin as their signer.
|
* sessions where the user explicitly chose BitLogin as their signer.
|
||||||
*/
|
*/
|
||||||
import type { NostrEvent } from "@bitlogin/core/nostr";
|
import { verifyNostrEvent, type NostrEvent } from "@bitlogin/core/nostr";
|
||||||
import type { UnsignedEventForSigning } from "../provider.js";
|
import type { UnsignedEventForSigning } from "../provider.js";
|
||||||
import {
|
import {
|
||||||
SignerTimeoutError,
|
SignerTimeoutError,
|
||||||
@@ -96,12 +96,18 @@ export class Nip07Signer implements Signer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Identity this extension reported; later signEvent results must come
|
||||||
|
* from it -- a multi-profile extension switching accounts mid-session
|
||||||
|
* must surface as an error, not as silently mixed authorship. */
|
||||||
|
private knownPublicKey: string | null = null;
|
||||||
|
|
||||||
async getPublicKey(): Promise<string> {
|
async getPublicKey(): Promise<string> {
|
||||||
const raw = await withDeadline(this.provider.getPublicKey(), "getPublicKey", this.timeoutMs);
|
const raw = await withDeadline(this.provider.getPublicKey(), "getPublicKey", this.timeoutMs);
|
||||||
const publicKey = typeof raw === "string" ? raw.toLowerCase() : "";
|
const publicKey = typeof raw === "string" ? raw.toLowerCase() : "";
|
||||||
if (!/^[0-9a-f]{64}$/.test(publicKey)) {
|
if (!/^[0-9a-f]{64}$/.test(publicKey)) {
|
||||||
throw new Error("The extension returned an invalid public key (expected 64 hex characters).");
|
throw new Error("The extension returned an invalid public key (expected 64 hex characters).");
|
||||||
}
|
}
|
||||||
|
this.knownPublicKey = publicKey;
|
||||||
return publicKey;
|
return publicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +121,25 @@ export class Nip07Signer implements Signer {
|
|||||||
tags: (event.tags ?? []) as string[][],
|
tags: (event.tags ?? []) as string[][],
|
||||||
created_at: event.created_at ?? Math.floor(Date.now() / 1000)
|
created_at: event.created_at ?? Math.floor(Date.now() / 1000)
|
||||||
};
|
};
|
||||||
return withDeadline(this.provider.signEvent(normalized), "signEvent", this.timeoutMs);
|
const signed = await withDeadline(this.provider.signEvent(normalized), "signEvent", this.timeoutMs);
|
||||||
|
// The extension is an external signer: verify what came back before any
|
||||||
|
// caller treats it as the event they asked for. NIP-07's contract is
|
||||||
|
// "the supplied event plus id/pubkey/sig" -- enforce exactly that.
|
||||||
|
if (!verifyNostrEvent(signed)) {
|
||||||
|
throw new Error("The extension returned an event that does not verify.");
|
||||||
|
}
|
||||||
|
if (this.knownPublicKey && signed.pubkey !== this.knownPublicKey) {
|
||||||
|
throw new Error("The extension signed with a different identity than this session's.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
signed.kind !== normalized.kind ||
|
||||||
|
signed.content !== normalized.content ||
|
||||||
|
signed.created_at !== normalized.created_at ||
|
||||||
|
JSON.stringify(signed.tags) !== JSON.stringify(normalized.tags)
|
||||||
|
) {
|
||||||
|
throw new Error("The extension returned a different event than the one requested.");
|
||||||
|
}
|
||||||
|
return signed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||||
|
|||||||
Reference in new issue
Block a user