@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/CHANGELOG.md +43 -5
- package/CODE_OF_CONDUCT.md +1 -1
- package/README.md +7 -5
- package/SECURITY.md +1 -1
- package/bin/ink.mjs +51 -0
- package/bin/verify-inclusion-impl.mjs +483 -0
- package/docs/maturity.md +3 -3
- package/docs/threat-model.md +1 -1
- package/package.json +7 -3
- package/specs/ink-auditability.md +37 -12
- package/specs/ink-compliance-checklist.md +9 -1
- package/src/audit/inclusion-receipt.ts +604 -0
- package/src/crypto/ink.ts +173 -29
- package/src/index.ts +14 -0
- package/src/models/ink-audit.ts +8 -8
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/models/ink-audit.ts
CHANGED
|
@@ -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
|
|