sirius/js/session.js

142 lines
5.5 KiB
JavaScript
Raw Normal View History

// Sirius.X persistent sign-in ("stay signed in on this device").
//
// Problem: every visit asked for the seed phrase, the password or the PIN
// again. A dashboard people come back to daily cannot work like that.
//
// Model: after one successful sign-in the wallet's recovery phrase is
// encrypted with a random AES-GCM key that lives ONLY inside IndexedDB as a
// non-extractable CryptoKey (the browser can use it, scripts cannot read
// its bytes). The ciphertext sits in localStorage. On the next visit the
// dashboard decrypts silently and the user is in — no prompt. Signing out,
// or turning "stay signed in" off in Settings, destroys both halves.
//
// Trust boundary, stated honestly: this is a device session, like a
// "remember me" cookie. Anyone who can run scripts in this browser profile
// can use the wallet while the session exists. The password-encrypted
// wallet blob (bns.wallet.v1) and the optional PIN escrow are unchanged and
// still the durable secrets. Users who want a prompt before every payment
// can keep that in Settings ("Ask for PIN before payments").
//
// window.siriusSession = { isEnabled, setEnabled, remember, restore, forget,
// requirePinForPayments, setRequirePinForPayments }
(() => {
const DB = "sirius-session";
const STORE = "keys";
const KEY_ID = "device";
const BLOB = "siriusSessionBlob";
const ENABLED = "siriusStaySignedIn"; // "0" = off; anything else = on (default on)
const REQUIRE_PIN = "siriusRequirePin"; // "1" = ask PIN before payments (default off)
const enc = new TextEncoder();
const dec = new TextDecoder();
const b64 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)));
const unb64 = (str) => Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
function openDb() {
return new Promise((resolve, reject) => {
if (!("indexedDB" in window)) return reject(new Error("IndexedDB unavailable"));
const req = indexedDB.open(DB, 1);
req.onupgradeneeded = () => { req.result.createObjectStore(STORE); };
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error || new Error("IndexedDB open failed"));
});
}
function idbGet(db, key) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
}
function idbPut(db, key, value) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
function idbDel(db, key) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function deviceKey(create) {
const db = await openDb();
try {
let key = await idbGet(db, KEY_ID);
if (!key && create) {
key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
await idbPut(db, KEY_ID, key);
}
return key;
} finally { db.close(); }
}
function isEnabled() {
try { return localStorage.getItem(ENABLED) !== "0"; } catch { return true; }
}
function setEnabled(on) {
try { localStorage.setItem(ENABLED, on ? "1" : "0"); } catch {}
if (!on) forget();
}
function requirePinForPayments() {
try { return localStorage.getItem(REQUIRE_PIN) === "1"; } catch { return false; }
}
function setRequirePinForPayments(on) {
try { localStorage.setItem(REQUIRE_PIN, on ? "1" : "0"); } catch {}
}
function hasSession() {
try { return !!localStorage.getItem(BLOB); } catch { return false; }
}
// Encrypt and store the recovery phrase for silent restore. `meta` may
// carry { accountPath } for wallets derived on a non-default path.
async function remember(mnemonic, meta = {}) {
if (!isEnabled() || !mnemonic) return false;
try {
const key = await deviceKey(true);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc.encode(mnemonic));
localStorage.setItem(BLOB, JSON.stringify({ v: 1, iv: b64(iv), ct: b64(ct), at: Date.now(), path: meta.accountPath || null }));
return true;
} catch { return false; }
}
// Silent restore. Resolves { mnemonic, accountPath } or null. Never throws.
async function restore() {
if (!isEnabled()) return null;
let raw = null;
try { raw = localStorage.getItem(BLOB); } catch {}
if (!raw) return null;
try {
const blob = JSON.parse(raw);
const key = await deviceKey(false);
if (!key) { forget(); return null; }
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(blob.iv) }, key, unb64(blob.ct));
return { mnemonic: dec.decode(pt), accountPath: blob.path || null };
} catch {
// Key rotated / storage cleared / tampered — drop the stale blob.
forget();
return null;
}
}
async function forget() {
try { localStorage.removeItem(BLOB); } catch {}
try { const db = await openDb(); try { await idbDel(db, KEY_ID); } finally { db.close(); } } catch {}
}
window.siriusSession = {
isEnabled, setEnabled, hasSession,
remember, restore, forget,
requirePinForPayments, setRequirePinForPayments,
};
})();