memory-provider-encrypted.mjs
229 lines 7.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Encrypted file-based memory provider: wraps FileMemoryProvider with AES-256-GCM.
3 * Each JSONL line and the state.json content are encrypted at rest.
4 *
5 * Key derivation: scrypt(secret, per-vault-salt, 32).
6 * Salt: random 16 bytes stored in {memoryDir}/.salt (created once per vault).
7 * Ciphertext format per line: base64url(iv):base64url(authTag):base64url(ciphertext)
8 */
9
10 import crypto from 'crypto';
11 import fs from 'fs';
12 import path from 'path';
13
14 const IV_LENGTH = 12;
15 const AUTH_TAG_LENGTH = 16;
16 const KEY_LENGTH = 32;
17 const SALT_LENGTH = 16;
18 const SCRYPT_N = 16384;
19
20 export class EncryptedFileMemoryProvider {
21 #baseDir;
22 #key;
23
24 /**
25 * @param {string} baseDir — memory directory for one vault
26 * @param {string} secret — encryption secret (from KNOWTATION_MEMORY_SECRET or config)
27 */
28 constructor(baseDir, secret) {
29 if (!secret || typeof secret !== 'string' || secret.length < 8) {
30 throw new Error('Encrypted memory requires a secret of at least 8 characters.');
31 }
32 this.#baseDir = baseDir;
33 fs.mkdirSync(baseDir, { recursive: true });
34 const salt = this.#loadOrCreateSalt();
35 this.#key = crypto.scryptSync(secret, salt, KEY_LENGTH, { N: SCRYPT_N });
36 }
37
38 get baseDir() {
39 return this.#baseDir;
40 }
41
42 #saltPath() {
43 return path.join(this.#baseDir, '.salt');
44 }
45
46 #eventsPath() {
47 return path.join(this.#baseDir, 'events.jsonl.enc');
48 }
49
50 #statePath() {
51 return path.join(this.#baseDir, 'state.json.enc');
52 }
53
54 #loadOrCreateSalt() {
55 const sp = this.#saltPath();
56 if (fs.existsSync(sp)) {
57 return fs.readFileSync(sp);
58 }
59 const salt = crypto.randomBytes(SALT_LENGTH);
60 fs.writeFileSync(sp, salt);
61 return salt;
62 }
63
64 #encrypt(plaintext) {
65 const iv = crypto.randomBytes(IV_LENGTH);
66 const cipher = crypto.createCipheriv('aes-256-gcm', this.#key, iv);
67 const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
68 const authTag = cipher.getAuthTag();
69 return `${iv.toString('base64url')}:${authTag.toString('base64url')}:${encrypted.toString('base64url')}`;
70 }
71
72 #decrypt(line) {
73 const parts = line.split(':');
74 if (parts.length !== 3) throw new Error('Malformed encrypted line');
75 const iv = Buffer.from(parts[0], 'base64url');
76 const authTag = Buffer.from(parts[1], 'base64url');
77 const encrypted = Buffer.from(parts[2], 'base64url');
78 const decipher = crypto.createDecipheriv('aes-256-gcm', this.#key, iv);
79 decipher.setAuthTag(authTag);
80 return decipher.update(encrypted, null, 'utf8') + decipher.final('utf8');
81 }
82
83 #readState() {
84 const p = this.#statePath();
85 if (!fs.existsSync(p)) return {};
86 try {
87 const enc = fs.readFileSync(p, 'utf8').trim();
88 if (!enc) return {};
89 const json = this.#decrypt(enc);
90 return JSON.parse(json);
91 } catch (_) {
92 return {};
93 }
94 }
95
96 #writeState(state) {
97 const json = JSON.stringify(state, null, 2);
98 fs.writeFileSync(this.#statePath(), this.#encrypt(json) + '\n', 'utf8');
99 }
100
101 #readAllEvents() {
102 const p = this.#eventsPath();
103 if (!fs.existsSync(p)) return [];
104 const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean);
105 const events = [];
106 for (const line of lines) {
107 try {
108 const json = this.#decrypt(line);
109 events.push(JSON.parse(json));
110 } catch (_) {}
111 }
112 return events;
113 }
114
115 storeEvent(event) {
116 fs.mkdirSync(this.#baseDir, { recursive: true });
117 const encLine = this.#encrypt(JSON.stringify(event)) + '\n';
118 fs.appendFileSync(this.#eventsPath(), encLine, 'utf8');
119
120 const state = this.#readState();
121 state[event.type] = event;
122 this.#writeState(state);
123
124 return { id: event.id, ts: event.ts };
125 }
126
127 getLatest(type) {
128 const state = this.#readState();
129 return state[type] ?? null;
130 }
131
132 listEvents(opts = {}) {
133 const limit = Math.min(opts.limit ?? 100, 1000);
134 let events = this.#readAllEvents();
135 if (opts.type) events = events.filter((e) => e.type === opts.type);
136 if (opts.since) events = events.filter((e) => e.ts >= opts.since);
137 if (opts.until) events = events.filter((e) => e.ts <= opts.until);
138 events.sort((a, b) => (b.ts > a.ts ? 1 : b.ts < a.ts ? -1 : 0));
139 return events.slice(0, limit);
140 }
141
142 searchEvents(_query, _opts) {
143 return [];
144 }
145
146 supportsSearch() {
147 return false;
148 }
149
150 clearEvents(opts = {}) {
151 const events = this.#readAllEvents();
152 const before = events.length;
153 let kept = events;
154 if (opts.type) kept = kept.filter((e) => e.type !== opts.type);
155 if (opts.before) kept = kept.filter((e) => e.ts >= opts.before);
156 if (!opts.type && !opts.before) kept = [];
157
158 const cleared = before - kept.length;
159 fs.mkdirSync(this.#baseDir, { recursive: true });
160 if (kept.length === 0) {
161 fs.writeFileSync(this.#eventsPath(), '', 'utf8');
162 } else {
163 const lines = kept.map((e) => this.#encrypt(JSON.stringify(e)));
164 fs.writeFileSync(this.#eventsPath(), lines.join('\n') + '\n', 'utf8');
165 }
166
167 const state = this.#readState();
168 const removedTypes = new Set(events.filter((e) => !kept.includes(e)).map((e) => e.type));
169 for (const t of removedTypes) {
170 const latestOfType = kept.filter((e) => e.type === t).sort((a, b) => (b.ts > a.ts ? 1 : -1))[0];
171 if (latestOfType) {
172 state[t] = latestOfType;
173 } else {
174 delete state[t];
175 }
176 }
177 this.#writeState(state);
178 return { cleared };
179 }
180
181 pruneExpired(retentionDays) {
182 if (!retentionDays || retentionDays <= 0) return { pruned: 0 };
183 const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
184 const events = this.#readAllEvents();
185 const kept = events.filter((e) => e.ts >= cutoff);
186 const pruned = events.length - kept.length;
187 if (pruned === 0) return { pruned: 0 };
188
189 fs.mkdirSync(this.#baseDir, { recursive: true });
190 if (kept.length === 0) {
191 fs.writeFileSync(this.#eventsPath(), '', 'utf8');
192 } else {
193 const lines = kept.map((e) => this.#encrypt(JSON.stringify(e)));
194 fs.writeFileSync(this.#eventsPath(), lines.join('\n') + '\n', 'utf8');
195 }
196
197 const state = this.#readState();
198 const removedTypes = new Set(events.filter((e) => e.ts < cutoff).map((e) => e.type));
199 for (const t of removedTypes) {
200 const latestOfType = kept.filter((e) => e.type === t).sort((a, b) => (b.ts > a.ts ? 1 : -1))[0];
201 if (latestOfType) {
202 state[t] = latestOfType;
203 } else {
204 delete state[t];
205 }
206 }
207 this.#writeState(state);
208 return { pruned };
209 }
210
211 getStats() {
212 const events = this.#readAllEvents();
213 if (events.length === 0) {
214 return { counts_by_type: {}, total: 0, oldest: null, newest: null, size_bytes: 0 };
215 }
216 const counts = {};
217 let oldest = null;
218 let newest = null;
219 for (const e of events) {
220 counts[e.type] = (counts[e.type] || 0) + 1;
221 if (!oldest || e.ts < oldest) oldest = e.ts;
222 if (!newest || e.ts > newest) newest = e.ts;
223 }
224 let size = 0;
225 try { size = fs.statSync(this.#eventsPath()).size; } catch (_) {}
226 try { size += fs.statSync(this.#statePath()).size; } catch (_) {}
227 return { counts_by_type: counts, total: events.length, oldest, newest, size_bytes: size };
228 }
229 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago