mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
NIP-07 sign-in: delegate to an installed extension behind the same facade
Implements login-methods.md §LM4. New signers/ layer in the widget: detectForeignNip07Provider never mistakes BitLogin's own provider for an extension, and Nip07Signer wraps the snapshotted provider with capability probing (typed SignerUnsupportedError instead of TypeErrors off missing nip44), deadlines on every call (SignerTimeoutError -- no silent hangs), pubkey validation, and unsigned-event normalization. The element routes its public API through the active signer, so hosts get the extension backend transparently. Extension sessions are thin (§LM7): no capsule, no vault (requestNwcConnection resolves null), no persistence, and window.nostr is left to the extension -- claimSigner stays reserved for BitLogin-account sessions. bitlogin-login now carries method and capabilities; publicKey is unchanged for existing hosts. Password-first pinned (owner decision, §LM9.1): the extension option renders as a secondary link under the primary sign-in, enforced in securityPolicy.test.ts alongside a pin that the extension confirm path never claims window.nostr or writes storage. 14 new signer tests. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
20a3c50dc2
commit
a7adef05f9
6 files changed
+531
-21
No files matched your search
@@ -1,8 +1,10 @@
|
||||
# BitLogin as the Single Login Surface — NIP-07 and NIP-46 Methods
|
||||
|
||||
**Document version:** 0.1 (design proposal)
|
||||
**Status:** Proposal only — nothing here is implemented. §LM6 records a settled
|
||||
rejection (nsec paste as a login method), kept on the record.
|
||||
**Document version:** 0.2
|
||||
**Status:** §LM4 (NIP-07) is **implemented** (2026-08-04): `signers/` in
|
||||
`@bitlogin/widget`, thin sessions per §LM7, password-first presentation per
|
||||
§LM9.1. §LM5 (NIP-46) remains a proposal. §LM6 records a settled rejection
|
||||
(nsec paste as a login method), kept on the record.
|
||||
**Depends on:** `docs/spec.md` (§ references point there unless marked §LM or
|
||||
§SF), `docs/second-factor.md` §SF10 (imported identities, implemented)
|
||||
|
||||
@@ -222,9 +224,10 @@ Each step ships independently; the login UI grows one option at a time.
|
||||
|
||||
# LM9. Open questions
|
||||
|
||||
1. Method presentation: is password-first with an "other ways to sign in"
|
||||
expander the right default, or should extension detection promote NIP-07
|
||||
when present?
|
||||
1. **Resolved 2026-08-04 (owner decision):** username/password stays the
|
||||
primary presentation; alternative methods appear as secondary affordances
|
||||
below it, never promoted above the password path even when an extension is
|
||||
detected. Pinned by `securityPolicy.test.ts`.
|
||||
2. Should a NIP-07 session survive reload by silently re-asking the
|
||||
extension, or require an explicit click each visit (extension prompt
|
||||
fatigue vs. surprise sessions)?
|
||||
|
||||
+161
-15
@@ -15,9 +15,12 @@ import { WIDGET_STYLES } from "./styles.js";
|
||||
import { chooseWalletViaBitcoinConnect } from "./vault/bitcoinConnect.js";
|
||||
import type { VaultConnectionSummary } from "./worker/protocol.js";
|
||||
import { buildVaultIntegrityWarnings } from "./vault/integrityWarnings.js";
|
||||
import { detectForeignNip07Provider, Nip07Signer } from "./signers/nip07.js";
|
||||
import type { SignerCapabilities, SignerMethod } from "./signers/types.js";
|
||||
|
||||
type Screen =
|
||||
| "welcome"
|
||||
| "extension-confirm"
|
||||
| "import-key"
|
||||
| "create-name"
|
||||
| "create-credential"
|
||||
@@ -70,7 +73,17 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
private offlineExportFile: RecoveryExportFile | null = null;
|
||||
private offlineExportFileNotice: string | undefined;
|
||||
|
||||
private session: { publicKey: string; npub: string; accountId?: string } | null = null;
|
||||
private session: { publicKey: string; npub: string; accountId?: string; method: SignerMethod } | null = null;
|
||||
|
||||
// ---- NIP-07 extension sessions (docs/login-methods.md §LM4, §LM7) ----
|
||||
// `pending` holds the signer between "the extension answered getPublicKey" and the
|
||||
// user confirming that npub is theirs; only `active` routes the public API. Thin by
|
||||
// design: no capsule, no vault, no persistence -- a reload simply signs the user out,
|
||||
// and the extension is asked again next visit.
|
||||
private pendingExtensionSigner: Nip07Signer | null = null;
|
||||
private activeExtensionSigner: Nip07Signer | null = null;
|
||||
private extensionPreviewNpub = "";
|
||||
private extensionPreviewPubkey = "";
|
||||
private sessionWarnings: string[] = [];
|
||||
private lastSignedEventJson = "";
|
||||
private exportedNsec = "";
|
||||
@@ -170,10 +183,15 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
}
|
||||
|
||||
// ---- Public API mirroring window.nostr, scoped to this element instance ----
|
||||
// Routed through the active signer: the worker for BitLogin-account sessions (as
|
||||
// always), the extension's provider for a NIP-07 session (§LM3). Hosts holding a
|
||||
// reference to this element get the right backend either way.
|
||||
async getPublicKey(): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.getPublicKey();
|
||||
return (await this.worker.getPublicKey()).publicKey;
|
||||
}
|
||||
async signEvent(event: { kind: number; tags?: string[][]; content: string; created_at?: number }) {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.signEvent(event);
|
||||
return this.worker.signEvent(event);
|
||||
}
|
||||
/**
|
||||
@@ -185,9 +203,11 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
* racing other providers for that slot.
|
||||
*/
|
||||
async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.nip44Encrypt(peerPublicKey, plaintext);
|
||||
return (await this.worker.nip44Encrypt({ peerPublicKey, plaintext })).ciphertext;
|
||||
}
|
||||
async nip44Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.nip44Decrypt(peerPublicKey, payload);
|
||||
return (await this.worker.nip44Decrypt({ peerPublicKey, payload })).plaintext;
|
||||
}
|
||||
/**
|
||||
@@ -196,12 +216,23 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
* (or their older DM code paths) that still expect NIP-04 rather than NIP-44.
|
||||
*/
|
||||
async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.nip04Encrypt(peerPublicKey, plaintext);
|
||||
return (await this.worker.nip04Encrypt({ peerPublicKey, plaintext })).ciphertext;
|
||||
}
|
||||
async nip04Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
if (this.activeExtensionSigner) return this.activeExtensionSigner.nip04Decrypt(peerPublicKey, payload);
|
||||
return (await this.worker.nip04Decrypt({ peerPublicKey, payload })).plaintext;
|
||||
}
|
||||
async logout(): Promise<void> {
|
||||
if (this.activeExtensionSigner) {
|
||||
// Nothing to tear down beyond our own reference: the session held no worker
|
||||
// state, persisted nothing, and never claimed window.nostr (§LM4).
|
||||
this.activeExtensionSigner = null;
|
||||
this.session = null;
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-logout"));
|
||||
this.goto("welcome");
|
||||
return;
|
||||
}
|
||||
await this.worker.logout();
|
||||
this.session = null;
|
||||
this.releaseSigner();
|
||||
@@ -226,6 +257,9 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
*/
|
||||
async requestNwcConnection(options: { appName?: string; reason?: string } = {}): Promise<string | null> {
|
||||
if (this.vaultRequest) throw new Error("A wallet connection request is already in progress.");
|
||||
// Extension sessions are signer-only (§LM7): no vault, and prompting a password
|
||||
// sign-in over an active session would be worse than an honest decline.
|
||||
if (this.activeExtensionSigner) return null;
|
||||
const origin = window.location.origin;
|
||||
const appName = options.appName?.trim() || window.location.hostname || "This app";
|
||||
return new Promise<string | null>((resolve) => {
|
||||
@@ -507,6 +541,26 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
* relay-disagreement warnings -- so a user isn't left wondering why some other app still
|
||||
* seems to be using a different signer.
|
||||
*/
|
||||
/** Single dispatch point for bitlogin-login so every sign-in path reports the same
|
||||
* detail shape: which method granted the session and what it can do (§LM3). The
|
||||
* pre-existing `publicKey` field is unchanged for hosts written before `method`. */
|
||||
private dispatchLogin(): void {
|
||||
const capabilities: SignerCapabilities = this.activeExtensionSigner?.capabilities ?? {
|
||||
nip44: true,
|
||||
nip04: true,
|
||||
getRelays: true
|
||||
};
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("bitlogin-login", {
|
||||
detail: {
|
||||
publicKey: this.session?.publicKey,
|
||||
method: this.session?.method ?? "bitlogin",
|
||||
capabilities
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private noteSignerClaim(claimed: boolean): void {
|
||||
if (claimed) return;
|
||||
this.sessionWarnings = [
|
||||
@@ -661,8 +715,14 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
return;
|
||||
case "goto-welcome":
|
||||
this.pendingRollback = null;
|
||||
this.pendingExtensionSigner = null;
|
||||
this.goto("welcome");
|
||||
return;
|
||||
case "extension-signin":
|
||||
return this.handleExtensionSignIn();
|
||||
case "extension-continue":
|
||||
this.handleExtensionConfirm();
|
||||
return;
|
||||
case "goto-dashboard":
|
||||
this.goto("dashboard");
|
||||
return;
|
||||
@@ -857,7 +917,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
const words = this.recoveryPhrase.split(" ");
|
||||
const indices = pickRandomIndices(words.length, 3);
|
||||
this.confirmSlots = indices.map((index) => ({ index, value: "" }));
|
||||
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId };
|
||||
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId, method: "bitlogin" };
|
||||
this.busy = false;
|
||||
this.goto("confirm-phrase");
|
||||
// §15.8/§19.6 — publish relay preferences immediately after successful registration,
|
||||
@@ -885,7 +945,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
}
|
||||
this.sessionWarnings = [];
|
||||
this.noteSignerClaim(this.claimSigner());
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } }));
|
||||
this.dispatchLogin();
|
||||
this.flashSuccess("dashboard", "Account created");
|
||||
}
|
||||
|
||||
@@ -895,6 +955,56 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
await this.attemptLogin(loginName, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Use your Nostr extension instead" (§LM4) -- asks the detected extension for its
|
||||
* public key, then shows it for the user to confirm before any session exists. The
|
||||
* provider reference is snapshotted here so a later change of window.nostr occupant
|
||||
* can never swap the backend mid-session.
|
||||
*/
|
||||
private async handleExtensionSignIn(): Promise<void> {
|
||||
if (this.busy) return;
|
||||
const provider = detectForeignNip07Provider();
|
||||
if (!provider) {
|
||||
this.errorMessage = "No Nostr signer extension was found in this browser.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.setBusy(true);
|
||||
try {
|
||||
const signer = new Nip07Signer(provider);
|
||||
const publicKey = await signer.getPublicKey();
|
||||
this.pendingExtensionSigner = signer;
|
||||
this.extensionPreviewPubkey = publicKey;
|
||||
this.extensionPreviewNpub = encodeNpub(publicKey);
|
||||
this.busy = false;
|
||||
this.goto("extension-confirm");
|
||||
} catch (err) {
|
||||
this.pendingExtensionSigner = null;
|
||||
this.fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** The user confirmed the npub the extension reported is theirs; grant the thin
|
||||
* session (§LM7). Deliberately no claimSigner(): the extension owns window.nostr
|
||||
* and this session's whole point is to use it, not replace it (§LM4). */
|
||||
private handleExtensionConfirm(): void {
|
||||
const signer = this.pendingExtensionSigner;
|
||||
if (!signer || !this.extensionPreviewPubkey) {
|
||||
this.goto("welcome");
|
||||
return;
|
||||
}
|
||||
this.pendingExtensionSigner = null;
|
||||
this.activeExtensionSigner = signer;
|
||||
this.session = { publicKey: this.extensionPreviewPubkey, npub: this.extensionPreviewNpub, method: "nip07" };
|
||||
this.sessionWarnings = [];
|
||||
// A wallet request that was waiting on sign-in expected a BitLogin account; an
|
||||
// extension session has no vault, so settle it honestly instead of letting the
|
||||
// dashboard hook run it into worker calls that cannot succeed (§LM7).
|
||||
if (this.vaultRequest) this.finishVaultRequest(null);
|
||||
this.dispatchLogin();
|
||||
this.flashSuccess("dashboard", "Signed in");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once per connectedCallback, right after "configure" -- restores whatever
|
||||
* a prior login/register/rotate cached locally (§21), so a page reload lands
|
||||
@@ -912,10 +1022,11 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.session = {
|
||||
publicKey: result.everydayPublicKey,
|
||||
npub: encodeNpub(result.everydayPublicKey),
|
||||
accountId: result.accountId
|
||||
accountId: result.accountId,
|
||||
method: "bitlogin"
|
||||
};
|
||||
this.noteSignerClaim(this.claimSigner());
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } }));
|
||||
this.dispatchLogin();
|
||||
this.goto("dashboard");
|
||||
} catch {
|
||||
// No cached session, or the worker/IndexedDB isn't available -- fall
|
||||
@@ -933,11 +1044,11 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
try {
|
||||
const result = await this.worker.login({ loginName, password, acknowledgeRollback });
|
||||
this.loginName = loginName;
|
||||
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId };
|
||||
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId, method: "bitlogin" };
|
||||
this.sessionWarnings = [result.rollbackWarning, result.relayDisagreementWarning].filter((w): w is string => !!w);
|
||||
this.busy = false;
|
||||
this.noteSignerClaim(this.claimSigner());
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } }));
|
||||
this.dispatchLogin();
|
||||
this.flashSuccess("dashboard", "Signed in");
|
||||
void this.offerToSaveCredential(loginName, password);
|
||||
} catch (err) {
|
||||
@@ -976,7 +1087,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
dmRelaysCount: result.dmRelays.length,
|
||||
chainWarning: result.chainWarning
|
||||
};
|
||||
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId };
|
||||
this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId, method: "bitlogin" };
|
||||
this.newCredentialAfterRecovery = generatePassphrase().secret;
|
||||
this.busy = false;
|
||||
this.goto("recover-new-credentials");
|
||||
@@ -996,7 +1107,7 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
this.busy = false;
|
||||
this.sessionWarnings = [];
|
||||
this.noteSignerClaim(this.claimSigner());
|
||||
this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } }));
|
||||
this.dispatchLogin();
|
||||
this.flashSuccess("dashboard", "Account recovered");
|
||||
void this.offerToSaveCredential(newLoginName, newPassword);
|
||||
}
|
||||
@@ -1046,9 +1157,15 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
}
|
||||
|
||||
private async handleSignTestEvent(): Promise<void> {
|
||||
const event = await this.worker.signEvent({ kind: 1, content: `Hello from BitLogin at ${new Date().toISOString()}` });
|
||||
// this.signEvent, not this.worker.signEvent: routes to the extension for a NIP-07 session.
|
||||
try {
|
||||
const event = await this.signEvent({ kind: 1, content: `Hello from BitLogin at ${new Date().toISOString()}` });
|
||||
this.lastSignedEventJson = JSON.stringify(event, null, 2);
|
||||
this.render();
|
||||
} catch (err) {
|
||||
// An extension can decline or time out where the worker path couldn't (§LM4).
|
||||
this.fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRevealNsec(): Promise<void> {
|
||||
@@ -1119,7 +1236,14 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
|
||||
private renderScreen(): string {
|
||||
switch (this.screen) {
|
||||
case "welcome":
|
||||
case "welcome": {
|
||||
// Username/password is the primary method; a detected extension appears as a
|
||||
// secondary affordance only (§LM9.1, resolved: password-first).
|
||||
const extensionOption = detectForeignNip07Provider()
|
||||
? `<button class="link" data-action="extension-signin" ${this.busy ? "disabled" : ""}>${
|
||||
this.busy ? '<span class="spinner"></span>Asking your extension…' : "Use your Nostr extension instead"
|
||||
}</button>`
|
||||
: "";
|
||||
return `
|
||||
${this.renderBrandLockup()}
|
||||
<p class="sub">A portable Nostr identity with a familiar login name and password.</p>
|
||||
@@ -1128,6 +1252,19 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
<button class="secondary" data-action="goto-create">Create account</button>
|
||||
<button class="link" data-action="goto-import">Import an existing Nostr key</button>
|
||||
<button class="link" data-action="goto-recover">Forgot password? Recover with phrase</button>
|
||||
${extensionOption}
|
||||
`;
|
||||
}
|
||||
|
||||
case "extension-confirm":
|
||||
return `
|
||||
<h2>Sign in with your extension</h2>
|
||||
<p class="sub">Your Nostr signer extension reports this identity:</p>
|
||||
<div class="credential-box">${escapeHtml(this.extensionPreviewNpub)}</div>
|
||||
<p class="small">Check this is the profile you expect — extensions can hold more than one. BitLogin never sees this identity's private key; your extension signs on its behalf. This session lasts until you log out or leave the page, and BitLogin account features (wallet connections, password rotation, recovery) stay with BitLogin accounts.</p>
|
||||
${this.renderError()}
|
||||
<button class="primary" type="button" data-action="extension-continue">This is me — continue</button>
|
||||
<button class="link" data-action="goto-welcome">Back</button>
|
||||
`;
|
||||
|
||||
case "import-key": {
|
||||
@@ -1295,7 +1432,17 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
`;
|
||||
}
|
||||
|
||||
case "dashboard":
|
||||
case "dashboard": {
|
||||
// Extension sessions are signer-only (§LM7): the account actions below the
|
||||
// divider are all worker-backed (vault, rotation, export) and have no meaning
|
||||
// without a BitLogin account, so they are omitted rather than left to fail.
|
||||
const isExtensionSession = this.session?.method === "nip07";
|
||||
const accountActions = isExtensionSession
|
||||
? `<p class="small">Signed in through your Nostr extension. Wallet connections, password rotation, and identity export are features of BitLogin accounts — create one to get your settings on every device.</p>`
|
||||
: `
|
||||
<button class="secondary" type="button" data-action="goto-vault-manage">Wallet connections</button>
|
||||
<button class="secondary" type="button" data-action="goto-change-password">Rotate password</button>
|
||||
<button class="secondary" type="button" data-action="goto-export">Export identity</button>`;
|
||||
return `
|
||||
<h2>Signed in</h2>
|
||||
${this.renderWarnings()}
|
||||
@@ -1308,11 +1455,10 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
: ""
|
||||
}
|
||||
<div class="divider"></div>
|
||||
<button class="secondary" type="button" data-action="goto-vault-manage">Wallet connections</button>
|
||||
<button class="secondary" type="button" data-action="goto-change-password">Rotate password</button>
|
||||
<button class="secondary" type="button" data-action="goto-export">Export identity</button>
|
||||
${accountActions}
|
||||
<button class="secondary" type="button" data-action="logout">Log out</button>
|
||||
`;
|
||||
}
|
||||
|
||||
case "vault-consent": {
|
||||
const request = this.vaultRequest;
|
||||
|
||||
@@ -29,6 +29,31 @@ describe("widget security policy", () => {
|
||||
expect(widgetSource).not.toContain("entropyBits");
|
||||
});
|
||||
|
||||
it("keeps username/password primary; the extension method is a secondary affordance", () => {
|
||||
// Owner decision 2026-08-04 (login-methods.md §LM9.1): alternative methods must
|
||||
// not displace the password path. The welcome screen's primary sign-in button has
|
||||
// to appear before the extension option in the rendered template.
|
||||
const passwordSignIn = widgetSource.indexOf('data-action="goto-login">Sign in</button>');
|
||||
const extensionSlot = widgetSource.indexOf("${extensionOption}");
|
||||
expect(passwordSignIn).toBeGreaterThan(-1);
|
||||
expect(extensionSlot).toBeGreaterThan(-1);
|
||||
expect(passwordSignIn).toBeLessThan(extensionSlot);
|
||||
});
|
||||
|
||||
it("extension sessions never claim window.nostr and never persist", () => {
|
||||
// §LM4: NIP-07 mode delegates to the extension's provider; fighting it for the
|
||||
// window.nostr slot is reserved for BitLogin-account sessions. There must be no
|
||||
// claimSigner() call in the extension confirm path, and no storage write.
|
||||
const confirmBody = widgetSource.slice(
|
||||
widgetSource.indexOf("private handleExtensionConfirm"),
|
||||
widgetSource.indexOf("private async tryRestoreSession"),
|
||||
);
|
||||
expect(confirmBody.length).toBeGreaterThan(0);
|
||||
expect(confirmBody).not.toContain("claimSigner");
|
||||
expect(confirmBody).not.toContain("localStorage");
|
||||
expect(confirmBody).not.toContain("sessionCache");
|
||||
});
|
||||
|
||||
it("renders vault integrity warnings on the management screen", () => {
|
||||
expect(widgetSource).toContain("buildVaultIntegrityWarnings(listed)");
|
||||
expect(widgetSource).toContain("this.vaultIntegrityWarnings.map");
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectForeignNip07Provider, Nip07Signer, type ForeignNip07Provider } from "./nip07.js";
|
||||
import { SignerTimeoutError, SignerUnsupportedError } from "./types.js";
|
||||
|
||||
const PUBKEY = "a".repeat(64);
|
||||
|
||||
function fullProvider(overrides: Partial<ForeignNip07Provider> = {}): ForeignNip07Provider {
|
||||
return {
|
||||
getPublicKey: async () => PUBKEY,
|
||||
signEvent: async (event) => ({
|
||||
...event,
|
||||
id: "e".repeat(64),
|
||||
pubkey: PUBKEY,
|
||||
sig: "f".repeat(128)
|
||||
}),
|
||||
getRelays: async () => ({}),
|
||||
nip44: {
|
||||
encrypt: async (peer, plaintext) => `nip44(${peer.slice(0, 4)},${plaintext})`,
|
||||
decrypt: async (peer, payload) => `plain44(${payload})`
|
||||
},
|
||||
nip04: {
|
||||
encrypt: async (peer, plaintext) => `nip04(${plaintext})`,
|
||||
decrypt: async (peer, payload) => `plain04(${payload})`
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("detectForeignNip07Provider", () => {
|
||||
it("returns null when window.nostr is empty", () => {
|
||||
expect(detectForeignNip07Provider({})).toBeNull();
|
||||
expect(detectForeignNip07Provider({ nostr: undefined })).toBeNull();
|
||||
expect(detectForeignNip07Provider({ nostr: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("never mistakes BitLogin's own provider (this or another widget instance) for an extension", () => {
|
||||
const ours = { ...fullProvider(), _bitlogin: true };
|
||||
expect(detectForeignNip07Provider({ nostr: ours })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects occupants missing the two mandatory NIP-07 calls", () => {
|
||||
expect(detectForeignNip07Provider({ nostr: { getPublicKey: async () => PUBKEY } })).toBeNull();
|
||||
expect(detectForeignNip07Provider({ nostr: { signEvent: async () => ({}) } })).toBeNull();
|
||||
expect(detectForeignNip07Provider({ nostr: "not-an-object" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the same reference it was given for a real extension provider", () => {
|
||||
const extension = fullProvider();
|
||||
expect(detectForeignNip07Provider({ nostr: extension })).toBe(extension);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nip07Signer capability probing", () => {
|
||||
it("reports full capabilities for a complete provider", () => {
|
||||
const signer = new Nip07Signer(fullProvider());
|
||||
expect(signer.method).toBe("nip07");
|
||||
expect(signer.capabilities).toEqual({ nip44: true, nip04: true, getRelays: true });
|
||||
});
|
||||
|
||||
it("reports honestly when nip44 or nip04 is missing or partial", () => {
|
||||
expect(new Nip07Signer(fullProvider({ nip44: undefined })).capabilities.nip44).toBe(false);
|
||||
expect(
|
||||
new Nip07Signer(fullProvider({ nip44: { encrypt: async () => "" } })).capabilities.nip44
|
||||
).toBe(false);
|
||||
expect(new Nip07Signer(fullProvider({ nip04: undefined })).capabilities.nip04).toBe(false);
|
||||
expect(new Nip07Signer(fullProvider({ getRelays: undefined })).capabilities.getRelays).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsupported calls with a typed error instead of a TypeError", async () => {
|
||||
const signer = new Nip07Signer(fullProvider({ nip44: undefined }));
|
||||
await expect(signer.nip44Encrypt(PUBKEY, "hi")).rejects.toBeInstanceOf(SignerUnsupportedError);
|
||||
await expect(signer.nip44Decrypt(PUBKEY, "payload")).rejects.toBeInstanceOf(SignerUnsupportedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nip07Signer delegation", () => {
|
||||
it("returns the extension's public key, lowercased", async () => {
|
||||
const signer = new Nip07Signer(fullProvider({ getPublicKey: async () => PUBKEY.toUpperCase() }));
|
||||
expect(await signer.getPublicKey()).toBe(PUBKEY);
|
||||
});
|
||||
|
||||
it("rejects a malformed public key instead of passing it downstream", async () => {
|
||||
const signer = new Nip07Signer(fullProvider({ getPublicKey: async () => "npub1notrawhex" }));
|
||||
await expect(signer.getPublicKey()).rejects.toThrow(/invalid public key/);
|
||||
});
|
||||
|
||||
it("normalizes the unsigned event: fills created_at and tags, preserves them when given", async () => {
|
||||
let seen: { kind: number; tags: string[][]; content: string; created_at: number } | null = null;
|
||||
const signer = new Nip07Signer(
|
||||
fullProvider({
|
||||
signEvent: async (event) => {
|
||||
seen = event;
|
||||
return { ...event, id: "e".repeat(64), pubkey: PUBKEY, sig: "f".repeat(128) };
|
||||
}
|
||||
})
|
||||
);
|
||||
await signer.signEvent({ kind: 1, content: "hello" });
|
||||
expect(seen!.tags).toEqual([]);
|
||||
expect(typeof seen!.created_at).toBe("number");
|
||||
|
||||
await signer.signEvent({ kind: 1, content: "hello", tags: [["t", "x"]], created_at: 1234 });
|
||||
expect(seen!.tags).toEqual([["t", "x"]]);
|
||||
expect(seen!.created_at).toBe(1234);
|
||||
});
|
||||
|
||||
it("passes nip44/nip04 calls through and surfaces the extension's own rejections", async () => {
|
||||
const signer = new Nip07Signer(fullProvider());
|
||||
expect(await signer.nip44Encrypt(PUBKEY, "hi")).toBe(`nip44(${PUBKEY.slice(0, 4)},hi)`);
|
||||
expect(await signer.nip04Decrypt(PUBKEY, "x")).toBe("plain04(x)");
|
||||
|
||||
const declining = new Nip07Signer(
|
||||
fullProvider({ signEvent: async () => Promise.reject(new Error("User rejected")) })
|
||||
);
|
||||
await expect(declining.signEvent({ kind: 1, content: "" })).rejects.toThrow("User rejected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nip07Signer deadlines (no silent hangs)", () => {
|
||||
const never = () => new Promise<never>(() => {});
|
||||
|
||||
it("times out getPublicKey with a typed, retryable error", async () => {
|
||||
const signer = new Nip07Signer(fullProvider({ getPublicKey: never }), { timeoutMs: 20 });
|
||||
await expect(signer.getPublicKey()).rejects.toBeInstanceOf(SignerTimeoutError);
|
||||
});
|
||||
|
||||
it("times out signEvent and encryption calls too", async () => {
|
||||
const signer = new Nip07Signer(
|
||||
fullProvider({
|
||||
signEvent: never,
|
||||
nip44: { encrypt: never, decrypt: never }
|
||||
}),
|
||||
{ timeoutMs: 20 }
|
||||
);
|
||||
await expect(signer.signEvent({ kind: 1, content: "" })).rejects.toBeInstanceOf(SignerTimeoutError);
|
||||
await expect(signer.nip44Encrypt(PUBKEY, "hi")).rejects.toBeInstanceOf(SignerTimeoutError);
|
||||
});
|
||||
|
||||
it("does not time out a call that answers in time", async () => {
|
||||
const signer = new Nip07Signer(fullProvider(), { timeoutMs: 1000 });
|
||||
expect(await signer.getPublicKey()).toBe(PUBKEY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/** NIP-07 delegation: sign in with a signer extension already in the browser (§LM4).
|
||||
*
|
||||
* No key custody, no stored secrets, no relay traffic -- the extension's provider is the
|
||||
* backend and this signer is a passthrough with three additions: capability probing,
|
||||
* deadlines on every call, and normalization of the unsigned event. A NIP-07 session
|
||||
* persists nothing and the widget leaves window.nostr alone -- the extension already
|
||||
* owns that slot, and fighting it (claimSigner) is reserved for BitLogin-account
|
||||
* sessions where the user explicitly chose BitLogin as their signer.
|
||||
*/
|
||||
import type { NostrEvent } from "@bitlogin/core/nostr";
|
||||
import type { UnsignedEventForSigning } from "../provider.js";
|
||||
import {
|
||||
SignerTimeoutError,
|
||||
SignerUnsupportedError,
|
||||
type Signer,
|
||||
type SignerCapabilities
|
||||
} from "./types.js";
|
||||
|
||||
interface EncryptDecrypt {
|
||||
encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** The subset of window.nostr this signer relies on. getPublicKey and signEvent are the
|
||||
* only calls NIP-07 actually mandates; everything else is probed (§LM4). */
|
||||
export interface ForeignNip07Provider {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: {
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
created_at: number;
|
||||
}): Promise<NostrEvent>;
|
||||
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>;
|
||||
nip44?: Partial<EncryptDecrypt>;
|
||||
nip04?: Partial<EncryptDecrypt>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the window.nostr occupant if -- and only if -- it is someone else's NIP-07
|
||||
* provider: a browser extension (Alby, nos2x, …), not this widget's own provider nor
|
||||
* another <bitlogin-auth> instance's (both marked `_bitlogin`). The snapshot is taken
|
||||
* when called; the caller holds the returned reference so a later change of occupant
|
||||
* (including our own claimSigner) can never swap backends mid-session.
|
||||
*
|
||||
* `host` exists for tests; real callers use the default.
|
||||
*/
|
||||
export function detectForeignNip07Provider(
|
||||
host: { nostr?: unknown } = window as unknown as { nostr?: unknown }
|
||||
): ForeignNip07Provider | null {
|
||||
const candidate = host.nostr as
|
||||
| { _bitlogin?: unknown; getPublicKey?: unknown; signEvent?: unknown }
|
||||
| undefined
|
||||
| null;
|
||||
if (!candidate || typeof candidate !== "object") return null;
|
||||
if (candidate._bitlogin === true) return null;
|
||||
if (typeof candidate.getPublicKey !== "function" || typeof candidate.signEvent !== "function") return null;
|
||||
return candidate as unknown as ForeignNip07Provider;
|
||||
}
|
||||
|
||||
/** Generous because these calls are interactive -- most extensions pop an approval
|
||||
* prompt the user has to find and click -- but finite, per §LM3's no-silent-hangs rule. */
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
function withDeadline<T>(promise: Promise<T>, call: string, timeoutMs: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new SignerTimeoutError(call, timeoutMs)), timeoutMs);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(err: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export class Nip07Signer implements Signer {
|
||||
readonly method = "nip07" as const;
|
||||
readonly capabilities: SignerCapabilities;
|
||||
private provider: ForeignNip07Provider;
|
||||
private timeoutMs: number;
|
||||
|
||||
constructor(provider: ForeignNip07Provider, options: { timeoutMs?: number } = {}) {
|
||||
this.provider = provider;
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.capabilities = {
|
||||
nip44:
|
||||
typeof provider.nip44?.encrypt === "function" && typeof provider.nip44?.decrypt === "function",
|
||||
nip04:
|
||||
typeof provider.nip04?.encrypt === "function" && typeof provider.nip04?.decrypt === "function",
|
||||
getRelays: typeof provider.getRelays === "function"
|
||||
};
|
||||
}
|
||||
|
||||
async getPublicKey(): Promise<string> {
|
||||
const raw = await withDeadline(this.provider.getPublicKey(), "getPublicKey", this.timeoutMs);
|
||||
const publicKey = typeof raw === "string" ? raw.toLowerCase() : "";
|
||||
if (!/^[0-9a-f]{64}$/.test(publicKey)) {
|
||||
throw new Error("The extension returned an invalid public key (expected 64 hex characters).");
|
||||
}
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
async signEvent(event: UnsignedEventForSigning): Promise<NostrEvent> {
|
||||
// NIP-07 leaves it to the caller to complete the unsigned event; some extensions
|
||||
// reject when created_at or tags are absent, so fill both here rather than making
|
||||
// every caller remember to.
|
||||
const normalized = {
|
||||
kind: event.kind,
|
||||
content: event.content,
|
||||
tags: (event.tags ?? []) as string[][],
|
||||
created_at: event.created_at ?? Math.floor(Date.now() / 1000)
|
||||
};
|
||||
return withDeadline(this.provider.signEvent(normalized), "signEvent", this.timeoutMs);
|
||||
}
|
||||
|
||||
async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
if (!this.capabilities.nip44) throw new SignerUnsupportedError("nip44.encrypt", this.method);
|
||||
return withDeadline(this.provider.nip44!.encrypt!(peerPublicKey, plaintext), "nip44.encrypt", this.timeoutMs);
|
||||
}
|
||||
|
||||
async nip44Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
if (!this.capabilities.nip44) throw new SignerUnsupportedError("nip44.decrypt", this.method);
|
||||
return withDeadline(this.provider.nip44!.decrypt!(peerPublicKey, payload), "nip44.decrypt", this.timeoutMs);
|
||||
}
|
||||
|
||||
async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise<string> {
|
||||
if (!this.capabilities.nip04) throw new SignerUnsupportedError("nip04.encrypt", this.method);
|
||||
return withDeadline(this.provider.nip04!.encrypt!(peerPublicKey, plaintext), "nip04.encrypt", this.timeoutMs);
|
||||
}
|
||||
|
||||
async nip04Decrypt(peerPublicKey: string, payload: string): Promise<string> {
|
||||
if (!this.capabilities.nip04) throw new SignerUnsupportedError("nip04.decrypt", this.method);
|
||||
return withDeadline(this.provider.nip04!.decrypt!(peerPublicKey, payload), "nip04.decrypt", this.timeoutMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Signer backends behind the widget's provider facade (docs/login-methods.md §LM3).
|
||||
*
|
||||
* A Signer is one way of producing signatures and encryption for the identity the user
|
||||
* chose at sign-in. The derived BitLogin account path stays worker-backed and is not
|
||||
* wrapped in this interface today (the element talks to WorkerClient directly, as it
|
||||
* always has); alternative methods -- NIP-07 now, NIP-46 later -- implement this
|
||||
* interface, and the element routes its public API through whichever is active.
|
||||
*/
|
||||
import type { NostrEvent } from "@bitlogin/core/nostr";
|
||||
import type { UnsignedEventForSigning } from "../provider.js";
|
||||
|
||||
export type SignerMethod = "bitlogin" | "nip07";
|
||||
|
||||
/** What the active signer actually supports. NIP-07 extensions vary -- some lack nip44,
|
||||
* older ones lack nip04 -- and the facade reports honestly instead of throwing a
|
||||
* TypeError off an undefined property (§LM4). Surfaced to the host in the
|
||||
* bitlogin-login event detail. */
|
||||
export interface SignerCapabilities {
|
||||
nip44: boolean;
|
||||
nip04: boolean;
|
||||
getRelays: boolean;
|
||||
}
|
||||
|
||||
export interface Signer {
|
||||
readonly method: SignerMethod;
|
||||
readonly capabilities: SignerCapabilities;
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: UnsignedEventForSigning): Promise<NostrEvent>;
|
||||
nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
nip44Decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
nip04Encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
|
||||
nip04Decrypt(peerPublicKey: string, payload: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** A call the active signer's backend doesn't implement. Deliberately a typed, stable
|
||||
* name so hosts can distinguish "this extension can't do nip44" from a real failure. */
|
||||
export class SignerUnsupportedError extends Error {
|
||||
constructor(call: string, method: SignerMethod) {
|
||||
super(`The active signer (${method}) does not support ${call}.`);
|
||||
this.name = "SignerUnsupportedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** No silent hangs (§LM3): every facade call on an external signer has a deadline.
|
||||
* Timing out is retryable -- the extension may just be waiting on a user prompt that
|
||||
* got lost, or a remote signer may be asleep. */
|
||||
export class SignerTimeoutError extends Error {
|
||||
constructor(call: string, timeoutMs: number) {
|
||||
super(
|
||||
`The signer did not answer ${call} within ${Math.round(timeoutMs / 1000)}s. It may be waiting for your approval in another window, or unavailable -- check your signer and try again.`
|
||||
);
|
||||
this.name = "SignerTimeoutError";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user