@adastracomputing/ink 0.1.0-alpha.1 → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/crypto/ink.ts CHANGED
@@ -211,7 +211,7 @@ export function buildSignatureBase(input: InkSignInput): string {
211
211
  throw new Error("Signature base body exceeds maximum allowed complexity");
212
212
  }
213
213
  const canonical = jcsCanonicalize(input.body);
214
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
214
+ if (new TextEncoder().encode(canonical).length > MAX_SIGBASE_BODY_BYTES) {
215
215
  throw new Error("Signature base body exceeds maximum allowed size");
216
216
  }
217
217
  return `ink/0.1\n${input.method}\n${input.path}\n${input.recipientDid}\n${canonical}\n${input.timestamp}`;
@@ -685,10 +685,10 @@ export async function computeMessageHash(body: Record<string, unknown>): Promise
685
685
  throw new Error("Message body exceeds maximum allowed complexity");
686
686
  }
687
687
  const canonical = jcsCanonicalize(body);
688
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
688
+ const bytes = new TextEncoder().encode(canonical);
689
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
689
690
  throw new Error("Message body exceeds maximum allowed size");
690
691
  }
691
- const bytes = new TextEncoder().encode(canonical);
692
692
  const digest = await crypto.subtle.digest("SHA-256", bytes);
693
693
  return bytesToHex(new Uint8Array(digest));
694
694
  }
@@ -713,12 +713,11 @@ export async function signAuditEvent(
713
713
  throw new Error("Audit event exceeds maximum allowed complexity");
714
714
  }
715
715
  const canonical = jcsCanonicalize(eventWithoutSig);
716
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
717
- throw new Error("Audit event exceeds maximum allowed size");
718
- }
719
- // Domain separation: prefix prevents cross-protocol signature replay
720
716
  const prefixed = `ink/audit-event\n${canonical}`;
721
717
  const bytes = new TextEncoder().encode(prefixed);
718
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
719
+ throw new Error("Audit event exceeds maximum allowed size");
720
+ }
722
721
  const sig = await ed.signAsync(bytes, privateKey);
723
722
  return base64urlEncode(sig);
724
723
  }
@@ -741,24 +740,58 @@ export async function verifyAuditEventSignature(
741
740
  // attacker-supplied object that would only get rejected by the size cap
742
741
  // below. Cheap enough that it adds no cost for real events.
743
742
  if (!isWithinCanonicalizeBounds(eventWithoutSig)) return false;
744
- const canonical = jcsCanonicalize(eventWithoutSig);
745
- // Defense-in-depth: cap canonicalized body size to bound pre-verify work.
746
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) return false;
747
- // Domain separation: must match signAuditEvent prefix
748
- const prefixed = `ink/audit-event\n${canonical}`;
749
- const bytes = new TextEncoder().encode(prefixed);
750
743
  try {
744
+ const canonical = jcsCanonicalize(eventWithoutSig);
745
+ const prefixed = `ink/audit-event\n${canonical}`;
746
+ const bytes = new TextEncoder().encode(prefixed);
747
+ // Defense-in-depth: cap signed-body byte count to bound pre-verify work.
748
+ // UTF-8 byte length, not JS string length, so multi-byte event data
749
+ // cannot smuggle past the cap.
750
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) return false;
751
751
  const sig = base64urlDecode(signature);
752
752
  return await ed.verifyAsync(sig, bytes, publicKey);
753
753
  } catch {
754
- // Malformed signature (wrong length, invalid chars, bad key) — treat as invalid
755
754
  return false;
756
755
  }
757
756
  }
758
757
 
758
+ /**
759
+ * Compute the RFC 6962 Merkle leaf hash for an INK audit event:
760
+ *
761
+ * SHA-256(0x00 || JCS(event-without-agentSignature))
762
+ *
763
+ * This is the leaf-hashing rule a witness MUST use when building its
764
+ * transparency log (Auditability §7.3). It is distinct from
765
+ * `computeEventHash`, which omits the 0x00 prefix and is used only for
766
+ * `previousEventHash` chain linkage inside the agent's local audit log.
767
+ *
768
+ * Returns the lowercase-hex digest.
769
+ */
770
+ export async function computeAuditMerkleLeafHash(event: Record<string, unknown>): Promise<string> {
771
+ if (event === null || typeof event !== "object" || Array.isArray(event)) {
772
+ throw new Error("event must be a non-null object");
773
+ }
774
+ const { agentSignature: _, ...eventWithoutSig } = event;
775
+ if (!isWithinCanonicalizeBounds(eventWithoutSig)) {
776
+ throw new Error("Audit event exceeds maximum allowed complexity");
777
+ }
778
+ const canonical = jcsCanonicalize(eventWithoutSig);
779
+ const canonicalBytes = new TextEncoder().encode(canonical);
780
+ if (canonicalBytes.length > MAX_SIGBASE_BODY_BYTES) {
781
+ throw new Error("Audit event exceeds maximum allowed size");
782
+ }
783
+ const prefixed = new Uint8Array(canonicalBytes.length + 1);
784
+ prefixed[0] = 0x00;
785
+ prefixed.set(canonicalBytes, 1);
786
+ const digest = await crypto.subtle.digest("SHA-256", prefixed);
787
+ return bytesToHex(new Uint8Array(digest));
788
+ }
789
+
759
790
  /**
760
791
  * Compute SHA-256 hash of JCS-canonicalized audit event (excluding agentSignature).
761
- * Used for previousEventHash chain linkage.
792
+ * Used for previousEventHash chain linkage. NOT the Merkle leaf hash:
793
+ * see `computeAuditMerkleLeafHash` for the RFC 6962 leaf-hash rule used
794
+ * by witness transparency logs.
762
795
  */
763
796
  export async function computeEventHash(event: Record<string, unknown>): Promise<string> {
764
797
  if (event === null || typeof event !== "object" || Array.isArray(event)) {
@@ -773,10 +806,10 @@ export async function computeEventHash(event: Record<string, unknown>): Promise<
773
806
  throw new Error("Audit event exceeds maximum allowed complexity");
774
807
  }
775
808
  const canonical = jcsCanonicalize(eventWithoutSig);
776
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
809
+ const bytes = new TextEncoder().encode(canonical);
810
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
777
811
  throw new Error("Audit event exceeds maximum allowed size");
778
812
  }
779
- const bytes = new TextEncoder().encode(canonical);
780
813
  const digest = await crypto.subtle.digest("SHA-256", bytes);
781
814
  return bytesToHex(new Uint8Array(digest));
782
815
  }
@@ -797,14 +830,14 @@ export async function signAuditResponse(
797
830
  throw new Error("Audit response events exceed maximum allowed complexity");
798
831
  }
799
832
  const canonical = jcsCanonicalize(events);
800
- // Cap canonicalized body size — mirrors the verify path's guard so the
801
- // sign side can't be used to mint signatures over payloads larger than
802
- // any conformant verifier would accept.
803
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
804
- throw new Error("Audit response events exceed maximum allowed size");
805
- }
806
833
  const prefixed = `ink/audit-response\n${canonical}`;
807
834
  const bytes = new TextEncoder().encode(prefixed);
835
+ // Cap signed-body byte count. Mirrors the verify path's guard so the
836
+ // sign side can't mint signatures over payloads larger than any
837
+ // conformant verifier would accept.
838
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
839
+ throw new Error("Audit response events exceed maximum allowed size");
840
+ }
808
841
  const sig = await ed.signAsync(bytes, privateKey);
809
842
  return base64urlEncode(sig);
810
843
  }
@@ -825,16 +858,14 @@ export async function verifyAuditResponseSignature(
825
858
  if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
826
859
  // Pre-canonicalize complexity cap (see verifyAuditEventSignature).
827
860
  if (!isWithinCanonicalizeBounds(events)) return false;
828
- const canonical = jcsCanonicalize(events);
829
- // Defense-in-depth: cap canonicalized body size to bound pre-verify work.
830
- if (canonical.length > MAX_SIGBASE_BODY_BYTES) return false;
831
- const prefixed = `ink/audit-response\n${canonical}`;
832
- const bytes = new TextEncoder().encode(prefixed);
833
861
  try {
862
+ const canonical = jcsCanonicalize(events);
863
+ const prefixed = `ink/audit-response\n${canonical}`;
864
+ const bytes = new TextEncoder().encode(prefixed);
865
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) return false;
834
866
  const sig = base64urlDecode(signature);
835
867
  return await ed.verifyAsync(sig, bytes, publicKey);
836
868
  } catch {
837
- // Malformed signature (wrong length, invalid chars, bad key) — treat as invalid
838
869
  return false;
839
870
  }
840
871
  }
@@ -898,5 +929,118 @@ export async function verifyAuditEventChain(
898
929
  return { valid: true };
899
930
  }
900
931
 
932
+ // ── Audit-query response (witness side, Auditability Section 7.3) ──
933
+ //
934
+ // Distinct from signAuditResponse, which is the bilateral peer-to-peer
935
+ // audit-exchange response between two agents. The witness query response
936
+ // commits the WITNESS to (a) the events, (b) per-event Merkle proofs,
937
+ // (c) the witness's treeSize / rootHash at response time, (d) the
938
+ // messageId queried, signed under the witness's identity key.
939
+
940
+ /**
941
+ * Sign an INK audit-query response from a witness. The signed bytes are:
942
+ *
943
+ * "ink/audit-query-response/v1\n" + JCS(response object minus serviceSignature)
944
+ *
945
+ * Callers pass the response object EXCLUDING `serviceSignature`. The
946
+ * canonical bytes bind every other field, including `protocol`, `type`,
947
+ * `messageId`, `events`, `proofs`, `treeSize`, `rootHash`, `serviceDid`,
948
+ * and `timestamp`, so verifiers cannot rebind a valid signature to a
949
+ * different witness/message/root.
950
+ */
951
+ export async function signAuditQueryResponse(
952
+ responseWithoutSignature: Record<string, unknown>,
953
+ privateKey: Uint8Array,
954
+ ): Promise<string> {
955
+ if (responseWithoutSignature === null || typeof responseWithoutSignature !== "object" || Array.isArray(responseWithoutSignature)) {
956
+ throw new Error("response must be a non-null object");
957
+ }
958
+ // §7.3 / §7.4 sign-side scope enforcement. A conformant witness must
959
+ // not mint a signature over a response where any event falls outside
960
+ // the envelope's (messageId, requester) scope: those rules apply at
961
+ // sign time as well as at verify time. Without this, a witness that
962
+ // composed payloads incorrectly could ship alpha.3-invalid signed
963
+ // bytes that the high-level verifier would then reject. Catching it
964
+ // here ensures the primitive is self-defending.
965
+ const envMessageId = (responseWithoutSignature as { messageId?: unknown }).messageId;
966
+ const envRequester = (responseWithoutSignature as { requester?: unknown }).requester;
967
+ const events = (responseWithoutSignature as { events?: unknown }).events;
968
+ if (Array.isArray(events) && events.length > 0) {
969
+ if (typeof envMessageId !== "string" || envMessageId.length === 0) {
970
+ throw new Error("Audit-query response must include a non-empty messageId");
971
+ }
972
+ if (typeof envRequester !== "string" || envRequester.length === 0) {
973
+ throw new Error("Audit-query response must include a non-empty requester");
974
+ }
975
+ for (const event of events) {
976
+ if (event === null || typeof event !== "object" || Array.isArray(event)) {
977
+ throw new Error("Every event must be a non-null object");
978
+ }
979
+ const e = event as { messageId?: unknown; agentId?: unknown; counterpartyId?: unknown; agentSignature?: unknown };
980
+ if (e.messageId !== envMessageId) {
981
+ throw new Error("Per-event scope violation: event.messageId does not match envelope.messageId");
982
+ }
983
+ const requesterIsParty =
984
+ (typeof e.agentId === "string" && e.agentId === envRequester) ||
985
+ (typeof e.counterpartyId === "string" && e.counterpartyId === envRequester);
986
+ if (!requesterIsParty) {
987
+ throw new Error("Per-event scope violation: requester is not a party (agentId/counterpartyId)");
988
+ }
989
+ // §7.3 verifier MUST check agentSignature; sign-side mirror so a
990
+ // witness using this primitive cannot ship signed responses that
991
+ // strip per-event provenance.
992
+ if (typeof e.agentSignature !== "string" || e.agentSignature.length === 0) {
993
+ throw new Error("Per-event scope violation: event.agentSignature is missing or empty");
994
+ }
995
+ }
996
+ }
997
+ if (!isWithinCanonicalizeBounds(responseWithoutSignature)) {
998
+ throw new Error("Audit-query response exceeds maximum allowed complexity");
999
+ }
1000
+ const canonical = jcsCanonicalize(responseWithoutSignature);
1001
+ const prefixed = `ink/audit-query-response/v1\n${canonical}`;
1002
+ const bytes = new TextEncoder().encode(prefixed);
1003
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) {
1004
+ throw new Error("Audit-query response exceeds maximum allowed size");
1005
+ }
1006
+ const sig = await ed.signAsync(bytes, privateKey);
1007
+ return base64urlEncode(sig);
1008
+ }
1009
+
1010
+ /**
1011
+ * Verify the Ed25519 signature on an audit-query response. This is the
1012
+ * LOW-LEVEL primitive. Most consumers should call
1013
+ * `verifyAuditQueryResponse` (from `src/audit/inclusion-receipt.ts`)
1014
+ * instead: it enforces envelope shape, requester binding, the
1015
+ * events-to-proofs one-to-one mapping, and walks every Merkle proof.
1016
+ *
1017
+ * Calling this function alone does NOT prove the response is acceptable.
1018
+ * A signed but malformed envelope (wrong type, wrong protocol, no
1019
+ * proofs, wrong requester) can still pass here. Caller is responsible
1020
+ * for pinning / resolving the witness public key out of band (e.g.
1021
+ * via /.well-known/did.json). Returns false (never throws) for any
1022
+ * malformed input.
1023
+ */
1024
+ export async function verifyAuditQueryResponseSignature(
1025
+ responseWithoutSignature: Record<string, unknown>,
1026
+ signature: string,
1027
+ publicKey: Uint8Array,
1028
+ ): Promise<boolean> {
1029
+ if (responseWithoutSignature === null || typeof responseWithoutSignature !== "object" || Array.isArray(responseWithoutSignature)) return false;
1030
+ if (typeof signature !== "string") return false;
1031
+ if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
1032
+ if (!isWithinCanonicalizeBounds(responseWithoutSignature)) return false;
1033
+ try {
1034
+ const canonical = jcsCanonicalize(responseWithoutSignature);
1035
+ const prefixed = `ink/audit-query-response/v1\n${canonical}`;
1036
+ const bytes = new TextEncoder().encode(prefixed);
1037
+ if (bytes.length > MAX_SIGBASE_BODY_BYTES) return false;
1038
+ const sig = base64urlDecode(signature);
1039
+ return await ed.verifyAsync(sig, bytes, publicKey);
1040
+ } catch {
1041
+ return false;
1042
+ }
1043
+ }
1044
+
901
1045
  // Re-export encoding helpers for test use
