@aroha-sdk/core 1.0.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.
- package/package.json +35 -0
- package/src/conformance/l0-did-identity.conformance.test.ts +183 -0
- package/src/conformance/l1-signed-envelopes.conformance.test.ts +334 -0
- package/src/crypto/encryption.test.ts +45 -0
- package/src/crypto/encryption.ts +160 -0
- package/src/crypto/index.ts +2 -0
- package/src/crypto/signing.test.ts +79 -0
- package/src/crypto/signing.ts +104 -0
- package/src/identity/credentials.ts +164 -0
- package/src/identity/did-cache.ts +113 -0
- package/src/identity/did.test.ts +232 -0
- package/src/identity/did.ts +426 -0
- package/src/identity/index.ts +4 -0
- package/src/identity/web-did.ts +427 -0
- package/src/index.ts +14 -0
- package/src/messages/envelope.test.ts +289 -0
- package/src/messages/envelope.ts +232 -0
- package/src/messages/idempotency.ts +142 -0
- package/src/messages/index.ts +4 -0
- package/src/messages/nonce.test.ts +92 -0
- package/src/messages/nonce.ts +116 -0
- package/src/messages/types.ts +357 -0
- package/src/transport/client.ts +236 -0
- package/src/transport/http-utils.ts +30 -0
- package/src/transport/index.ts +3 -0
- package/src/transport/server.ts +383 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as ed from "@noble/ed25519";
|
|
3
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
4
|
+
import { buildEnvelope, validateEnvelope, newCorrelationId, senderDID } from "./envelope.js";
|
|
5
|
+
import { NonceRegistry } from "./nonce.js";
|
|
6
|
+
|
|
7
|
+
ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
|
|
8
|
+
|
|
9
|
+
async function makeKeyPair(id: string) {
|
|
10
|
+
const priv = ed.utils.randomPrivateKey();
|
|
11
|
+
const pub = await ed.getPublicKeyAsync(priv);
|
|
12
|
+
return { did: `did:aroha:${id}`, priv, pub };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("buildEnvelope", () => {
|
|
16
|
+
it("builds a signed envelope with all required fields", async () => {
|
|
17
|
+
const sender = await makeKeyPair("sender");
|
|
18
|
+
const cid = newCorrelationId();
|
|
19
|
+
const env = await buildEnvelope(
|
|
20
|
+
"ArohaRequest",
|
|
21
|
+
sender.did,
|
|
22
|
+
"did:aroha:recipient",
|
|
23
|
+
{ capability: "search", params: {} },
|
|
24
|
+
cid,
|
|
25
|
+
sender.priv
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(env.type).toBe("ArohaRequest");
|
|
29
|
+
expect(env.from).toBe(sender.did);
|
|
30
|
+
expect(env.to).toBe("did:aroha:recipient");
|
|
31
|
+
expect(env.correlationId).toBe(cid);
|
|
32
|
+
expect(env.id).toMatch(/^urn:uuid:/);
|
|
33
|
+
expect(env.proof).toBeDefined();
|
|
34
|
+
expect(env.proof!.type).toBe("Ed25519Signature2020");
|
|
35
|
+
expect(env.nonce).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("sets expiry in the future", async () => {
|
|
39
|
+
const sender = await makeKeyPair("sender2");
|
|
40
|
+
const env = await buildEnvelope(
|
|
41
|
+
"ArohaResponse",
|
|
42
|
+
sender.did,
|
|
43
|
+
"did:aroha:other",
|
|
44
|
+
{ capability: "search", result: {} },
|
|
45
|
+
newCorrelationId(),
|
|
46
|
+
sender.priv,
|
|
47
|
+
60
|
|
48
|
+
);
|
|
49
|
+
expect(new Date(env.expires) > new Date()).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("validateEnvelope", () => {
|
|
54
|
+
it("accepts a valid, freshly-built envelope", async () => {
|
|
55
|
+
const sender = await makeKeyPair("valid-sender");
|
|
56
|
+
const myDID = "did:aroha:receiver";
|
|
57
|
+
const reg = new NonceRegistry();
|
|
58
|
+
|
|
59
|
+
const env = await buildEnvelope(
|
|
60
|
+
"ArohaRequest",
|
|
61
|
+
sender.did,
|
|
62
|
+
myDID,
|
|
63
|
+
{ capability: "do-thing", params: {} },
|
|
64
|
+
newCorrelationId(),
|
|
65
|
+
sender.priv
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const result = await validateEnvelope(env, sender.pub, myDID, reg);
|
|
69
|
+
expect(result.valid).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects when addressed to the wrong recipient", async () => {
|
|
73
|
+
const sender = await makeKeyPair("wrong-to-sender");
|
|
74
|
+
const reg = new NonceRegistry();
|
|
75
|
+
|
|
76
|
+
const env = await buildEnvelope(
|
|
77
|
+
"ArohaRequest",
|
|
78
|
+
sender.did,
|
|
79
|
+
"did:aroha:someone-else",
|
|
80
|
+
{ capability: "x", params: {} },
|
|
81
|
+
newCorrelationId(),
|
|
82
|
+
sender.priv
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const result = await validateEnvelope(env, sender.pub, "did:aroha:me", reg);
|
|
86
|
+
expect(result.valid).toBe(false);
|
|
87
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/addressed to/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects an expired envelope", async () => {
|
|
91
|
+
const sender = await makeKeyPair("expired-sender");
|
|
92
|
+
const myDID = "did:aroha:me";
|
|
93
|
+
const reg = new NonceRegistry();
|
|
94
|
+
|
|
95
|
+
const env = await buildEnvelope(
|
|
96
|
+
"ArohaRequest",
|
|
97
|
+
sender.did,
|
|
98
|
+
myDID,
|
|
99
|
+
{ capability: "x", params: {} },
|
|
100
|
+
newCorrelationId(),
|
|
101
|
+
sender.priv,
|
|
102
|
+
-10 // already expired
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const result = await validateEnvelope(env, sender.pub, myDID, reg);
|
|
106
|
+
expect(result.valid).toBe(false);
|
|
107
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/expired/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("rejects a replayed envelope", async () => {
|
|
111
|
+
const sender = await makeKeyPair("replay-sender");
|
|
112
|
+
const myDID = "did:aroha:me";
|
|
113
|
+
const reg = new NonceRegistry();
|
|
114
|
+
|
|
115
|
+
const env = await buildEnvelope(
|
|
116
|
+
"ArohaRequest",
|
|
117
|
+
sender.did,
|
|
118
|
+
myDID,
|
|
119
|
+
{ capability: "x", params: {} },
|
|
120
|
+
newCorrelationId(),
|
|
121
|
+
sender.priv
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
await validateEnvelope(env, sender.pub, myDID, reg); // first — accepted
|
|
125
|
+
const result = await validateEnvelope(env, sender.pub, myDID, reg); // second — replay
|
|
126
|
+
expect(result.valid).toBe(false);
|
|
127
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/[Rr]eplay/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects a tampered signature", async () => {
|
|
131
|
+
const sender = await makeKeyPair("tamper-sender");
|
|
132
|
+
const myDID = "did:aroha:me";
|
|
133
|
+
const reg = new NonceRegistry();
|
|
134
|
+
|
|
135
|
+
const env = await buildEnvelope(
|
|
136
|
+
"ArohaRequest",
|
|
137
|
+
sender.did,
|
|
138
|
+
myDID,
|
|
139
|
+
{ capability: "x", params: {} },
|
|
140
|
+
newCorrelationId(),
|
|
141
|
+
sender.priv
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const tampered = {
|
|
145
|
+
...env,
|
|
146
|
+
proof: { ...env.proof!, proofValue: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" },
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const result = await validateEnvelope(tampered, sender.pub, myDID, reg);
|
|
150
|
+
expect(result.valid).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("newCorrelationId", () => {
|
|
155
|
+
it("generates unique IDs each call", () => {
|
|
156
|
+
const a = newCorrelationId();
|
|
157
|
+
const b = newCorrelationId();
|
|
158
|
+
expect(a).not.toBe(b);
|
|
159
|
+
expect(a).toMatch(/^saga-/);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("senderDID", () => {
|
|
164
|
+
it("returns the from field", async () => {
|
|
165
|
+
const sender = await makeKeyPair("sid");
|
|
166
|
+
const env = await buildEnvelope(
|
|
167
|
+
"ArohaRequest",
|
|
168
|
+
sender.did,
|
|
169
|
+
"did:aroha:other",
|
|
170
|
+
{ capability: "x", params: {} },
|
|
171
|
+
newCorrelationId(),
|
|
172
|
+
sender.priv
|
|
173
|
+
);
|
|
174
|
+
expect(senderDID(env)).toBe(sender.did);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("validateEnvelope clock tolerance", () => {
|
|
179
|
+
it("rejects an expired envelope with no tolerance", async () => {
|
|
180
|
+
const sender = await makeKeyPair("expired-no-tolerance-sender");
|
|
181
|
+
const myDID = "did:aroha:me";
|
|
182
|
+
const reg = new NonceRegistry();
|
|
183
|
+
|
|
184
|
+
// Build a normal envelope
|
|
185
|
+
const env = await buildEnvelope(
|
|
186
|
+
"ArohaRequest",
|
|
187
|
+
sender.did,
|
|
188
|
+
myDID,
|
|
189
|
+
{ capability: "x", params: {} },
|
|
190
|
+
newCorrelationId(),
|
|
191
|
+
sender.priv,
|
|
192
|
+
300
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Mutate expires to the past
|
|
196
|
+
const expiredEnv = {
|
|
197
|
+
...env,
|
|
198
|
+
expires: new Date(Date.now() - 5000).toISOString(), // 5 seconds ago
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Validate with skipSignature to avoid needing to re-sign
|
|
202
|
+
const result = await validateEnvelope(
|
|
203
|
+
expiredEnv,
|
|
204
|
+
sender.pub,
|
|
205
|
+
myDID,
|
|
206
|
+
reg,
|
|
207
|
+
{ skipSignature: true }
|
|
208
|
+
);
|
|
209
|
+
expect(result.valid).toBe(false);
|
|
210
|
+
expect((result as { valid: false; reason: string }).reason).toMatch(/expired/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("accepts a slightly-expired envelope within clock tolerance", async () => {
|
|
214
|
+
const sender = await makeKeyPair("expired-within-tolerance-sender");
|
|
215
|
+
const myDID = "did:aroha:me";
|
|
216
|
+
const reg = new NonceRegistry();
|
|
217
|
+
|
|
218
|
+
// Build a normal envelope
|
|
219
|
+
const env = await buildEnvelope(
|
|
220
|
+
"ArohaRequest",
|
|
221
|
+
sender.did,
|
|
222
|
+
myDID,
|
|
223
|
+
{ capability: "x", params: {} },
|
|
224
|
+
newCorrelationId(),
|
|
225
|
+
sender.priv,
|
|
226
|
+
300
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Mutate expires to 3 seconds ago
|
|
230
|
+
const expiredEnv = {
|
|
231
|
+
...env,
|
|
232
|
+
expires: new Date(Date.now() - 3000).toISOString(),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Validate with 10 second tolerance
|
|
236
|
+
const result = await validateEnvelope(
|
|
237
|
+
expiredEnv,
|
|
238
|
+
sender.pub,
|
|
239
|
+
myDID,
|
|
240
|
+
reg,
|
|
241
|
+
{ skipSignature: true, clockToleranceMs: 10_000 }
|
|
242
|
+
);
|
|
243
|
+
expect(result.valid).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("respects exact tolerance boundary", async () => {
|
|
247
|
+
const sender = await makeKeyPair("tolerance-boundary-sender");
|
|
248
|
+
const myDID = "did:aroha:me";
|
|
249
|
+
const regA = new NonceRegistry();
|
|
250
|
+
const regB = new NonceRegistry();
|
|
251
|
+
|
|
252
|
+
// Build a normal envelope
|
|
253
|
+
const env = await buildEnvelope(
|
|
254
|
+
"ArohaRequest",
|
|
255
|
+
sender.did,
|
|
256
|
+
myDID,
|
|
257
|
+
{ capability: "x", params: {} },
|
|
258
|
+
newCorrelationId(),
|
|
259
|
+
sender.priv,
|
|
260
|
+
300
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// Mutate expires to exactly 5 seconds ago
|
|
264
|
+
const expiredEnv = {
|
|
265
|
+
...env,
|
|
266
|
+
expires: new Date(Date.now() - 5000).toISOString(),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Test just below tolerance boundary (4999ms ago, 5000ms tolerance) — should accept
|
|
270
|
+
const resultBelowBoundary = await validateEnvelope(
|
|
271
|
+
{ ...expiredEnv, expires: new Date(Date.now() - 4999).toISOString() },
|
|
272
|
+
sender.pub,
|
|
273
|
+
myDID,
|
|
274
|
+
regA,
|
|
275
|
+
{ skipSignature: true, clockToleranceMs: 5000 }
|
|
276
|
+
);
|
|
277
|
+
expect(resultBelowBoundary.valid).toBe(true);
|
|
278
|
+
|
|
279
|
+
// Test just above tolerance boundary (5001ms ago, 5000ms tolerance) — should reject
|
|
280
|
+
const resultAboveBoundary = await validateEnvelope(
|
|
281
|
+
{ ...expiredEnv, expires: new Date(Date.now() - 5001).toISOString() },
|
|
282
|
+
sender.pub,
|
|
283
|
+
myDID,
|
|
284
|
+
regB,
|
|
285
|
+
{ skipSignature: true, clockToleranceMs: 5000 }
|
|
286
|
+
);
|
|
287
|
+
expect(resultAboveBoundary.valid).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aroha Protocol — Layer 3 Message Envelope
|
|
3
|
+
*
|
|
4
|
+
* Implements the spec's message envelope format.
|
|
5
|
+
* Every Aroha message, regardless of type, uses this envelope.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Build outbound envelopes (build + sign)
|
|
9
|
+
* - Validate inbound envelopes (schema + signature + nonce + expiry + recipient)
|
|
10
|
+
*
|
|
11
|
+
* This module is pure protocol. It does not route messages or apply
|
|
12
|
+
* business logic — that belongs to the application tier.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomBytes } from "@noble/hashes/utils";
|
|
16
|
+
import { v4 as uuidv4 } from "uuid";
|
|
17
|
+
import { signMessage, verifyMessageSignature, type MessageProof } from "../crypto/signing.js";
|
|
18
|
+
import { NonceRegistry } from "./nonce.js";
|
|
19
|
+
import { type ArohaMessageType, type ArohaBodyByType } from "./types.js";
|
|
20
|
+
|
|
21
|
+
// ─── Envelope Type ────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface ArohaEnvelope<T extends ArohaMessageType = ArohaMessageType> {
|
|
24
|
+
"@context": string[];
|
|
25
|
+
id: string; // urn:uuid:<uuid-v4>
|
|
26
|
+
type: T;
|
|
27
|
+
from: string; // did:aroha:<sender>
|
|
28
|
+
to: string; // did:aroha:<recipient>
|
|
29
|
+
created: string; // ISO8601
|
|
30
|
+
expires: string; // ISO8601
|
|
31
|
+
nonce: string; // random hex-32, replay prevention
|
|
32
|
+
correlationId: string; // links messages in a saga or session
|
|
33
|
+
body: T extends keyof ArohaBodyByType ? ArohaBodyByType[T] : Record<string, unknown>;
|
|
34
|
+
proof?: MessageProof;
|
|
35
|
+
/** W3C Trace Context traceparent header — injected by @aroha-sdk/telemetry for distributed tracing. */
|
|
36
|
+
traceparent?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Model routing hint — set by @aroha-sdk/orchestrator ModelRouter.
|
|
39
|
+
* Informational only: tells the receiving agent which model tier to use
|
|
40
|
+
* for processing this request. Never enforced by the transport layer.
|
|
41
|
+
*/
|
|
42
|
+
routingHint?: {
|
|
43
|
+
complexity: "trivial" | "standard" | "complex";
|
|
44
|
+
suggestedModel?: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Build ────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const Aroha_CONTEXT = [
|
|
51
|
+
"https://aroha-labs.com/contexts/v1",
|
|
52
|
+
"https://w3id.org/security/suites/ed25519-2020/v1",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build and sign an outbound Aroha message envelope.
|
|
57
|
+
*
|
|
58
|
+
* @param type One of the 15 Aroha message types
|
|
59
|
+
* @param from Sender DID (did:aroha:<id>)
|
|
60
|
+
* @param to Recipient DID (did:aroha:<id>)
|
|
61
|
+
* @param body Message body — typed per message type
|
|
62
|
+
* @param correlationId Saga or session ID linking related messages
|
|
63
|
+
* @param privateKey Sender's Ed25519 private key
|
|
64
|
+
* @param ttlSeconds How long the message is valid (default: 300s / 5min)
|
|
65
|
+
*/
|
|
66
|
+
export interface BuildEnvelopeOptions {
|
|
67
|
+
ttlSeconds?: number;
|
|
68
|
+
/** W3C Trace Context traceparent — propagated by @aroha-sdk/telemetry. */
|
|
69
|
+
traceparent?: string;
|
|
70
|
+
/** Model routing hint — set by @aroha-sdk/orchestrator ModelRouter. */
|
|
71
|
+
routingHint?: ArohaEnvelope["routingHint"];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function buildEnvelope<T extends ArohaMessageType>(
|
|
75
|
+
type: T,
|
|
76
|
+
from: string,
|
|
77
|
+
to: string,
|
|
78
|
+
body: T extends keyof ArohaBodyByType ? ArohaBodyByType[T] : Record<string, unknown>,
|
|
79
|
+
correlationId: string,
|
|
80
|
+
privateKey: Uint8Array,
|
|
81
|
+
ttlSecondsOrOptions: number | BuildEnvelopeOptions = 300
|
|
82
|
+
): Promise<ArohaEnvelope<T>> {
|
|
83
|
+
const opts: BuildEnvelopeOptions =
|
|
84
|
+
typeof ttlSecondsOrOptions === "number"
|
|
85
|
+
? { ttlSeconds: ttlSecondsOrOptions }
|
|
86
|
+
: ttlSecondsOrOptions;
|
|
87
|
+
const ttlSeconds = opts.ttlSeconds ?? 300;
|
|
88
|
+
|
|
89
|
+
const now = new Date();
|
|
90
|
+
const expires = new Date(now.getTime() + ttlSeconds * 1000);
|
|
91
|
+
|
|
92
|
+
const envelope: ArohaEnvelope<T> = {
|
|
93
|
+
"@context": Aroha_CONTEXT,
|
|
94
|
+
id: `urn:uuid:${uuidv4()}`,
|
|
95
|
+
type,
|
|
96
|
+
from,
|
|
97
|
+
to,
|
|
98
|
+
created: now.toISOString(),
|
|
99
|
+
expires: expires.toISOString(),
|
|
100
|
+
nonce: Buffer.from(randomBytes(32)).toString("hex"),
|
|
101
|
+
correlationId,
|
|
102
|
+
body,
|
|
103
|
+
...(opts.traceparent ? { traceparent: opts.traceparent } : {}),
|
|
104
|
+
...(opts.routingHint ? { routingHint: opts.routingHint } : {}),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const keyId = `${from}#key-1`;
|
|
108
|
+
const proof = await signMessage(
|
|
109
|
+
envelope as unknown as Record<string, unknown>,
|
|
110
|
+
privateKey,
|
|
111
|
+
keyId
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return { ...envelope, proof };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build an unsigned Aroha envelope for use within a trusted network mesh
|
|
119
|
+
* (VPC, mTLS service mesh). The receiving server must be configured with
|
|
120
|
+
* bypassSignatureFor to accept envelopes from this sender DID.
|
|
121
|
+
*/
|
|
122
|
+
export async function buildUnsignedEnvelope<T extends ArohaMessageType>(
|
|
123
|
+
type: T,
|
|
124
|
+
from: string,
|
|
125
|
+
to: string,
|
|
126
|
+
body: T extends keyof ArohaBodyByType ? ArohaBodyByType[T] : Record<string, unknown>,
|
|
127
|
+
correlationId: string,
|
|
128
|
+
opts: BuildEnvelopeOptions = {}
|
|
129
|
+
): Promise<ArohaEnvelope<T>> {
|
|
130
|
+
const ttlSeconds = opts.ttlSeconds ?? 300;
|
|
131
|
+
const now = new Date();
|
|
132
|
+
const expires = new Date(now.getTime() + ttlSeconds * 1000);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"@context": Aroha_CONTEXT,
|
|
136
|
+
id: `urn:uuid:${uuidv4()}`,
|
|
137
|
+
type,
|
|
138
|
+
from,
|
|
139
|
+
to,
|
|
140
|
+
created: now.toISOString(),
|
|
141
|
+
expires: expires.toISOString(),
|
|
142
|
+
nonce: Buffer.from(randomBytes(32)).toString("hex"),
|
|
143
|
+
correlationId,
|
|
144
|
+
body,
|
|
145
|
+
...(opts.traceparent ? { traceparent: opts.traceparent } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Validate ─────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export type ValidationResult =
|
|
152
|
+
| { valid: true }
|
|
153
|
+
| { valid: false; reason: string };
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Validate an inbound Aroha message envelope.
|
|
157
|
+
*
|
|
158
|
+
* Checks (per spec):
|
|
159
|
+
* 1. Signature verification (against provided public key)
|
|
160
|
+
* 2. Message expiry (expires field)
|
|
161
|
+
* 3. Nonce freshness (replay attack prevention)
|
|
162
|
+
* 4. Recipient match (to field must equal this agent's DID)
|
|
163
|
+
*
|
|
164
|
+
* @param envelope The received message envelope
|
|
165
|
+
* @param senderPubKey Ed25519 public key of the claimed sender (looked up from registry)
|
|
166
|
+
* @param myDID This agent's DID (to verify the "to" field)
|
|
167
|
+
* @param nonceRegistry Shared nonce registry for this agent
|
|
168
|
+
*/
|
|
169
|
+
export interface ValidateEnvelopeOptions {
|
|
170
|
+
/** Skip Ed25519 signature check — use only within trusted mesh / VPC environments. */
|
|
171
|
+
skipSignature?: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* Clock skew tolerance in milliseconds applied to the expires check.
|
|
174
|
+
* Accepts messages that expired up to this many ms ago.
|
|
175
|
+
* Recommended: 5000 (5s) for cross-region deployments.
|
|
176
|
+
* Default: 0 (strict).
|
|
177
|
+
*/
|
|
178
|
+
clockToleranceMs?: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function validateEnvelope(
|
|
182
|
+
envelope: ArohaEnvelope,
|
|
183
|
+
senderPubKey: Uint8Array,
|
|
184
|
+
myDID: string,
|
|
185
|
+
nonceRegistry: NonceRegistry,
|
|
186
|
+
options?: ValidateEnvelopeOptions
|
|
187
|
+
): Promise<ValidationResult> {
|
|
188
|
+
// 1. Recipient check — must be addressed to us
|
|
189
|
+
if (envelope.to !== myDID) {
|
|
190
|
+
return { valid: false, reason: `Message addressed to ${envelope.to}, not ${myDID}` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 2. Expiry check
|
|
194
|
+
const tolerance = options?.clockToleranceMs ?? 0;
|
|
195
|
+
if (tolerance < 0) {
|
|
196
|
+
return { valid: false, reason: "Invalid clockToleranceMs: must be non-negative" };
|
|
197
|
+
}
|
|
198
|
+
if (new Date(envelope.expires).getTime() + tolerance < Date.now()) {
|
|
199
|
+
return { valid: false, reason: `Message expired at ${envelope.expires} (outside tolerance window)` };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 3. Nonce check (replay prevention)
|
|
203
|
+
const nonceFresh = await nonceRegistry.checkAsync(envelope.nonce, envelope.expires);
|
|
204
|
+
if (!nonceFresh) {
|
|
205
|
+
return { valid: false, reason: "Nonce replay detected" };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 4. Signature verification (skip in trusted mesh mode)
|
|
209
|
+
if (!options?.skipSignature) {
|
|
210
|
+
const isValid = await verifyMessageSignature(
|
|
211
|
+
envelope as unknown as Record<string, unknown>,
|
|
212
|
+
senderPubKey
|
|
213
|
+
);
|
|
214
|
+
if (!isValid) {
|
|
215
|
+
return { valid: false, reason: "Signature verification failed" };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { valid: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/** Create a new correlationId for a new saga or session. */
|
|
225
|
+
export function newCorrelationId(): string {
|
|
226
|
+
return `saga-${uuidv4()}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Extract the sender's DID from an envelope. */
|
|
230
|
+
export function senderDID(envelope: ArohaEnvelope): string {
|
|
231
|
+
return envelope.from;
|
|
232
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency Store — server-side deduplication for ArohaRequest.
|
|
3
|
+
*
|
|
4
|
+
* ArohaCommit is already spec-idempotent. But ArohaRequest (capability
|
|
5
|
+
* invocations) are not — a retried "book-flight" request could create a
|
|
6
|
+
* duplicate booking. The idempotency store fixes this.
|
|
7
|
+
*
|
|
8
|
+
* Protocol contract:
|
|
9
|
+
* Sender includes idempotencyKey: string in ArohaRequestBody.
|
|
10
|
+
* Receiver checks the store before processing. If the key exists and
|
|
11
|
+
* the cached response is still valid, the cached response is returned
|
|
12
|
+
* immediately — no work is done twice.
|
|
13
|
+
*
|
|
14
|
+
* Key design: senderDID:idempotencyKey — scoped per sender so different
|
|
15
|
+
* orchestrators cannot accidentally share idempotency keys.
|
|
16
|
+
*
|
|
17
|
+
* Reference: Stripe API idempotency (https://stripe.com/docs/idempotency)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type ArohaEnvelope } from "./envelope.js";
|
|
21
|
+
|
|
22
|
+
// ─── Interface ────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface IIdempotencyStore {
|
|
25
|
+
/**
|
|
26
|
+
* Retrieve a cached response for this key.
|
|
27
|
+
* Returns null if not found or expired.
|
|
28
|
+
*/
|
|
29
|
+
get(senderDID: string, idempotencyKey: string): Promise<ArohaEnvelope | null>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Store a response for this key with a TTL.
|
|
33
|
+
* TTL default: 86_400_000 ms (24 hours) — matches typical saga window.
|
|
34
|
+
*/
|
|
35
|
+
set(
|
|
36
|
+
senderDID: string,
|
|
37
|
+
idempotencyKey: string,
|
|
38
|
+
response: ArohaEnvelope,
|
|
39
|
+
ttlMs?: number
|
|
40
|
+
): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── In-memory implementation ─────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
interface StoredEntry {
|
|
46
|
+
response: ArohaEnvelope;
|
|
47
|
+
expiresAt: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class InMemoryIdempotencyStore implements IIdempotencyStore {
|
|
51
|
+
private readonly defaultTtlMs: number;
|
|
52
|
+
private readonly store = new Map<string, StoredEntry>();
|
|
53
|
+
private readonly cleanupIntervalMs: number;
|
|
54
|
+
private readonly cleanupTimer: ReturnType<typeof setInterval>;
|
|
55
|
+
|
|
56
|
+
constructor(config: { defaultTtlMs?: number; cleanupIntervalMs?: number } = {}) {
|
|
57
|
+
this.defaultTtlMs = config.defaultTtlMs ?? 86_400_000; // 24h
|
|
58
|
+
this.cleanupIntervalMs = config.cleanupIntervalMs ?? 300_000; // 5m
|
|
59
|
+
|
|
60
|
+
// Periodically evict expired entries to prevent unbounded memory growth
|
|
61
|
+
this.cleanupTimer = setInterval(() => this.evictExpired(), this.cleanupIntervalMs);
|
|
62
|
+
// Don't prevent process exit if only cleanup timer is pending
|
|
63
|
+
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async get(senderDID: string, idempotencyKey: string): Promise<ArohaEnvelope | null> {
|
|
67
|
+
const key = storeKey(senderDID, idempotencyKey);
|
|
68
|
+
const entry = this.store.get(key);
|
|
69
|
+
if (!entry) return null;
|
|
70
|
+
if (Date.now() > entry.expiresAt) {
|
|
71
|
+
this.store.delete(key);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return entry.response;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async set(
|
|
78
|
+
senderDID: string,
|
|
79
|
+
idempotencyKey: string,
|
|
80
|
+
response: ArohaEnvelope,
|
|
81
|
+
ttlMs = this.defaultTtlMs
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
this.store.set(storeKey(senderDID, idempotencyKey), {
|
|
84
|
+
response,
|
|
85
|
+
expiresAt: Date.now() + ttlMs,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
destroy(): void {
|
|
90
|
+
clearInterval(this.cleanupTimer);
|
|
91
|
+
this.store.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get size(): number {
|
|
95
|
+
return this.store.size;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private evictExpired(): void {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
for (const [key, entry] of this.store) {
|
|
101
|
+
if (now > entry.expiresAt) this.store.delete(key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Utility ──────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function storeKey(senderDID: string, idempotencyKey: string): string {
|
|
109
|
+
return `${senderDID}:${idempotencyKey}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check the idempotency store and return a cached response if one exists.
|
|
114
|
+
* Returns null if the request should proceed normally.
|
|
115
|
+
*
|
|
116
|
+
* Usage in an agent's message handler:
|
|
117
|
+
* const cached = await checkIdempotency(store, envelope);
|
|
118
|
+
* if (cached) { respond(cached); return; }
|
|
119
|
+
* // ... process normally ...
|
|
120
|
+
* const response = buildResponse(...);
|
|
121
|
+
* await storeIdempotency(store, envelope, response);
|
|
122
|
+
* respond(response);
|
|
123
|
+
*/
|
|
124
|
+
export async function checkIdempotency(
|
|
125
|
+
store: IIdempotencyStore,
|
|
126
|
+
envelope: ArohaEnvelope
|
|
127
|
+
): Promise<ArohaEnvelope | null> {
|
|
128
|
+
const body = envelope.body as { idempotencyKey?: string };
|
|
129
|
+
if (!body.idempotencyKey) return null;
|
|
130
|
+
return store.get(envelope.from, body.idempotencyKey);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function storeIdempotency(
|
|
134
|
+
store: IIdempotencyStore,
|
|
135
|
+
requestEnvelope: ArohaEnvelope,
|
|
136
|
+
responseEnvelope: ArohaEnvelope,
|
|
137
|
+
ttlMs?: number
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const body = requestEnvelope.body as { idempotencyKey?: string };
|
|
140
|
+
if (!body.idempotencyKey) return;
|
|
141
|
+
await store.set(requestEnvelope.from, body.idempotencyKey, responseEnvelope, ttlMs);
|
|
142
|
+
}
|