@adastracomputing/ink 0.1.0-alpha.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CODE_OF_CONDUCT.md +42 -0
  3. package/LICENSE-APACHE +201 -0
  4. package/LICENSE-MIT +21 -0
  5. package/README.md +133 -0
  6. package/SECURITY.md +57 -0
  7. package/docs/key-rotation-rule.md +108 -0
  8. package/docs/logo.svg +8 -0
  9. package/docs/maturity.md +81 -0
  10. package/docs/threat-model.md +150 -0
  11. package/package.json +72 -0
  12. package/specs/ink-agent-containment-and-governance-extension-spec.md +508 -0
  13. package/specs/ink-auditability.md +652 -0
  14. package/specs/ink-authorization-chain.md +242 -0
  15. package/specs/ink-compatibility-policy.md +263 -0
  16. package/specs/ink-compliance-checklist.md +309 -0
  17. package/specs/ink-containment-phase1-implementation-spec.md +593 -0
  18. package/specs/ink-introduction-receipts-extension.md +501 -0
  19. package/specs/ink-key-rotation-spec.md +535 -0
  20. package/src/crypto/ink.ts +902 -0
  21. package/src/crypto/keys.ts +211 -0
  22. package/src/crypto/multi-key-verify.ts +170 -0
  23. package/src/crypto/sign.ts +155 -0
  24. package/src/crypto/verify.ts +1 -0
  25. package/src/discovery/agent-card.ts +508 -0
  26. package/src/index.ts +59 -0
  27. package/src/ink/checkpoint.ts +75 -0
  28. package/src/ink/discovery-gating.ts +147 -0
  29. package/src/ink/handshake-budget.ts +413 -0
  30. package/src/ink/receipts.ts +114 -0
  31. package/src/ink/transport-auth.ts +96 -0
  32. package/src/middleware/ink-auth.ts +263 -0
  33. package/src/models/agent-card.ts +63 -0
  34. package/src/models/ink-audit.ts +205 -0
  35. package/src/models/ink-handshake.ts +123 -0
  36. package/src/models/intent.ts +201 -0
  37. package/src/models/key-entry.ts +52 -0
  38. package/src/models/profile.ts +31 -0
  39. package/test-vectors/README.md +129 -0
  40. package/test-vectors/encryption.json +90 -0
  41. package/test-vectors/handshake.json +482 -0
  42. package/test-vectors/jcs.json +30 -0
  43. package/test-vectors/key-rotation.json +101 -0
  44. package/test-vectors/keys.json +32 -0
  45. package/test-vectors/receipts-and-audit.json +142 -0
  46. package/test-vectors/replay.json +88 -0
  47. package/test-vectors/signing.json +61 -0
  48. package/test-vectors/witness.json +394 -0
@@ -0,0 +1,123 @@
1
+ import { z } from "zod";
2
+
3
+ // ── Transport identifiers (INK Containment §7) ──
4
+
5
+ export const InkTransportSchema = z.enum([
6
+ "ink_http",
7
+ "ink_ws",
8
+ "extension_api",
9
+ "voice",
10
+ "line_phone",
11
+ "human_review_queue",
12
+ ]);
13
+
14
+ export type InkTransport = z.infer<typeof InkTransportSchema>;
15
+
16
+ // ── Backoff hints (INK Containment §5.2) ──
17
+
18
+ export const InkBackoffHintSchema = z.object({
19
+ retryAfterSeconds: z.number().int().positive().optional(),
20
+ cooldownUntil: z.string().datetime().optional(),
21
+ backoffClass: z.enum(["sender", "intent_ref", "counterparty"]).optional(),
22
+ });
23
+
24
+ export type InkBackoffHint = z.infer<typeof InkBackoffHintSchema>;
25
+
26
+ // ── Agent Card visibility (INK Containment §6) ──
27
+
28
+ export const AgentCardVisibilitySchema = z.enum([
29
+ "public",
30
+ "network_only",
31
+ "capability_gated",
32
+ "private",
33
+ ]);
34
+
35
+ export type AgentCardVisibility = z.infer<typeof AgentCardVisibilitySchema>;
36
+
37
+ // ── Challenge (network.tulpa.challenge) — Stage 2a ──
38
+
39
+ export const ChallengeTypeSchema = z.enum([
40
+ "mutual_connection_proof",
41
+ "identity_verification",
42
+ "availability_query",
43
+ "context_request",
44
+ "none",
45
+ ]);
46
+
47
+ export type ChallengeType = z.infer<typeof ChallengeTypeSchema>;
48
+
49
+ export const InkChallengeSchema = z.object({
50
+ protocol: z.literal("ink/0.1"),
51
+ type: z.literal("network.tulpa.challenge"),
52
+ intentRef: z.string(),
53
+ challengeType: ChallengeTypeSchema,
54
+ fields: z.array(z.string()).optional(),
55
+ availableWindows: z.array(z.string()).optional(),
56
+ contextFields: z.array(z.string()).optional(),
57
+ nonce: z.string(),
58
+ timestamp: z.string().datetime(),
59
+ });
60
+
61
+ export type InkChallenge = z.infer<typeof InkChallengeSchema>;
62
+
63
+ // ── Rejection (network.tulpa.rejection) — Stage 2b ──
64
+
65
+ export const RejectionReasonSchema = z.enum([
66
+ "policy_violation",
67
+ "trust_threshold",
68
+ "capacity",
69
+ "unsupported_intent",
70
+ "rate_limited",
71
+ "expired",
72
+ // Containment extension (Phase 1)
73
+ "handshake_budget_exhausted",
74
+ "counterparty_cooldown",
75
+ "sender_rate_limited",
76
+ "delegation_budget_exhausted",
77
+ "transport_scope_violation",
78
+ ]);
79
+
80
+ export type RejectionReason = z.infer<typeof RejectionReasonSchema>;
81
+
82
+ export const InkRejectionSchema = z.object({
83
+ protocol: z.literal("ink/0.1"),
84
+ type: z.literal("network.tulpa.rejection"),
85
+ intentRef: z.string(),
86
+ reason: RejectionReasonSchema,
87
+ detail: z.string().max(500).optional(),
88
+ retryAfter: z.string().optional(),
89
+ backoffHint: InkBackoffHintSchema.optional(),
90
+ nonce: z.string(),
91
+ timestamp: z.string().datetime(),
92
+ });
93
+
94
+ export type InkRejection = z.infer<typeof InkRejectionSchema>;
95
+
96
+ // ── Resolution (network.tulpa.resolution) — Stage 3 ──
97
+
98
+ export const ResolutionOutcomeSchema = z.enum([
99
+ "accepted",
100
+ "declined",
101
+ "escalated_to_human",
102
+ "expired",
103
+ ]);
104
+
105
+ export type ResolutionOutcome = z.infer<typeof ResolutionOutcomeSchema>;
106
+
107
+ export const ResolutionDetailsSchema = z.object({
108
+ scheduledAt: z.string().optional(),
109
+ duration: z.string().optional(),
110
+ }).passthrough();
111
+
112
+ export const InkResolutionSchema = z.object({
113
+ protocol: z.literal("ink/0.1"),
114
+ type: z.literal("network.tulpa.resolution"),
115
+ intentRef: z.string(),
116
+ outcome: ResolutionOutcomeSchema,
117
+ details: ResolutionDetailsSchema.optional(),
118
+ counterpartyDid: z.string().optional(),
119
+ nonce: z.string(),
120
+ timestamp: z.string().datetime(),
121
+ });
122
+
123
+ export type InkResolution = z.infer<typeof InkResolutionSchema>;
@@ -0,0 +1,201 @@
1
+ import { z } from "zod";
2
+ import { ProfileSnapshotSchema } from "./profile.js";
3
+
4
+ // --- Intent Types ---
5
+
6
+ export const IntentTypeSchema = z.enum([
7
+ "schedule_meeting",
8
+ "schedule_meeting_response",
9
+ "intro_request",
10
+ "intro_response",
11
+ "opportunity",
12
+ "opportunity_response",
13
+ "follow_up",
14
+ "ask",
15
+ "ask_response",
16
+ "connection_request",
17
+ "connection_response",
18
+ "context_share",
19
+ "ping",
20
+ "retract",
21
+ "multi_party_sync",
22
+ ]);
23
+
24
+ export type IntentType = z.infer<typeof IntentTypeSchema>;
25
+
26
+ // --- Intent Payloads ---
27
+
28
+ export const ScheduleMeetingPayloadSchema = z.object({
29
+ proposedTimes: z.array(z.string()).min(1).max(10),
30
+ topic: z.string().max(500),
31
+ format: z.enum(["video", "phone", "in_person", "async"]),
32
+ urgency: z.enum(["low", "normal", "urgent"]),
33
+ context: z.string().max(2000).optional(),
34
+ location: z.string().max(500).optional(),
35
+ });
36
+
37
+ export const ScheduleMeetingResponsePayloadSchema = z.object({
38
+ status: z.enum(["accepted", "declined", "countered"]),
39
+ confirmedTime: z.string().optional(),
40
+ counterTimes: z.array(z.string()).max(10).optional(),
41
+ meetingLink: z.string().url().optional(),
42
+ note: z.string().max(1000).optional(),
43
+ declineReason: z
44
+ .enum(["unavailable", "not_interested", "too_busy", "deferred"])
45
+ .optional(),
46
+ });
47
+
48
+ export const IntroRequestPayloadSchema = z.object({
49
+ target: z.string(),
50
+ reason: z.string().max(2000),
51
+ context: z.string().max(2000).optional(),
52
+ urgency: z.enum(["low", "normal"]),
53
+ });
54
+
55
+ export const IntroResponsePayloadSchema = z.object({
56
+ status: z.enum(["forwarded", "declined", "pending_target"]),
57
+ note: z.string().max(1000).optional(),
58
+ targetResponse: z.enum(["accepted", "declined", "pending"]).optional(),
59
+ });
60
+
61
+ export const OpportunityPayloadSchema = z.object({
62
+ type: z.enum([
63
+ "role",
64
+ "investment",
65
+ "collaboration",
66
+ "advisory",
67
+ "event",
68
+ "other",
69
+ ]),
70
+ title: z.string().max(500),
71
+ org: z.string().max(200).optional(),
72
+ description: z.string().max(5000),
73
+ matchReason: z.string().max(2000),
74
+ expiresAt: z.string().optional(),
75
+ url: z.string().url().optional(),
76
+ });
77
+
78
+ export const OpportunityResponsePayloadSchema = z.object({
79
+ status: z.enum(["interested", "not_interested", "maybe_later"]),
80
+ note: z.string().max(1000).optional(),
81
+ followUpIntent: IntentTypeSchema.optional(),
82
+ });
83
+
84
+ export const ConnectionRequestPayloadSchema = z.object({
85
+ method: z.enum(["qr", "intro", "discovery", "import"]),
86
+ introducedBy: z.string().optional(),
87
+ context: z.string().max(2000),
88
+ profileSnapshot: ProfileSnapshotSchema,
89
+ });
90
+
91
+ export const ConnectionResponsePayloadSchema = z.object({
92
+ status: z.enum(["accepted", "declined", "pending"]),
93
+ profileSnapshot: ProfileSnapshotSchema.optional(),
94
+ note: z.string().max(1000).optional(),
95
+ });
96
+
97
+ export const FollowUpPayloadSchema = z.object({
98
+ referenceId: z.string(),
99
+ message: z.string().max(5000),
100
+ actionRequested: z.enum(["reply", "schedule", "review", "none"]).optional(),
101
+ });
102
+
103
+ export const AskPayloadSchema = z.object({
104
+ question: z.string().max(5000),
105
+ context: z.string().max(2000).optional(),
106
+ responseFormat: z.enum(["text", "choice"]).optional(),
107
+ choices: z.array(z.string().max(500)).max(10).optional(),
108
+ deadline: z.string().optional(),
109
+ });
110
+
111
+ export const AskResponsePayloadSchema = z.object({
112
+ answer: z.string().max(5000),
113
+ choiceIndex: z.number().int().min(0).optional(),
114
+ });
115
+
116
+ export const PingPayloadSchema = z.object({
117
+ note: z.string().max(1000).optional(),
118
+ });
119
+
120
+ export const RetractPayloadSchema = z.object({
121
+ targetMessageId: z.string(),
122
+ reason: z.string().max(1000).optional(),
123
+ });
124
+
125
+ export const ContextSharePayloadSchema = z.object({
126
+ context: z.string().max(5000),
127
+ category: z.enum(["professional_background", "project_update", "expertise", "availability", "general"]),
128
+ referenceId: z.string().optional(),
129
+ expiresAt: z.string().optional(),
130
+ });
131
+
132
+ export const MultiPartySyncPayloadSchema = z.object({
133
+ enclaveType: z.enum(["meeting_sync"]),
134
+ purpose: z.string().max(500),
135
+ participants: z.array(z.string()).min(2).max(20),
136
+ expiresAt: z.string(),
137
+ });
138
+
139
+ // --- Payload discriminated union ---
140
+
141
+ const payloadSchemas = {
142
+ schedule_meeting: ScheduleMeetingPayloadSchema,
143
+ schedule_meeting_response: ScheduleMeetingResponsePayloadSchema,
144
+ intro_request: IntroRequestPayloadSchema,
145
+ intro_response: IntroResponsePayloadSchema,
146
+ opportunity: OpportunityPayloadSchema,
147
+ opportunity_response: OpportunityResponsePayloadSchema,
148
+ follow_up: FollowUpPayloadSchema,
149
+ ask: AskPayloadSchema,
150
+ ask_response: AskResponsePayloadSchema,
151
+ connection_request: ConnectionRequestPayloadSchema,
152
+ connection_response: ConnectionResponsePayloadSchema,
153
+ context_share: ContextSharePayloadSchema,
154
+ ping: PingPayloadSchema,
155
+ retract: RetractPayloadSchema,
156
+ multi_party_sync: MultiPartySyncPayloadSchema,
157
+ } as const;
158
+
159
+ // --- Message Envelope ---
160
+
161
+ export const MessageProvenanceSchema = z.object({
162
+ origin: z.enum(["human", "agent_approved", "agent_autonomous"]),
163
+ extensionId: z.string(),
164
+ installationId: z.string().uuid(),
165
+ }).optional();
166
+
167
+ export const MessageEnvelopeSchema = z.object({
168
+ protocol: z.literal("ink/0.1"),
169
+ id: z.string(),
170
+ correlationId: z.string(),
171
+ createdAt: z.string(),
172
+ expiresAt: z.string().optional(),
173
+ from: z.string(),
174
+ to: z.string(),
175
+ intent: IntentTypeSchema,
176
+ payload: z.unknown(),
177
+ signature: z.string(),
178
+ signingKeyId: z.string().optional(),
179
+ provenance: MessageProvenanceSchema,
180
+ });
181
+
182
+ export type MessageEnvelope = z.infer<typeof MessageEnvelopeSchema>;
183
+
184
+ /**
185
+ * Validate a message envelope AND its payload based on the intent type.
186
+ * Returns the validated message or throws a ZodError.
187
+ */
188
+ export function validateMessage(raw: unknown): MessageEnvelope {
189
+ const envelope = MessageEnvelopeSchema.parse(raw);
190
+ const payloadSchema = payloadSchemas[envelope.intent];
191
+ // Validate payload strictly — reject unknown fields
192
+ payloadSchema.strict().parse(envelope.payload);
193
+ return envelope;
194
+ }
195
+
196
+ /**
197
+ * Get the payload schema for a given intent type.
198
+ */
199
+ export function getPayloadSchema(intent: IntentType) {
200
+ return payloadSchemas[intent];
201
+ }
@@ -0,0 +1,52 @@
1
+ import { z } from "zod";
2
+
3
+ export const KeyStatusSchema = z.enum(["active", "retired", "revoked"]);
4
+ export type KeyStatus = z.infer<typeof KeyStatusSchema>;
5
+
6
+ export const KeyRoleSchema = z.enum(["signing", "encryption"]);
7
+ export type KeyRole = z.infer<typeof KeyRoleSchema>;
8
+
9
+ export const KeyEntrySchema = z.object({
10
+ keyId: z.string().min(1),
11
+ algorithm: z.enum(["Ed25519", "X25519"]),
12
+ publicKeyMultibase: z.string().startsWith("z"),
13
+ status: KeyStatusSchema,
14
+ validFrom: z.string().datetime(),
15
+ validUntil: z.string().datetime().optional(),
16
+ revokedAt: z.string().datetime().optional(),
17
+ revokeReason: z.string().optional(),
18
+ });
19
+
20
+ export type KeyEntry = z.infer<typeof KeyEntrySchema>;
21
+
22
+ export interface CandidateKey {
23
+ keyId: string;
24
+ publicKey: Uint8Array;
25
+ status: KeyStatus;
26
+ /** ISO 8601 timestamp the key becomes usable. Verifier rejects messages
27
+ * whose `body.timestamp` falls outside [validFrom, validUntil]. Optional
28
+ * for backward compat with legacy callers that don't track windows. */
29
+ validFrom?: string;
30
+ /** ISO 8601 timestamp the key stops being usable. Typically set when a
31
+ * key transitions to `retired`. A retired key with no validUntil keeps
32
+ * verifying indefinitely (legacy behavior); set validUntil to bound it. */
33
+ validUntil?: string;
34
+ /** ISO 8601 timestamp the key was revoked. Defensive: status === "revoked"
35
+ * already blocks verification; this field documents the moment. */
36
+ revokedAt?: string;
37
+ }
38
+
39
+ export interface StoredKey {
40
+ keyId: string;
41
+ agentId: string;
42
+ role: KeyRole;
43
+ algorithm: string;
44
+ publicKeyMultibase: string;
45
+ privateKey: Uint8Array | null;
46
+ status: KeyStatus;
47
+ validFrom: string;
48
+ validUntil: string | null;
49
+ revokedAt: string | null;
50
+ createdAt: string;
51
+ updatedAt: string;
52
+ }
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+
3
+ export const AvailabilityConfigSchema = z.object({
4
+ timezone: z.string(),
5
+ meetingHours: z.string().optional(),
6
+ responseSla: z.string().optional(),
7
+ });
8
+
9
+ export const ProfileSnapshotSchema = z.object({
10
+ headline: z.string().max(500),
11
+ skills: z.array(z.string().max(100)).max(50),
12
+ interests: z.array(z.string().max(100)).max(50),
13
+ availability: AvailabilityConfigSchema.optional(),
14
+ openTo: z.array(z.string().max(100)).max(20),
15
+ });
16
+
17
+ export const ProfileSchema = z.object({
18
+ agentId: z.string(),
19
+ handle: z.string(),
20
+ displayName: z.string().max(200),
21
+ bio: z.string().max(2000),
22
+ snapshots: z.object({
23
+ public: ProfileSnapshotSchema,
24
+ connected: ProfileSnapshotSchema,
25
+ custom: z.record(z.string(), ProfileSnapshotSchema),
26
+ }),
27
+ });
28
+
29
+ export type AvailabilityConfig = z.infer<typeof AvailabilityConfigSchema>;
30
+ export type ProfileSnapshot = z.infer<typeof ProfileSnapshotSchema>;
31
+ export type Profile = z.infer<typeof ProfileSchema>;
@@ -0,0 +1,129 @@
1
+ # INK v0.1 Test Vectors
2
+
3
+ Reference test vectors for INK v0.1 signing, encryption, replay protection, handshake flows, witness transport auth and key rotation. These vectors use fixed key material and deterministic inputs so that two independent implementations can verify byte-for-byte correctness.
4
+
5
+ ## Files
6
+
7
+ | File | Covers | Vector count |
8
+ |------|--------|-------------|
9
+ | `keys.json` | Fixed Ed25519 and X25519 key pairs for Alice and Bob (hex-encoded) |, |
10
+ | `signing.json` | Signature generation and verification (§3.3) | 3 |
11
+ | `encryption.json` | ECIES encryption/decryption (§3.4) | 2 |
12
+ | `jcs.json` | JCS canonicalization (RFC 8785) | 4 |
13
+ | `replay.json` | Replay protection acceptance/rejection (§3.5) | 6 |
14
+ | `receipts-and-audit.json` | Receipt signatures, audit query signatures, hash-chained audit events and fork detection (Auditability §1–§3) | 4 |
15
+ | `handshake.json` | Challenge (Stage 2a), rejection (Stage 2b) and resolution (Stage 3), valid signatures, path/recipient/body binding failures, replay protection | 22 |
16
+ | `witness.json` | Audit submit and query with INK transport auth, plus cross-service interop cases | 15 |
17
+ | `key-rotation.json` | Auth header keyId format, rotated-key verification, historical verification, revoked-key rejection, refresh-on-miss, keyId precedence, unknown keyId fallthrough, audit event signingKeyId tracking | 8 |
18
+
19
+ **Total: 64 deterministic vectors across 9 families**
20
+
21
+ ## Vector categories
22
+
23
+ ### Signing (`signing.json`)
24
+ Covers the INK Ed25519 signature base construction (`ink/0.1\nMETHOD\nPATH\nrecipientDid\nJCS(body)\ntimestamp`) and verification, including wrong-key and tampered-path negative cases.
25
+
26
+ ### Handshake (`handshake.json`)
27
+ Each handshake message type (challenge, rejection, resolution) has:
28
+ - **Valid**: canonical body, signature base, signature, full round-trip
29
+ - **Invalid path**: same signature replayed against a different endpoint
30
+ - **Invalid recipient**: same body/signature but wrong recipient DID
31
+ - **Tampered body**: a field modified after signing
32
+ - **Expired timestamp**: >5 minutes old
33
+ - **Future timestamp**: >30 seconds ahead (challenge only)
34
+ - **Duplicate nonce**: nonce already in deduplication window
35
+
36
+ Resolution vectors include all four outcome variants: `accepted`, `declined`, `escalated_to_human`, `expired`.
37
+
38
+ ### Witness (`witness.json`)
39
+ Covers the witness transport auth model (INK-Ed25519 on both submit and query):
40
+
41
+ **Submit** (`POST /ink/v1/audit/submit`):
42
+ - Transport signature over full body (which contains the signed audit event)
43
+ - Path binding, recipient binding, timestamp freshness, body tamper detection
44
+ - **Event signature vs transport signature separation**: transport auth valid but embedded event signature forged, witness must reject
45
+
46
+ **Query** (`POST /ink/v1/audit/query`):
47
+ - Signed POST body (not GET), sender identity derived from verified auth, not caller input
48
+ - Path binding, body tamper detection, timestamp/nonce replay protection
49
+
50
+ **Interop cases**:
51
+ - Submit signature replayed against query path → fails (path binding)
52
+ - Query signature replayed against different witness DID → fails (recipient binding)
53
+ - Intent from main worker replayed as witness submit → fails (both path and recipient differ)
54
+
55
+ ### Replay protection (`replay.json`)
56
+ Standalone timestamp freshness and nonce deduplication tests. The handshake and witness vectors also include replay cases inline.
57
+
58
+ ## Usage
59
+
60
+ 1. Load key material from `keys.json`
61
+ 2. Run each test case: construct the expected output from the inputs and compare
62
+ 3. All base64url values use no-padding encoding (RFC 4648 §5)
63
+ 4. All hex values are lowercase
64
+
65
+ ## Fixture shapes
66
+
67
+ ### Signature vectors (handshake, witness submit/query)
68
+ ```json
69
+ {
70
+ "id": "challenge-valid",
71
+ "input": {
72
+ "method": "POST",
73
+ "path": "/ink/v1/did:plc:bob456test/challenge",
74
+ "recipientDid": "did:plc:bob456test",
75
+ "body": { ... },
76
+ "timestamp": "2026-03-25T12:00:00Z",
77
+ "signerPrivateKeyHex": "..."
78
+ },
79
+ "expected": {
80
+ "canonicalBody": "...",
81
+ "signatureBase": "ink/0.1\nPOST\n/ink/v1/.../challenge\n...\n...\n...",
82
+ "signatureBase64url": "...",
83
+ "accepted": true
84
+ }
85
+ }
86
+ ```
87
+
88
+ ### Negative signature vectors
89
+ ```json
90
+ {
91
+ "id": "challenge-invalid-path",
92
+ "input": {
93
+ "method": "POST",
94
+ "path": "/ink/v1/did:plc:bob456test/rejection",
95
+ "body": { ... },
96
+ "originalSignatureBase64url": "..."
97
+ },
98
+ "expected": { "accepted": false, "reason": "..." }
99
+ }
100
+ ```
101
+
102
+ ### Replay vectors (inline in handshake/witness)
103
+ ```json
104
+ {
105
+ "id": "challenge-timestamp-expired",
106
+ "input": {
107
+ "messageTimestamp": "2026-03-25T11:50:00Z",
108
+ "receiverClock": "2026-03-25T12:00:00Z",
109
+ "nonce": "..."
110
+ },
111
+ "expected": { "accepted": false, "errorCode": "expired_message" }
112
+ }
113
+ ```
114
+
115
+ ### Key rotation (`key-rotation.json`)
116
+ Scenario-based vectors for key rotation behavior. Unlike other vector families, key rotation vectors use runtime-generated keys (not the fixed Alice/Bob keys) because they test lifecycle transitions. The companion test suite `test/ink-key-rotation.test.ts` exercises all 8 scenarios:
117
+
118
+ - **Auth header keyId format**, regex parsing of `INK-Ed25519 <sig> keyId=<keyId>` with and without keyId
119
+ - **Rotated-key verification**, new key signs, verifier with [old=retired, new=active] keyset accepts
120
+ - **Historical message verification**, messages signed before rotation still verify against retired key
121
+ - **Revoked-key rejection**, cryptographically valid signatures from revoked keys are rejected
122
+ - **Refresh-on-miss**, stale cache fails, refetch Agent Card, retry succeeds (max 1 retry)
123
+ - **keyId precedence**, auth header keyId takes precedence over body `signingKeyId`
124
+ - **Unknown keyId fallthrough**, unknown keyId hint skipped, normal iteration finds correct key
125
+ - **Audit event signingKeyId**, audit events record `signingKeyId` in data field for historical verification
126
+
127
+ ## Key generation
128
+
129
+ The test keys were generated deterministically from fixed seeds. They are NOT suitable for production use. The seeds are included so implementations can verify key derivation if needed.
@@ -0,0 +1,90 @@
1
+ {
2
+ "description": "INK ECIES encryption and decryption test cases (INK §3.4)",
3
+ "vectors": [
4
+ {
5
+ "description": "Alice encrypts a scheduling intent to Bob using ephemeral X25519",
6
+ "input": {
7
+ "plaintextEnvelope": {
8
+ "protocol": "ink/0.1",
9
+ "type": "network.tulpa.intent",
10
+ "from": "did:plc:alice123test",
11
+ "to": "did:plc:bob456test",
12
+ "intentType": "scheduling",
13
+ "purpose": "Discuss partnership opportunity",
14
+ "urgency": "normal",
15
+ "expiresAt": "2026-03-25T00:00:00Z",
16
+ "nonce": "dGVzdG5vbmNlMTIzNDU2Nzg",
17
+ "timestamp": "2026-03-18T12:00:00Z"
18
+ },
19
+ "senderDid": "did:plc:alice123test",
20
+ "recipientDid": "did:plc:bob456test",
21
+ "recipientEncryptionKeyHex": "613ca2aa2ff1102e440dd05ead7f05c11ea440d1109f3d058f9e5302cbf26f0b",
22
+ "ephemeralPublicKeyHex": "50bf0e81d5e4475282ed7739da2772594fb0d24816258dbceec766e580c5454b",
23
+ "ephemeralPrivateKeyHex": "40fa01588b6f88871ad04a49fef0215fa00d79f2f5bb33cb3ee9742853601077",
24
+ "aesGcmNonceHex": "000102030405060708090a0b",
25
+ "hkdfSalt": "ink/0.1",
26
+ "hkdfInfo": "ink/0.1/encrypt",
27
+ "hkdfOutputLength": 32
28
+ },
29
+ "intermediate": {
30
+ "sharedSecretHex": "422b9ee6108ce1bc74a4ca0720afb6e14056a1c7e5e46dcd1e2275b1e953500a",
31
+ "symmetricKeyHex": "dd86250b0b92b1aa6c293e06d21dbe6da02f28e52d1ba5b030d940306c954f0f",
32
+ "plaintextJson": "{\"protocol\":\"ink/0.1\",\"type\":\"network.tulpa.intent\",\"from\":\"did:plc:alice123test\",\"to\":\"did:plc:bob456test\",\"intentType\":\"scheduling\",\"purpose\":\"Discuss partnership opportunity\",\"urgency\":\"normal\",\"expiresAt\":\"2026-03-25T00:00:00Z\",\"nonce\":\"dGVzdG5vbmNlMTIzNDU2Nzg\",\"timestamp\":\"2026-03-18T12:00:00Z\"}"
33
+ },
34
+ "expected": {
35
+ "outerEnvelope": {
36
+ "protocol": "ink/0.1",
37
+ "type": "network.tulpa.encrypted",
38
+ "from": "did:plc:alice123test",
39
+ "ephemeralKey": "UL8OgdXkR1KC7Xc52idyWU-w0kgWJY287sdm5YDFRUs",
40
+ "nonce": "AAECAwQFBgcICQoL",
41
+ "ciphertext": "yDmawjopzzEzCSY23AfHbJ-cnQvee2tqL8u2zyP6blpcu7twsHFY25uxtfEqdwcpU8BGF1cVZ1E0lIxutqu21r05fSDtAxSOLpPgHdBXwqnJpQfMtX8R4gHe34BVYdLgPjP8iTj-QHDxsK6UMHmMfgutmQ_vFHDCPfwCf2u4B871-E8LQnSB0lcpSgoacM-KgQR3ZjkKU6N6ueTpgVjL788Uhe5m-ZRtlAmOVqgA3d166Jmt8Z-SYwVJXmGJz2AK_skMbIvH25pPyR2SpiCztWJxiPLJVSRE05WqiEvLR6i0ZRhD7UYz1S5r1VRgjV3CxjXJMF3LE90PLYj6DdK5kKCWf6_orM3xCeQugx5-gL17wnHpCD_zcdHtoDef9nqYLpoLmUJe54EmYjzuuGfaL6Tp3mINOWpNfZRrZlI",
42
+ "timestamp": "2026-03-18T12:00:00Z",
43
+ "messageNonce": "cmVwbGF5LXByb3RlY3Rpb24tdGVzdA"
44
+ },
45
+ "ciphertextWithTagBase64url": "yDmawjopzzEzCSY23AfHbJ-cnQvee2tqL8u2zyP6blpcu7twsHFY25uxtfEqdwcpU8BGF1cVZ1E0lIxutqu21r05fSDtAxSOLpPgHdBXwqnJpQfMtX8R4gHe34BVYdLgPjP8iTj-QHDxsK6UMHmMfgutmQ_vFHDCPfwCf2u4B871-E8LQnSB0lcpSgoacM-KgQR3ZjkKU6N6ueTpgVjL788Uhe5m-ZRtlAmOVqgA3d166Jmt8Z-SYwVJXmGJz2AK_skMbIvH25pPyR2SpiCztWJxiPLJVSRE05WqiEvLR6i0ZRhD7UYz1S5r1VRgjV3CxjXJMF3LE90PLYj6DdK5kKCWf6_orM3xCeQugx5-gL17wnHpCD_zcdHtoDef9nqYLpoLmUJe54EmYjzuuGfaL6Tp3mINOWpNfZRrZlI"
46
+ }
47
+ },
48
+ {
49
+ "description": "Bob decrypts the outer envelope and verifies inner/outer consistency",
50
+ "input": {
51
+ "outerEnvelope": {
52
+ "protocol": "ink/0.1",
53
+ "type": "network.tulpa.encrypted",
54
+ "from": "did:plc:alice123test",
55
+ "ephemeralKey": "UL8OgdXkR1KC7Xc52idyWU-w0kgWJY287sdm5YDFRUs",
56
+ "nonce": "AAECAwQFBgcICQoL",
57
+ "ciphertext": "yDmawjopzzEzCSY23AfHbJ-cnQvee2tqL8u2zyP6blpcu7twsHFY25uxtfEqdwcpU8BGF1cVZ1E0lIxutqu21r05fSDtAxSOLpPgHdBXwqnJpQfMtX8R4gHe34BVYdLgPjP8iTj-QHDxsK6UMHmMfgutmQ_vFHDCPfwCf2u4B871-E8LQnSB0lcpSgoacM-KgQR3ZjkKU6N6ueTpgVjL788Uhe5m-ZRtlAmOVqgA3d166Jmt8Z-SYwVJXmGJz2AK_skMbIvH25pPyR2SpiCztWJxiPLJVSRE05WqiEvLR6i0ZRhD7UYz1S5r1VRgjV3CxjXJMF3LE90PLYj6DdK5kKCWf6_orM3xCeQugx5-gL17wnHpCD_zcdHtoDef9nqYLpoLmUJe54EmYjzuuGfaL6Tp3mINOWpNfZRrZlI",
58
+ "timestamp": "2026-03-18T12:00:00Z",
59
+ "messageNonce": "cmVwbGF5LXByb3RlY3Rpb24tdGVzdA"
60
+ },
61
+ "recipientDid": "did:plc:bob456test",
62
+ "recipientEncryptionPrivateKeyHex": "986e5e08b69232b6c888c838ada1669139c2993db417b482a780b86f067d8658"
63
+ },
64
+ "intermediate": {
65
+ "bobSharedSecretHex": "422b9ee6108ce1bc74a4ca0720afb6e14056a1c7e5e46dcd1e2275b1e953500a",
66
+ "bobSymmetricKeyHex": "dd86250b0b92b1aa6c293e06d21dbe6da02f28e52d1ba5b030d940306c954f0f",
67
+ "sharedSecretsMatch": true,
68
+ "symmetricKeysMatch": true,
69
+ "symmetricKeyHex": "dd86250b0b92b1aa6c293e06d21dbe6da02f28e52d1ba5b030d940306c954f0f"
70
+ },
71
+ "expected": {
72
+ "decryptedEnvelope": {
73
+ "protocol": "ink/0.1",
74
+ "type": "network.tulpa.intent",
75
+ "from": "did:plc:alice123test",
76
+ "to": "did:plc:bob456test",
77
+ "intentType": "scheduling",
78
+ "purpose": "Discuss partnership opportunity",
79
+ "urgency": "normal",
80
+ "expiresAt": "2026-03-25T00:00:00Z",
81
+ "nonce": "dGVzdG5vbmNlMTIzNDU2Nzg",
82
+ "timestamp": "2026-03-18T12:00:00Z"
83
+ },
84
+ "fromMatchesOuter": true,
85
+ "toMatchesRecipient": true,
86
+ "decryptionSucceeds": true
87
+ }
88
+ }
89
+ ]
90
+ }