-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsecureStorage.ts
More file actions
169 lines (154 loc) · 5.36 KB
/
Copy pathsecureStorage.ts
File metadata and controls
169 lines (154 loc) · 5.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
/**
* Encrypted at-rest storage for the AI Assistant API key.
*
* Stores the API key in IndexedDB encrypted with AES-GCM-256. The
* wrapping key is a Web Crypto :class:`CryptoKey` generated with
* ``extractable: false`` and persisted in the same database via
* structured cloning — JS code can therefore call ``encrypt``/
* ``decrypt`` but can never read the raw key bytes back.
*
* Threat model (honest):
* - ✅ Protects against passive disk reads: backups, profile sync
* (OneDrive, ...), malware that exfiltrates ``localStorage`` files,
* browser extensions whose permissions only cover ``localStorage``.
* - ✅ Hides the key from a casual look at DevTools → Application.
* - ❌ Does NOT protect against an attacker running JS in this origin
* (XSS, malicious extension with full host permissions, compromised
* dependency). They can call :func:`loadApiKey` themselves.
*
* No first-party backend is required; this is a pure-browser solution
* that survives sessions without prompting the user.
*/
import { openDB, type DBSchema, type IDBPDatabase } from "idb";
const DB_NAME = "datalab-web.aiassistant.secrets";
const DB_VERSION = 1;
const STORE = "kv";
/** Stable IDB record id for the (non-extractable) AES-GCM wrapping key. */
const WRAP_KEY_ID = "wrap-key";
/** Stable IDB record id for the encrypted API-key payload. */
const API_KEY_ID = "api-key";
/** Encrypted payload shape. ``iv`` is the 12-byte AES-GCM nonce, regen
* on every write. The explicit ``ArrayBuffer`` backing is required by
* TypeScript ≥ 5.7's narrower ``BufferSource`` definition (which now
* excludes ``SharedArrayBuffer``-backed views). */
interface EncryptedPayload {
iv: Uint8Array<ArrayBuffer>;
ciphertext: Uint8Array<ArrayBuffer>;
}
interface SecretsRecord {
id: string;
/** Present when ``id === WRAP_KEY_ID``. */
key?: CryptoKey;
/** Present when ``id === API_KEY_ID``. */
payload?: EncryptedPayload;
}
interface Schema extends DBSchema {
[STORE]: {
key: string;
value: SecretsRecord;
};
}
let _dbPromise: Promise<IDBPDatabase<Schema>> | null = null;
function getDb(): Promise<IDBPDatabase<Schema>> {
if (!_dbPromise) {
_dbPromise = openDB<Schema>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "id" });
}
},
});
}
return _dbPromise;
}
/** Reset the in-memory connection (tests). */
export function _resetSecureStorageForTests(): void {
_dbPromise = null;
}
function getSubtle(): SubtleCrypto | null {
// ``crypto.subtle`` is only available in secure contexts (https / localhost).
// When unavailable we degrade to a no-op so the rest of the app keeps
// working (the API key just won't persist).
try {
return globalThis.crypto?.subtle ?? null;
} catch {
return null;
}
}
/** Lazily fetch — or generate and persist — the non-extractable wrapping
* key. Returns ``null`` when Web Crypto is unavailable. */
async function getOrCreateWrapKey(): Promise<CryptoKey | null> {
const subtle = getSubtle();
if (!subtle) return null;
const db = await getDb();
const existing = await db.get(STORE, WRAP_KEY_ID);
if (existing?.key) return existing.key;
const key = await subtle.generateKey(
{ name: "AES-GCM", length: 256 },
/* extractable */ false,
["encrypt", "decrypt"],
);
await db.put(STORE, { id: WRAP_KEY_ID, key });
return key;
}
/** Persist *plaintext* under :data:`API_KEY_ID`, replacing any previous
* value. An empty string clears the stored key. */
export async function saveApiKey(plaintext: string): Promise<void> {
if (!plaintext) {
await clearApiKey();
return;
}
const subtle = getSubtle();
const wrapKey = await getOrCreateWrapKey();
if (!subtle || !wrapKey) return;
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode(plaintext);
const cipher = await subtle.encrypt({ name: "AES-GCM", iv }, wrapKey, data);
const db = await getDb();
await db.put(STORE, {
id: API_KEY_ID,
payload: { iv, ciphertext: new Uint8Array(cipher) },
});
}
/** Decrypt and return the stored API key, or ``""`` when none is set or
* decryption fails (corrupted record / Web Crypto unavailable). */
export async function loadApiKey(): Promise<string> {
const subtle = getSubtle();
if (!subtle) return "";
try {
const db = await getDb();
const rec = await db.get(STORE, API_KEY_ID);
if (!rec?.payload) return "";
const wrapKey = await getOrCreateWrapKey();
if (!wrapKey) return "";
const plain = await subtle.decrypt(
{ name: "AES-GCM", iv: rec.payload.iv },
wrapKey,
rec.payload.ciphertext,
);
return new TextDecoder().decode(plain);
} catch {
return "";
}
}
/** Remove the encrypted API-key payload (the wrapping key is kept so a
* subsequent :func:`saveApiKey` reuses the same origin-bound key). */
export async function clearApiKey(): Promise<void> {
try {
const db = await getDb();
await db.delete(STORE, API_KEY_ID);
} catch {
/* ignore */
}
}
/** True when an encrypted API-key payload exists in storage (without
* attempting decryption). */
export async function hasStoredApiKey(): Promise<boolean> {
try {
const db = await getDb();
const rec = await db.get(STORE, API_KEY_ID);
return Boolean(rec?.payload);
} catch {
return false;
}
}