Files
bitvid/js/nostr/managers/SignerManager.js
T
thePR0M3TH3ANandClaude Opus 4.8 4795d5edbd [auth] Bug pass on login/signer/session surface: 4 fixes before promotion
Requested pre-promotion sweep of login methods, active signers, and anon session
actors. Four real bugs found and fixed:

1. Logging out could wipe EVERY saved account's remembered nsec key. The no-arg
   clearStoredSessionActor() removed the whole per-account v2 map, and it was
   reachable from SignerManager.logout() (non-persisted nsec cleanup) and from
   the blocked-key cleanup in registerPrivateKeySigner — same bug class as the
   NIP-46 session-map wipe fixed earlier. Now: the no-arg form clears only the
   legacy v1 slot; logout targets the departing account (and skips entirely when
   it can't name one); the blocked-key cleanup targets the blocked pubkey
   (mirroring the already-targeted twin in unlockStoredSessionActor).
   Spec-corrected the test that had encoded the wipe-all as intended behavior.

2. Removing a saved profile left its keys on the device. The switcher's
   remove-account flow cleared NWC + the keep-unlocked cache but kept the
   remembered (encrypted) nsec key and the NIP-46 session. New
   client.forgetStoredAccount(pubkey) (SignerManager.forgetStoredSessionActor +
   clearStoredNip46Session) forgets both WITHOUT touching the active session's
   in-memory actors; wired into handleProfileLogoutRequest.

3. Logout never actually re-locked a kept-unlocked device. The forget read
   detail.pubkey / this.pubkey — both already nulled by authService.logout()
   (the detail only carries previousPubkey) — so the cached decrypted key
   silently survived logout. Now reads previousPubkey.

4. restoreUnlockedSigner bypassed access control: a since-blacklisted account's
   cached key would silently restore its signer. It now accepts a validator
   (mirroring unlockStoredSessionActor), forgets the cached key on denial, and
   both app-level call sites (_initAutoLogin, ensureEncryptionCapableSigner)
   pass an accessControl.canAccess gate.

Also verified-safe during the pass (no action): logout's persisted-nsec keep
logic under the ephemeral session-actor overwrite; NIP-46 logout keepStored;
the ephemeral actor as a harmless extra DM-decrypt candidate; corrupt-map
recovery clears; anon blacklist-seeding now always has a signing-capable actor.

Tests: session-actor multi-account suite spec-corrected + re-verified (no-arg
clear keeps per-account keys); 57 tests across session-actor / nip46 /
unlocked-key / switch / signer-gate / feed-gate suites green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:59:52 -04:00

1241 lines
41 KiB
JavaScript

import {
isSessionActor,
readStoredSessionActorEntry,
decryptSessionPrivateKey,
encryptSessionPrivateKey,
persistSessionActor as persistSessionActorEntry,
clearStoredSessionActor as clearStoredSessionActorEntry,
} from "../sessionActor.js";
import {
waitForNip07Extension,
NIP07_EXTENSION_WAIT_TIMEOUT_MS,
requestEnablePermissions,
normalizePermissionMethod,
readStoredNip07Permissions,
writeStoredNip07Permissions,
clearStoredNip07Permissions,
runNip07WithRetry,
DEFAULT_NIP07_ENCRYPTION_METHODS,
DEFAULT_NIP07_CORE_METHODS,
DEFAULT_NIP07_PERMISSION_METHODS,
} from "../nip07Permissions.js";
import {
setActiveSigner as setActiveSignerInRegistry,
getActiveSigner as getActiveSignerFromRegistry,
clearActiveSigner as clearActiveSignerInRegistry,
logoutSigner as logoutSignerFromRegistry,
} from "../../nostrClientRegistry.js";
import {
resolveSignerCapabilities,
hydrateExtensionSignerCapabilities,
attachNipMethodAliases,
resolveActiveSigner,
buildExtensionSignerAdapter,
} from "../signerCapabilities.js";
// Re-exported for existing importers (e.g. js/nostr/client.js) that historically
// imported these from SignerManager.
export {
resolveSignerCapabilities,
hydrateExtensionSignerCapabilities,
attachNipMethodAliases,
};
import {
readStoredNip46Session,
writeStoredNip46Session,
clearStoredNip46Session,
Nip46RpcClient,
decryptNip46Session,
parseNip46ConnectionString,
sanitizeNip46Metadata,
normalizeNip46EncryptionAlgorithm,
resolveNip46Relays,
encodeHexToNpub,
generateNip46Secret,
NIP46_RPC_KIND,
NIP46_HANDSHAKE_TIMEOUT_MS,
attemptDecryptNip46HandshakePayload,
normalizeNostrPubkey,
} from "../nip46Client.js";
import {
summarizeHexForLog,
summarizeSecretForLog,
summarizeMetadataForLog,
summarizeUrlForLog,
} from "../nip46LoggingUtils.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { HEX64_REGEX } from "../../utils/hex.js";
import { createPrivateKeyCipherClosures } from "../signerHelpers.js";
import { queueSignEvent } from "../signRequestQueue.js";
import { signEventWithPrivateKey } from "../publishHelpers.js";
import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js";
import { accessControl } from "../../accessControl.js";
import { waitForRemoteSignerHandshake } from "../signerRemoteHandshake.js";
export class SignerManager {
constructor(client) {
this.client = client;
this.pubkey = null;
this.sessionActor = null;
this.lockedSessionActor = null;
this.nip46Client = null;
this.extensionReady = false;
this.extensionPermissionsGranted = false;
this.extensionPermissionCache = new Map();
const storedPermissions = readStoredNip07Permissions();
if (storedPermissions && storedPermissions.size > 0) {
for (const method of storedPermissions) {
this.extensionPermissionCache.set(method, true);
}
}
this.sessionActorCipherClosures = null;
this.sessionActorCipherClosuresPrivateKey = null;
this.remoteSignerListeners = new Set();
this.remoteSignerStatus = {};
}
setActiveSigner(signer) {
if (!signer || typeof signer !== "object") {
return;
}
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities");
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
setActiveSignerInRegistry(signer);
}
getActiveSigner() {
const signer = getActiveSignerFromRegistry();
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
if (signer && typeof signer === "object") {
const capsDescriptor = Object.getOwnPropertyDescriptor(
signer,
"capabilities",
);
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
}
return signer;
}
resolveActiveSigner(pubkey) {
return resolveActiveSigner(pubkey);
}
async ensureSessionActor(force = false) {
// The session actor is an ANONYMOUS telemetry key used to SIGN reactions /
// view events / watch-history when they aren't tied to the login. It must
// therefore carry a usable plaintext private key. A persisted nsec login is
// passphrase-encrypted (privateKey ""), so adopting one as the session actor
// made reactions fail with "Missing signing primitives" (a leftover saved-nsec
// entry hijacked the anonymous key). Only reuse an actor that can actually
// sign; otherwise fall through and generate a fresh ephemeral key.
const hasUsableKey = (actor) =>
Boolean(
actor &&
typeof actor.privateKey === "string" &&
HEX64_REGEX.test(actor.privateKey),
);
if (!force) {
if (hasUsableKey(this.sessionActor)) {
return this.sessionActor.pubkey;
}
const storedEntry = readStoredSessionActorEntry();
if (storedEntry) {
// Track a locked/encrypted entry (other flows read it) but don't sign with it.
this.lockedSessionActor = storedEntry;
}
if (hasUsableKey(storedEntry)) {
this.sessionActor = storedEntry;
return storedEntry.pubkey;
}
}
const tools = await ensureNostrTools();
if (!tools) {
return null;
}
let secret;
try {
if (typeof tools.generateSecretKey === "function") {
secret = tools.generateSecretKey();
} else if (typeof window !== "undefined" && window.crypto) {
secret = new Uint8Array(32);
window.crypto.getRandomValues(secret);
} else {
return null;
}
} catch (error) {
devLogger.warn("[nostr] Failed to generate session actor key:", error);
return null;
}
const hexKey = Array.from(secret)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const pubkey = tools.getPublicKey(secret);
const newActor = {
pubkey,
privateKey: hexKey,
source: "session",
createdAt: Date.now(),
};
this.sessionActor = newActor;
return newActor.pubkey;
}
// Forget a stored (remembered) key. Pass the account's pubkey — the no-arg
// form only clears the legacy v1 slot and must never be used to log out one
// account (it used to wipe EVERY saved account's key).
clearStoredSessionActor(pubkey) {
clearStoredSessionActorEntry(pubkey);
this.sessionActor = null;
this.lockedSessionActor = null;
}
// Forget ONE account's remembered key WITHOUT touching the current session's
// in-memory actors (used when removing a non-active saved profile). Only drops
// the in-memory references if they belong to the removed account.
forgetStoredSessionActor(pubkey) {
const target =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (!target) {
return;
}
clearStoredSessionActorEntry(target);
const matches = (actor) =>
typeof actor?.pubkey === "string" &&
actor.pubkey.trim().toLowerCase() === target;
if (matches(this.lockedSessionActor)) {
this.lockedSessionActor = null;
}
if (matches(this.sessionActor) && !this.sessionActor?.privateKey) {
this.sessionActor = null;
}
}
async ensureActiveSignerForPubkey(pubkey) {
const normalizedPubkey =
typeof pubkey === "string" && pubkey.trim()
? pubkey.trim().toLowerCase()
: "";
const existingSigner = this.resolveActiveSigner(normalizedPubkey);
if (existingSigner && typeof existingSigner.signEvent === "function") {
return existingSigner;
}
let extension =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!extension && this.extensionPermissionCache && this.extensionPermissionCache.size > 0) {
try {
await waitForNip07Extension(NIP07_EXTENSION_WAIT_TIMEOUT_MS);
extension = window.nostr;
} catch (error) {
// Fall through to existing signer check
}
}
const recheckedSigner = this.resolveActiveSigner(normalizedPubkey);
if (recheckedSigner && typeof recheckedSigner.signEvent === "function") {
return recheckedSigner;
}
if (!extension) {
return existingSigner;
}
let extensionPubkey = normalizedPubkey;
if (typeof extension.getPublicKey === "function") {
try {
const retrieved = await runNip07WithRetry(
() => extension.getPublicKey(),
{ label: "extension.getPublicKey" },
);
if (typeof retrieved === "string" && retrieved.trim()) {
extensionPubkey = retrieved.trim().toLowerCase();
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to hydrate active signer from extension pubkey:",
error,
);
return existingSigner;
}
}
if (normalizedPubkey && extensionPubkey !== normalizedPubkey) {
return existingSigner;
}
const raceWinner = this.resolveActiveSigner(extensionPubkey || normalizedPubkey);
if (raceWinner && typeof raceWinner.signEvent === "function") {
return raceWinner;
}
// Registry empty (e.g. page refresh restored the pubkey/UI but no fresh
// loginWithExtension ran): build + register the adapter from the live
// extension so getActiveSigner()/decrypt/sign work app-wide (storage unlock)
// rather than relying on per-call fallbacks. Guarded on a matching pubkey so
// we never adopt a different account.
const resolvedPubkey = extensionPubkey || normalizedPubkey;
if (
resolvedPubkey &&
(!normalizedPubkey || resolvedPubkey === normalizedPubkey) &&
typeof extension.signEvent === "function"
) {
const adapter = buildExtensionSignerAdapter(extension, resolvedPubkey);
this.pubkey = resolvedPubkey;
this.setActiveSigner(adapter);
// Return through resolveActiveSigner so capability aliases
// (nip04Decrypt/nip44Decrypt) are hydrated onto the adapter.
return this.resolveActiveSigner(resolvedPubkey) || adapter;
}
return null;
}
async ensureExtensionPermissions(
requiredMethods = DEFAULT_NIP07_CORE_METHODS,
{ context = "general" } = {},
) {
const methods = Array.isArray(requiredMethods)
? requiredMethods
: [requiredMethods];
if (!methods.length) {
return { ok: true };
}
const cacheKey = methods.slice().sort().join(",");
if (this.extensionPermissionCache.has(cacheKey)) {
return { ok: true };
}
const cachedPermissions = readStoredNip07Permissions();
const missing = methods.filter(
(method) => !cachedPermissions.has(method),
);
if (missing.length === 0) {
this.extensionPermissionsGranted = true;
this.extensionPermissionCache.set(cacheKey, true);
for (const method of methods) {
this.extensionPermissionCache.set(method, true);
}
return { ok: true };
}
let extension = null;
try {
extension = await waitForNip07Extension();
} catch (error) {
return { ok: false, error: "extension-missing" };
}
if (!extension && typeof window !== "undefined") {
extension = window.nostr;
}
try {
const response = await requestEnablePermissions(extension, missing);
if (response?.ok) {
writeStoredNip07Permissions(missing);
this.extensionPermissionsGranted = true;
this.extensionPermissionCache.set(cacheKey, true);
for (const method of missing) {
this.extensionPermissionCache.set(method, true);
}
return { ok: true };
}
return {
ok: false,
error: response?.error || "permission-denied",
};
} catch (error) {
devLogger.warn("[nostr] Extension permission request failed:", error);
return { ok: false, error: "request-failed" };
}
}
async loginWithExtension(options = {}) {
let extension = null;
let attempts = 0;
const maxAttempts = 3;
if (this.extensionReady && typeof window !== "undefined") {
extension = window.nostr || null;
if (!extension) {
this.extensionReady = false;
}
}
if (!extension && typeof window !== "undefined" && window.nostr) {
extension = window.nostr;
this.extensionReady = true;
} else {
while (!extension && attempts < maxAttempts) {
try {
const timeout = attempts === 0 ? 1500 : 3000;
await waitForNip07Extension(timeout);
extension = window.nostr;
this.extensionReady = Boolean(extension);
} catch (waitError) {
devLogger.log(
`Timed out waiting for extension injection (attempt ${
attempts + 1
}/${maxAttempts}):`,
waitError,
);
}
if (extension) {
break;
}
attempts++;
if (attempts < maxAttempts) {
devLogger.log("Retrying NIP-07 detection...");
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
if (!extension) {
devLogger.log("No Nostr extension found");
throw new Error("Please install a Nostr extension (Alby, nos2x, etc.).");
}
const {
allowAccountSelection = false,
expectPubkey,
getPublicKeyTimeoutMs,
getPublicKeyRetryMultiplier,
} =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension.",
);
}
const permissionResult = await this.client.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
{ context: "login", logMetrics: true, showStatus: false },
);
if (!permissionResult.ok) {
const permissionErrorDetail =
permissionResult && typeof permissionResult === "object"
? permissionResult.error
: null;
const permissionDetailMessage =
permissionErrorDetail instanceof Error &&
typeof permissionErrorDetail.message === "string" &&
permissionErrorDetail.message.trim()
? permissionErrorDetail.message.trim()
: typeof permissionErrorDetail === "string" &&
permissionErrorDetail.trim()
? permissionErrorDetail.trim()
: "";
const denialMessage =
permissionDetailMessage
? `The NIP-07 extension denied the permission request (${permissionDetailMessage}). Please approve the prompt and try again.`
: 'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
const denialError = new Error(denialMessage);
if (permissionResult.error) {
denialError.cause = permissionResult.error;
}
throw denialError;
}
const pubkey = await runNip07WithRetry(
() => extension.getPublicKey(),
{
label: "extension.getPublicKey",
timeoutMs: getPublicKeyTimeoutMs,
retryMultiplier: getPublicKeyRetryMultiplier,
},
);
if (!pubkey) {
throw new Error("Extension did not return a public key.");
}
const normalized = pubkey.toLowerCase();
if (normalizedExpectedPubkey && normalized !== normalizedExpectedPubkey) {
throw new Error("Extension returned a different public key than expected.");
}
this.pubkey = normalized;
const adapter = buildExtensionSignerAdapter(extension, normalized);
this.setActiveSigner(adapter);
return { pubkey: normalized, signer: adapter };
}
installNip46Client(nip46Client, { userPubkey } = {}) {
if (!nip46Client) {
return;
}
this.nip46Client = nip46Client;
if (userPubkey) {
this.pubkey = userPubkey;
this.setActiveSigner(nip46Client);
}
this.emitRemoteSignerChange({
state: "connected",
userPubkey: userPubkey || this.pubkey,
remotePubkey: nip46Client.remotePubkey,
});
}
/**
* Prepares a NIP-46 `nostrconnect://` handshake: generates an ephemeral client
* keypair and builds the connection URI the user shares with their remote
* signer (link/QR). The returned handshake is then handed to
* connectRemoteSigner(). This is the "generate connect link" login path; it was
* previously only implemented on the unused Nip46Connector, so the client's
* `prepareRemoteSignerHandshake` proxy had nothing to call.
*
* @param {object} [params]
* @param {object} [params.metadata]
* @param {string[]} [params.relays]
* @param {string} [params.secret]
* @param {string} [params.permissions]
* @returns {Promise<object>} handshake { connectionString, clientPrivateKey, clientPublicKey, relays, secret, permissions, metadata }
*/
async prepareRemoteSignerHandshake({ metadata, relays, secret, permissions } = {}) {
const tools = await ensureNostrTools();
if (
!tools ||
typeof tools.generateSecretKey !== "function" ||
typeof tools.getPublicKey !== "function"
) {
throw new Error(
"Nostr tools are unavailable; cannot prepare a remote signer handshake.",
);
}
const secretKey = tools.generateSecretKey();
const clientPrivateKey = Array.from(secretKey)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const clientPublicKey = tools.getPublicKey(secretKey);
const sanitizedMetadata = sanitizeNip46Metadata(metadata);
const requestedPermissions =
typeof permissions === "string" && permissions.trim()
? permissions.trim()
: "";
const resolvedRelays = resolveNip46Relays(relays, this.client?.relays);
const handshakeSecret =
typeof secret === "string" && secret.trim()
? secret.trim()
: generateNip46Secret();
const params = [];
for (const relay of resolvedRelays) {
params.push(`relay=${encodeURIComponent(relay)}`);
}
if (handshakeSecret) {
params.push(`secret=${encodeURIComponent(handshakeSecret)}`);
}
if (requestedPermissions) {
params.push(`perms=${encodeURIComponent(requestedPermissions)}`);
}
for (const [key, value] of Object.entries(sanitizedMetadata)) {
params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
const query = params.length ? `?${params.join("&")}` : "";
const uri = `nostrconnect://${clientPublicKey}${query}`;
devLogger.debug("[nostr] Prepared remote signer handshake", {
clientPublicKey: summarizeHexForLog(clientPublicKey),
relays: resolvedRelays,
permissions: requestedPermissions || null,
});
return {
type: "client",
connectionString: uri,
uri,
clientPrivateKey,
clientPublicKey,
relays: resolvedRelays,
secret: handshakeSecret,
permissions: requestedPermissions,
metadata: sanitizedMetadata,
};
}
/**
* Runs the access-control validator against a remote signer's resolved user
* pubkey. The validator (from the auth provider) throws on denial (blocked /
* not permitted) or returns false; either rejects the login. No-op when no
* validator is supplied.
*/
async _enforceRemoteSignerValidator(validator, userPubkey) {
if (typeof validator !== "function") {
return;
}
const isValid = await validator(userPubkey);
if (isValid === false) {
const error = new Error("Access denied.");
error.code = "remote-signer-access-denied";
throw error;
}
}
/**
* Waits for a remote signer to acknowledge a `nostrconnect://` handshake.
* Subscribes to NIP-46 RPC events addressed to the client pubkey; the first
* acknowledgement (matching the handshake secret, or a generic ack) reveals
* the remote signer's pubkey, which is needed before the RPC client can be
* constructed. Ported from the (unused) Nip46Connector.
*
* @returns {Promise<{remotePubkey:string, eventPubkey:string, response:object, algorithm:string}>}
*/
async _waitForRemoteSignerHandshake(options = {}) {
return waitForRemoteSignerHandshake(this, options);
}
async connectRemoteSigner({
connectionString,
remember = true,
clientPrivateKey: providedClientPrivateKey = "",
clientPublicKey: providedClientPublicKey = "",
relays: providedRelays = [],
secret: providedSecret = "",
permissions: providedPermissions = "",
metadata: providedMetadata = {},
onAuthUrl,
onStatus,
handshakeTimeoutMs,
passphrase,
validator,
} = {}) {
const parsed = parseNip46ConnectionString(connectionString);
if (!parsed) {
const error = new Error(
"Unsupported NIP-46 URI. Provide a nostrconnect:// handshake or bunker:// pointer.",
);
error.code = "invalid-connection-string";
throw error;
}
const baseMetadata = sanitizeNip46Metadata(parsed.metadata);
const overrideMetadata = sanitizeNip46Metadata(providedMetadata);
const metadata = { ...baseMetadata, ...overrideMetadata };
devLogger.debug("[nostr] Connecting to remote signer", {
connectionType: parsed.type,
parsedRemotePubkey: summarizeHexForLog(parsed.remotePubkey || ""),
providedClientPublicKey: summarizeHexForLog(providedClientPublicKey),
providedClientPrivateKey: summarizeSecretForLog(
typeof providedClientPrivateKey === "string" ? providedClientPrivateKey : "",
),
providedSecret: summarizeSecretForLog(providedSecret),
providedPermissions: providedPermissions || null,
handshakeTimeoutMs,
parsedRelays: parsed.relays,
overrideRelayCount: Array.isArray(providedRelays) ? providedRelays.length : 0,
metadataKeys: summarizeMetadataForLog(metadata),
});
let clientPrivateKey = providedClientPrivateKey || "";
if (!clientPrivateKey) {
const tools = await ensureNostrTools();
if (tools && typeof tools.generateSecretKey === 'function') {
const secret = tools.generateSecretKey();
// Convert to hex
clientPrivateKey = Array.from(secret).map(b => b.toString(16).padStart(2, '0')).join('');
} else if (typeof window !== 'undefined' && window.crypto) {
const randomBytes = new Uint8Array(32);
window.crypto.getRandomValues(randomBytes);
clientPrivateKey = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for non-secure contexts (not ideal but functional for test/dev)
clientPrivateKey = generateNip46Secret(64);
}
}
const handleStatus = (status) => {
if (typeof onStatus !== "function") {
return;
}
try {
onStatus(status);
} catch (error) {
devLogger.warn("[nostr] Remote signer status callback threw:", error);
}
};
const handleAuthChallenge = async (url, context = {}) => {
if (typeof onAuthUrl !== "function" || !url) {
return;
}
try {
devLogger.debug("[nostr] Remote signer auth challenge surfaced", {
url: summarizeUrlForLog(url),
context,
});
const result = onAuthUrl(url, context);
if (result && typeof result.then === "function") {
await result.catch((error) => {
devLogger.warn("[nostr] Auth challenge callback promise rejected:", error);
});
}
} catch (error) {
devLogger.warn("[nostr] Auth challenge callback threw:", error);
}
};
await this.client.ensurePool();
// A nostrconnect:// link is client-initiated: the remote signer's pubkey is
// unknown until the user's signer scans the QR / opens the link and
// acknowledges. Wait for that ACK to learn the remote pubkey before building
// the RPC client (which requires it). bunker:// URIs already carry it.
let remotePubkey = parsed.remotePubkey;
if (!remotePubkey && parsed.type === "client") {
const clientPublicKey =
providedClientPublicKey || parsed.clientPublicKey || "";
handleStatus({
phase: "handshake",
state: "waiting",
message: "Waiting for your signer to approve the connection…",
});
const ack = await this._waitForRemoteSignerHandshake({
clientPrivateKey,
clientPublicKey,
relays: resolveNip46Relays(parsed.relays, providedRelays),
secret: providedSecret || parsed.secret,
onAuthUrl: handleAuthChallenge,
onStatus: handleStatus,
timeoutMs: handshakeTimeoutMs,
});
remotePubkey = ack.remotePubkey;
if (!remotePubkey) {
throw new Error("Remote signer did not provide a usable pubkey.");
}
}
this.nip46Client = new Nip46RpcClient({
nostrClient: this.client,
relays: resolveNip46Relays(parsed.relays, providedRelays),
clientPrivateKey: clientPrivateKey,
remotePubkey,
secret: providedSecret || parsed.secret,
signEvent: signEventWithPrivateKey,
});
// Subscribe to events from the NIP46 client?
// The previous code had onStatusChange in the constructor which Nip46RpcClient doesn't seem to support directly based on the read_file output of nip46Client.js.
// However, Nip46RpcClient has no public event emitter interface shown in the file read.
// We will just use it to connect.
this.emitRemoteSignerChange({ state: "connecting" });
try {
const result = await this.nip46Client.connect({
permissions: providedPermissions,
});
if (remember) {
await writeStoredNip46Session(
{
version: 1,
clientPrivateKey: this.nip46Client.clientPrivateKey,
clientPublicKey: this.nip46Client.clientPublicKey,
remotePubkey: this.nip46Client.remotePubkey,
relays: this.nip46Client.relays,
encryption: this.nip46Client.encryptionAlgorithm,
secret: this.nip46Client.secret,
permissions: this.nip46Client.permissions,
metadata: this.nip46Client.metadata,
userPubkey: this.nip46Client.userPubkey,
lastConnectedAt: Date.now(),
},
passphrase
);
}
const userPubkey = await this.nip46Client.getUserPubkey();
// Enforce access control BEFORE activating the signer, so a blocked /
// non-permitted pubkey can't establish a session via a remote signer.
await this._enforceRemoteSignerValidator(validator, userPubkey);
this.pubkey = userPubkey;
this.setActiveSigner(this.nip46Client.getActiveSigner());
this.emitRemoteSignerChange();
return { ok: true, result, pubkey: userPubkey };
} catch(err) {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
throw err;
}
}
async useStoredRemoteSigner(options = {}) {
const normalizedOptions =
options && typeof options === "object" ? options : {};
const silent = normalizedOptions.silent === true;
const forgetOnError = normalizedOptions.forgetOnError === true;
const passphrase =
typeof normalizedOptions.passphrase === "string"
? normalizedOptions.passphrase
: null;
const validator =
typeof normalizedOptions.validator === "function"
? normalizedOptions.validator
: null;
// When switching accounts, select that account's stored session so we
// reconnect the one the user asked for — not just the last-connected default.
const targetPubkey =
typeof normalizedOptions.pubkey === "string"
? normalizedOptions.pubkey.trim().toLowerCase()
: "";
let stored = readStoredNip46Session(targetPubkey || undefined);
if (!stored) {
const error = new Error(
"No remote signer session is stored on this device.",
);
error.code = "no-stored-session";
throw error;
}
if (stored.encryptedSecrets) {
if (!passphrase) {
const error = new Error("Passphrase required to unlock session.");
error.code = "passphrase-required";
throw error;
}
try {
stored = await decryptNip46Session(stored, passphrase);
} catch (decryptError) {
const error = new Error("Failed to decrypt stored session.");
error.code = "decrypt-failed";
error.cause = decryptError;
throw error;
}
}
const relays = resolveNip46Relays(stored.relays, this.client.relays);
devLogger.debug("[nostr] Reconnecting to stored remote signer", {
remotePubkey: summarizeHexForLog(stored.remotePubkey || ""),
relays,
encryption: stored.encryption || "",
permissions: stored.permissions || null,
hasCredentials: Boolean(stored.clientPrivateKey && stored.secret),
});
this.emitRemoteSignerChange({
state: "connecting",
relays,
remotePubkey: stored.remotePubkey,
});
await this.client.ensurePool();
this.nip46Client = new Nip46RpcClient({
nostrClient: this.client,
relays,
clientPrivateKey: stored.clientPrivateKey,
remotePubkey: stored.remotePubkey,
secret: stored.secret,
encryption: stored.encryption,
signEvent: signEventWithPrivateKey,
});
try {
// Just verify we can ping or get pubkey
await this.nip46Client.ensureSubscription();
const pubkey = await this.nip46Client.getUserPubkey();
// Re-check access control when restoring a stored session, so a pubkey
// that has since been blocked can't silently reconnect.
await this._enforceRemoteSignerValidator(validator, pubkey);
this.pubkey = pubkey;
this.setActiveSigner(this.nip46Client.getActiveSigner());
this.emitRemoteSignerChange();
return { ok: true, pubkey };
} catch (err) {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
// Always forget a stored session that was rejected by access control —
// no point keeping a blocked pubkey's session to retry. Forget only the
// account we tried, leaving other saved sessions switchable.
if (forgetOnError || err?.code === "remote-signer-access-denied") {
clearStoredNip46Session(stored?.userPubkey || targetPubkey || undefined);
}
throw err;
}
}
// Access-control gate for restoring a stored signer. Waits for the admin
// lists to load (so the decision is accurate on a cold start) and throws if
// the pubkey is no longer allowed, matching the fresh-login/connect paths.
// useStoredRemoteSigner clears the stored session when this rejects.
buildAccessControlValidator() {
return async (pubkey) => {
try {
if (typeof accessControl.waitForReady === "function") {
await accessControl.waitForReady();
}
} catch (error) {
// If readiness can't be confirmed, fall through to the live check.
}
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators only.");
}
return true;
};
}
async scheduleStoredRemoteSignerRestore() {
const stored = readStoredNip46Session();
// [nip46-diag] TEMP (todo #33): trace whether a NIP-46 session is restored on
// load — the "unknown user after refresh" symptom. Remove with the fix.
userLogger.info("[nip46-diag] restore: scheduleStoredRemoteSignerRestore", {
hasStored: !!stored,
storedPubkey: stored?.pubkey || stored?.userPubkey || null,
storedKeys: stored ? Object.keys(stored) : [],
});
if (!stored) {
return;
}
const decryptAndConnect = async () => {
try {
// Enforce access control on the silent startup restore too — without a
// validator a since-blocked pubkey's stored session would reconnect
// unchecked.
await this.useStoredRemoteSigner({
silent: true,
validator: this.buildAccessControlValidator(),
});
userLogger.info("[nip46-diag] restore: succeeded", {
resolvedPubkey: this.pubkey || null,
});
} catch (err) {
// Surfaced via userLogger so it's visible on unstable (devLogger is gated).
userLogger.warn("[nip46-diag] restore: FAILED", {
message: err?.message || String(err),
code: err?.code || null,
});
}
};
decryptAndConnect();
}
async disconnectRemoteSigner({ keepStored = false } = {}) {
// [nip46-diag] TEMP (todo #33): the single shared nip46Client is torn down here;
// logout/switch calling this is the suspected "logs out everyone" cause.
userLogger.info("[nip46-diag] disconnectRemoteSigner", {
keepStored,
hadClient: !!this.nip46Client,
pubkey: this.pubkey || null,
stack: new Error().stack?.split("\n").slice(2, 5).join(" <- "),
});
// Abort an in-flight nostrconnect:// handshake wait, if any.
if (typeof this.pendingHandshakeCancel === "function") {
try {
this.pendingHandshakeCancel();
} catch (error) {
// ignore cancel errors
}
}
if (this.nip46Client) {
if (typeof this.nip46Client.destroy === 'function') {
this.nip46Client.destroy();
}
this.nip46Client = null;
}
if (!keepStored) {
clearStoredNip46Session();
}
this.emitRemoteSignerChange();
}
getStoredNip46Metadata(pubkey) {
const stored = readStoredNip46Session(pubkey) || {};
const userPubkey =
typeof stored.userPubkey === "string" ? stored.userPubkey : "";
return {
hasSession: Boolean(stored.remotePubkey && stored.clientPrivateKey),
remotePubkey: stored.remotePubkey || "",
metadata: stored.metadata || {},
relays: stored.relays || [],
encryption: stored.encryption || "",
userPubkey,
userNpub: encodeHexToNpub(userPubkey),
};
}
getRemoteSignerStatus() {
return { ...this.remoteSignerStatus };
}
emitRemoteSignerChange(status = {}) {
const stored = this.getStoredNip46Metadata();
const nextState =
typeof status.state === "string" && status.state.trim()
? status.state.trim()
: this.nip46Client
? "connected"
: stored.hasSession
? "stored"
: "idle";
const remotePubkey =
(typeof status.remotePubkey === "string" && status.remotePubkey.trim()) ||
this.nip46Client?.remotePubkey ||
stored.remotePubkey ||
"";
const userPubkey =
(typeof status.userPubkey === "string" && status.userPubkey.trim()) ||
this.nip46Client?.userPubkey ||
stored.userPubkey ||
"";
const metadataCandidate =
status.metadata || this.nip46Client?.metadata || stored.metadata || {};
const metadata =
metadataCandidate && typeof metadataCandidate === "object"
? { ...metadataCandidate }
: {};
const relays = Array.isArray(status.relays)
? status.relays.slice()
: (this.nip46Client?.relays || stored.relays || []).slice();
const encryption =
(typeof status.encryption === "string" && status.encryption.trim()
? normalizeNip46EncryptionAlgorithm(status.encryption)
: "") || this.nip46Client?.encryptionAlgorithm || stored.encryption || "";
const snapshot = {
state: nextState,
remotePubkey,
userPubkey,
relays,
metadata,
encryption,
error: status.error || null,
};
this.remoteSignerStatus = snapshot;
for (const listener of this.remoteSignerListeners) {
try {
listener(snapshot);
} catch (error) {
devLogger.warn("[nostr] Remote signer listener threw:", error);
}
}
return snapshot;
}
onRemoteSignerChange(listener) {
if (typeof listener !== "function") {
return () => {};
}
this.remoteSignerListeners.add(listener);
return () => {
this.remoteSignerListeners.delete(listener);
};
}
logout() {
const previousPubkey = this.pubkey;
// [nip46-diag] TEMP (todo #33): trace logout scope — which pubkey + whether a
// shared remote-signer client is being torn down (affects other NIP-46 profiles).
userLogger.info("[nip46-diag] logout", {
previousPubkey: previousPubkey || null,
hadRemoteClient: !!this.nip46Client,
sessionActorSource: this.sessionActor?.source || null,
});
this.pubkey = null;
logoutSignerFromRegistry(previousPubkey);
const previousSessionActor = this.sessionActor;
this.sessionActor = null;
this.sessionActorCipherClosures = null;
this.sessionActorCipherClosuresPrivateKey = null;
if (this.nip46Client) {
this.disconnectRemoteSigner({ keepStored: true });
}
const shouldClearStoredSession =
previousSessionActor &&
previousSessionActor.source === "nsec" &&
previousSessionActor.persisted !== true;
if (shouldClearStoredSession) {
// Forget ONLY the departing account's remembered key. The no-arg clear
// used to wipe the whole per-account map, so logging out one non-persisted
// nsec login destroyed every OTHER saved account's key. Skip entirely when
// we can't name the account — never wipe-all implicitly.
const clearTarget = previousSessionActor.pubkey || previousPubkey || "";
if (clearTarget) {
this.lockedSessionActor = null;
this.clearStoredSessionActor(clearTarget);
}
}
if (
this.extensionPermissionCache &&
typeof this.extensionPermissionCache.clear === "function"
) {
this.extensionPermissionCache.clear();
}
clearStoredNip07Permissions();
this.extensionReady = false;
this.extensionPermissionsGranted = false;
this.emitRemoteSignerChange();
}
installNip46Client(rpcClient, { userPubkey } = {}) {
this.nip46Client = rpcClient;
if (userPubkey) {
this.pubkey = userPubkey;
}
if (rpcClient && typeof rpcClient.getActiveSigner === "function") {
const signer = rpcClient.getActiveSigner();
if (signer) {
this.setActiveSigner(signer);
}
}
this.emitRemoteSignerChange();
}
async derivePrivateKeyFromSecret(secret) {
if (!secret || typeof secret !== "string") {
throw new Error("A private key or nsec string is required.");
}
const trimmed = secret.trim();
const tools = (await ensureNostrTools()) || getCachedNostrTools();
// Handle hex private key
if (HEX64_REGEX.test(trimmed)) {
const privateKeyHex = trimmed.toLowerCase();
if (!tools || typeof tools.getPublicKey !== "function") {
throw new Error("Nostr tools unavailable for key derivation.");
}
const pubkey = tools.getPublicKey(privateKeyHex);
return { privateKey: privateKeyHex, pubkey };
}
// Handle nsec bech32 encoding
if (trimmed.startsWith("nsec1")) {
const nip19 = tools?.nip19;
if (!nip19 || typeof nip19.decode !== "function") {
throw new Error("Nostr tools unavailable for nsec decoding.");
}
const decoded = nip19.decode(trimmed);
if (!decoded || decoded.type !== "nsec" || !decoded.data) {
throw new Error("Invalid nsec key.");
}
const secretBytes = decoded.data;
const privateKeyHex =
typeof secretBytes === "string"
? secretBytes
: Array.from(secretBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (!HEX64_REGEX.test(privateKeyHex)) {
throw new Error("Decoded nsec key is invalid.");
}
const pubkey = tools.getPublicKey(privateKeyHex);
return { privateKey: privateKeyHex, pubkey };
}
throw new Error("Unrecognized key format. Provide a hex private key or nsec string.");
}
async registerPrivateKeySigner({ privateKey, pubkey, persist, passphrase, validator } = {}) {
if (!privateKey || typeof privateKey !== "string" || !HEX64_REGEX.test(privateKey)) {
throw new Error("A valid hex private key is required.");
}
const normalizedPrivateKey = privateKey.toLowerCase();
const normalizedPubkey =
typeof pubkey === "string" && pubkey.trim()
? pubkey.trim().toLowerCase()
: null;
if (!normalizedPubkey || !HEX64_REGEX.test(normalizedPubkey)) {
throw new Error("A valid hex public key is required.");
}
if (typeof validator === "function") {
validator(normalizedPubkey);
}
const cipherClosures = await createPrivateKeyCipherClosures(normalizedPrivateKey);
const adapter = {
type: "nsec",
pubkey: normalizedPubkey,
signEvent: (event) => signEventWithPrivateKey(event, normalizedPrivateKey),
...cipherClosures,
};
this.setActiveSigner(adapter);
this.pubkey = normalizedPubkey;
if (persist && passphrase) {
try {
const encrypted = await encryptSessionPrivateKey(normalizedPrivateKey, passphrase);
persistSessionActorEntry({
pubkey: normalizedPubkey,
encrypted,
source: "nsec",
persisted: true,
});
this.sessionActor = { pubkey: normalizedPubkey, source: "nsec", persisted: true };
} catch (error) {
devLogger.warn("[nostr] Failed to persist private key signer:", error);
}
}
return normalizedPubkey;
}
}