Add direct key auth flow with encrypted key storage

This commit is contained in:
thePR0M3TH3AN committed 2025-10-21 15:43:53 -04:00
1 parent 8cb2a866ea
commit cf65ace78e
7 files changed
+1268 -57

No files matched your search

+103 -8
View File
@@ -72,14 +72,109 @@
</span>
</button>
</template>
<button
id="loginNSEC"
class="btn-ghost w-full"
disabled
type="button"
>
Login with NSEC (Coming Soon)
</button>
<template data-login-nsec-dialog>
<form
data-nsec-form
class="space-y-4 rounded-xl border border-border bg-overlay-panel-soft px-5 py-4"
>
<div class="space-y-2">
<h3 class="text-lg font-semibold text-text">Direct key login</h3>
<p class="text-sm text-text-muted" data-nsec-warning>
"Unsafe by default" — imported keys stay decrypted only for this session
unless you protect them with a passphrase.
</p>
</div>
<div
data-nsec-error
class="hidden rounded-lg border border-danger/60 bg-danger/10 px-4 py-2 text-sm text-danger"
role="alert"
></div>
<fieldset data-nsec-unlock-section class="space-y-3 hidden">
<legend class="text-sm font-semibold text-text">Unlock saved key</legend>
<label class="inline-flex items-center gap-2 text-sm text-text">
<input
type="radio"
name="nsec-mode"
value="unlock"
class="radio"
data-nsec-mode="unlock"
/>
<span>Use the encrypted key stored on this device</span>
</label>
<div class="space-y-2 pl-6" data-nsec-unlock-fields>
<label class="block text-sm font-medium text-text" for="nsecUnlockPassphrase">
Passphrase
</label>
<input
id="nsecUnlockPassphrase"
type="password"
class="input"
autocomplete="current-password"
data-nsec-unlock-passphrase
required
/>
</div>
</fieldset>
<fieldset data-nsec-import-section class="space-y-3">
<legend class="text-sm font-semibold text-text">Import a new secret</legend>
<label class="inline-flex items-center gap-2 text-sm text-text">
<input
type="radio"
name="nsec-mode"
value="import"
class="radio"
data-nsec-mode="import"
checked
/>
<span>Paste an nsec, hex key, or mnemonic seed</span>
</label>
<div class="space-y-3 pl-6" data-nsec-import-fields>
<label class="block text-sm font-medium text-text" for="nsecSecretInput">
Secret
</label>
<textarea
id="nsecSecretInput"
class="input"
rows="3"
spellcheck="false"
autocomplete="off"
data-nsec-secret
required
></textarea>
<label class="inline-flex items-center gap-2 text-sm text-text">
<input type="checkbox" class="checkbox" data-nsec-remember />
<span>Remember this key on this device</span>
</label>
<div class="space-y-2" data-nsec-passphrase-fields hidden>
<label class="block text-sm font-medium text-text" for="nsecPassphraseInput">
Passphrase
</label>
<input
id="nsecPassphraseInput"
type="password"
class="input"
autocomplete="new-password"
data-nsec-passphrase
/>
<label class="block text-sm font-medium text-text" for="nsecPassphraseConfirm">
Confirm passphrase
</label>
<input
id="nsecPassphraseConfirm"
type="password"
class="input"
autocomplete="new-password"
data-nsec-passphrase-confirm
/>
</div>
</div>
</fieldset>
<div class="flex justify-end gap-2">
<button type="button" class="btn-ghost" data-nsec-cancel>Cancel</button>
<button type="submit" class="btn" data-nsec-submit>Continue</button>
</div>
</form>
</template>
</div>
</div>
</div>
+7
View File
@@ -42,4 +42,11 @@ Automated regression coverage now exercises these flows on the unified design-sy
- Play the same video until a view event is logged, then log out (or switch to a different pubkey) and repeat the playback.
- Confirm two distinct view events appear (e.g., via relay logs or UI counters) and the aggregated view count increments twice.
8. **Direct key login (nsec)**
- Choose the "Direct key" option in the login modal and confirm the warning copy reads `"Unsafe by default" — imported keys stay decrypted only for this session unless you protect them with a passphrase.`
- Sign in with a new nsec or mnemonic, opt into remembering it, and verify the passphrase prompt blocks submission until both passphrase fields match.
- Reload the page, select "Unlock saved key", enter the passphrase, and confirm the account unlocks without re-entering the secret.
- Publish a video while logged in with the direct-key provider and confirm the resulting event is signed (relays accept it) and the UI reports success.
- Log out and ensure the session clears decrypted key material; stored keys should require the passphrase to unlock again.
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
+688 -46
View File
@@ -188,6 +188,13 @@ function clearStoredNip07Permissions() {
}
}
const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
const SESSION_ACTOR_ENCRYPTION_VERSION = 1;
const SESSION_ACTOR_KDF_ITERATIONS = 250_000;
const SESSION_ACTOR_KDF_HASH = "SHA-256";
const SESSION_ACTOR_ENCRYPTION_ALGORITHM = "AES-GCM";
const SESSION_ACTOR_SALT_BYTES = 16;
const SESSION_ACTOR_IV_BYTES = 12;
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
@@ -2717,6 +2724,314 @@ function bytesToHex(bytes) {
.join("");
}
function arrayBufferToBase64(buffer) {
if (!buffer) {
return "";
}
let view;
if (buffer instanceof ArrayBuffer) {
view = new Uint8Array(buffer);
} else if (ArrayBuffer.isView(buffer)) {
view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
} else {
return "";
}
if (typeof globalThis?.btoa === "function") {
let binary = "";
for (let index = 0; index < view.length; index += 1) {
binary += String.fromCharCode(view[index]);
}
return globalThis.btoa(binary);
}
if (typeof Buffer !== "undefined") {
return Buffer.from(view).toString("base64");
}
return "";
}
function base64ToUint8Array(base64) {
if (typeof base64 !== "string" || !base64.trim()) {
return null;
}
let binary;
try {
if (typeof globalThis?.atob === "function") {
binary = globalThis.atob(base64);
} else if (typeof Buffer !== "undefined") {
binary = Buffer.from(base64, "base64").toString("binary");
} else {
return null;
}
} catch (error) {
return null;
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function generateRandomBytes(length) {
const size = Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0;
if (size <= 0) {
return new Uint8Array(0);
}
if (globalThis?.crypto?.getRandomValues) {
const array = new Uint8Array(size);
globalThis.crypto.getRandomValues(array);
return array;
}
throw new Error("secure-random-unavailable");
}
function isSubtleCryptoAvailable() {
return !!(
globalThis?.crypto?.subtle &&
typeof globalThis.crypto.subtle.importKey === "function"
);
}
async function deriveSessionEncryptionKey(passphrase, saltBytes, iterations, hash) {
if (!isSubtleCryptoAvailable()) {
throw new Error("webcrypto-unavailable");
}
if (!(saltBytes instanceof Uint8Array)) {
throw new Error("invalid-salt");
}
const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null;
if (!encoder) {
throw new Error("text-encoder-unavailable");
}
const passphraseBytes = encoder.encode(passphrase);
const baseKey = await globalThis.crypto.subtle.importKey(
"raw",
passphraseBytes,
{ name: "PBKDF2" },
false,
["deriveKey"],
);
const normalizedIterations = Number.isFinite(iterations)
? Math.max(1, Math.floor(iterations))
: SESSION_ACTOR_KDF_ITERATIONS;
const normalizedHash = typeof hash === "string" && hash.trim()
? hash.trim()
: SESSION_ACTOR_KDF_HASH;
return globalThis.crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: saltBytes,
iterations: normalizedIterations,
hash: normalizedHash,
},
baseKey,
{ name: SESSION_ACTOR_ENCRYPTION_ALGORITHM, length: 256 },
false,
["encrypt", "decrypt"],
);
}
async function encryptSessionPrivateKey(privateKey, passphrase) {
if (typeof privateKey !== "string" || !privateKey.trim()) {
throw new Error("invalid-private-key");
}
if (typeof passphrase !== "string" || !passphrase.trim()) {
throw new Error("passphrase-required");
}
if (!isSubtleCryptoAvailable()) {
throw new Error("webcrypto-unavailable");
}
const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null;
if (!encoder) {
throw new Error("text-encoder-unavailable");
}
const payload = encoder.encode(privateKey);
const salt = generateRandomBytes(SESSION_ACTOR_SALT_BYTES);
const iv = generateRandomBytes(SESSION_ACTOR_IV_BYTES);
const key = await deriveSessionEncryptionKey(
passphrase,
salt,
SESSION_ACTOR_KDF_ITERATIONS,
SESSION_ACTOR_KDF_HASH,
);
const ciphertext = await globalThis.crypto.subtle.encrypt(
{ name: SESSION_ACTOR_ENCRYPTION_ALGORITHM, iv },
key,
payload,
);
return {
ciphertext: arrayBufferToBase64(ciphertext),
salt: arrayBufferToBase64(salt),
iv: arrayBufferToBase64(iv),
iterations: SESSION_ACTOR_KDF_ITERATIONS,
hash: SESSION_ACTOR_KDF_HASH,
algorithm: SESSION_ACTOR_ENCRYPTION_ALGORITHM,
version: SESSION_ACTOR_ENCRYPTION_VERSION,
};
}
function normalizeStoredEncryptionMetadata(metadata) {
if (!metadata || typeof metadata !== "object") {
return null;
}
const salt = typeof metadata.salt === "string" ? metadata.salt.trim() : "";
const iv = typeof metadata.iv === "string" ? metadata.iv.trim() : "";
if (!salt || !iv) {
return null;
}
const iterations = Number.isFinite(metadata.iterations)
? Math.max(1, Math.floor(metadata.iterations))
: SESSION_ACTOR_KDF_ITERATIONS;
const version = Number.isFinite(metadata.version)
? Math.max(1, Math.floor(metadata.version))
: SESSION_ACTOR_ENCRYPTION_VERSION;
const algorithm =
typeof metadata.algorithm === "string" && metadata.algorithm.trim()
? metadata.algorithm.trim()
: SESSION_ACTOR_ENCRYPTION_ALGORITHM;
const hash =
typeof metadata.hash === "string" && metadata.hash.trim()
? metadata.hash.trim()
: SESSION_ACTOR_KDF_HASH;
return { version, algorithm, salt, iv, iterations, hash };
}
async function decryptSessionPrivateKey(payload, passphrase) {
if (!payload || typeof payload !== "object") {
throw new Error("encrypted-session-invalid");
}
if (typeof passphrase !== "string" || !passphrase.trim()) {
throw new Error("passphrase-required");
}
if (!isSubtleCryptoAvailable()) {
throw new Error("webcrypto-unavailable");
}
const ciphertext =
typeof payload.privateKeyEncrypted === "string"
? payload.privateKeyEncrypted.trim()
: "";
const encryption = normalizeStoredEncryptionMetadata(payload.encryption);
if (!ciphertext || !encryption) {
throw new Error("encrypted-session-invalid");
}
const cipherBytes = base64ToUint8Array(ciphertext);
const saltBytes = base64ToUint8Array(encryption.salt);
const ivBytes = base64ToUint8Array(encryption.iv);
if (!cipherBytes || !saltBytes || !ivBytes) {
throw new Error("encrypted-session-invalid");
}
const key = await deriveSessionEncryptionKey(
passphrase,
saltBytes,
encryption.iterations,
encryption.hash,
);
let decrypted;
try {
decrypted = await globalThis.crypto.subtle.decrypt(
{ name: encryption.algorithm || SESSION_ACTOR_ENCRYPTION_ALGORITHM, iv: ivBytes },
key,
cipherBytes,
);
} catch (error) {
const failure = new Error("Failed to decrypt the stored private key.");
failure.code = "decrypt-failed";
failure.cause = error;
throw failure;
}
const decoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
if (!decoder) {
throw new Error("text-decoder-unavailable");
}
return decoder.decode(decrypted);
}
function readStoredSessionActorEntry() {
if (typeof localStorage === "undefined") {
return null;
}
let raw = null;
try {
raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to read session actor from storage:", error);
return null;
}
if (!raw || typeof raw !== "string") {
return null;
}
try {
const parsed = JSON.parse(raw);
const pubkey =
typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : "";
const privateKey =
typeof parsed?.privateKey === "string" ? parsed.privateKey.trim() : "";
const privateKeyEncrypted =
typeof parsed?.privateKeyEncrypted === "string"
? parsed.privateKeyEncrypted.trim()
: "";
const encryption = normalizeStoredEncryptionMetadata(parsed?.encryption);
const createdAt = Number.isFinite(parsed?.createdAt)
? parsed.createdAt
: Date.now();
return {
pubkey,
privateKey,
privateKeyEncrypted,
encryption,
createdAt,
};
} catch (error) {
devLogger.warn("[nostr] Failed to parse stored session actor:", error);
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear corrupt session actor entry:",
cleanupError,
);
}
}
return null;
}
async function computeWatchHistoryFingerprintForItems(items) {
const serialized = serializeWatchHistoryItems(items);
const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null;
@@ -3289,6 +3604,7 @@ export class NostrClient {
this.hasRestoredLocalData = false;
this.sessionActor = null;
this.lockedSessionActor = null;
this.nip71Cache = new Map();
this.watchHistoryCache = new Map();
this.watchHistoryStorage = null;
@@ -3595,52 +3911,77 @@ export class NostrClient {
}
restoreSessionActorFromStorage() {
if (typeof localStorage === "undefined") {
const entry = readStoredSessionActorEntry();
if (!entry) {
this.lockedSessionActor = null;
return null;
}
let raw = null;
try {
raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to read session actor from storage:", error);
return null;
}
if (!raw || typeof raw !== "string") {
return null;
}
try {
const parsed = JSON.parse(raw);
const pubkey =
typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : "";
const privateKey =
typeof parsed?.privateKey === "string"
? parsed.privateKey.trim()
: "";
if (!pubkey || !privateKey) {
return null;
}
const { pubkey, privateKey, privateKeyEncrypted, encryption, createdAt } = entry;
if (privateKey && HEX64_REGEX.test(privateKey)) {
return {
pubkey,
privateKey,
createdAt: Number.isFinite(parsed?.createdAt)
? parsed.createdAt
: Date.now(),
privateKey: privateKey.toLowerCase(),
createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
};
}
if (privateKeyEncrypted && encryption) {
this.lockedSessionActor = {
pubkey,
privateKeyEncrypted,
encryption,
createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
};
} else {
this.lockedSessionActor = null;
}
return null;
}
getStoredSessionActorMetadata() {
const entry = readStoredSessionActorEntry();
if (!entry) {
this.lockedSessionActor = null;
return null;
}
const { pubkey, privateKeyEncrypted, encryption, createdAt, privateKey } = entry;
const normalizedCreatedAt = Number.isFinite(createdAt)
? createdAt
: Date.now();
if (privateKeyEncrypted && encryption) {
this.lockedSessionActor = {
pubkey,
privateKeyEncrypted,
encryption,
createdAt: normalizedCreatedAt,
};
return {
pubkey,
hasEncryptedKey: true,
createdAt: normalizedCreatedAt,
};
}
this.lockedSessionActor = null;
if (privateKey) {
return {
pubkey,
hasEncryptedKey: false,
createdAt: normalizedCreatedAt,
};
}
if (pubkey) {
return {
pubkey,
hasEncryptedKey: false,
createdAt: normalizedCreatedAt,
};
} catch (error) {
devLogger.warn("[nostr] Failed to parse stored session actor:", error);
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear corrupt session actor entry:",
cleanupError
);
}
}
return null;
@@ -3655,20 +3996,42 @@ export class NostrClient {
!actor ||
typeof actor.pubkey !== "string" ||
!actor.pubkey ||
typeof actor.privateKey !== "string" ||
!actor.privateKey
((typeof actor.privateKey !== "string" || !actor.privateKey) &&
(typeof actor.privateKeyEncrypted !== "string" ||
!actor.privateKeyEncrypted))
) {
return;
}
const createdAt = Number.isFinite(actor.createdAt)
? actor.createdAt
: Date.now();
const payload = {
pubkey: actor.pubkey,
privateKey: actor.privateKey,
createdAt: Number.isFinite(actor.createdAt)
? actor.createdAt
: Date.now(),
createdAt,
};
if (typeof actor.privateKey === "string" && actor.privateKey) {
payload.privateKey = actor.privateKey;
} else if (
typeof actor.privateKeyEncrypted === "string" &&
actor.privateKeyEncrypted &&
actor.encryption &&
typeof actor.encryption === "object"
) {
const normalizedEncryption = normalizeStoredEncryptionMetadata(
actor.encryption,
);
if (!normalizedEncryption) {
return;
}
payload.privateKeyEncrypted = actor.privateKeyEncrypted;
payload.encryption = normalizedEncryption;
} else {
return;
}
try {
localStorage.setItem(
SESSION_ACTOR_STORAGE_KEY,
@@ -3752,6 +4115,272 @@ export class NostrClient {
};
}
async derivePrivateKeyFromSecret(secret) {
const trimmed = typeof secret === "string" ? secret.trim() : "";
if (!trimmed) {
throw new Error("A private key is required.");
}
const tools = (await ensureNostrTools()) || getCachedNostrTools();
if (!tools) {
throw new Error("Key derivation helpers are unavailable.");
}
let privateKey = "";
const normalizedInput = trimmed.toLowerCase();
if (HEX64_REGEX.test(trimmed)) {
privateKey = trimmed.toLowerCase();
} else if (normalizedInput.startsWith("nsec1") || normalizedInput.startsWith("sec1")) {
const decoder = tools?.nip19?.decode;
if (typeof decoder !== "function") {
throw new Error("nsec decoding is unavailable.");
}
let decoded;
try {
decoded = decoder(trimmed);
} catch (error) {
const failure = new Error("Invalid nsec secret.");
failure.cause = error;
throw failure;
}
if (!decoded || (decoded.type !== "nsec" && decoded.type !== "sec")) {
throw new Error("Unsupported nsec secret.");
}
if (typeof decoded.data === "string") {
privateKey = decoded.data;
} else if (decoded.data instanceof Uint8Array) {
privateKey = bytesToHex(decoded.data);
} else if (Array.isArray(decoded.data)) {
privateKey = bytesToHex(Uint8Array.from(decoded.data));
}
if (!HEX64_REGEX.test(privateKey)) {
throw new Error("Decoded private key is invalid.");
}
privateKey = privateKey.toLowerCase();
} else if (trimmed.split(/\s+/).length >= 12) {
const nip06 = tools?.nip06;
if (!nip06 || typeof nip06.privateKeyFromSeedWords !== "function") {
throw new Error("Seed word support is unavailable.");
}
try {
privateKey = nip06.privateKeyFromSeedWords(trimmed);
} catch (error) {
const failure = new Error("Failed to derive a key from the provided seed words.");
failure.cause = error;
throw failure;
}
if (!HEX64_REGEX.test(privateKey)) {
throw new Error("Derived private key is invalid.");
}
privateKey = privateKey.toLowerCase();
} else {
throw new Error(
"Unsupported secret format. Provide a hex key, nsec, or mnemonic seed.",
);
}
const getPublicKey =
typeof tools.getPublicKey === "function" ? tools.getPublicKey : null;
if (!getPublicKey) {
throw new Error("Public key derivation is unavailable.");
}
let pubkey = "";
try {
pubkey = getPublicKey(privateKey);
} catch (error) {
const failure = new Error("Failed to derive the public key.");
failure.cause = error;
throw failure;
}
if (typeof pubkey !== "string" || !pubkey.trim()) {
throw new Error("Derived public key is invalid.");
}
return { privateKey, pubkey: pubkey.trim() };
}
async registerPrivateKeySigner({
privateKey,
pubkey,
persist = false,
passphrase,
} = {}) {
const normalizedPrivateKey =
typeof privateKey === "string" && HEX64_REGEX.test(privateKey)
? privateKey.toLowerCase()
: "";
if (!normalizedPrivateKey) {
const error = new Error("A valid private key is required.");
error.code = "invalid-private-key";
throw error;
}
let normalizedPubkey =
typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : "";
if (!normalizedPubkey) {
const tools = (await ensureNostrTools()) || getCachedNostrTools();
const getPublicKey =
tools && typeof tools.getPublicKey === "function"
? tools.getPublicKey
: null;
if (!getPublicKey) {
throw new Error("Public key derivation is unavailable.");
}
try {
normalizedPubkey = getPublicKey(normalizedPrivateKey);
} catch (error) {
const failure = new Error("Failed to derive the public key.");
failure.cause = error;
throw failure;
}
}
if (!normalizedPubkey) {
throw new Error("Unable to resolve the public key for this private key.");
}
this.sessionActor = {
pubkey: normalizedPubkey,
privateKey: normalizedPrivateKey,
createdAt: Date.now(),
source: "nsec",
persisted: persist === true,
};
setActiveSigner({
type: "nsec",
pubkey: normalizedPubkey,
signEvent: (event) => signEventWithPrivateKey(event, normalizedPrivateKey),
});
if (persist) {
if (typeof passphrase !== "string" || !passphrase.trim()) {
const error = new Error("A passphrase is required to remember this key.");
error.code = "passphrase-required";
throw error;
}
const encrypted = await encryptSessionPrivateKey(
normalizedPrivateKey,
passphrase,
);
const payload = {
pubkey: normalizedPubkey,
privateKeyEncrypted: encrypted.ciphertext,
encryption: {
version: encrypted.version,
algorithm: encrypted.algorithm,
iterations: encrypted.iterations,
hash: encrypted.hash,
salt: encrypted.salt,
iv: encrypted.iv,
},
createdAt: this.sessionActor.createdAt,
};
this.lockedSessionActor = { ...payload };
this.persistSessionActor(payload);
} else {
this.lockedSessionActor = null;
this.clearStoredSessionActor();
}
return { pubkey: normalizedPubkey };
}
async unlockStoredSessionActor(passphrase) {
if (typeof passphrase !== "string" || !passphrase.trim()) {
const error = new Error("A passphrase is required to unlock the saved key.");
error.code = "passphrase-required";
throw error;
}
if (
!this.lockedSessionActor ||
!this.lockedSessionActor.privateKeyEncrypted ||
!this.lockedSessionActor.encryption
) {
const entry = readStoredSessionActorEntry();
if (entry && entry.privateKeyEncrypted && entry.encryption) {
this.lockedSessionActor = {
pubkey: entry.pubkey,
privateKeyEncrypted: entry.privateKeyEncrypted,
encryption: entry.encryption,
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(),
};
}
}
const locked = this.lockedSessionActor;
if (!locked || !locked.privateKeyEncrypted || !locked.encryption) {
const error = new Error("No encrypted key is stored on this device.");
error.code = "no-stored-key";
throw error;
}
const decrypted = await decryptSessionPrivateKey(locked, passphrase);
if (!HEX64_REGEX.test(decrypted)) {
const error = new Error("The stored key could not be decrypted with this passphrase.");
error.code = "decrypt-failed";
throw error;
}
const normalizedPrivateKey = decrypted.toLowerCase();
let normalizedPubkey =
typeof locked.pubkey === "string" && locked.pubkey.trim()
? locked.pubkey.trim()
: "";
if (!normalizedPubkey) {
const tools = (await ensureNostrTools()) || getCachedNostrTools();
const getPublicKey =
tools && typeof tools.getPublicKey === "function"
? tools.getPublicKey
: null;
if (!getPublicKey) {
throw new Error("Public key derivation is unavailable.");
}
try {
normalizedPubkey = getPublicKey(normalizedPrivateKey);
} catch (error) {
const failure = new Error("Failed to derive the public key.");
failure.cause = error;
throw failure;
}
}
this.sessionActor = {
pubkey: normalizedPubkey,
privateKey: normalizedPrivateKey,
createdAt: Number.isFinite(locked.createdAt) ? locked.createdAt : Date.now(),
source: "nsec",
persisted: true,
};
setActiveSigner({
type: "nsec",
pubkey: normalizedPubkey,
signEvent: (event) => signEventWithPrivateKey(event, normalizedPrivateKey),
});
const storedPayload = {
pubkey: normalizedPubkey,
privateKeyEncrypted: locked.privateKeyEncrypted,
encryption: locked.encryption,
createdAt: Number.isFinite(locked.createdAt) ? locked.createdAt : Date.now(),
};
this.lockedSessionActor = { ...storedPayload };
this.persistSessionActor(storedPayload);
return { pubkey: normalizedPubkey };
}
async ensureSessionActor(forceRenew = false) {
const normalizedLogged =
typeof this.pubkey === "string" && this.pubkey
@@ -6326,6 +6955,19 @@ export class NostrClient {
logout() {
this.pubkey = null;
clearActiveSigner();
const previousSessionActor = this.sessionActor;
this.sessionActor = null;
const shouldClearStoredSession =
previousSessionActor &&
previousSessionActor.source === "nsec" &&
previousSessionActor.persisted !== true;
if (shouldClearStoredSession) {
this.lockedSessionActor = null;
this.clearStoredSessionActor();
}
for (const timer of this.watchHistoryRepublishTimers.values()) {
if (timer && typeof timer.timer === "number") {
clearTimeout(timer.timer);
+2 -1
View File
@@ -1,4 +1,5 @@
import nip07 from "./nip07.js";
import nsec from "./nsec.js";
function normalizeProviderId(provider, fallbackId) {
if (provider && typeof provider.id === "string") {
@@ -36,7 +37,7 @@ function normalizeProviderMetadata(provider, fallbackId) {
return Object.freeze({ id, label, description, badgeVariant });
}
const providerList = [nip07].filter(Boolean);
const providerList = [nip07, nsec].filter(Boolean);
const providerMap = {};
const metadataMap = {};
+145
View File
@@ -0,0 +1,145 @@
const PROVIDER_ID = "nsec";
const PROVIDER_LABEL = "Direct key";
const PROVIDER_DESCRIPTION = "Import an nsec or mnemonic seed.";
const PROVIDER_CAPABILITIES = Object.freeze([
Object.freeze({
id: "signing",
label: "Signs locally",
variant: "warning",
}),
Object.freeze({
id: "session",
label: "Encrypted backup optional",
variant: "neutral",
}),
]);
const PROVIDER_BUTTON = Object.freeze({
variant: "ghost",
});
const PROVIDER_MESSAGES = Object.freeze({
loading: "Unlocking direct key…",
slow: "Still deriving your key…",
error: "Failed to unlock the direct key. Check your secret and try again.",
});
function normalizePubkey(result) {
if (typeof result === "string") {
return result;
}
if (!result || typeof result !== "object") {
return "";
}
if (typeof result.pubkey === "string") {
return result.pubkey;
}
if (typeof result.publicKey === "string") {
return result.publicKey;
}
return "";
}
function normalizeOptions(options) {
if (!options || typeof options !== "object") {
return {
secret: "",
persist: false,
passphrase: "",
unlockStored: false,
};
}
return {
secret: typeof options.secret === "string" ? options.secret : "",
persist: options.persist === true,
passphrase: typeof options.passphrase === "string" ? options.passphrase : "",
unlockStored: options.unlockStored === true,
};
}
export default {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
description: PROVIDER_DESCRIPTION,
badgeVariant: "warning",
capabilities: PROVIDER_CAPABILITIES,
button: PROVIDER_BUTTON,
messages: PROVIDER_MESSAGES,
errorMessage: PROVIDER_MESSAGES.error,
async login({ nostrClient, options } = {}) {
if (!nostrClient || typeof nostrClient !== "object") {
const error = new Error("Direct key login is not available.");
error.code = "provider-unavailable";
throw error;
}
if (
typeof nostrClient.derivePrivateKeyFromSecret !== "function" ||
typeof nostrClient.registerPrivateKeySigner !== "function"
) {
const error = new Error("Direct key login is not supported in this environment.");
error.code = "provider-unavailable";
throw error;
}
const normalized = normalizeOptions(options);
if (normalized.unlockStored) {
if (typeof nostrClient.unlockStoredSessionActor !== "function") {
const error = new Error("Stored key unlock is not supported.");
error.code = "unlock-unavailable";
throw error;
}
const suppliedPassphrase = normalized.passphrase;
if (typeof suppliedPassphrase !== "string" || !suppliedPassphrase.trim()) {
const error = new Error("A passphrase is required to unlock the saved key.");
error.code = "passphrase-required";
throw error;
}
const unlockResult = await nostrClient.unlockStoredSessionActor(suppliedPassphrase);
const unlockedPubkey = normalizePubkey(unlockResult);
return {
authType: PROVIDER_ID,
pubkey: unlockedPubkey,
};
}
const trimmedSecret = normalized.secret.trim();
if (!trimmedSecret) {
const error = new Error("A private key is required to continue.");
error.code = "secret-required";
throw error;
}
const { privateKey, pubkey } = await nostrClient.derivePrivateKeyFromSecret(trimmedSecret);
const shouldPersist = normalized.persist === true;
const passphrase = shouldPersist ? normalized.passphrase : "";
if (shouldPersist && (!passphrase || !passphrase.trim())) {
const error = new Error("A passphrase is required to remember this key.");
error.code = "passphrase-required";
throw error;
}
const registrationResult = await nostrClient.registerPrivateKeySigner({
privateKey,
pubkey,
persist: shouldPersist,
passphrase: shouldPersist ? passphrase : undefined,
});
const registeredPubkey = normalizePubkey(registrationResult) || pubkey;
return {
authType: PROVIDER_ID,
pubkey: registeredPubkey,
};
},
};
+6 -2
View File
@@ -188,9 +188,13 @@ class SubscriptionsManager {
}
};
addRelayCandidates(nostrClient.readRelays);
addRelayCandidates(nostrClient.relays);
addRelayCandidates(DEFAULT_RELAY_URLS);
if (!relaySet.size) {
addRelayCandidates(nostrClient.readRelays);
}
if (!relaySet.size) {
addRelayCandidates(DEFAULT_RELAY_URLS);
}
const relayUrls = Array.from(relaySet);
if (!relayUrls.length) {
+317
View File
@@ -229,10 +229,15 @@ export default class LoginModalController {
};
this.providerContainer = null;
this.modalBody =
this.modalElement?.querySelector(".modal-body") || null;
this.nsecTemplate =
this.modalElement?.querySelector("template[data-login-nsec-dialog]") || null;
this.template = null;
this.providerEntries = new Map();
this.slowTimers = new Map();
this.boundClickHandler = (event) => this.handleContainerClick(event);
this.activeNsecForm = null;
this.initialized = false;
this.initialize();
@@ -259,6 +264,294 @@ export default class LoginModalController {
this.initialized = true;
}
getNostrClient() {
const service = this.services?.authService;
if (!service || typeof service !== "object") {
return null;
}
return service.nostrClient || null;
}
getStoredNsecMetadata() {
const nostrClient = this.getNostrClient();
if (!nostrClient || typeof nostrClient.getStoredSessionActorMetadata !== "function") {
return null;
}
try {
return nostrClient.getStoredSessionActorMetadata();
} catch (error) {
devLogger.warn("[LoginModalController] Failed to inspect stored session actor:", error);
}
return null;
}
async promptForNsecOptions() {
if (!this.modalBody || !this.nsecTemplate) {
userLogger.warn("[LoginModalController] Direct key login is unavailable in this build.");
return null;
}
if (this.activeNsecForm) {
return null;
}
const fragment = this.nsecTemplate.content
? this.nsecTemplate.content.cloneNode(true)
: null;
if (!fragment) {
return null;
}
const form = fragment.querySelector("[data-nsec-form]");
if (!(form instanceof HTMLFormElement)) {
return null;
}
const unlockSection = form.querySelector("[data-nsec-unlock-section]");
const unlockRadio = form.querySelector('[data-nsec-mode="unlock"]');
const importRadio = form.querySelector('[data-nsec-mode="import"]');
const unlockPassphrase = form.querySelector("[data-nsec-unlock-passphrase]");
const importFields = form.querySelector("[data-nsec-import-fields]");
const secretField = form.querySelector("[data-nsec-secret]");
const rememberCheckbox = form.querySelector("[data-nsec-remember]");
const passphraseFields = form.querySelector("[data-nsec-passphrase-fields]");
const passphraseInput = form.querySelector("[data-nsec-passphrase]");
const passphraseConfirm = form.querySelector("[data-nsec-passphrase-confirm]");
const cancelButton = form.querySelector("[data-nsec-cancel]");
const errorNode = form.querySelector("[data-nsec-error]");
const storedMetadata = this.getStoredNsecMetadata();
const hasEncryptedStored = storedMetadata?.hasEncryptedKey === true;
if (unlockSection instanceof HTMLElement) {
unlockSection.classList.toggle("hidden", !hasEncryptedStored);
}
if (unlockRadio instanceof HTMLInputElement) {
unlockRadio.checked = hasEncryptedStored;
}
if (importRadio instanceof HTMLInputElement) {
importRadio.checked = !hasEncryptedStored;
}
const elementsToHide = [];
for (const child of Array.from(this.modalBody.children)) {
if (child instanceof HTMLElement && child !== form) {
if (child.tagName === "TEMPLATE") {
continue;
}
const wasHidden = child.classList.contains("hidden");
if (!wasHidden) {
child.classList.add("hidden");
}
elementsToHide.push({ element: child, wasHidden });
}
}
this.modalBody.appendChild(form);
this.activeNsecForm = form;
const setError = (message) => {
if (!(errorNode instanceof HTMLElement)) {
return;
}
const normalized = typeof message === "string" ? message.trim() : "";
if (normalized) {
errorNode.textContent = normalized;
errorNode.classList.remove("hidden");
} else {
errorNode.textContent = "";
errorNode.classList.add("hidden");
}
};
const updatePassphraseVisibility = () => {
if (!(passphraseFields instanceof HTMLElement)) {
return;
}
const shouldShow = !!(rememberCheckbox instanceof HTMLInputElement && rememberCheckbox.checked);
passphraseFields.hidden = !shouldShow;
if (passphraseInput instanceof HTMLInputElement) {
passphraseInput.disabled = !shouldShow;
passphraseInput.required = shouldShow;
if (!shouldShow) {
passphraseInput.value = "";
}
}
if (passphraseConfirm instanceof HTMLInputElement) {
passphraseConfirm.disabled = !shouldShow;
passphraseConfirm.required = shouldShow;
if (!shouldShow) {
passphraseConfirm.value = "";
}
}
};
const updateMode = () => {
const usingUnlock = unlockRadio instanceof HTMLInputElement && unlockRadio.checked;
if (importFields instanceof HTMLElement) {
importFields.classList.toggle("hidden", usingUnlock);
}
if (unlockPassphrase instanceof HTMLInputElement) {
unlockPassphrase.disabled = !usingUnlock;
unlockPassphrase.required = usingUnlock;
if (!usingUnlock) {
unlockPassphrase.value = "";
}
}
if (secretField instanceof HTMLTextAreaElement) {
secretField.disabled = usingUnlock;
secretField.required = !usingUnlock;
if (usingUnlock) {
secretField.value = "";
}
}
if (rememberCheckbox instanceof HTMLInputElement) {
rememberCheckbox.disabled = usingUnlock;
if (usingUnlock) {
rememberCheckbox.checked = false;
}
}
updatePassphraseVisibility();
if (usingUnlock) {
if (unlockPassphrase instanceof HTMLInputElement) {
unlockPassphrase.focus();
}
} else if (secretField instanceof HTMLTextAreaElement) {
secretField.focus();
}
};
updatePassphraseVisibility();
updateMode();
setError("");
const handleModeChange = () => {
setError("");
updateMode();
};
if (unlockRadio instanceof HTMLInputElement) {
unlockRadio.addEventListener("change", handleModeChange);
}
if (importRadio instanceof HTMLInputElement) {
importRadio.addEventListener("change", handleModeChange);
}
const handleRememberChange = () => {
setError("");
updatePassphraseVisibility();
};
if (rememberCheckbox instanceof HTMLInputElement) {
rememberCheckbox.addEventListener("change", handleRememberChange);
}
const cleanup = () => {
if (unlockRadio instanceof HTMLInputElement) {
unlockRadio.removeEventListener("change", handleModeChange);
}
if (importRadio instanceof HTMLInputElement) {
importRadio.removeEventListener("change", handleModeChange);
}
if (rememberCheckbox instanceof HTMLInputElement) {
rememberCheckbox.removeEventListener("change", handleRememberChange);
}
if (form.parentElement) {
form.parentElement.removeChild(form);
}
for (const entry of elementsToHide) {
if (!(entry?.element instanceof HTMLElement)) {
continue;
}
if (!entry.wasHidden) {
entry.element.classList.remove("hidden");
}
}
this.activeNsecForm = null;
};
return new Promise((resolve) => {
let settled = false;
const finish = (detail) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(detail);
};
form.addEventListener("submit", (event) => {
event.preventDefault();
setError("");
const usingUnlock = unlockRadio instanceof HTMLInputElement && unlockRadio.checked && hasEncryptedStored;
if (usingUnlock) {
if (!(unlockPassphrase instanceof HTMLInputElement) || !unlockPassphrase.value.trim()) {
setError("Enter the passphrase to unlock your stored key.");
if (unlockPassphrase instanceof HTMLInputElement) {
unlockPassphrase.focus();
}
return;
}
finish({ unlockStored: true, passphrase: unlockPassphrase.value });
return;
}
if (!(secretField instanceof HTMLTextAreaElement)) {
setError("A private key is required to continue.");
return;
}
const secret = secretField.value.trim();
if (!secret) {
setError("Paste an nsec, hex key, or mnemonic to continue.");
secretField.focus();
return;
}
const remember = rememberCheckbox instanceof HTMLInputElement && rememberCheckbox.checked;
let passphrase = "";
if (remember) {
if (!(passphraseInput instanceof HTMLInputElement) || !(passphraseConfirm instanceof HTMLInputElement)) {
setError("A passphrase is required to remember this key.");
return;
}
const pass = passphraseInput.value;
const confirm = passphraseConfirm.value;
if (!pass || !confirm) {
setError("Enter and confirm a passphrase to encrypt your key.");
passphraseInput.focus();
return;
}
if (pass !== confirm) {
setError("Passphrases do not match.");
passphraseConfirm.focus();
return;
}
passphrase = pass;
}
finish(
remember
? { secret, persist: true, passphrase }
: { secret, persist: false }
);
});
if (cancelButton instanceof HTMLButtonElement) {
cancelButton.addEventListener("click", () => finish(null), { once: true });
}
});
}
resolveTemplate() {
if (!this.modalElement) {
return null;
@@ -563,6 +856,29 @@ export default class LoginModalController {
safeInvoke(this.callbacks.onProviderSelected, providerId);
let providerOptions = {};
if (entry.provider.id === "nsec") {
try {
const nsecOptions = await this.promptForNsecOptions();
if (!nsecOptions) {
devLogger.log(
"[LoginModalController] Direct key login cancelled by the user.",
);
if (entry.button instanceof HTMLButtonElement) {
entry.button.focus();
}
return;
}
providerOptions = nsecOptions;
} catch (promptError) {
devLogger.error(
"[LoginModalController] Failed to collect direct key credentials:",
promptError,
);
return;
}
}
this.setLoadingState(providerId, true);
devLogger.log(
`[LoginModalController] Starting login for provider ${providerId}.`,
@@ -571,6 +887,7 @@ export default class LoginModalController {
try {
const result = await this.services.authService.requestLogin({
providerId,
...providerOptions,
});
devLogger.log(