902
1046
  export { base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize };
package/src/index.ts CHANGED
@@ -9,11 +9,14 @@ export {
9
9
  buildAuthHeader,
10
10
  computeMessageHash,
11
11
  computeEventHash,
12
+ computeAuditMerkleLeafHash,
12
13
  signAuditEvent,
13
14
  verifyAuditEventSignature,
14
15
  signAuditResponse,
15
16
  verifyAuditResponseSignature,
16
17
  verifyAuditEventChain,
18
+ signAuditQueryResponse,
19
+ verifyAuditQueryResponseSignature,
17
20
  encryptInkPayload,
18
21
  decryptInkPayload,
19
22
  checkReplay,
@@ -46,6 +49,17 @@ export {
46
49
  // Middleware: transport-level INK auth
47
50
  export { verifyInkAuth, type NonceStore } from "./middleware/ink-auth.js";
48
51
 
52
+ // Audit: inclusion-receipt + audit-query-response verification
53
+ export {
54
+ verifyInclusionReceipt,
55
+ verifyAuditQueryResponse,
56
+ type InclusionReceipt,
57
+ type InclusionReceiptVerifyResult,
58
+ type AuditQueryResponse,
59
+ type AuditQueryResponseVerifyResult,
60
+ type VerifyStep,
61
+ } from "./audit/inclusion-receipt.js";
62
+
49
63
  // Optional containment / governance primitives
50
64
  export { HandshakeBudgetTracker } from "./ink/handshake-budget.js";
51
65
 
@@ -66,18 +66,18 @@ export type InkAuditEventType = z.infer<typeof InkAuditEventTypeSchema>;
66
66
  // ── INK Audit Event (hash-chained, signed) ──
67
67
 
68
68
  export const InkAuditEventSchema = z.object({
69
- id: z.string(),
69
+ id: z.string().min(1),
70
70
  version: z.literal("ink-audit/1"),
71
- agentId: z.string(),
72
- agentSignature: z.string(),
71
+ agentId: z.string().min(1),
72
+ agentSignature: z.string().min(1),
73
73
  sequence: z.number().int().positive(),
74
- previousEventHash: z.string().nullable(),
74
+ previousEventHash: z.string().regex(/^[0-9a-f]{64}$/).nullable(),
75
75
  eventType: InkAuditEventTypeSchema,
76
76
  timestamp: z.string().datetime(),
77
- messageId: z.string().optional(),
78
- correlationId: z.string().optional(),
79
- counterpartyId: z.string().optional(),
80
- signingKeyId: z.string().optional(),
77
+ messageId: z.string().min(1).optional(),
78
+ correlationId: z.string().min(1).optional(),
79
+ counterpartyId: z.string().min(1).optional(),
80
+ signingKeyId: z.string().min(1).optional(),
81
81
  data: z.record(z.unknown()).optional(),
82
82
  });
83
83