icp-attestation-client.mjs
268 lines 7.6 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 /**
2 * AIR Improvement E — ICP attestation canister client.
3 *
4 * Wraps @icp-sdk/core to call the attestation canister from the gateway.
5 * Uses Secp256k1KeyIdentity derived from ICP_ATTESTATION_KEY env var.
6 *
7 * Env vars:
8 * ICP_ATTESTATION_CANISTER_ID — attestation canister principal (required to enable)
9 * ICP_ATTESTATION_KEY — 32-byte hex seed for gateway identity (required to store)
10 * ICP_ATTESTATION_HOST — IC host (default: https://ic0.app)
11 */
12
13 import { HttpAgent, Actor } from '@icp-sdk/core/agent';
14 import { IDL } from '@icp-sdk/core/candid';
15 import { Principal } from '@icp-sdk/core/principal';
16
17 const DEFAULT_HOST = 'https://ic0.app';
18 const DEFAULT_STORE_TIMEOUT_MS = 4000;
19 const DEFAULT_QUERY_TIMEOUT_MS = 3000;
20
21 // ---------------------------------------------------------------------------
22 // Candid IDL factory for the attestation canister
23 // ---------------------------------------------------------------------------
24
25 const AttestationRecord = IDL.Record({
26 id: IDL.Text,
27 action: IDL.Text,
28 path: IDL.Text,
29 timestamp: IDL.Text,
30 content_hash: IDL.Text,
31 sig: IDL.Text,
32 seq: IDL.Nat,
33 stored_at: IDL.Text,
34 });
35
36 const StoreInput = IDL.Record({
37 id: IDL.Text,
38 action: IDL.Text,
39 path: IDL.Text,
40 timestamp: IDL.Text,
41 content_hash: IDL.Text,
42 sig: IDL.Text,
43 });
44
45 const StoreResult = IDL.Variant({
46 ok: IDL.Record({ seq: IDL.Nat }),
47 err: IDL.Text,
48 });
49
50 const ListResult = IDL.Record({
51 records: IDL.Vec(AttestationRecord),
52 total: IDL.Nat,
53 });
54
55 /** @type {import('@icp-sdk/core/candid').InterfaceFactory} */
56 const idlFactory = ({ IDL: _IDL }) => {
57 return IDL.Service({
58 storeAttestation: IDL.Func([StoreInput], [StoreResult], []),
59 getAttestation: IDL.Func([IDL.Text], [IDL.Opt(AttestationRecord)], ['query']),
60 listAttestations: IDL.Func([IDL.Nat, IDL.Nat], [ListResult], ['query']),
61 getStats: IDL.Func([], [IDL.Record({ total: IDL.Nat, nextSeq: IDL.Nat })], ['query']),
62 getAuthorizedCallers: IDL.Func([], [IDL.Vec(IDL.Principal)], ['query']),
63 setAuthorizedCallers: IDL.Func([IDL.Vec(IDL.Principal)], [], []),
64 });
65 };
66
67 // ---------------------------------------------------------------------------
68 // Singleton agent + actor (lazy init)
69 // ---------------------------------------------------------------------------
70
71 let _identity = null;
72 let _agent = null;
73 let _actor = null;
74
75 function getCanisterId() {
76 const id = process.env.ICP_ATTESTATION_CANISTER_ID;
77 return id && id.trim().length > 0 ? id.trim() : null;
78 }
79
80 function getKeyHex() {
81 const k = process.env.ICP_ATTESTATION_KEY;
82 return k && k.trim().length >= 64 ? k.trim() : null;
83 }
84
85 function getHost() {
86 return (process.env.ICP_ATTESTATION_HOST || DEFAULT_HOST).replace(/\/$/, '');
87 }
88
89 /** @returns {boolean} */
90 export function isIcpAttestationConfigured() {
91 return Boolean(getCanisterId() && getKeyHex());
92 }
93
94 /** @returns {string|null} */
95 export function getAttestationCanisterId() {
96 return getCanisterId();
97 }
98
99 async function getIdentity() {
100 if (_identity) return _identity;
101 const keyHex = getKeyHex();
102 if (!keyHex) return null;
103 const { Secp256k1KeyIdentity } = await import('@icp-sdk/core/identity/secp256k1');
104 const seed = Uint8Array.from(Buffer.from(keyHex, 'hex'));
105 _identity = Secp256k1KeyIdentity.fromSecretKey(seed);
106 return _identity;
107 }
108
109 async function getAgent() {
110 if (_agent) return _agent;
111 const identity = await getIdentity();
112 if (!identity) return null;
113 _agent = await HttpAgent.create({
114 identity,
115 host: getHost(),
116 });
117 return _agent;
118 }
119
120 async function getActor() {
121 if (_actor) return _actor;
122 const agent = await getAgent();
123 if (!agent) return null;
124 const canisterId = getCanisterId();
125 if (!canisterId) return null;
126 _actor = Actor.createActor(idlFactory, {
127 agent,
128 canisterId,
129 });
130 return _actor;
131 }
132
133 /**
134 * Get the Principal of the gateway identity (for setAuthorizedCallers setup).
135 * @returns {Promise<string|null>}
136 */
137 export async function getGatewayPrincipal() {
138 const identity = await getIdentity();
139 if (!identity) return null;
140 return identity.getPrincipal().toText();
141 }
142
143 // ---------------------------------------------------------------------------
144 // Public API
145 // ---------------------------------------------------------------------------
146
147 /**
148 * Anchor an attestation record on the ICP canister.
149 *
150 * @param {{ id: string, action: string, path: string, timestamp: string, content_hash: string, sig: string }} record
151 * @param {{ timeoutMs?: number }} [opts]
152 * @returns {Promise<{ seq: number } | null>} seq on success, null on failure/timeout
153 */
154 export async function anchorAttestation(record, opts = {}) {
155 if (_testAnchorFn) return _testAnchorFn(record, opts);
156 if (!isIcpAttestationConfigured()) return null;
157
158 const timeoutMs = opts.timeoutMs ?? DEFAULT_STORE_TIMEOUT_MS;
159
160 try {
161 const actor = await getActor();
162 if (!actor) return null;
163
164 const result = await Promise.race([
165 actor.storeAttestation({
166 id: record.id,
167 action: record.action || '',
168 path: record.path || '',
169 timestamp: record.timestamp || '',
170 content_hash: record.content_hash || '',
171 sig: record.sig || '',
172 }),
173 new Promise((_, reject) =>
174 setTimeout(() => reject(new Error('ICP attestation store timeout')), timeoutMs),
175 ),
176 ]);
177
178 if (result && 'ok' in result) {
179 return { seq: Number(result.ok.seq) };
180 }
181
182 if (result && 'err' in result) {
183 console.error('[icp-attest] canister returned error:', result.err);
184 return null;
185 }
186
187 return null;
188 } catch (e) {
189 console.error('[icp-attest] anchorAttestation failed:', e?.message || String(e));
190 return null;
191 }
192 }
193
194 /**
195 * Query an attestation record from the ICP canister.
196 *
197 * @param {string} id
198 * @param {{ timeoutMs?: number }} [opts]
199 * @returns {Promise<object|null>} record object or null
200 */
201 export async function queryAttestation(id, opts = {}) {
202 if (_testQueryFn) return _testQueryFn(id, opts);
203 if (!getCanisterId()) return null;
204
205 const timeoutMs = opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS;
206
207 try {
208 const actor = await getActor();
209 if (!actor) return null;
210
211 const result = await Promise.race([
212 actor.getAttestation(id),
213 new Promise((_, reject) =>
214 setTimeout(() => reject(new Error('ICP attestation query timeout')), timeoutMs),
215 ),
216 ]);
217
218 if (result && result.length > 0 && result[0]) {
219 const r = result[0];
220 return {
221 id: r.id,
222 action: r.action,
223 path: r.path,
224 timestamp: r.timestamp,
225 content_hash: r.content_hash,
226 sig: r.sig,
227 seq: Number(r.seq),
228 stored_at: r.stored_at,
229 };
230 }
231
232 return null;
233 } catch (e) {
234 console.error('[icp-attest] queryAttestation failed:', e?.message || String(e));
235 return null;
236 }
237 }
238
239 /**
240 * Reset cached agent/actor (for testing or key rotation).
241 */
242 export function resetClient() {
243 _identity = null;
244 _agent = null;
245 _actor = null;
246 }
247
248 // ---------------------------------------------------------------------------
249 // Test hooks — allow tests to override anchor/query without fighting ESM
250 // ---------------------------------------------------------------------------
251
252 let _testAnchorFn = null;
253 let _testQueryFn = null;
254
255 /**
256 * Override anchorAttestation / queryAttestation for unit tests.
257 * Pass null to restore real implementations.
258 * @param {{ anchor?: Function|null, query?: Function|null }} overrides
259 */
260 export function _setTestOverrides(overrides) {
261 _testAnchorFn = overrides?.anchor ?? null;
262 _testQueryFn = overrides?.query ?? null;
263 }
264
265 /** @internal */
266 export function _getTestOverrides() {
267 return { anchor: _testAnchorFn, query: _testQueryFn };
268 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago