/** — the embeddable BitLogin login/create/recover widget (§3, §27). */ import { generatePassphrase, isValidLoginName, parseRecoveryExport, RecoveryExportParseError, type RecoveryExportFile } from "@bitlogin/core/account"; import { encodeNpub } from "@bitlogin/core/nostr"; import { randomUniformInt } from "@bitlogin/core/crypto"; import { WorkerClient } from "./worker/workerClient.js"; import { createElementRoutedProvider, type Nip07Provider } from "./provider.js"; import { readConfigFromElement } from "./config.js"; 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 { Nip46Signer } from "./signers/nip46.js"; import type { Signer, SignerCapabilities, SignerMethod } from "./signers/types.js"; import { renderQrSvg } from "./qr.js"; type Screen = | "welcome" | "extension-confirm" | "bunker-connect" | "bunker-confirm" | "import-key" | "create-name" | "create-credential" | "confirm-phrase" | "verify-phrase" | "login" | "recover-phrase" | "recover-new-credentials" | "dashboard" | "change-password" | "export" | "rollback-confirm" | "vault-consent" | "vault-import" | "vault-manage" | "vault-offer"; interface ConfirmSlot { index: number; value: string; } /** Line icons for the "More sign-in options" menu: stroke-based so they pick * up the muted foreground in either theme, sized by .option-icon. */ const OPTION_ICONS = { extension: ``, remote: ``, importKey: ``, recover: `` } as const; const CHEVRON_DOWN = ``; const CHEVRON_LEFT = ``; export class BitLoginAuthElement extends HTMLElement { private root: ShadowRoot; private worker: WorkerClient; private vaultRelayUrls: string[] = []; private discoveryRelayUrls: string[] = []; private installedProvider: Nip07Provider | null = null; private screen: Screen = "welcome"; private busy = false; private errorMessage: string | undefined; /** Welcome-screen disclosure for non-primary sign-in paths; survives * re-renders so an open menu doesn't snap shut on an error render. */ private moreOptionsOpen = false; private loginName = ""; private generatedCredential = ""; private savedCheckbox = false; private recoveryPhrase = ""; private confirmSlots: ConfirmSlot[] = []; // Import flow (§SF10). importKey holds the pasted nsec/hex only until registration completes. private importKey = ""; private importPreviewNpub = ""; private recoverPhraseInput = ""; private recoveredPreview: { generalRelaysCount: number; dmRelaysCount: number; chainWarning?: string } | null = null; private newCredentialAfterRecovery = ""; // Optional recovery-export-file fallback (§19.5) for when live relays can't be reached. // Never a substitute for the phrase -- the file never contains it or any phrase-derived key. private offlineExportFile: RecoveryExportFile | null = null; private offlineExportFileNotice: string | undefined; private session: { publicKey: string; npub: string; accountId?: string; method: SignerMethod } | null = null; // ---- Alternative-method sessions (docs/login-methods.md §LM4, §LM5, §LM7) ---- // `pending*` state holds a candidate between "the signer answered" and the user // confirming that npub is theirs; only `altSigner` routes the public API. Thin by // design: no capsule, no vault, no persistence -- a reload simply signs the user // out, and the signer is asked again next visit. private pendingExtensionSigner: Nip07Signer | null = null; private altSigner: Signer | null = null; private extensionPreviewNpub = ""; private extensionPreviewPubkey = ""; // NIP-46 connect flow (§LM5). The QR/copyable nostrconnect URI, the signer's // interactive-approval URL if one arrives mid-connect, and the confirmed-but- // not-yet-adopted user identity. private bunkerConnectUri = ""; private bunkerAuthUrl = ""; private pendingBunker: { userPubkey: string; npub: string } | null = null; private sessionWarnings: string[] = []; private lastSignedEventJson = ""; private exportedNsec = ""; private changePasswordNewCredential = ""; // Brief animated brass-seal stamp (matching the widget's own brand mark) shown once over // the destination screen right after a real security-relevant success -- see flashSuccess(). private pendingSuccessLabel: string | null = null; private successDismissTimer: ReturnType | null = null; // Rollback confirmation (§16.2 step 6). A RollbackDetectedError from either login or // password-change means this device has already seen a newer credential generation than the // one just read -- most likely a rotated-away password being replayed from a relay that never // processed its tombstone. Rather than a passive dashboard banner shown after the fact (which // let an old, "revoked" password fully unlock a session), this blocks BEFORE claiming the // signer or dispatching bitlogin-login, and requires an explicit second step to proceed. private pendingRollback: | { kind: "login"; loginName: string; password: string } | { kind: "change-password"; oldPassword: string; newPassword: string } | null = null; private rollbackMessage = ""; // ---- Connection Vault request flow (vault-ux.md §2-§4, reveal mode) ---- // stage "auth": waiting for the user to sign in first; the goto("dashboard") // hook resumes the request exactly once. stage "active": the vault screens // own navigation until finishVaultRequest() settles the promise. private vaultRequest: { appName: string; reason?: string; origin: string; stage: "auth" | "active"; resolve: (uri: string | null) => void; } | null = null; private vaultCandidate: VaultConnectionSummary | null = null; /** True when the account cannot store the connection (no vault root); the * flow still hands the URI to the app, labeled as unsaved. */ private vaultUnsaved = false; private vaultUnsavedReason: "no-vault" | "stale-cache" | undefined; private vaultConnections: VaultConnectionSummary[] | null = null; private vaultIntegrityWarnings: string[] = []; /** offerNwcConnection state: the app already holds this URI; the only * question on screen is whether a copy enters the user's vault. */ /** Claimed synchronously by offerNwcConnection before any await (see there). */ private offerInFlight = false; private vaultOffer: { uri: string; appName: string; label: string; resolve: (outcome: "saved" | "declined") => void; } | null = null; constructor() { super(); this.root = this.attachShadow({ mode: "open" }); // A parsed CSSStyleSheet assigned via adoptedStyleSheets isn't subject to a page's // style-src CSP the way an inline