mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add direct key auth flow with encrypted key storage
This commit is contained in:
1 parent
8cb2a866ea
commit
cf65ace78e
7 files changed
+1268
-57
No files matched your search
+103
-8
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user