mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
424 lines
20 KiB
TypeScript
424 lines
20 KiB
TypeScript
import type { App } from "obsidian";
|
|
import { encryptStash, decryptStash, argon2Available, type StashKdf } from "./stash-crypto";
|
|
import { newId } from "./id-service";
|
|
import { generateIdentityKeys, wrapDekTo, unwrapDekWith } from "./vault-keyring";
|
|
import { KeyfileStore, emptyKeyfile, type VaultKeyfile, type KeyfileIdentity, type KeyfileJoinRequest } from "./vault-keyfile";
|
|
|
|
/** Legacy global keychain id (pre-0.99.24). Namespaced per-vault now — see
|
|
* `keychainId()`; kept for a one-time migration read. */
|
|
const LEGACY_KEYCHAIN_ID = "stashpad-vault-encryption";
|
|
|
|
interface SecretStore {
|
|
getSecret(id: string): string | null;
|
|
setSecret(id: string, value: string): void | Promise<void>;
|
|
removeSecret?(id: string): void | Promise<void>;
|
|
}
|
|
|
|
/** Vault encryption — key management for one shared vault DEK.
|
|
*
|
|
* v1 (0.97.x) wrapped a per-device random DEK under the vault password and kept
|
|
* it in per-device settings. That made COLLABORATION impossible: two people on
|
|
* one synced vault minted two unrelated DEKs (the coworker's "two keys" bug).
|
|
*
|
|
* v2 (this version) distributes ONE vault DEK by PUBLIC KEY (see
|
|
* docs/branches/encryption-collab.md):
|
|
* - Each device has an ECDH identity keypair. The private key is wrapped under
|
|
* the user's password and stored per-device; the public key is published in a
|
|
* SYNCED keyfile (`.stashpad/keys.json` + `_keys/` backups).
|
|
* - The DEK is wrapped TO each authorized public key (one `slot` per member).
|
|
* A member unlocks by unwrapping their private key (password) → unwrapping the
|
|
* DEK from their slot.
|
|
* - Adding a member needs only their PUBLIC key, so no shared password is ever
|
|
* exchanged. Removing a member drops their slot (NOT true revocation without
|
|
* a DEK rotation — a follow-up).
|
|
*
|
|
* The unwrapped DEK lives only in memory (`sessionKey`), dropped on lock() /
|
|
* idle / restart. `.stashenc` blobs are unchanged (single DEK, no key-id). */
|
|
|
|
/** Persisted PER-DEVICE state (plugin settings). The vault-wide key material lives
|
|
* in the synced keyfile, NOT here. */
|
|
export interface EncryptionConfig {
|
|
/** LEGACY v1: base64 of `encryptStash(dek, password)`. Read for migration only;
|
|
* no longer written once a keyfile exists. */
|
|
wrappedKey: string | null;
|
|
kdf: StashKdf | null;
|
|
/** This device's identity id (matches a keyfile identity / slot recipientId). */
|
|
identityId: string | null;
|
|
/** Human label for this device's identity (shown to collaborators). */
|
|
identityLabel: string | null;
|
|
/** base64 SPKI public key (also published in the keyfile; cached here). */
|
|
identityPub: string | null;
|
|
/** base64 of `encryptStash(pkcs8PrivateKey, password)` — the password-protected
|
|
* private key, the only secret stored on this device. */
|
|
identityPrivWrapped: string | null;
|
|
identityPrivKdf: StashKdf | null;
|
|
}
|
|
|
|
export function defaultEncryptionConfig(): EncryptionConfig {
|
|
return {
|
|
wrappedKey: null, kdf: null,
|
|
identityId: null, identityLabel: null, identityPub: null,
|
|
identityPrivWrapped: null, identityPrivKdf: null,
|
|
};
|
|
}
|
|
|
|
function toB64(bytes: Uint8Array): string {
|
|
let s = "";
|
|
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
return btoa(s);
|
|
}
|
|
function fromB64(b64: string): Uint8Array {
|
|
const s = atob(b64);
|
|
const out = new Uint8Array(s.length);
|
|
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
const DEK_LEN = 32; // 256-bit vault key
|
|
|
|
/** This device's relationship to the vault's encryption. */
|
|
export type AccessState = "none" | "member" | "pending" | "outsider";
|
|
|
|
export class EncryptionService {
|
|
private sessionKey: Uint8Array | null = null;
|
|
private idleTimer: number | null = null;
|
|
private keyfiles: KeyfileStore;
|
|
/** In-memory cache of the synced keyfile (refreshed via init()/refresh()). */
|
|
private kf: VaultKeyfile | null = null;
|
|
|
|
constructor(
|
|
private app: App,
|
|
private load: () => EncryptionConfig,
|
|
private save: (cfg: EncryptionConfig) => Promise<void>,
|
|
private idleMinutes: () => number = () => 0,
|
|
) { this.keyfiles = new KeyfileStore(app); }
|
|
|
|
argonProbe(): Promise<boolean> { return argon2Available(); }
|
|
|
|
/** Load the synced keyfile into the in-memory cache. Call on plugin load and
|
|
* before any operation that needs a fresh view of collaborators. */
|
|
async init(): Promise<void> { await this.refresh(); }
|
|
async refresh(): Promise<void> { this.kf = await this.keyfiles.load(); }
|
|
|
|
// ---- keychain (per-device convenience copy of the unlock password) ----
|
|
private secretStore(): SecretStore | null {
|
|
return (this.app as App & { secretStorage?: SecretStore }).secretStorage ?? null;
|
|
}
|
|
keychainAvailable(): boolean { return !!this.secretStore(); }
|
|
private keychainId(): string {
|
|
const appId = (this.app as App & { appId?: string }).appId || "default";
|
|
return `${LEGACY_KEYCHAIN_ID}-${appId}`;
|
|
}
|
|
isRemembered(): boolean {
|
|
try { return !!this.secretStore()?.getSecret(this.keychainId()); } catch { return false; }
|
|
}
|
|
/** The password saved in this device's keychain, or null. Lets flows that
|
|
* need the password (e.g. Remove encryption) pull it automatically instead
|
|
* of forcing the user to retype it. Falls back to the legacy entry. */
|
|
rememberedPassword(): string | null {
|
|
const ss = this.secretStore();
|
|
if (!ss) return null;
|
|
try { const v = ss.getSecret(this.keychainId()); if (v) return v; } catch { /* fall through */ }
|
|
try { return ss.getSecret(LEGACY_KEYCHAIN_ID) || null; } catch { return null; }
|
|
}
|
|
/** True if the keychain holds a password that actually verifies against the
|
|
* current key — so a flow can treat "keychain present" as proof of access
|
|
* without prompting. */
|
|
async verifyWithKeychain(): Promise<boolean> {
|
|
const pw = this.rememberedPassword();
|
|
if (!pw) return false;
|
|
try { return await this.verifyPassword(pw); } catch { return false; }
|
|
}
|
|
private async remember(password: string): Promise<void> {
|
|
try { await this.secretStore()?.setSecret(this.keychainId(), password); }
|
|
catch (e) { console.warn("[Stashpad] couldn't save password to keychain", e); }
|
|
}
|
|
async forgetKeychain(): Promise<void> {
|
|
const ss = this.secretStore();
|
|
if (!ss) return;
|
|
try { if (ss.removeSecret) await ss.removeSecret(this.keychainId()); else await ss.setSecret(this.keychainId(), ""); }
|
|
catch (e) { console.warn("[Stashpad] couldn't clear keychain", e); }
|
|
}
|
|
async tryAutoUnlock(): Promise<boolean> {
|
|
if (!this.isConfigured() || this.isUnlocked()) return this.isUnlocked();
|
|
// Can't auto-unlock unless this device can unlock at all: a member (its own
|
|
// slot) or any device when a shared password is enabled.
|
|
if (this.accessState() !== "member" && !this.hasSharedPassword()) return false;
|
|
const ss = this.secretStore();
|
|
let stored: string | null = null;
|
|
try { stored = ss?.getSecret(this.keychainId()) ?? null; } catch { stored = null; }
|
|
if (stored) return this.unlock(stored);
|
|
let legacy: string | null = null;
|
|
try { legacy = ss?.getSecret(LEGACY_KEYCHAIN_ID) ?? null; } catch { legacy = null; }
|
|
if (!legacy) return false;
|
|
const ok = await this.unlock(legacy);
|
|
if (ok) { await this.remember(legacy); try { if (ss?.removeSecret) await ss.removeSecret(LEGACY_KEYCHAIN_ID); else await ss?.setSecret(LEGACY_KEYCHAIN_ID, ""); } catch { /* */ } }
|
|
return ok;
|
|
}
|
|
|
|
// ---- state ----
|
|
/** Is encryption set up in this VAULT (by anyone)? */
|
|
isConfigured(): boolean {
|
|
return !!this.kf || !!this.load().wrappedKey;
|
|
}
|
|
isUnlocked(): boolean { return this.sessionKey !== null; }
|
|
kdf(): StashKdf | null { return this.load().identityPrivKdf ?? this.load().kdf; }
|
|
|
|
private hasIdentity(): boolean { return !!this.load().identityPrivWrapped && !!this.load().identityId; }
|
|
private mySlot() {
|
|
const id = this.load().identityId;
|
|
return id ? (this.kf?.slots.find((s) => s.recipientId === id) ?? null) : null;
|
|
}
|
|
/** This device's relationship to the vault encryption. */
|
|
accessState(): AccessState {
|
|
const cfg = this.load();
|
|
// Legacy single-device (v1) with no keyfile yet → treat as member (migrates on unlock).
|
|
if (!this.kf && cfg.wrappedKey) return "member";
|
|
if (!this.kf) return "none";
|
|
if (this.hasIdentity() && this.mySlot()) return "member";
|
|
if (cfg.identityId && this.kf.joinRequests.some((r) => r.id === cfg.identityId)) return "pending";
|
|
return "outsider";
|
|
}
|
|
amIMember(): boolean { return this.accessState() === "member"; }
|
|
|
|
// ---- setup / unlock / migration ----
|
|
/** First-time setup for a brand-new vault (state "none"): mint the vault DEK,
|
|
* create this device's identity, write the keyfile with one slot. */
|
|
async setup(password: string, remember = false, label?: string): Promise<void> {
|
|
await this.refresh();
|
|
if (this.isConfigured()) throw new Error("Encryption is already set up in this vault.");
|
|
if (!password) throw new Error("Password required.");
|
|
const dek = crypto.getRandomValues(new Uint8Array(DEK_LEN));
|
|
const id = await this.mintIdentity(password, label);
|
|
const kf = emptyKeyfile(newId(8));
|
|
kf.identities.push(this.identityRecord(id));
|
|
kf.slots.push(await wrapDekTo(dek, fromB64(id.pub), id.id));
|
|
await this.keyfiles.save(kf);
|
|
this.kf = kf;
|
|
this.sessionKey = dek;
|
|
if (remember) await this.remember(password); else await this.forgetKeychain();
|
|
this.armIdle();
|
|
}
|
|
|
|
/** Unlock the session DEK with this device's password. Returns false on wrong
|
|
* password. For a v1 vault with no keyfile, migrates to the keyring on the way. */
|
|
async unlock(password: string, remember = false): Promise<boolean> {
|
|
await this.refresh();
|
|
const cfg = this.load();
|
|
// v1 → v2 migration: legacy wrapped DEK, no keyfile yet.
|
|
if (!this.kf && cfg.wrappedKey) {
|
|
let dek: Uint8Array;
|
|
try { dek = await decryptStash(fromB64(cfg.wrappedKey), password); } catch { return false; }
|
|
const id = await this.mintIdentity(password, cfg.identityLabel ?? undefined);
|
|
const kf = emptyKeyfile(newId(8));
|
|
kf.identities.push(this.identityRecord(id));
|
|
kf.slots.push(await wrapDekTo(dek, fromB64(id.pub), id.id));
|
|
await this.keyfiles.save(kf);
|
|
this.kf = kf;
|
|
this.sessionKey = dek;
|
|
// Only ADD to the keychain on explicit remember — never forget here. (An
|
|
// auto-unlock calls unlock() with remember=false; force-forgetting would
|
|
// wipe the very password it just used to unlock.)
|
|
if (remember) await this.remember(password);
|
|
this.armIdle();
|
|
return true;
|
|
}
|
|
// Member (device-approval) path — unwrap my private key, then the DEK from my
|
|
// slot. On any failure, fall through to the shared-password path (the typed
|
|
// password might be the shared one, not this device's).
|
|
const slot = this.hasIdentity() ? this.mySlot() : null;
|
|
if (slot) {
|
|
try {
|
|
const priv = await decryptStash(fromB64(cfg.identityPrivWrapped!), password);
|
|
try {
|
|
const dek = await unwrapDekWith(slot, priv);
|
|
priv.fill(0);
|
|
this.sessionKey = dek;
|
|
if (remember) await this.remember(password);
|
|
this.armIdle();
|
|
return true;
|
|
} catch { priv.fill(0); }
|
|
} catch { /* wrong password for my key — try shared password below */ }
|
|
}
|
|
// Shared-password path (Model 1) — try the typed password against any shared
|
|
// password slot. Lets a device with no identity join just by knowing it.
|
|
for (const ps of (this.kf?.passwordSlots ?? [])) {
|
|
try {
|
|
const dek = await decryptStash(fromB64(ps.wrapped), password);
|
|
this.sessionKey = dek;
|
|
if (remember) await this.remember(password);
|
|
this.armIdle();
|
|
return true;
|
|
} catch { /* not this slot */ }
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** True if a shared password is enabled for this vault. */
|
|
hasSharedPassword(): boolean { return (this.kf?.passwordSlots?.length ?? 0) > 0; }
|
|
|
|
/** Set (or replace) the shared password: wrap the unlocked DEK under `passphrase`
|
|
* so anyone who knows it can unlock — no per-device approval (Model 1). Requires
|
|
* the vault to be unlocked (we need the DEK in hand). */
|
|
async setSharedPassword(passphrase: string): Promise<void> {
|
|
if (!this.sessionKey) throw new Error("Unlock encryption first.");
|
|
if (!passphrase) throw new Error("Password required.");
|
|
await this.refresh();
|
|
if (!this.kf) throw new Error("Encryption is not set up.");
|
|
const wrapped = await encryptStash(this.sessionKey, passphrase);
|
|
this.kf.passwordSlots = [{ id: newId(8), label: "Shared password", wrapped: toB64(wrapped.data), kdf: wrapped.kdf, createdAt: new Date().toISOString() }];
|
|
await this.keyfiles.save(this.kf);
|
|
}
|
|
|
|
/** Turn off the shared password. Devices that only had it can no longer unlock
|
|
* with it (same "not true revocation of already-synced copies" caveat as
|
|
* removeMember). */
|
|
async removeSharedPassword(): Promise<void> {
|
|
await this.refresh();
|
|
if (!this.kf || !this.kf.passwordSlots?.length) return;
|
|
this.kf.passwordSlots = [];
|
|
await this.keyfiles.save(this.kf);
|
|
}
|
|
|
|
/** Verify a password without changing session state (destructive-action gates). */
|
|
async verifyPassword(password: string): Promise<boolean> {
|
|
const cfg = this.load();
|
|
try {
|
|
if (cfg.identityPrivWrapped) { (await decryptStash(fromB64(cfg.identityPrivWrapped), password)).fill(0); return true; }
|
|
if (cfg.wrappedKey) { (await decryptStash(fromB64(cfg.wrappedKey), password)).fill(0); return true; }
|
|
} catch { /* fall through */ }
|
|
return false;
|
|
}
|
|
|
|
/** Re-wrap THIS device's private key under a new password. The DEK and the
|
|
* keyfile are untouched (other members unaffected). */
|
|
async changePassword(oldPassword: string, newPassword: string, remember = false): Promise<boolean> {
|
|
const cfg = this.load();
|
|
if (!newPassword) throw new Error("New password required.");
|
|
if (!cfg.identityPrivWrapped) {
|
|
// v1 vault not yet migrated — unlock (which migrates), then re-wrap.
|
|
if (!(await this.unlock(oldPassword, false))) return false;
|
|
}
|
|
const fresh = this.load();
|
|
let priv: Uint8Array;
|
|
try { priv = await decryptStash(fromB64(fresh.identityPrivWrapped!), oldPassword); } catch { return false; }
|
|
const wrapped = await encryptStash(priv, newPassword);
|
|
priv.fill(0);
|
|
await this.save({ ...fresh, identityPrivWrapped: toB64(wrapped.data), identityPrivKdf: wrapped.kdf });
|
|
if (remember) await this.remember(newPassword); else await this.forgetKeychain();
|
|
this.armIdle();
|
|
return true;
|
|
}
|
|
|
|
// ---- collaboration ----
|
|
/** Create this device's identity (if needed) and publish a join request in the
|
|
* keyfile. `password` protects this device's new private key. */
|
|
async requestAccess(label: string, password: string, remember = false): Promise<void> {
|
|
await this.refresh();
|
|
if (!this.kf) throw new Error("This vault has no encryption set up yet.");
|
|
if (this.amIMember()) return;
|
|
if (!password) throw new Error("Password required.");
|
|
const id = this.hasIdentity()
|
|
? { id: this.load().identityId!, label: this.load().identityLabel ?? label, pub: this.load().identityPub! }
|
|
: await this.mintIdentity(password, label);
|
|
if (label && id.label !== label) { await this.save({ ...this.load(), identityLabel: label }); id.label = label; }
|
|
const req: KeyfileJoinRequest = { id: id.id, label: id.label, pubKey: id.pub, requestedAt: new Date().toISOString() };
|
|
this.kf.joinRequests = [...this.kf.joinRequests.filter((r) => r.id !== id.id), req];
|
|
await this.keyfiles.save(this.kf);
|
|
// Remembering now lets this device auto-unlock the moment a member approves
|
|
// it and the keyfile syncs here (the password already protects its priv key).
|
|
if (remember) await this.remember(password); else await this.forgetKeychain();
|
|
}
|
|
|
|
pendingJoinRequests(): KeyfileJoinRequest[] { return this.kf?.joinRequests ?? []; }
|
|
members(): KeyfileIdentity[] { return this.kf?.identities ?? []; }
|
|
myIdentityId(): string | null { return this.load().identityId; }
|
|
|
|
/** Authorize a pending device: wrap the (unlocked) DEK to its public key. */
|
|
async approveJoinRequest(requestId: string, label?: string): Promise<boolean> {
|
|
if (!this.sessionKey) throw new Error("Unlock encryption first.");
|
|
await this.refresh();
|
|
if (!this.kf) return false;
|
|
const req = this.kf.joinRequests.find((r) => r.id === requestId);
|
|
if (!req) return false;
|
|
this.kf.slots = [...this.kf.slots.filter((s) => s.recipientId !== req.id), await wrapDekTo(this.sessionKey, fromB64(req.pubKey), req.id)];
|
|
this.kf.identities = [...this.kf.identities.filter((i) => i.id !== req.id), { id: req.id, label: label ?? req.label, pubKey: req.pubKey, addedAt: new Date().toISOString() }];
|
|
this.kf.joinRequests = this.kf.joinRequests.filter((r) => r.id !== requestId);
|
|
await this.keyfiles.save(this.kf);
|
|
return true;
|
|
}
|
|
|
|
/** Remove a member's slot + identity. NOT true revocation (no DEK rotation) —
|
|
* caller must warn. */
|
|
async removeMember(id: string): Promise<void> {
|
|
await this.refresh();
|
|
if (!this.kf) return;
|
|
this.kf.slots = this.kf.slots.filter((s) => s.recipientId !== id);
|
|
this.kf.identities = this.kf.identities.filter((i) => i.id !== id);
|
|
this.kf.joinRequests = this.kf.joinRequests.filter((r) => r.id !== id);
|
|
await this.keyfiles.save(this.kf);
|
|
}
|
|
|
|
/** Reject a pending request without authorizing it. */
|
|
async denyJoinRequest(id: string): Promise<void> {
|
|
await this.refresh();
|
|
if (!this.kf) return;
|
|
this.kf.joinRequests = this.kf.joinRequests.filter((r) => r.id !== id);
|
|
await this.keyfiles.save(this.kf);
|
|
}
|
|
|
|
// ---- session key + lifecycle ----
|
|
lock(): void {
|
|
if (this.sessionKey) this.sessionKey.fill(0);
|
|
this.sessionKey = null;
|
|
this.clearIdle();
|
|
}
|
|
|
|
/** Remove encryption entirely (caller gates on "no .stashenc exists"): wipe the
|
|
* session key, this device's identity, the legacy wrap, AND the synced keyfile
|
|
* + backups (no encrypted content remains, so this is safe). */
|
|
async clear(): Promise<void> {
|
|
this.lock();
|
|
await this.forgetKeychain();
|
|
try {
|
|
const a = this.app.vault.adapter;
|
|
for (const p of [".stashpad/keys.json"]) { try { if (await a.exists(p)) await a.remove(p); } catch { /* */ } }
|
|
try { const list = await a.list("_keys"); for (const f of (list.files || [])) { if (/\/keys-\d+\.json$/.test(f)) { try { await a.remove(f); } catch { /* */ } } } } catch { /* */ }
|
|
} catch { /* best-effort */ }
|
|
this.kf = null;
|
|
await this.save(defaultEncryptionConfig());
|
|
}
|
|
|
|
getSessionKey(): Uint8Array | null {
|
|
if (this.sessionKey) this.armIdle();
|
|
return this.sessionKey ? this.sessionKey.slice() : null;
|
|
}
|
|
|
|
// ---- helpers ----
|
|
/** Generate a device identity keypair, wrap its private key under `password`,
|
|
* persist it to per-device settings, and return the public parts. */
|
|
private async mintIdentity(password: string, label?: string): Promise<{ id: string; label: string; pub: string }> {
|
|
const keys = await generateIdentityKeys();
|
|
const wrapped = await encryptStash(keys.privKeyPkcs8, password);
|
|
keys.privKeyPkcs8.fill(0);
|
|
const cfg = this.load();
|
|
const id = cfg.identityId ?? newId(8);
|
|
const lbl = label ?? cfg.identityLabel ?? "This device";
|
|
const pub = toB64(keys.pubKeySpki);
|
|
await this.save({ ...cfg, identityId: id, identityLabel: lbl, identityPub: pub, identityPrivWrapped: toB64(wrapped.data), identityPrivKdf: wrapped.kdf });
|
|
return { id, label: lbl, pub };
|
|
}
|
|
private identityRecord(id: { id: string; label: string; pub: string }): KeyfileIdentity {
|
|
return { id: id.id, label: id.label, pubKey: id.pub, addedAt: new Date().toISOString() };
|
|
}
|
|
|
|
private armIdle(): void {
|
|
this.clearIdle();
|
|
const mins = this.idleMinutes();
|
|
if (mins > 0) this.idleTimer = window.setTimeout(() => this.lock(), mins * 60_000);
|
|
}
|
|
private clearIdle(): void {
|
|
if (this.idleTimer != null) { window.clearTimeout(this.idleTimer); this.idleTimer = null; }
|
|
}
|
|
dispose(): void { this.lock(); }
|
|
}
|