@haiai/haiai 0.1.2
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/README.md +127 -0
- package/bin/haiai.cjs +70 -0
- package/dist/cjs/a2a.js +352 -0
- package/dist/cjs/a2a.js.map +1 -0
- package/dist/cjs/agent.js +236 -0
- package/dist/cjs/agent.js.map +1 -0
- package/dist/cjs/client.js +2168 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/config.js +176 -0
- package/dist/cjs/config.js.map +1 -0
- package/dist/cjs/errors.js +102 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/hash.js +52 -0
- package/dist/cjs/hash.js.map +1 -0
- package/dist/cjs/index.js +84 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/integrations.js +193 -0
- package/dist/cjs/integrations.js.map +1 -0
- package/dist/cjs/jacs.js +66 -0
- package/dist/cjs/jacs.js.map +1 -0
- package/dist/cjs/mime.js +100 -0
- package/dist/cjs/mime.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/signing.js +190 -0
- package/dist/cjs/signing.js.map +1 -0
- package/dist/cjs/sse.js +76 -0
- package/dist/cjs/sse.js.map +1 -0
- package/dist/cjs/types.js +6 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/verify.js +76 -0
- package/dist/cjs/verify.js.map +1 -0
- package/dist/cjs/ws.js +206 -0
- package/dist/cjs/ws.js.map +1 -0
- package/dist/esm/a2a.js +305 -0
- package/dist/esm/a2a.js.map +1 -0
- package/dist/esm/agent.js +231 -0
- package/dist/esm/agent.js.map +1 -0
- package/dist/esm/client.js +2131 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/config.js +171 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/errors.js +88 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/hash.js +49 -0
- package/dist/esm/hash.js.map +1 -0
- package/dist/esm/index.js +27 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/integrations.js +147 -0
- package/dist/esm/integrations.js.map +1 -0
- package/dist/esm/jacs.js +61 -0
- package/dist/esm/jacs.js.map +1 -0
- package/dist/esm/mime.js +97 -0
- package/dist/esm/mime.js.map +1 -0
- package/dist/esm/signing.js +183 -0
- package/dist/esm/signing.js.map +1 -0
- package/dist/esm/sse.js +73 -0
- package/dist/esm/sse.js.map +1 -0
- package/dist/esm/types.js +5 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/verify.js +72 -0
- package/dist/esm/verify.js.map +1 -0
- package/dist/esm/ws.js +168 -0
- package/dist/esm/ws.js.map +1 -0
- package/dist/types/a2a.d.ts +52 -0
- package/dist/types/a2a.d.ts.map +1 -0
- package/dist/types/agent.d.ts +202 -0
- package/dist/types/agent.d.ts.map +1 -0
- package/dist/types/client.d.ts +486 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/config.d.ts +31 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/errors.d.ts +50 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/hash.d.ts +32 -0
- package/dist/types/hash.d.ts.map +1 -0
- package/dist/types/index.d.ts +22 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/integrations.d.ts +25 -0
- package/dist/types/integrations.d.ts.map +1 -0
- package/dist/types/jacs.d.ts +26 -0
- package/dist/types/jacs.d.ts.map +1 -0
- package/dist/types/mime.d.ts +39 -0
- package/dist/types/mime.d.ts.map +1 -0
- package/dist/types/signing.d.ts +58 -0
- package/dist/types/signing.d.ts.map +1 -0
- package/dist/types/sse.d.ts +8 -0
- package/dist/types/sse.d.ts.map +1 -0
- package/dist/types/types.d.ts +652 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/verify.d.ts +20 -0
- package/dist/types/verify.d.ts.map +1 -0
- package/dist/types/ws.d.ts +30 -0
- package/dist/types/ws.d.ts.map +1 -0
- package/examples/a2a_quickstart.ts +138 -0
- package/examples/hai_quickstart.ts +111 -0
- package/examples/mcp_quickstart.ts +53 -0
- package/npm/@haiai/cli-darwin-arm64/package.json +16 -0
- package/npm/@haiai/cli-darwin-x64/package.json +16 -0
- package/npm/@haiai/cli-linux-arm64/package.json +16 -0
- package/npm/@haiai/cli-linux-x64/package.json +16 -0
- package/npm/@haiai/cli-win32-x64/package.json +16 -0
- package/package.json +68 -0
- package/scripts/build-platform-packages.js +132 -0
- package/scripts/smoke-package.cjs +114 -0
- package/scripts/write-cjs-package.cjs +9 -0
- package/src/a2a.ts +463 -0
- package/src/agent.ts +302 -0
- package/src/client.ts +2504 -0
- package/src/config.ts +204 -0
- package/src/errors.ts +99 -0
- package/src/hash.ts +66 -0
- package/src/index.ts +163 -0
- package/src/integrations.ts +210 -0
- package/src/jacs.ts +86 -0
- package/src/mime.ts +131 -0
- package/src/signing.ts +233 -0
- package/src/sse.ts +86 -0
- package/src/types.ts +773 -0
- package/src/verify.ts +89 -0
- package/src/ws.ts +198 -0
- package/tests/_debug_jacs.cjs +29 -0
- package/tests/a2a-contract.test.ts +271 -0
- package/tests/a2a-fixtures.test.ts +73 -0
- package/tests/a2a.test.ts +379 -0
- package/tests/binary.test.ts +90 -0
- package/tests/client-api-methods.test.ts +176 -0
- package/tests/client-path-escaping.test.ts +80 -0
- package/tests/client-register.test.ts +61 -0
- package/tests/config.test.ts +281 -0
- package/tests/contract.test.ts +360 -0
- package/tests/cross-lang-contract.test.ts +67 -0
- package/tests/email-conformance.test.ts +289 -0
- package/tests/email-integration.test.ts +217 -0
- package/tests/email.test.ts +767 -0
- package/tests/errors.test.ts +167 -0
- package/tests/init-contract.test.ts +129 -0
- package/tests/integrations.test.ts +132 -0
- package/tests/jacs-passthrough.test.ts +125 -0
- package/tests/key-cache.test.ts +201 -0
- package/tests/key-integration.test.ts +119 -0
- package/tests/key-lookups.test.ts +187 -0
- package/tests/key-rotation.test.ts +362 -0
- package/tests/mime.test.ts +127 -0
- package/tests/security.test.ts +109 -0
- package/tests/setup.ts +60 -0
- package/tests/signing.test.ts +142 -0
- package/tests/sse.test.ts +125 -0
- package/tests/types.test.ts +294 -0
- package/tests/verify-link.test.ts +81 -0
- package/tests/ws.test.ts +213 -0
- package/tsconfig.cjs.json +11 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { HaiClient } from '../src/client.js';
|
|
3
|
+
import { generateTestKeypair as generateKeypair } from './setup.js';
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import * as os from 'node:os';
|
|
7
|
+
|
|
8
|
+
/** Create a test client with a generated keypair and a temporary key directory. */
|
|
9
|
+
async function setupTestAgent(tmpDir: string) {
|
|
10
|
+
const keypair = generateKeypair();
|
|
11
|
+
const keyDir = path.join(tmpDir, 'keys');
|
|
12
|
+
await fs.mkdir(keyDir, { recursive: true });
|
|
13
|
+
|
|
14
|
+
// Write key files
|
|
15
|
+
const privPath = path.join(keyDir, 'agent_private_key.pem');
|
|
16
|
+
const pubPath = path.join(keyDir, 'agent_public_key.pem');
|
|
17
|
+
await fs.writeFile(privPath, keypair.privateKeyPem, { mode: 0o600 });
|
|
18
|
+
await fs.writeFile(pubPath, keypair.publicKeyPem, { mode: 0o644 });
|
|
19
|
+
|
|
20
|
+
// Write config file
|
|
21
|
+
const config = {
|
|
22
|
+
jacsAgentName: 'test-rotation-agent',
|
|
23
|
+
jacsAgentVersion: 'v1-original',
|
|
24
|
+
jacsKeyDir: keyDir,
|
|
25
|
+
jacsId: 'test-jacs-id-12345',
|
|
26
|
+
};
|
|
27
|
+
const configPath = path.join(tmpDir, 'jacs.config.json');
|
|
28
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
|
29
|
+
|
|
30
|
+
// Build client via fromCredentials (now async)
|
|
31
|
+
const client = await HaiClient.fromCredentials(
|
|
32
|
+
config.jacsId,
|
|
33
|
+
keypair.privateKeyPem,
|
|
34
|
+
{ url: 'https://hai.example', privateKeyPassphrase: 'keygen-password' },
|
|
35
|
+
);
|
|
36
|
+
// Patch the config to have the real keyDir and version
|
|
37
|
+
(client as any).config = { ...config };
|
|
38
|
+
|
|
39
|
+
return { client, keypair, keyDir, privPath, pubPath, configPath, config };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('rotateKeys', () => {
|
|
43
|
+
let tmpDir: string;
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hai-rotation-test-'));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
vi.unstubAllGlobals();
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
// Clean up temp directory
|
|
53
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('generates new key files and archives old ones', async () => {
|
|
57
|
+
const { client, privPath, pubPath, keyDir, configPath } = await setupTestAgent(tmpDir);
|
|
58
|
+
|
|
59
|
+
// Stub fetch so register() doesn't make real requests
|
|
60
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
61
|
+
|
|
62
|
+
// Set JACS_CONFIG_PATH to point to our tmp config
|
|
63
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
64
|
+
|
|
65
|
+
const result = await client.rotateKeys({ registerWithHai: false });
|
|
66
|
+
|
|
67
|
+
// New key files should exist at standard paths
|
|
68
|
+
const newPrivExists = await fs.stat(privPath).then(() => true).catch(() => false);
|
|
69
|
+
const newPubExists = await fs.stat(pubPath).then(() => true).catch(() => false);
|
|
70
|
+
expect(newPrivExists).toBe(true);
|
|
71
|
+
expect(newPubExists).toBe(true);
|
|
72
|
+
|
|
73
|
+
// Old keys should be archived with version suffix
|
|
74
|
+
const archivePriv = path.join(keyDir, 'agent_private_key.v1-original.pem');
|
|
75
|
+
const archiveExists = await fs.stat(archivePriv).then(() => true).catch(() => false);
|
|
76
|
+
expect(archiveExists).toBe(true);
|
|
77
|
+
|
|
78
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns a valid RotationResult with correct fields', async () => {
|
|
82
|
+
const { client, configPath } = await setupTestAgent(tmpDir);
|
|
83
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
84
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
85
|
+
|
|
86
|
+
const result = await client.rotateKeys({ registerWithHai: false });
|
|
87
|
+
|
|
88
|
+
expect(result.jacsId).toBe('test-jacs-id-12345');
|
|
89
|
+
expect(result.oldVersion).toBe('v1-original');
|
|
90
|
+
expect(result.newVersion).not.toBe('v1-original');
|
|
91
|
+
expect(result.newVersion.length).toBeGreaterThan(0);
|
|
92
|
+
// SHA-256 hex is 64 chars
|
|
93
|
+
expect(result.newPublicKeyHash).toHaveLength(64);
|
|
94
|
+
expect(result.registeredWithHai).toBe(false);
|
|
95
|
+
expect(result.signedAgentJson.length).toBeGreaterThan(0);
|
|
96
|
+
|
|
97
|
+
// Signed agent JSON should be valid JSON with expected fields
|
|
98
|
+
const doc = JSON.parse(result.signedAgentJson);
|
|
99
|
+
expect(doc.jacsId).toBe('test-jacs-id-12345');
|
|
100
|
+
expect(doc.jacsVersion).toBe(result.newVersion);
|
|
101
|
+
expect(doc.jacsPreviousVersion).toBe('v1-original');
|
|
102
|
+
expect(doc.jacsSignature).toBeDefined();
|
|
103
|
+
|
|
104
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('updates config file with new version', async () => {
|
|
108
|
+
const { client, configPath } = await setupTestAgent(tmpDir);
|
|
109
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
110
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
111
|
+
|
|
112
|
+
const result = await client.rotateKeys({ registerWithHai: false });
|
|
113
|
+
|
|
114
|
+
// Read config file and verify version was updated
|
|
115
|
+
const updatedConfig = JSON.parse(await fs.readFile(configPath, 'utf-8'));
|
|
116
|
+
expect(updatedConfig.jacsAgentVersion).toBe(result.newVersion);
|
|
117
|
+
// jacsId should be unchanged
|
|
118
|
+
expect(updatedConfig.jacsId).toBe('test-jacs-id-12345');
|
|
119
|
+
|
|
120
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('updates in-memory config version', async () => {
|
|
124
|
+
const { client, configPath } = await setupTestAgent(tmpDir);
|
|
125
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
126
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
127
|
+
|
|
128
|
+
const result = await client.rotateKeys({ registerWithHai: false });
|
|
129
|
+
|
|
130
|
+
// In-memory config should reflect the new version
|
|
131
|
+
expect((client as any).config.jacsAgentVersion).toBe(result.newVersion);
|
|
132
|
+
// The private key PEM should have changed
|
|
133
|
+
expect((client as any).privateKeyPem).not.toBe('');
|
|
134
|
+
|
|
135
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('calls register when registerWithHai is true', async () => {
|
|
139
|
+
const { client, configPath } = await setupTestAgent(tmpDir);
|
|
140
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
141
|
+
|
|
142
|
+
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => {
|
|
143
|
+
return new Response(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
agent_id: 'hai-agent-uuid',
|
|
146
|
+
jacs_id: 'test-jacs-id-12345',
|
|
147
|
+
hai_signature: 'sig-abc',
|
|
148
|
+
registration_id: 'reg-123',
|
|
149
|
+
registered_at: '2026-03-02T00:00:00Z',
|
|
150
|
+
}),
|
|
151
|
+
{ status: 201, headers: { 'Content-Type': 'application/json' } },
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
155
|
+
|
|
156
|
+
const result = await client.rotateKeys({
|
|
157
|
+
registerWithHai: true,
|
|
158
|
+
haiUrl: 'https://hai.example',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(result.registeredWithHai).toBe(true);
|
|
162
|
+
// Verify register was called (fetch was invoked)
|
|
163
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
164
|
+
const callUrl = String(fetchMock.mock.calls[0][0]);
|
|
165
|
+
expect(callUrl).toContain('/api/v1/agents/register');
|
|
166
|
+
|
|
167
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('preserves local rotation when HAI registration fails', async () => {
|
|
171
|
+
const { client, configPath } = await setupTestAgent(tmpDir);
|
|
172
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
173
|
+
|
|
174
|
+
const fetchMock = vi.fn(async () => {
|
|
175
|
+
return new Response('Internal Server Error', {
|
|
176
|
+
status: 500,
|
|
177
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
181
|
+
|
|
182
|
+
const result = await client.rotateKeys({
|
|
183
|
+
registerWithHai: true,
|
|
184
|
+
haiUrl: 'https://hai.example',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Local rotation should succeed
|
|
188
|
+
expect(result.jacsId).toBe('test-jacs-id-12345');
|
|
189
|
+
expect(result.newVersion).not.toBe('v1-original');
|
|
190
|
+
// But HAI registration should have failed
|
|
191
|
+
expect(result.registeredWithHai).toBe(false);
|
|
192
|
+
|
|
193
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('throws when no jacsId is set', async () => {
|
|
197
|
+
const keypair = generateKeypair();
|
|
198
|
+
const client = await HaiClient.fromCredentials(
|
|
199
|
+
'no-id-agent',
|
|
200
|
+
keypair.privateKeyPem,
|
|
201
|
+
{ url: 'https://hai.example', privateKeyPassphrase: 'keygen-password' },
|
|
202
|
+
);
|
|
203
|
+
// Remove jacsId from config
|
|
204
|
+
(client as any).config = {
|
|
205
|
+
jacsAgentName: 'no-id-agent',
|
|
206
|
+
jacsAgentVersion: 'v1',
|
|
207
|
+
jacsKeyDir: '/nonexistent',
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
await expect(client.rotateKeys({ registerWithHai: false }))
|
|
211
|
+
.rejects.toThrow(/no jacsId/i);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('throws when private key file not found', async () => {
|
|
215
|
+
const keypair = generateKeypair();
|
|
216
|
+
const emptyKeyDir = path.join(tmpDir, 'empty-keys');
|
|
217
|
+
await fs.mkdir(emptyKeyDir, { recursive: true });
|
|
218
|
+
|
|
219
|
+
const client = await HaiClient.fromCredentials(
|
|
220
|
+
'test-id',
|
|
221
|
+
keypair.privateKeyPem,
|
|
222
|
+
{ url: 'https://hai.example', privateKeyPassphrase: 'keygen-password' },
|
|
223
|
+
);
|
|
224
|
+
(client as any).config = {
|
|
225
|
+
jacsAgentName: 'test-agent',
|
|
226
|
+
jacsAgentVersion: 'v1',
|
|
227
|
+
jacsKeyDir: emptyKeyDir,
|
|
228
|
+
jacsId: 'test-id',
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
await expect(client.rotateKeys({ registerWithHai: false }))
|
|
232
|
+
.rejects.toThrow(/private key not found/i);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('new key produces valid signature on agent document', async () => {
|
|
236
|
+
const { client, configPath, pubPath } = await setupTestAgent(tmpDir);
|
|
237
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
238
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
239
|
+
|
|
240
|
+
const result = await client.rotateKeys({ registerWithHai: false });
|
|
241
|
+
|
|
242
|
+
// Read the new public key from disk
|
|
243
|
+
const newPubPem = await fs.readFile(pubPath, 'utf-8');
|
|
244
|
+
expect(newPubPem).toContain('-----BEGIN PUBLIC KEY-----');
|
|
245
|
+
|
|
246
|
+
// Parse signed agent JSON and ensure the signed payload references the
|
|
247
|
+
// rotated key/version material. The native binding currently cannot
|
|
248
|
+
// reload freshly generated temp agents reliably in-process, so this test
|
|
249
|
+
// stays at the contract/document level rather than duplicating JACS
|
|
250
|
+
// verification behavior.
|
|
251
|
+
const doc = JSON.parse(result.signedAgentJson);
|
|
252
|
+
expect(doc.jacsSignature?.signature).toBeDefined();
|
|
253
|
+
expect(doc.jacsPublicKey).toContain('BEGIN PUBLIC KEY');
|
|
254
|
+
expect(doc.jacsVersion).toBe(result.newVersion);
|
|
255
|
+
expect(doc.jacsPreviousVersion).toBe('v1-original');
|
|
256
|
+
|
|
257
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('new version is a valid UUID v4', async () => {
|
|
261
|
+
const { client, configPath } = await setupTestAgent(tmpDir);
|
|
262
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
263
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
264
|
+
|
|
265
|
+
const result = await client.rotateKeys({ registerWithHai: false });
|
|
266
|
+
|
|
267
|
+
// UUID v4 pattern: 8-4-4-4-12 hex chars
|
|
268
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
269
|
+
expect(result.newVersion).toMatch(uuidRegex);
|
|
270
|
+
|
|
271
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('double rotation archives both versions', async () => {
|
|
275
|
+
const { client, configPath, keyDir, privPath, pubPath } = await setupTestAgent(tmpDir);
|
|
276
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
277
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
278
|
+
|
|
279
|
+
// First rotation: V1 -> V2
|
|
280
|
+
const result1 = await client.rotateKeys({ registerWithHai: false });
|
|
281
|
+
const v2 = result1.newVersion;
|
|
282
|
+
|
|
283
|
+
// Second rotation: V2 -> V3
|
|
284
|
+
const result2 = await client.rotateKeys({ registerWithHai: false });
|
|
285
|
+
|
|
286
|
+
// Current key files should exist
|
|
287
|
+
const newPrivExists = await fs.stat(privPath).then(() => true).catch(() => false);
|
|
288
|
+
const newPubExists = await fs.stat(pubPath).then(() => true).catch(() => false);
|
|
289
|
+
expect(newPrivExists).toBe(true);
|
|
290
|
+
expect(newPubExists).toBe(true);
|
|
291
|
+
|
|
292
|
+
// V1 archive should exist
|
|
293
|
+
const archiveV1 = path.join(keyDir, 'agent_private_key.v1-original.pem');
|
|
294
|
+
const v1Exists = await fs.stat(archiveV1).then(() => true).catch(() => false);
|
|
295
|
+
expect(v1Exists).toBe(true);
|
|
296
|
+
|
|
297
|
+
// V2 archive should exist
|
|
298
|
+
const archiveV2 = path.join(keyDir, `agent_private_key.${v2}.pem`);
|
|
299
|
+
const v2Exists = await fs.stat(archiveV2).then(() => true).catch(() => false);
|
|
300
|
+
expect(v2Exists).toBe(true);
|
|
301
|
+
|
|
302
|
+
// Version chain should be consistent
|
|
303
|
+
expect(result1.oldVersion).toBe('v1-original');
|
|
304
|
+
expect(result1.newVersion).toBe(result2.oldVersion);
|
|
305
|
+
expect(result2.newVersion).not.toBe(result2.oldVersion);
|
|
306
|
+
|
|
307
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('rotation result fields match shared fixture contract', async () => {
|
|
311
|
+
const fixturePath = path.join(__dirname, '..', '..', 'fixtures', 'rotation_result.json');
|
|
312
|
+
let fixture: Record<string, unknown>;
|
|
313
|
+
try {
|
|
314
|
+
fixture = JSON.parse(await fs.readFile(fixturePath, 'utf-8'));
|
|
315
|
+
} catch {
|
|
316
|
+
// Skip if fixture doesn't exist
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// RotationResult interface fields (snake_case in fixture -> camelCase in TS)
|
|
321
|
+
const fixtureKeys = new Set(Object.keys(fixture));
|
|
322
|
+
const expectedKeys = new Set([
|
|
323
|
+
'jacs_id', 'old_version', 'new_version',
|
|
324
|
+
'new_public_key_hash', 'registered_with_hai', 'signed_agent_json',
|
|
325
|
+
]);
|
|
326
|
+
expect(fixtureKeys).toEqual(expectedKeys);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('register payload contains agent_json with new version', async () => {
|
|
330
|
+
const { client, configPath } = await setupTestAgent(tmpDir);
|
|
331
|
+
process.env.JACS_CONFIG_PATH = configPath;
|
|
332
|
+
|
|
333
|
+
let capturedBody: Record<string, unknown> | null = null;
|
|
334
|
+
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
|
|
335
|
+
capturedBody = JSON.parse(String(init?.body ?? '{}'));
|
|
336
|
+
return new Response(
|
|
337
|
+
JSON.stringify({
|
|
338
|
+
agent_id: 'hai-uuid',
|
|
339
|
+
jacs_id: 'test-jacs-id-12345',
|
|
340
|
+
hai_signature: 'sig',
|
|
341
|
+
registration_id: 'reg-1',
|
|
342
|
+
registered_at: '2026-03-02T00:00:00Z',
|
|
343
|
+
}),
|
|
344
|
+
{ status: 201, headers: { 'Content-Type': 'application/json' } },
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
348
|
+
|
|
349
|
+
const result = await client.rotateKeys({
|
|
350
|
+
registerWithHai: true,
|
|
351
|
+
haiUrl: 'https://hai.example',
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(capturedBody).not.toBeNull();
|
|
355
|
+
expect(capturedBody!.agent_json).toBeDefined();
|
|
356
|
+
const agentDoc = JSON.parse(capturedBody!.agent_json as string);
|
|
357
|
+
expect(agentDoc.jacsVersion).toBe(result.newVersion);
|
|
358
|
+
expect(agentDoc.jacsId).toBe('test-jacs-id-12345');
|
|
359
|
+
|
|
360
|
+
delete process.env.JACS_CONFIG_PATH;
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildRfc5322Email } from '../src/mime.js';
|
|
3
|
+
import type { MimeSendEmailOptions } from '../src/mime.js';
|
|
4
|
+
|
|
5
|
+
describe('buildRfc5322Email', () => {
|
|
6
|
+
it('produces valid RFC 5322 for simple text email', () => {
|
|
7
|
+
const opts: MimeSendEmailOptions = {
|
|
8
|
+
to: 'recipient@hai.ai',
|
|
9
|
+
subject: 'Test Subject',
|
|
10
|
+
body: 'Hello, world!',
|
|
11
|
+
};
|
|
12
|
+
const raw = buildRfc5322Email(opts, 'sender@hai.ai');
|
|
13
|
+
const text = raw.toString('utf-8');
|
|
14
|
+
|
|
15
|
+
expect(text).toContain('From: <sender@hai.ai>\r\n');
|
|
16
|
+
expect(text).toContain('To: recipient@hai.ai\r\n');
|
|
17
|
+
expect(text).toContain('Subject: Test Subject\r\n');
|
|
18
|
+
expect(text).toContain('Date: ');
|
|
19
|
+
expect(text).toContain('Message-ID: <');
|
|
20
|
+
expect(text).toContain('MIME-Version: 1.0\r\n');
|
|
21
|
+
expect(text).toContain('Content-Type: text/plain; charset=utf-8\r\n');
|
|
22
|
+
expect(text).toContain('Hello, world!');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('handles attachments', () => {
|
|
26
|
+
const opts: MimeSendEmailOptions = {
|
|
27
|
+
to: 'recipient@hai.ai',
|
|
28
|
+
subject: 'With Attachments',
|
|
29
|
+
body: 'See attached.',
|
|
30
|
+
attachments: [
|
|
31
|
+
{
|
|
32
|
+
filename: 'file1.txt',
|
|
33
|
+
contentType: 'text/plain',
|
|
34
|
+
data: Buffer.from('content of file 1'),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
filename: 'file2.pdf',
|
|
38
|
+
contentType: 'application/pdf',
|
|
39
|
+
data: Buffer.from('fake pdf content'),
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const raw = buildRfc5322Email(opts, 'sender@hai.ai');
|
|
45
|
+
const text = raw.toString('utf-8');
|
|
46
|
+
|
|
47
|
+
expect(text).toContain('Content-Type: multipart/mixed; boundary=');
|
|
48
|
+
expect(text).toContain('Content-Disposition: attachment; filename="file1.txt"');
|
|
49
|
+
expect(text).toContain('Content-Disposition: attachment; filename="file2.pdf"');
|
|
50
|
+
expect(text).toContain('Content-Transfer-Encoding: base64');
|
|
51
|
+
expect(text).toContain('See attached.');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('handles reply threading', () => {
|
|
55
|
+
const opts: MimeSendEmailOptions = {
|
|
56
|
+
to: 'recipient@hai.ai',
|
|
57
|
+
subject: 'Re: Original',
|
|
58
|
+
body: 'Reply body',
|
|
59
|
+
inReplyTo: '<original-id@hai.ai>',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const raw = buildRfc5322Email(opts, 'sender@hai.ai');
|
|
63
|
+
const text = raw.toString('utf-8');
|
|
64
|
+
|
|
65
|
+
expect(text).toContain('In-Reply-To: <original-id@hai.ai>\r\n');
|
|
66
|
+
expect(text).toContain('References: <original-id@hai.ai>\r\n');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('sanitizes CRLF injection', () => {
|
|
70
|
+
const opts: MimeSendEmailOptions = {
|
|
71
|
+
to: 'recipient@hai.ai',
|
|
72
|
+
subject: 'Bad\r\nBcc: attacker@evil.com',
|
|
73
|
+
body: 'Body',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const raw = buildRfc5322Email(opts, 'sender@hai.ai');
|
|
77
|
+
const text = raw.toString('utf-8');
|
|
78
|
+
|
|
79
|
+
// No line should start with "Bcc:" (CRLF injection prevented)
|
|
80
|
+
const lines = text.split('\r\n');
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
expect(line.startsWith('Bcc:')).toBe(false);
|
|
83
|
+
}
|
|
84
|
+
// Subject should be sanitized
|
|
85
|
+
expect(text).toContain('Subject: BadBcc: attacker@evil.com\r\n');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('sanitizes double quotes in filenames to prevent parameter injection', () => {
|
|
89
|
+
const opts: MimeSendEmailOptions = {
|
|
90
|
+
to: 'recipient@hai.ai',
|
|
91
|
+
subject: 'Test',
|
|
92
|
+
body: 'Body',
|
|
93
|
+
attachments: [
|
|
94
|
+
{
|
|
95
|
+
filename: 'file"; name="evil',
|
|
96
|
+
contentType: 'text/plain',
|
|
97
|
+
data: Buffer.from('content'),
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const raw = buildRfc5322Email(opts, 'sender@hai.ai');
|
|
103
|
+
const text = raw.toString('utf-8');
|
|
104
|
+
|
|
105
|
+
expect(text).not.toContain('filename="file"');
|
|
106
|
+
expect(text).not.toContain('name="evil"');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('uses CRLF line endings', () => {
|
|
110
|
+
const opts: MimeSendEmailOptions = {
|
|
111
|
+
to: 'recipient@hai.ai',
|
|
112
|
+
subject: 'Test',
|
|
113
|
+
body: 'Body',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const raw = buildRfc5322Email(opts, 'sender@hai.ai');
|
|
117
|
+
const text = raw.toString('utf-8');
|
|
118
|
+
|
|
119
|
+
expect(text).toContain('\r\n');
|
|
120
|
+
// Split by CRLF, no remaining bare \n in any part (except within body text)
|
|
121
|
+
const headerSection = text.split('\r\n\r\n')[0];
|
|
122
|
+
const headerParts = headerSection.split('\r\n');
|
|
123
|
+
for (const part of headerParts) {
|
|
124
|
+
expect(part).not.toContain('\n');
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { HaiClient } from '../src/client.js';
|
|
3
|
+
import { generateTestKeypair as generateKeypair } from './setup.js';
|
|
4
|
+
|
|
5
|
+
async function makeClient(): Promise<HaiClient> {
|
|
6
|
+
const kp = generateKeypair();
|
|
7
|
+
const client = await HaiClient.fromCredentials('security-agent', kp.privateKeyPem, {
|
|
8
|
+
url: 'https://hai.example',
|
|
9
|
+
privateKeyPassphrase: 'keygen-password',
|
|
10
|
+
});
|
|
11
|
+
(client as any)._publicKeyPem = kp.publicKeyPem;
|
|
12
|
+
return client;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('security behaviors (node)', () => {
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('register does not send private key material and keeps bootstrap request unauthenticated', async () => {
|
|
22
|
+
const client = await makeClient();
|
|
23
|
+
|
|
24
|
+
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
|
|
25
|
+
const headers = new Headers(init?.headers);
|
|
26
|
+
expect(headers.get('Authorization')).toBeNull();
|
|
27
|
+
expect(headers.get('Content-Type')).toBe('application/json');
|
|
28
|
+
|
|
29
|
+
const rawBody = String(init?.body ?? '');
|
|
30
|
+
expect(rawBody).not.toContain('BEGIN PRIVATE KEY');
|
|
31
|
+
|
|
32
|
+
const payload = JSON.parse(rawBody) as Record<string, string>;
|
|
33
|
+
expect(typeof payload.agent_json).toBe('string');
|
|
34
|
+
expect(payload.public_key).toBeTypeOf('string');
|
|
35
|
+
|
|
36
|
+
return new Response(JSON.stringify({
|
|
37
|
+
agent_id: 'agent-123',
|
|
38
|
+
jacs_id: 'security-agent',
|
|
39
|
+
registered_at: '2026-01-01T00:00:00Z',
|
|
40
|
+
}), {
|
|
41
|
+
status: 201,
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
46
|
+
|
|
47
|
+
await client.register({
|
|
48
|
+
ownerEmail: 'owner@hai.ai',
|
|
49
|
+
domain: 'agent.example',
|
|
50
|
+
description: 'Security test agent',
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('checkUsername remains unauthenticated public endpoint', async () => {
|
|
55
|
+
const client = await makeClient();
|
|
56
|
+
|
|
57
|
+
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
|
|
58
|
+
const headers = new Headers(init?.headers);
|
|
59
|
+
expect(headers.get('Authorization')).toBeNull();
|
|
60
|
+
|
|
61
|
+
return new Response(JSON.stringify({
|
|
62
|
+
available: true,
|
|
63
|
+
username: 'agent',
|
|
64
|
+
}), {
|
|
65
|
+
status: 200,
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
70
|
+
|
|
71
|
+
const result = await client.checkUsername('agent');
|
|
72
|
+
expect(result.available).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('registerNewAgent omits Authorization and sends base64 public key only', async () => {
|
|
76
|
+
const client = await makeClient();
|
|
77
|
+
|
|
78
|
+
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
|
|
79
|
+
const headers = new Headers(init?.headers);
|
|
80
|
+
expect(headers.get('Authorization')).toBeNull();
|
|
81
|
+
expect(headers.get('Content-Type')).toBe('application/json');
|
|
82
|
+
|
|
83
|
+
const payload = JSON.parse(String(init?.body ?? '{}')) as Record<string, string>;
|
|
84
|
+
const decodedPublicKey = Buffer.from(payload.public_key, 'base64').toString('utf-8');
|
|
85
|
+
expect(decodedPublicKey).toContain('BEGIN PUBLIC KEY');
|
|
86
|
+
expect(decodedPublicKey).not.toContain('BEGIN PRIVATE KEY');
|
|
87
|
+
|
|
88
|
+
return new Response(JSON.stringify({
|
|
89
|
+
agent_id: 'agent-999',
|
|
90
|
+
jacs_id: 'security-agent',
|
|
91
|
+
registration_id: 'reg-999',
|
|
92
|
+
registered_at: '2026-01-01T00:00:00Z',
|
|
93
|
+
}), {
|
|
94
|
+
status: 201,
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
99
|
+
|
|
100
|
+
const result = await client.registerNewAgent('security-agent', {
|
|
101
|
+
ownerEmail: 'owner@hai.ai',
|
|
102
|
+
domain: 'agent.example',
|
|
103
|
+
description: 'Security bootstrap',
|
|
104
|
+
quiet: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(result.agentId).toBe('agent-999');
|
|
108
|
+
});
|
|
109
|
+
});
|
package/tests/setup.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
2
|
+
import { JacsAgent } from '@hai.ai/jacs';
|
|
3
|
+
|
|
4
|
+
/** Strong test password that meets JACS security requirements. */
|
|
5
|
+
export const TEST_PASSWORD = 'Xk9#mP2vL7qR4!nB8wZ';
|
|
6
|
+
|
|
7
|
+
/** A test JACS ID. */
|
|
8
|
+
export const TEST_JACS_ID = 'test-agent-001';
|
|
9
|
+
|
|
10
|
+
/** The test JACS agent (ephemeral, in-memory). Can sign and verify. */
|
|
11
|
+
export const TEST_AGENT = new JacsAgent();
|
|
12
|
+
const _ephResult = JSON.parse(TEST_AGENT.ephemeralSync('ring-Ed25519'));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A PEM-encoded public key from a generated Ed25519 keypair.
|
|
16
|
+
* Used for tests that need deterministic PEM-shaped key material without
|
|
17
|
+
* depending on JACS on-disk agent layout.
|
|
18
|
+
*/
|
|
19
|
+
export const TEST_PUBLIC_KEY_PEM = (() => {
|
|
20
|
+
const { publicKey } = generateKeyPairSync('ed25519');
|
|
21
|
+
return publicKey.export({ format: 'pem', type: 'spki' }).toString().trim();
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate an Ed25519 keypair as plaintext PEM for tests that only need
|
|
26
|
+
* stable key fixtures or a public/private pair written to disk.
|
|
27
|
+
*/
|
|
28
|
+
export function generateTestKeypair(): { publicKeyPem: string; privateKeyPem: string } {
|
|
29
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
30
|
+
return {
|
|
31
|
+
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }).toString().trim(),
|
|
32
|
+
privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString().trim(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a mock SSE response body from a list of events.
|
|
38
|
+
*/
|
|
39
|
+
export function createSseBody(events: Array<{ event?: string; data: string; id?: string }>): ReadableStream<Uint8Array> {
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
let sent = false;
|
|
42
|
+
|
|
43
|
+
return new ReadableStream({
|
|
44
|
+
pull(controller) {
|
|
45
|
+
if (sent) {
|
|
46
|
+
controller.close();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
sent = true;
|
|
50
|
+
|
|
51
|
+
let text = '';
|
|
52
|
+
for (const evt of events) {
|
|
53
|
+
if (evt.event) text += `event: ${evt.event}\n`;
|
|
54
|
+
if (evt.id) text += `id: ${evt.id}\n`;
|
|
55
|
+
text += `data: ${evt.data}\n\n`;
|
|
56
|
+
}
|
|
57
|
+
controller.enqueue(encoder.encode(text));
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|