attest-store.mjs
340 lines 10.3 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * AIR Improvements D + E — attestation storage layer.
3 *
4 * D: Creates signed attestation records (HMAC-SHA256) and stores them in
5 * Netlify Blobs (on Netlify) or a local JSON file (local dev).
6 *
7 * E: Dual-write — after the Blob/file write, attempts to anchor the record
8 * on the ICP attestation canister. ICP failure never blocks the write path;
9 * records are marked icp_status "pending" and can be reconciled later.
10 *
11 * Blob store handle comes from globalThis.__knowtation_attest_blob, set by
12 * netlify/functions/gateway.mjs (same pattern as billing-store.mjs).
13 */
14
15 import { createHmac, randomUUID } from 'node:crypto';
16 import fs from 'fs/promises';
17 import path from 'path';
18 import { fileURLToPath } from 'url';
19 import {
20 isIcpAttestationConfigured,
21 anchorAttestation,
22 queryAttestation,
23 getAttestationCanisterId,
24 } from './icp-attestation-client.mjs';
25
26 let projectRoot;
27 try {
28 const __dirname = path.dirname(fileURLToPath(import.meta.url));
29 projectRoot = path.resolve(__dirname, '..', '..');
30 } catch (_) {
31 projectRoot = process.cwd();
32 }
33
34 const ATTESTATIONS_FILE = path.join(projectRoot, 'data', 'hosted_attestations.json');
35
36 function getBlobStore() {
37 return globalThis.__knowtation_attest_blob;
38 }
39
40 function getSecret() {
41 const s = process.env.ATTESTATION_SECRET;
42 return s && s.length >= 32 ? s : null;
43 }
44
45 /** @returns {boolean} */
46 export function isAttestationConfigured() {
47 return getSecret() !== null;
48 }
49
50 /**
51 * @param {string} id
52 * @param {string} action
53 * @param {string} notePath
54 * @param {string} timestamp
55 * @param {string} secret
56 * @returns {string}
57 */
58 function computeSig(id, action, notePath, timestamp, secret) {
59 return createHmac('sha256', secret)
60 .update(id + action + notePath + timestamp)
61 .digest('hex');
62 }
63
64 // ---------------------------------------------------------------------------
65 // Blob / file dual-path (mirrors billing-store.mjs)
66 // ---------------------------------------------------------------------------
67
68 function blobKey(id) {
69 return `attestation/${id}`;
70 }
71
72 /** @param {string} id */
73 async function getRecord(id) {
74 const store = getBlobStore();
75 if (store) {
76 const raw = await store.get(blobKey(id), { type: 'json' });
77 return raw || null;
78 }
79 return getRecordFromFile(id);
80 }
81
82 /** @param {object} record */
83 async function putRecord(record) {
84 const store = getBlobStore();
85 if (store) {
86 await store.setJSON(blobKey(record.id), record);
87 return;
88 }
89 await putRecordToFile(record);
90 }
91
92 async function getRecordFromFile(id) {
93 try {
94 const raw = await fs.readFile(ATTESTATIONS_FILE, 'utf8');
95 const db = JSON.parse(raw);
96 return (db.records && db.records[id]) || null;
97 } catch (e) {
98 if (e.code === 'ENOENT') return null;
99 throw e;
100 }
101 }
102
103 async function putRecordToFile(record) {
104 let db;
105 try {
106 const raw = await fs.readFile(ATTESTATIONS_FILE, 'utf8');
107 db = JSON.parse(raw);
108 } catch (e) {
109 if (e.code === 'ENOENT') db = {};
110 else throw e;
111 }
112 if (!db.records || typeof db.records !== 'object') db.records = {};
113 db.records[record.id] = record;
114 await fs.mkdir(path.dirname(ATTESTATIONS_FILE), { recursive: true });
115 await fs.writeFile(ATTESTATIONS_FILE, JSON.stringify(db, null, 2), 'utf8');
116 }
117
118 // ---------------------------------------------------------------------------
119 // Public API
120 // ---------------------------------------------------------------------------
121
122 /**
123 * Create a signed attestation record, store it, return { id, timestamp }.
124 *
125 * Improvement E: after writing to Blobs/file, attempts to anchor the record
126 * on the ICP attestation canister. If ICP succeeds the Blob record is enriched
127 * with canister_id and seq. If ICP fails or times out, icp_status is "pending".
128 *
129 * @param {string} action - "write" | "export"
130 * @param {string} notePath - vault-relative path
131 * @param {string|null} [contentHash] - optional SHA-256 of content
132 * @returns {Promise<{ id: string, timestamp: string, icp_status?: string }>}
133 * @throws {Error} if ATTESTATION_SECRET is not configured
134 */
135 export async function createAttestation(action, notePath, contentHash = null) {
136 const secret = getSecret();
137 if (!secret) throw new Error('ATTESTATION_SECRET is not configured');
138
139 const id = 'air-' + randomUUID();
140 const timestamp = new Date().toISOString();
141 const sig = computeSig(id, action, notePath, timestamp, secret);
142
143 /** @type {Record<string, unknown>} */
144 const record = { id, action, path: notePath, timestamp, content_hash: contentHash, sig };
145
146 if (isIcpAttestationConfigured()) {
147 record.icp_status = 'pending';
148 record.canister_id = getAttestationCanisterId();
149 } else {
150 record.icp_status = 'disabled';
151 }
152
153 await putRecord(record);
154
155 let icpStatus = record.icp_status;
156
157 if (isIcpAttestationConfigured()) {
158 try {
159 const icpResult = await anchorAttestation(
160 { id, action, path: notePath, timestamp, content_hash: contentHash || '', sig },
161 { timeoutMs: 4000 },
162 );
163 if (icpResult) {
164 record.icp_status = 'anchored';
165 record.icp_seq = icpResult.seq;
166 icpStatus = 'anchored';
167 await putRecord(record);
168 }
169 } catch (e) {
170 console.error('[attest] ICP anchor failed (non-fatal):', e?.message || String(e));
171 }
172 }
173
174 return { id, timestamp, icp_status: icpStatus };
175 }
176
177 /**
178 * Fetch a record by id, recompute HMAC, return verification result.
179 * The sig field is never exposed in the response.
180 * @param {string} id
181 * @returns {Promise<{ verified: boolean, record: object|null }>}
182 */
183 export async function verifyAttestation(id) {
184 const secret = getSecret();
185 if (!secret) throw new Error('ATTESTATION_SECRET is not configured');
186
187 const record = await getRecord(id);
188 if (!record) return { verified: false, record: null };
189
190 const expected = computeSig(record.id, record.action, record.path, record.timestamp, secret);
191 const verified = expected === record.sig;
192
193 const { sig: _sig, ...safeRecord } = record;
194 return { verified, record: safeRecord };
195 }
196
197 // ---------------------------------------------------------------------------
198 // Improvement E — ICP verification and reconciliation
199 // ---------------------------------------------------------------------------
200
201 /**
202 * Verify an attestation against both Blobs/file storage and the ICP canister.
203 * @param {string} id
204 * @returns {Promise<{ id: string, verified: boolean, consensus: string, sources: object }>}
205 */
206 export async function verifyWithIcp(id) {
207 const secret = getSecret();
208 if (!secret) throw new Error('ATTESTATION_SECRET is not configured');
209
210 const blobRecord = await getRecord(id);
211 const blobsResult = { found: false, hmac_valid: false, record: null };
212
213 if (blobRecord) {
214 const expected = computeSig(blobRecord.id, blobRecord.action, blobRecord.path, blobRecord.timestamp, secret);
215 blobsResult.found = true;
216 blobsResult.hmac_valid = expected === blobRecord.sig;
217 const { sig: _s, ...safe } = blobRecord;
218 blobsResult.record = safe;
219 }
220
221 const icpResult = { found: false, canister_id: null, seq: null, record: null };
222
223 if (!isIcpAttestationConfigured()) {
224 const consensus = blobsResult.found ? 'icp_not_configured' : 'not_found';
225 return {
226 id,
227 verified: blobsResult.found && blobsResult.hmac_valid,
228 consensus,
229 sources: { blobs: blobsResult, icp: icpResult },
230 };
231 }
232
233 try {
234 const icpRecord = await queryAttestation(id, { timeoutMs: 3000 });
235 if (icpRecord) {
236 icpResult.found = true;
237 icpResult.canister_id = getAttestationCanisterId();
238 icpResult.seq = icpRecord.seq;
239 icpResult.record = icpRecord;
240 }
241 } catch (e) {
242 console.error('[attest] ICP query failed during verify:', e?.message || String(e));
243 }
244
245 let consensus;
246 if (!blobsResult.found && !icpResult.found) {
247 consensus = 'not_found';
248 } else if (blobsResult.found && !icpResult.found) {
249 const blobIcpStatus = blobRecord?.icp_status;
250 if (blobIcpStatus === 'pending') {
251 consensus = 'icp_pending';
252 } else if (blobIcpStatus === 'disabled') {
253 consensus = 'icp_not_configured';
254 } else {
255 consensus = 'blobs_only';
256 }
257 } else if (blobsResult.found && icpResult.found) {
258 const icpRec = icpResult.record;
259 const fieldsMatch =
260 blobRecord.id === icpRec.id &&
261 blobRecord.action === icpRec.action &&
262 blobRecord.path === icpRec.path &&
263 blobRecord.timestamp === icpRec.timestamp &&
264 (blobRecord.content_hash || '') === (icpRec.content_hash || '') &&
265 blobRecord.sig === icpRec.sig;
266 consensus = fieldsMatch ? 'match' : 'mismatch';
267 } else {
268 consensus = 'icp_only';
269 }
270
271 return {
272 id,
273 verified: blobsResult.hmac_valid || icpResult.found,
274 consensus,
275 sources: { blobs: blobsResult, icp: icpResult },
276 };
277 }
278
279 /**
280 * Attempt to anchor Blob records that have icp_status "pending".
281 * Intended for manual reconciliation or a scheduled job.
282 * Only works with Blob store (not local file, for simplicity).
283 * @param {string[]} pendingIds - IDs of attestations to retry
284 * @returns {Promise<{ anchored: number, failed: number, errors: string[] }>}
285 */
286 export async function anchorPendingAttestations(pendingIds) {
287 if (!isIcpAttestationConfigured()) {
288 return { anchored: 0, failed: 0, errors: ['ICP attestation not configured'] };
289 }
290 if (!pendingIds || pendingIds.length === 0) {
291 return { anchored: 0, failed: 0, errors: [] };
292 }
293
294 let anchored = 0;
295 let failed = 0;
296 const errors = [];
297
298 for (const id of pendingIds) {
299 try {
300 const record = await getRecord(id);
301 if (!record) {
302 errors.push(`${id}: record not found`);
303 failed++;
304 continue;
305 }
306 if (record.icp_status === 'anchored') {
307 anchored++;
308 continue;
309 }
310
311 const icpResult = await anchorAttestation(
312 {
313 id: record.id,
314 action: record.action,
315 path: record.path,
316 timestamp: record.timestamp,
317 content_hash: record.content_hash || '',
318 sig: record.sig,
319 },
320 { timeoutMs: 6000 },
321 );
322
323 if (icpResult) {
324 record.icp_status = 'anchored';
325 record.icp_seq = icpResult.seq;
326 record.canister_id = getAttestationCanisterId();
327 await putRecord(record);
328 anchored++;
329 } else {
330 errors.push(`${id}: ICP write returned null`);
331 failed++;
332 }
333 } catch (e) {
334 errors.push(`${id}: ${e?.message || String(e)}`);
335 failed++;
336 }
337 }
338
339 return { anchored, failed, errors };
340 }
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 2 days ago