main.mo
554 lines 15.8 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Knowtation Attestation Canister — immutable append-only ledger for AIR attestation records.
3 *
4 * AIR Improvement E: blockchain-backed attestation on ICP.
5 * Records are write-once: no update, no delete. Once stored, permanent.
6 *
7 * Access control:
8 * storeAttestation → authorized callers only (gateway identity)
9 * setAuthorizedCallers → canister controllers only
10 * getAttestation → public query (anyone can verify)
11 * listAttestations → public query (transparency)
12 * getStats → public query (canister health)
13 *
14 * HTTP interface (via IC HTTP gateway):
15 * GET /health → {"ok":true}
16 * GET /attest/<id> → attestation record JSON or 404
17 * GET /stats → { total, nextSeq }
18 */
19
20 import Array "mo:base/Array";
21 import Blob "mo:base/Blob";
22 import Buffer "mo:base/Buffer";
23 import Char "mo:base/Char";
24 import HashMap "mo:base/HashMap";
25 import Int "mo:base/Int";
26 import Iter "mo:base/Iter";
27 import Nat "mo:base/Nat";
28 import Nat32 "mo:base/Nat32";
29 import Principal "mo:base/Principal";
30 import Text "mo:base/Text";
31 import Time "mo:base/Time";
32
33 persistent actor Attestation {
34
35 // -------------------------------------------------------------------------
36 // Types
37 // -------------------------------------------------------------------------
38
39 type AttestationRecord = {
40 id : Text;
41 action : Text;
42 path : Text;
43 timestamp : Text;
44 content_hash : Text;
45 sig : Text;
46 seq : Nat;
47 stored_at : Text;
48 };
49
50 type StoreInput = {
51 id : Text;
52 action : Text;
53 path : Text;
54 timestamp : Text;
55 content_hash : Text;
56 sig : Text;
57 };
58
59 type StoreResult = {
60 #ok : { seq : Nat };
61 #err : Text;
62 };
63
64 type ListResult = {
65 records : [AttestationRecord];
66 total : Nat;
67 };
68
69 type Header = (Text, Text);
70 type HttpRequest = {
71 method : Text;
72 url : Text;
73 headers : [Header];
74 body : Blob;
75 };
76 type HttpResponse = {
77 status_code : Nat16;
78 headers : [Header];
79 body : Blob;
80 streaming_strategy : ?{
81 #Callback : {
82 callback : shared query (StreamingCallbackToken) -> async StreamingCallbackResponse;
83 token : StreamingCallbackToken;
84 };
85 };
86 upgrade : ?Bool;
87 };
88 type StreamingCallbackToken = {
89 key : Text;
90 content_encoding : Text;
91 index : Nat;
92 sha256 : ?Blob;
93 };
94 type StreamingCallbackResponse = {
95 body : Blob;
96 token : ?StreamingCallbackToken;
97 };
98
99 // -------------------------------------------------------------------------
100 // Stable state
101 // -------------------------------------------------------------------------
102
103 var entries : [(Text, AttestationRecord)] = [];
104 var nextSeq : Nat = 0;
105 var authorizedCallers : [Principal] = [];
106
107 // -------------------------------------------------------------------------
108 // Transient (rebuilt on upgrade)
109 // -------------------------------------------------------------------------
110
111 transient var byId = HashMap.HashMap<Text, AttestationRecord>(64, Text.equal, Text.hash);
112 transient var ordered = Buffer.Buffer<AttestationRecord>(64);
113
114 func loadTransient() {
115 byId := HashMap.HashMap<Text, AttestationRecord>(
116 Nat.max(64, entries.size()),
117 Text.equal,
118 Text.hash,
119 );
120 ordered := Buffer.Buffer<AttestationRecord>(Nat.max(64, entries.size()));
121 let sorted = Array.sort<(Text, AttestationRecord)>(
122 entries,
123 func(a : (Text, AttestationRecord), b : (Text, AttestationRecord)) : {
124 #less;
125 #equal;
126 #greater;
127 } {
128 if (a.1.seq < b.1.seq) { #less } else if (a.1.seq == b.1.seq) { #equal } else {
129 #greater;
130 };
131 },
132 );
133 for ((id, r) in Array.vals(sorted)) {
134 byId.put(id, r);
135 ordered.add(r);
136 };
137 };
138 loadTransient();
139
140 func saveStable() {
141 let buf = Buffer.Buffer<(Text, AttestationRecord)>(ordered.size());
142 for (r in ordered.vals()) {
143 buf.add((r.id, r));
144 };
145 entries := Buffer.toArray(buf);
146 };
147
148 // -------------------------------------------------------------------------
149 // Authorization helpers
150 // -------------------------------------------------------------------------
151
152 func isAuthorizedCaller(caller : Principal) : Bool {
153 for (p in Array.vals(authorizedCallers)) {
154 if (Principal.equal(p, caller)) { return true };
155 };
156 false;
157 };
158
159 func isController(caller : Principal) : Bool {
160 Principal.isController(caller);
161 };
162
163 // -------------------------------------------------------------------------
164 // Time helpers (same pattern as hub canister)
165 // -------------------------------------------------------------------------
166
167 func intToNatSafe(i : Int) : Nat {
168 if (i < 0) { return 0 };
169 switch (Nat.fromText(Int.toText(i))) {
170 case null { 0 };
171 case (?n) { n };
172 };
173 };
174
175 func isLeapYear(y : Nat) : Bool {
176 (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0);
177 };
178
179 func daysInMonth(y : Nat, m : Nat) : Nat {
180 switch (m) {
181 case 1 { 31 };
182 case 2 { if (isLeapYear(y)) { 29 } else { 28 } };
183 case 3 { 31 };
184 case 4 { 30 };
185 case 5 { 31 };
186 case 6 { 30 };
187 case 7 { 31 };
188 case 8 { 31 };
189 case 9 { 30 };
190 case 10 { 31 };
191 case 11 { 30 };
192 case 12 { 31 };
193 case _ { 31 };
194 };
195 };
196
197 func pad2(n : Nat) : Text {
198 if (n < 10) { "0" # Nat.toText(n) } else { Nat.toText(n) };
199 };
200
201 func pad3(n : Nat) : Text {
202 if (n < 10) { "00" # Nat.toText(n) } else if (n < 100) {
203 "0" # Nat.toText(n);
204 } else { Nat.toText(n) };
205 };
206
207 func pad4(n : Nat) : Text {
208 let t = Nat.toText(n);
209 let len = Text.size(t);
210 if (len >= 4) { t } else if (len == 3) { "0" # t } else if (len == 2) {
211 "00" # t;
212 } else if (len == 1) { "000" # t } else { "0000" };
213 };
214
215 func nowIsoUtc() : Text {
216 let ns = Time.now();
217 if (ns < 0) { return "1970-01-01T00:00:00.000Z" };
218 let secInt = ns / 1_000_000_000;
219 let remNs = ns % 1_000_000_000;
220 let secNat = intToNatSafe(secInt);
221 let msNat = intToNatSafe(remNs / 1_000_000);
222 let secsPerDay = 86400;
223 let totalDays = secNat / secsPerDay;
224 var sod = secNat % secsPerDay;
225 let hour = sod / 3600;
226 sod := sod % 3600;
227 let minute = sod / 60;
228 let second = sod % 60;
229 var y : Nat = 1970;
230 var d = totalDays;
231 label yearLoop loop {
232 let diy = if (isLeapYear(y)) { 366 } else { 365 };
233 if (d >= diy) { d -= diy; y += 1 } else { break yearLoop };
234 };
235 var m : Nat = 1;
236 label monthLoop loop {
237 let dim = daysInMonth(y, m);
238 if (d >= dim) { d -= dim; m += 1 } else { break monthLoop };
239 };
240 let day = d + 1;
241 pad4(y) # "-" # pad2(m) # "-" # pad2(day) # "T" # pad2(hour) # ":" # pad2(minute) # ":" # pad2(second) # "." # pad3(msNat % 1000) # "Z";
242 };
243
244 // -------------------------------------------------------------------------
245 // JSON helpers
246 // -------------------------------------------------------------------------
247
248 func natToHex4(code : Nat32) : Text {
249 let n = Nat32.toNat(code);
250 func hd(div : Nat) : Text {
251 let dd = (n / div) % 16;
252 switch (dd) {
253 case 0 { "0" };
254 case 1 { "1" };
255 case 2 { "2" };
256 case 3 { "3" };
257 case 4 { "4" };
258 case 5 { "5" };
259 case 6 { "6" };
260 case 7 { "7" };
261 case 8 { "8" };
262 case 9 { "9" };
263 case 10 { "a" };
264 case 11 { "b" };
265 case 12 { "c" };
266 case 13 { "d" };
267 case 14 { "e" };
268 case 15 { "f" };
269 case _ { "0" };
270 };
271 };
272 hd(4096) # hd(256) # hd(16) # hd(1);
273 };
274
275 func escapeJson(s : Text) : Text {
276 let chars = Text.toArray(s);
277 var out = "";
278 var idx : Nat = 0;
279 while (idx < chars.size()) {
280 let ch = chars[idx];
281 let code = Char.toNat32(ch);
282 if (code == 92) { out := out # "\\\\" } else if (code == 34) {
283 out := out # "\\\"";
284 } else if (code == 10) { out := out # "\\n" } else if (code == 13) {
285 out := out # "\\r";
286 } else if (code == 9) { out := out # "\\t" } else if (code < 32) {
287 out := out # "\\u" # natToHex4(code);
288 } else { out := out # Char.toText(ch) };
289 idx += 1;
290 };
291 out;
292 };
293
294 func jsonBody(s : Text) : Blob { Text.encodeUtf8(s) };
295
296 func corsHeaders() : [Header] {
297 [
298 ("Access-Control-Allow-Origin", "*"),
299 ("Access-Control-Allow-Methods", "GET, OPTIONS"),
300 ("Access-Control-Allow-Headers", "Content-Type, Accept"),
301 ("Content-Type", "application/json"),
302 ];
303 };
304
305 func recordToJson(r : AttestationRecord) : Text {
306 "{\"id\":\"" # escapeJson(r.id) # "\",\"action\":\"" # escapeJson(r.action) # "\",\"path\":\"" # escapeJson(r.path) # "\",\"timestamp\":\"" # escapeJson(r.timestamp) # "\",\"content_hash\":\"" # escapeJson(r.content_hash) # "\",\"sig\":\"" # escapeJson(r.sig) # "\",\"seq\":" # Nat.toText(r.seq) # ",\"stored_at\":\"" # escapeJson(r.stored_at) # "\"}";
307 };
308
309 // -------------------------------------------------------------------------
310 // URL parsing (same pattern as hub canister)
311 // -------------------------------------------------------------------------
312
313 func textSlice(t : Text, start : Nat, len : Nat) : Text {
314 let arr = Text.toArray(t);
315 let buf = Buffer.Buffer<Char>(len);
316 var i = start;
317 var n : Nat = 0;
318 while (n < len and i < arr.size()) {
319 buf.add(arr[i]);
320 i += 1;
321 n += 1;
322 };
323 Text.fromIter(buf.vals());
324 };
325
326 func textFind(t : Text, needle : Text) : ?Nat {
327 let tarr = Text.toArray(t);
328 let narr = Text.toArray(needle);
329 let nlen = narr.size();
330 let tlen = tarr.size();
331 if (nlen == 0) { return ?0 };
332 if (nlen > tlen) { return null };
333 var i : Nat = 0;
334 while (i + nlen <= tlen) {
335 var j : Nat = 0;
336 var ok = true;
337 while (j < nlen) {
338 if (tarr[i + j] != narr[j]) { ok := false; j := nlen } else {
339 j += 1;
340 };
341 };
342 if (ok) { return ?i };
343 i += 1;
344 };
345 null;
346 };
347
348 func pathOnly(rawUrl : Text) : Text {
349 let pathParts = Iter.toArray(Text.split(rawUrl, #char '?'));
350 var path = if (pathParts.size() > 0) { pathParts[0] } else { rawUrl };
351 switch (textFind(path, "://")) {
352 case (?k) {
353 let startAuth = k + 3;
354 let pathLen = Text.size(path);
355 if (startAuth < pathLen) {
356 let afterLen = pathLen - startAuth;
357 let after = textSlice(path, startAuth, afterLen);
358 switch (textFind(after, "/")) {
359 case (?m) {
360 let afterSz = Text.size(after);
361 if (m < afterSz) {
362 path := textSlice(after, m, afterSz - m);
363 } else { path := "/" };
364 };
365 case null { path := "/" };
366 };
367 } else { path := "/" };
368 };
369 case null {};
370 };
371 path;
372 };
373
374 func decodePercentEncoded(s : Text) : Text {
375 let chars = Text.toArray(s);
376 let buf = Buffer.Buffer<Char>(chars.size());
377 var i : Nat = 0;
378 while (i < chars.size()) {
379 let c = chars[i];
380 if (c == '%' and i + 2 < chars.size()) {
381 switch (hexDigitChar(chars[i + 1]), hexDigitChar(chars[i + 2])) {
382 case (?a, ?b) {
383 let code = a * 16 + b;
384 buf.add(Char.fromNat32(Nat32.fromNat(code)));
385 i += 3;
386 };
387 case _ { buf.add(c); i += 1 };
388 };
389 } else { buf.add(c); i += 1 };
390 };
391 Text.fromIter(buf.vals());
392 };
393
394 func hexDigitChar(ch : Char) : ?Nat {
395 let n = Char.toNat32(ch);
396 if (n >= 48 and n <= 57) return ?(Nat32.toNat(n - 48));
397 if (n >= 65 and n <= 70) return ?(Nat32.toNat(n - 55));
398 if (n >= 97 and n <= 102) return ?(Nat32.toNat(n - 87));
399 null;
400 };
401
402 // -------------------------------------------------------------------------
403 // Candid API — update methods (authenticated)
404 // -------------------------------------------------------------------------
405
406 public shared (msg) func storeAttestation(input : StoreInput) : async StoreResult {
407 if (not isAuthorizedCaller(msg.caller)) {
408 return #err("Unauthorized: caller " # Principal.toText(msg.caller) # " is not authorized");
409 };
410
411 if (Text.size(input.id) == 0) {
412 return #err("id is required");
413 };
414
415 switch (byId.get(input.id)) {
416 case (?_existing) {
417 return #err("Attestation " # input.id # " already exists (records are immutable)");
418 };
419 case null {};
420 };
421
422 let seq = nextSeq;
423 nextSeq += 1;
424
425 let record : AttestationRecord = {
426 id = input.id;
427 action = input.action;
428 path = input.path;
429 timestamp = input.timestamp;
430 content_hash = input.content_hash;
431 sig = input.sig;
432 seq;
433 stored_at = nowIsoUtc();
434 };
435
436 byId.put(record.id, record);
437 ordered.add(record);
438 saveStable();
439
440 #ok({ seq });
441 };
442
443 public shared (msg) func setAuthorizedCallers(callers : [Principal]) : async () {
444 if (not isController(msg.caller)) {
445 assert (false);
446 };
447 authorizedCallers := callers;
448 };
449
450 // -------------------------------------------------------------------------
451 // Candid API — query methods (public)
452 // -------------------------------------------------------------------------
453
454 public query func getAttestation(id : Text) : async ?AttestationRecord {
455 byId.get(id);
456 };
457
458 public query func listAttestations(offset : Nat, limit : Nat) : async ListResult {
459 let total = ordered.size();
460 let effectiveLimit = if (limit > 100) { 100 } else if (limit == 0) { 20 } else {
461 limit;
462 };
463 let start = if (offset >= total) { total } else { offset };
464 let end = Nat.min(start + effectiveLimit, total);
465 let buf = Buffer.Buffer<AttestationRecord>(end - start);
466 var i = start;
467 while (i < end) {
468 buf.add(ordered.get(i));
469 i += 1;
470 };
471 { records = Buffer.toArray(buf); total };
472 };
473
474 public query func getStats() : async { total : Nat; nextSeq : Nat } {
475 { total = ordered.size(); nextSeq };
476 };
477
478 public query func getAuthorizedCallers() : async [Principal] {
479 authorizedCallers;
480 };
481
482 // -------------------------------------------------------------------------
483 // HTTP interface — browser-based verification (read-only)
484 // -------------------------------------------------------------------------
485
486 public query func http_request(req : HttpRequest) : async HttpResponse {
487 let path = pathOnly(req.url);
488
489 if (path == "/health" or path == "/health/") {
490 return {
491 status_code = 200;
492 headers = corsHeaders();
493 body = jsonBody("{\"ok\":true,\"canister\":\"attestation\"}");
494 streaming_strategy = null;
495 upgrade = null;
496 };
497 };
498
499 if (path == "/stats" or path == "/stats/") {
500 return {
501 status_code = 200;
502 headers = corsHeaders();
503 body = jsonBody(
504 "{\"total\":" # Nat.toText(ordered.size()) # ",\"next_seq\":" # Nat.toText(nextSeq) # "}"
505 );
506 streaming_strategy = null;
507 upgrade = null;
508 };
509 };
510
511 if (Text.startsWith(path, #text "/attest/")) {
512 let rawId = Text.trimStart(path, #text "/attest/");
513 let id = decodePercentEncoded(rawId);
514 switch (byId.get(id)) {
515 case (?r) {
516 return {
517 status_code = 200;
518 headers = corsHeaders();
519 body = jsonBody(recordToJson(r));
520 streaming_strategy = null;
521 upgrade = null;
522 };
523 };
524 case null {
525 return {
526 status_code = 404;
527 headers = corsHeaders();
528 body = jsonBody("{\"error\":\"Attestation not found\",\"code\":\"NOT_FOUND\"}");
529 streaming_strategy = null;
530 upgrade = null;
531 };
532 };
533 };
534 };
535
536 if (req.method == "OPTIONS") {
537 return {
538 status_code = 204;
539 headers = corsHeaders();
540 body = jsonBody("");
541 streaming_strategy = null;
542 upgrade = null;
543 };
544 };
545
546 {
547 status_code = 404;
548 headers = corsHeaders();
549 body = jsonBody("{\"error\":\"Not found\",\"code\":\"NOT_FOUND\"}");
550 streaming_strategy = null;
551 upgrade = null;
552 };
553 };
554 };
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