attest-store.mjs
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