mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
1241 lines
41 KiB
JavaScript
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;
|
|
}
|
|
}
|