@crewhaus/federation-protocol 0.1.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 ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/federation-protocol",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Cross-deployment A2A wire protocol: federation envelope (extends @crewhaus/a2a-protocol) + mTLS HTTPS POST transport with cert pinning (Section 34)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/federation-protocol"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/federation-protocol#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,19 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIDDTCCAfWgAwIBAgIUNuMAUkUqNO9Tc0sSHfzY6tkBG+IwDQYJKoZIhvcNAQEL
3
+ BQAwFjEUMBIGA1UEAwwLZmVkLWZpeHR1cmUwHhcNMjYwNTA5MDYyNjU2WhcNMzYw
4
+ NTA2MDYyNjU2WjAWMRQwEgYDVQQDDAtmZWQtZml4dHVyZTCCASIwDQYJKoZIhvcN
5
+ AQEBBQADggEPADCCAQoCggEBAM1mvBIz/XRal0mR8YM92oVzYTg+gdS2x8RAmU7k
6
+ ryN1RXe8XVFg1agTLRObpzb0NxHV4zsN+9s+AVr1DBZuwcfaXpRHGlADf4t/cAUp
7
+ Sl6xoKdWf7cFk4RloE5BOKtkVYzgrAktY/xZVk5TN2i2/fNSrdj71D852nWhy/9/
8
+ LKInPuSRNRazdPMs3REvcJOvO1+Y0JQCiyQIcMfGRNc25piPQOhJaQDunBhcSWnR
9
+ G3RWNCZiwQwv6MozEHq/3ZAne5ypJBaVJ0c/5na2PEBSiOWcvKu6tJTHhauADhKH
10
+ 5r+npdlvRgoCVriBJ72Khy38pMYrI7SUnMD81ArBLc40+kECAwEAAaNTMFEwHQYD
11
+ VR0OBBYEFMBWXUbAQba+bx4z8KPFJoqv7jBvMB8GA1UdIwQYMBaAFMBWXUbAQba+
12
+ bx4z8KPFJoqv7jBvMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB
13
+ ACgcZnPU1/MsYGJSK7Lwu3vHV5MtMrbLmdCoHTFguo26moUHjjD/6BTDIv9sddp6
14
+ G4/BGHr/QWuaowMzqDTspIKFNMF8Pi4YuZvR4mdUOfXtq33vEb76YcVeyLeHr1da
15
+ ZJtySefk9o9igq7FRj65EtVcW9/gFsP8ztcolOHSgysTtopDkxra5xkS64w7BmZf
16
+ OlVoX1v1dctFWz23lddM43tYURmJq+ai2YSBmTIDy+aWDAdyHTf+s0iMJ/F4O7FH
17
+ 6n9gQliYsXCWOekTbjnLGz8aRnfhqwGQ/aCjPBhLv2QVjgRpZ5gNLpeMx6TSAqJt
18
+ vShbklJd013K4tjA/5wSVH4=
19
+ -----END CERTIFICATE-----
@@ -0,0 +1,28 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNZrwSM/10WpdJ
3
+ kfGDPdqFc2E4PoHUtsfEQJlO5K8jdUV3vF1RYNWoEy0Tm6c29DcR1eM7DfvbPgFa
4
+ 9QwWbsHH2l6URxpQA3+Lf3AFKUpesaCnVn+3BZOEZaBOQTirZFWM4KwJLWP8WVZO
5
+ Uzdotv3zUq3Y+9Q/Odp1ocv/fyyiJz7kkTUWs3TzLN0RL3CTrztfmNCUAoskCHDH
6
+ xkTXNuaYj0DoSWkA7pwYXElp0Rt0VjQmYsEML+jKMxB6v92QJ3ucqSQWlSdHP+Z2
7
+ tjxAUojlnLyrurSUx4WrgA4Sh+a/p6XZb0YKAla4gSe9ioct/KTGKyO0lJzA/NQK
8
+ wS3ONPpBAgMBAAECggEAMtMXeWec8SQTaFRxDtkIz9m3djvdU12w+6pCZSeoAJ2d
9
+ hV26N48+/vpTvpTW4P23/LVQp0W0CtGCc1fMWGaqk4HAwm7/n0nmTwXHGbfYW6sX
10
+ RiDctFRwZqPg8UwpMhu/IX9cgl7VbVVLylDEFqilGQbd1qGlqMlveYkCGeQFjs+2
11
+ vfBkC9Oag3JmNkJvmbKxNU/Sg1s56ipr4cWzngz3n6bweWFUYUYyupSDvKV8tkWm
12
+ gG3p2UltPN5dIFo0d6DRNcJrjdoiMPZADjcveW0Y1ceCxPnIpaeAzDb7kYQgkCYG
13
+ iRAqP/7/wZqJt05cOEyvbcPduMbzd7iNFNTPbOwWywKBgQDoOsb4Zejs674lR2BN
14
+ AkL/r+ESfnl7ezVvg2dxjOKk57dWZ9oip9iXPjiWNvZeM79pyEG7beJxVbvr7VH+
15
+ GK5KNWEMa78JirgmjU7dmnhAJ09byH59pzofKNvdVfCWysGKLutnUsMzqisFJDGI
16
+ WCfY/6IdcyEkxjNkEI7ylI1S4wKBgQDibPaCnvE365Il4U4KQKGdxmsIBFEJ9FNC
17
+ vvdY6QdDfRIqcoOgks7GYYcP+wnC4kqLud1zqYGwB1koF9TFfimPo5t0lvFMtRR8
18
+ LEFebtgHzNQDDbUo+Ud9vI6lN3IQJs5ezd+bjtZh4lvcTf98d/+Y4YiA8frJJone
19
+ ZafoLV9ziwKBgClw67bCANnei7T9UrsLT0dvbFuvhCA78WIv8dK6kGtbCkV6DNwo
20
+ VadPrCtqLXbMBzlqSgiXaFRPN1S3qe0NHHUTp3je9V1PiuMeTlePTCwul6PKWIA1
21
+ ylJrKSkLP/64uebdzpZGl5ztnfWx6sDo8ltv6s8Uj3KPh/YwWkIBrmJ/AoGBALYT
22
+ IPdQkHCDQfasAnFEH7IbyB2eOvxiOEHIBma8nFas0FrJ0wbght4HtvAm0magSYmq
23
+ YGWNvPesMQmIgFR/azRSP8O1TTx9sIdZnwcs4xMCpsn9z9uu+MonQh2hRFuwmOqr
24
+ alBQwBveRjgVkIiqhiKN2ZK3Aw+Vqe/oluig88yZAoGAffBD5P811mtiIHCfHjNJ
25
+ doQYDaMrGbOmRb35fSP0zbkjipPrxLZ4VfGINl+R7ARfuzeAuf/12IDeijU+mDnZ
26
+ B2qwnFAFbeR/nO3uN2CUJJMe8HPA4GllrEwUk2tKzbUZRP7tDUdbdKb4NI/sFwRo
27
+ KCgT8P8cB6fwxXHI0bm8N9g=
28
+ -----END PRIVATE KEY-----
@@ -0,0 +1,19 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIDCTCCAfGgAwIBAgIUAzomCw25jCV7mNtPw2n+QlhutFkwDQYJKoZIhvcNAQEL
3
+ BQAwFDESMBAGA1UEAwwJZmVkLW90aGVyMB4XDTI2MDUwOTA2MjcxNFoXDTM2MDUw
4
+ NjA2MjcxNFowFDESMBAGA1UEAwwJZmVkLW90aGVyMIIBIjANBgkqhkiG9w0BAQEF
5
+ AAOCAQ8AMIIBCgKCAQEAnvOVbMR+UkwpeiO2/bFzsT8FoxPD02xygTaBzEjTqenf
6
+ UyW2hjKbs4n4lLsepAeIXCiBehvqXN8igQb0JrVjzw79IBH6VFVltJWWAmI3REqi
7
+ cAeHgpvI8grWtdP71NqxaeIn1xF50InRozRiefiJV3L97QEW80v15c/wVQmVnIvl
8
+ eWBz5atAR+3LyImU92nRNbEhqryut4np9CUCt893koRgJvkDH8ptskI8+kG27jIG
9
+ Ppna0YOH+DRR6LeKfAcyzvL9dS5iY8gShMGu9EAuBwJqOpBnnpbLbAMd0OmrCELU
10
+ LK7L1PPpCHEWG8dpUyzCxWuGhjPddZWQrCtizFMjlQIDAQABo1MwUTAdBgNVHQ4E
11
+ FgQUZAzwEop03t58CqYfVC0p+EX8JQUwHwYDVR0jBBgwFoAUZAzwEop03t58CqYf
12
+ VC0p+EX8JQUwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAPV1E
13
+ SX+0JEr+9witMtq23mnHMOWFQhceRrelbxf3N5PhLWJVN7XEpKgYVpQEd0LuUj6K
14
+ tav0BTEVpNJtwi0YgGNvyjxG8WvMoJt3MiuI1ekQ+WteEehQajCF9dxm2MN+wtkK
15
+ 7FD+NXJM+HpJP6xQrHipE2xqKMK52ANQWfm4FqUWjJgGcQnxzEE8jf0ZNlx+XR+n
16
+ tD5ftM+M1VM8bR2KxTeC3jF/HOAOEaHCu+xShxwfJTxdO/oyx7wq52v/3gTC2eoH
17
+ 7bDN3oxzUgKPZB/P9xjAXhEnJHg75mHbulK/dEj/LmBW0QnYvSSv0ghqbV42rc/j
18
+ cCMRZXPrb3BCN7rHHA==
19
+ -----END CERTIFICATE-----
@@ -0,0 +1,28 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCe85VsxH5STCl6
3
+ I7b9sXOxPwWjE8PTbHKBNoHMSNOp6d9TJbaGMpuzifiUux6kB4hcKIF6G+pc3yKB
4
+ BvQmtWPPDv0gEfpUVWW0lZYCYjdESqJwB4eCm8jyCta10/vU2rFp4ifXEXnQidGj
5
+ NGJ5+IlXcv3tARbzS/Xlz/BVCZWci+V5YHPlq0BH7cvIiZT3adE1sSGqvK63ien0
6
+ JQK3z3eShGAm+QMfym2yQjz6QbbuMgY+mdrRg4f4NFHot4p8BzLO8v11LmJjyBKE
7
+ wa70QC4HAmo6kGeelstsAx3Q6asIQtQsrsvU8+kIcRYbx2lTLMLFa4aGM911lZCs
8
+ K2LMUyOVAgMBAAECggEAJ3OhXZT6nn4RWGOnblm+M5rHESYdd2smE3yeJEBaKsTl
9
+ vWsxnabPfvUqee3kqcVF2svh8RcfKQxn8bryW+4vFuwrNuvHZGmqu/LZv/81JPHa
10
+ VfCEeY4lhq/agVhbW8Yo/TUY+tg3UiN24SlmHMxreEVOEaZw1hO/NVpSViTyGZ+6
11
+ QaFU2+M3ZH95Svtmw9Qy1aY9qxjTUnbD7fm63CqXlCKNqJ1oDkpAAMa03Ufp4lxv
12
+ nucIuLdThKGkZFq815Y4GbExb0XogsMd1tN00dkqYPF4b9uAPaSf/jtz3HaFHMuz
13
+ Hr/PAhC/NyoVG6M7LXRDRYgNvlaK3bazRIoJBRgp8QKBgQDL1qjIGTsa2ulaTCzM
14
+ Iyb+O1L2JwmxTAzBQZXD0b1LJbCsHQCQK/M06W1sETjk9h+yiRKNEjefFi89bSvJ
15
+ JipbA+srsMO0fAVASIKmjVGaywvm1zquL1JAMr+0ZziMb8eRRuYyytPMwtOwaxp4
16
+ z3TTONKRHZpCN0UPdcxfOO8/8QKBgQDHoGcPiA4HyaFfC5yQLx8jq2+lZZefZFWl
17
+ bRPFYeo37xfYO+QItZV0nMz36NZYcWI9z+MuSmuvaHHCV27LQ9VtZqPxgPEqM8Qm
18
+ Rhf38nBOAinGLeitaZKaS5BMf9VU6yoi+kKOZQtd+96GbQYdUmp3HNYIrXwzUxh5
19
+ Mqr9Hv8B5QKBgCpgqVxYaoJNyr/cIGAcWsn2GWxVd11l2yz+bp10aG9MGavep7RR
20
+ ftGcSgRynCp1xOdAOhwcEnY/jXiuzrCV/65GZUkDCdzm/8x6hrcLoFCXMBVA39FA
21
+ w0/XfSWLZCVGQ+4/GDKtGlVyl8IQskM1lisnoBdNWTm09eWd7uxJEOxxAoGBAJq8
22
+ A4avcCirKpFQn8/HJrzwUr8Ci096Z0St0uhpaDJo+rOaYLw7fBiCvgHfkd3GLV86
23
+ N58XAndZXuxD91ZJQzLkn2lACC8tJvp/1G5VlqVw2c6KoVNhhMhij/wsDkEfc27f
24
+ Sx2yxufXpnnOIjIyQuIHEQRy7NWfsFWpZ46CKyndAoGBAKBXTSFaaH0q7nlxfqN3
25
+ agg/gg63MRjzWdIvWdFoNPm/5+M6ElxM6how34mYGUXSN0O+M9Dyeb+wdRFU3uqr
26
+ jdU1uajgBEZZaf99XLxZ0mve5mwYQmmYqWQwYU1+ZDWMzNL6Q9RPMI6z2AuQ9YzI
27
+ A8emo7hcJXJz0gFG3S9e5V/V
28
+ -----END PRIVATE KEY-----
@@ -0,0 +1,208 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ FEDERATION_VERSION,
5
+ type FederationEnvelope,
6
+ FederationProtocolError,
7
+ type FederationTransport,
8
+ decodeFederationEnvelope,
9
+ encodeFederationEnvelope,
10
+ federationCall,
11
+ fingerprintCert,
12
+ validateCredentials,
13
+ } from "./index";
14
+ import { type FixtureCertSet, makeFixtureCertSet, makeFixtureCertSetOther } from "./test-helpers";
15
+
16
+ const goodEnvelope: FederationEnvelope = {
17
+ version: FEDERATION_VERSION,
18
+ traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
19
+ federation: {
20
+ from: { deployment: "deployment-a", role: "researcher" },
21
+ to: { deployment: "deployment-b", role: "code-reviewer" },
22
+ mtls: { client_cert_subject: "CN=deployment-a" },
23
+ },
24
+ kind: "question",
25
+ payload: "review the patch in /tmp/x",
26
+ };
27
+
28
+ let certs: FixtureCertSet;
29
+ let otherCerts: FixtureCertSet;
30
+
31
+ beforeAll(() => {
32
+ certs = makeFixtureCertSet();
33
+ otherCerts = makeFixtureCertSetOther();
34
+ });
35
+
36
+ afterAll(() => {
37
+ certs.cleanup();
38
+ otherCerts.cleanup();
39
+ });
40
+
41
+ describe("encodeFederationEnvelope / decodeFederationEnvelope (T2)", () => {
42
+ test("round-trip happy path", () => {
43
+ const text = encodeFederationEnvelope(goodEnvelope);
44
+ const parsed = decodeFederationEnvelope(text);
45
+ expect(parsed).toEqual(goodEnvelope);
46
+ });
47
+
48
+ test("rejects invalid version", () => {
49
+ const bad = {
50
+ ...goodEnvelope,
51
+ version: "crewhaus.federation.v0",
52
+ } as unknown as FederationEnvelope;
53
+ expect(() => encodeFederationEnvelope(bad)).toThrow(/unsupported version/);
54
+ expect(() => decodeFederationEnvelope(JSON.stringify(bad))).toThrow(/unsupported version/);
55
+ });
56
+
57
+ test("rejects missing federation field", () => {
58
+ const bad = { ...goodEnvelope, federation: undefined } as unknown as FederationEnvelope;
59
+ expect(() => decodeFederationEnvelope(JSON.stringify(bad))).toThrow(/missing federation/);
60
+ });
61
+
62
+ test("rejects malformed party", () => {
63
+ const bad = JSON.parse(JSON.stringify(goodEnvelope)) as FederationEnvelope;
64
+ (bad.federation as unknown as { from: unknown }).from = { deployment: "x" };
65
+ expect(() => decodeFederationEnvelope(JSON.stringify(bad))).toThrow(/from\/to must be/);
66
+ });
67
+
68
+ test("rejects invalid kind", () => {
69
+ const bad = { ...goodEnvelope, kind: "explosion" } as unknown as FederationEnvelope;
70
+ expect(() => decodeFederationEnvelope(JSON.stringify(bad))).toThrow(/invalid kind/);
71
+ });
72
+
73
+ test("rejects non-string payload", () => {
74
+ const bad = { ...goodEnvelope, payload: 123 } as unknown as FederationEnvelope;
75
+ expect(() => decodeFederationEnvelope(JSON.stringify(bad))).toThrow(/payload must be a string/);
76
+ });
77
+
78
+ test("rejects missing traceparent", () => {
79
+ const bad = { ...goodEnvelope, traceparent: "" } as FederationEnvelope;
80
+ expect(() => decodeFederationEnvelope(JSON.stringify(bad))).toThrow(/traceparent/);
81
+ });
82
+
83
+ test("rejects malformed JSON", () => {
84
+ expect(() => decodeFederationEnvelope("{not json")).toThrow(FederationProtocolError);
85
+ });
86
+
87
+ test("decode rejects mtls.client_cert_subject missing", () => {
88
+ const bad = JSON.parse(JSON.stringify(goodEnvelope)) as FederationEnvelope;
89
+ (bad.federation as unknown as { mtls: unknown }).mtls = {};
90
+ expect(() => decodeFederationEnvelope(JSON.stringify(bad))).toThrow(/client_cert_subject/);
91
+ });
92
+ });
93
+
94
+ describe("validateCredentials (T8)", () => {
95
+ test("happy path accepts a valid set", () => {
96
+ expect(() =>
97
+ validateCredentials({
98
+ caCertPem: certs.caCertPem,
99
+ clientCertPem: certs.clientCertPem,
100
+ clientKeyPem: certs.clientKeyPem,
101
+ pinnedFingerprint: certs.pinnedFingerprint,
102
+ }),
103
+ ).not.toThrow();
104
+ });
105
+
106
+ test("rejects non-PEM ca", () => {
107
+ expect(() =>
108
+ validateCredentials({
109
+ caCertPem: "not a cert",
110
+ clientCertPem: certs.clientCertPem,
111
+ clientKeyPem: certs.clientKeyPem,
112
+ pinnedFingerprint: certs.pinnedFingerprint,
113
+ }),
114
+ ).toThrow(/caCertPem is not PEM-encoded/);
115
+ });
116
+
117
+ test("rejects non-PEM cert", () => {
118
+ expect(() =>
119
+ validateCredentials({
120
+ caCertPem: certs.caCertPem,
121
+ clientCertPem: "not a cert",
122
+ clientKeyPem: certs.clientKeyPem,
123
+ pinnedFingerprint: certs.pinnedFingerprint,
124
+ }),
125
+ ).toThrow(/clientCertPem is not PEM-encoded/);
126
+ });
127
+
128
+ test("rejects non-PEM key", () => {
129
+ expect(() =>
130
+ validateCredentials({
131
+ caCertPem: certs.caCertPem,
132
+ clientCertPem: certs.clientCertPem,
133
+ clientKeyPem: "not a key",
134
+ pinnedFingerprint: certs.pinnedFingerprint,
135
+ }),
136
+ ).toThrow(/clientKeyPem is not PEM-encoded/);
137
+ });
138
+
139
+ test("rejects malformed pinnedFingerprint (length)", () => {
140
+ expect(() =>
141
+ validateCredentials({
142
+ caCertPem: certs.caCertPem,
143
+ clientCertPem: certs.clientCertPem,
144
+ clientKeyPem: certs.clientKeyPem,
145
+ pinnedFingerprint: "abc",
146
+ }),
147
+ ).toThrow(/pinnedFingerprint must be 64-char hex/);
148
+ });
149
+
150
+ test("rejects malformed pinnedFingerprint (non-hex chars)", () => {
151
+ expect(() =>
152
+ validateCredentials({
153
+ caCertPem: certs.caCertPem,
154
+ clientCertPem: certs.clientCertPem,
155
+ clientKeyPem: certs.clientKeyPem,
156
+ pinnedFingerprint: "z".repeat(64),
157
+ }),
158
+ ).toThrow(/pinnedFingerprint must be 64-char hex/);
159
+ });
160
+ });
161
+
162
+ describe("fingerprintCert", () => {
163
+ test("matches the openssl-computed fingerprint", () => {
164
+ const fp = fingerprintCert(certs.clientCertPem);
165
+ expect(fp).toBe(certs.pinnedFingerprint);
166
+ });
167
+ });
168
+
169
+ describe("federationCall — injected transport (T2)", () => {
170
+ test("happy path round-trip", async () => {
171
+ const captured: Array<{ url: string; envelope: FederationEnvelope }> = [];
172
+ const transport: FederationTransport = async (url, envelope) => {
173
+ captured.push({ url, envelope });
174
+ return { status: 200, body: '{"reply":"ack"}' };
175
+ };
176
+ const result = await federationCall({
177
+ url: "https://deployment-b.example/federation",
178
+ envelope: goodEnvelope,
179
+ credentials: {
180
+ caCertPem: certs.caCertPem,
181
+ clientCertPem: certs.clientCertPem,
182
+ clientKeyPem: certs.clientKeyPem,
183
+ pinnedFingerprint: certs.pinnedFingerprint,
184
+ },
185
+ transport,
186
+ });
187
+ expect(result.status).toBe(200);
188
+ expect(captured[0]?.url).toBe("https://deployment-b.example/federation");
189
+ expect(captured[0]?.envelope).toEqual(goodEnvelope);
190
+ });
191
+
192
+ test("validates credentials before invoking transport", async () => {
193
+ const transport: FederationTransport = async () => ({ status: 200, body: "" });
194
+ await expect(
195
+ federationCall({
196
+ url: "https://deployment-b.example/",
197
+ envelope: goodEnvelope,
198
+ credentials: {
199
+ caCertPem: "not a cert",
200
+ clientCertPem: certs.clientCertPem,
201
+ clientKeyPem: certs.clientKeyPem,
202
+ pinnedFingerprint: certs.pinnedFingerprint,
203
+ },
204
+ transport,
205
+ }),
206
+ ).rejects.toThrow(/caCertPem is not PEM-encoded/);
207
+ });
208
+ });
package/src/index.ts ADDED
@@ -0,0 +1,275 @@
1
+ import { X509Certificate, createPrivateKey, createPublicKey } from "node:crypto";
2
+ import { type Agent as HttpsAgent, type RequestOptions, request as httpsRequest } from "node:https";
3
+ /**
4
+ * @crewhaus/federation-protocol — Section 34
5
+ *
6
+ * Cross-deployment A2A wire protocol. Extends the in-crew envelope from
7
+ * `@crewhaus/a2a-protocol` (Section 22) with `federation` fields:
8
+ *
9
+ * {
10
+ * ...A2AEnvelope,
11
+ * version: "crewhaus.federation.v1", // strict — rejected without exact match
12
+ * federation: {
13
+ * from: { deployment, role },
14
+ * to: { deployment, role },
15
+ * mtls: { client_cert_subject }
16
+ * }
17
+ * }
18
+ *
19
+ * Transport: HTTPS POST with mutual TLS. Each deployment has a
20
+ * CA-issued certificate identifying its `deployment_id`; peers verify
21
+ * via cert pinning (SHA256 fingerprint check) plus standard TLS chain
22
+ * validation.
23
+ *
24
+ * Errors map to `recovery-engine` taxonomy:
25
+ * - cert mismatch / expired → tombstone (auth failure)
26
+ * - connection refused / timeout → retry with exponential backoff
27
+ * - 5xx → retry; 4xx → tombstone
28
+ */
29
+ import { CrewhausError } from "@crewhaus/errors";
30
+
31
+ export class FederationProtocolError extends CrewhausError {
32
+ override readonly name = "FederationProtocolError";
33
+ constructor(message: string, cause?: unknown) {
34
+ super("config", message, cause);
35
+ }
36
+ }
37
+
38
+ export const FEDERATION_VERSION = "crewhaus.federation.v1" as const;
39
+ export type FederationVersion = typeof FEDERATION_VERSION;
40
+
41
+ export type FederationParty = {
42
+ readonly deployment: string;
43
+ readonly role: string;
44
+ };
45
+
46
+ export type FederationEnvelope = {
47
+ readonly version: FederationVersion;
48
+ readonly traceparent: string;
49
+ readonly federation: {
50
+ readonly from: FederationParty;
51
+ readonly to: FederationParty;
52
+ readonly mtls: { readonly client_cert_subject: string };
53
+ };
54
+ /** A2A envelope kind, mirroring `@crewhaus/a2a-protocol`. */
55
+ readonly kind: "question" | "answer" | "notify";
56
+ /** Free-form message body — typically a question prompt. */
57
+ readonly payload: string;
58
+ };
59
+
60
+ export function encodeFederationEnvelope(envelope: FederationEnvelope): string {
61
+ validateEnvelope(envelope);
62
+ return JSON.stringify(envelope);
63
+ }
64
+
65
+ export function decodeFederationEnvelope(text: string): FederationEnvelope {
66
+ let parsed: unknown;
67
+ try {
68
+ parsed = JSON.parse(text);
69
+ } catch (cause) {
70
+ throw new FederationProtocolError("federation envelope: invalid JSON", cause);
71
+ }
72
+ if (typeof parsed !== "object" || parsed === null) {
73
+ throw new FederationProtocolError("federation envelope: not an object");
74
+ }
75
+ const env = parsed as Record<string, unknown>;
76
+ if (env["version"] !== FEDERATION_VERSION) {
77
+ throw new FederationProtocolError(
78
+ `federation envelope: unsupported version ${String(env["version"])} (expected ${FEDERATION_VERSION})`,
79
+ );
80
+ }
81
+ validateEnvelope(env as unknown as FederationEnvelope);
82
+ return env as unknown as FederationEnvelope;
83
+ }
84
+
85
+ function validateEnvelope(env: FederationEnvelope): void {
86
+ if (env.version !== FEDERATION_VERSION) {
87
+ throw new FederationProtocolError(`unsupported version ${String(env.version)}`);
88
+ }
89
+ if (!env.federation || typeof env.federation !== "object") {
90
+ throw new FederationProtocolError("federation envelope: missing federation field");
91
+ }
92
+ const f = env.federation;
93
+ if (!isParty(f.from) || !isParty(f.to)) {
94
+ throw new FederationProtocolError("federation envelope: from/to must be {deployment,role}");
95
+ }
96
+ if (!f.mtls || typeof f.mtls.client_cert_subject !== "string") {
97
+ throw new FederationProtocolError("federation envelope: missing mtls.client_cert_subject");
98
+ }
99
+ if (env.kind !== "question" && env.kind !== "answer" && env.kind !== "notify") {
100
+ throw new FederationProtocolError(`federation envelope: invalid kind ${String(env.kind)}`);
101
+ }
102
+ if (typeof env.payload !== "string") {
103
+ throw new FederationProtocolError("federation envelope: payload must be a string");
104
+ }
105
+ if (typeof env.traceparent !== "string" || env.traceparent.length === 0) {
106
+ throw new FederationProtocolError("federation envelope: missing traceparent");
107
+ }
108
+ }
109
+
110
+ function isParty(p: unknown): p is FederationParty {
111
+ return (
112
+ typeof p === "object" &&
113
+ p !== null &&
114
+ typeof (p as FederationParty).deployment === "string" &&
115
+ typeof (p as FederationParty).role === "string" &&
116
+ (p as FederationParty).deployment.length > 0 &&
117
+ (p as FederationParty).role.length > 0
118
+ );
119
+ }
120
+
121
+ // ─── mTLS transport ────────────────────────────────────────────────────────
122
+
123
+ export type MtlsCredentials = {
124
+ /** PEM-encoded CA bundle the peer's cert chain must validate against. */
125
+ readonly caCertPem: string;
126
+ /** PEM-encoded client certificate this deployment presents. */
127
+ readonly clientCertPem: string;
128
+ /** PEM-encoded private key matching `clientCertPem`. */
129
+ readonly clientKeyPem: string;
130
+ /**
131
+ * SHA256 fingerprint of the peer's expected leaf cert (hex, lowercase,
132
+ * 64 chars; no `:` separators). Strict pin: any other cert is rejected
133
+ * even if it chains to the same CA.
134
+ */
135
+ readonly pinnedFingerprint: string;
136
+ };
137
+
138
+ export type FederationTransport = (
139
+ url: string,
140
+ envelope: FederationEnvelope,
141
+ creds: MtlsCredentials,
142
+ ) => Promise<{ status: number; body: string }>;
143
+
144
+ export type FederationCallOptions = {
145
+ readonly url: string;
146
+ readonly envelope: FederationEnvelope;
147
+ readonly credentials: MtlsCredentials;
148
+ /** Test injection point. Defaults to a real `node:https` POST. */
149
+ readonly transport?: FederationTransport;
150
+ /** Timeout in ms. Default 30s. */
151
+ readonly timeoutMs?: number;
152
+ };
153
+
154
+ export async function federationCall(
155
+ opts: FederationCallOptions,
156
+ ): Promise<{ status: number; body: string }> {
157
+ validateCredentials(opts.credentials);
158
+ const transport = opts.transport ?? defaultTransport(opts.timeoutMs ?? 30_000);
159
+ return transport(opts.url, opts.envelope, opts.credentials);
160
+ }
161
+
162
+ /**
163
+ * Asserts the credentials are well-formed BEFORE we try to build a TLS
164
+ * agent. Surfaces clear, actionable errors instead of openssl-flavoured
165
+ * mystery.
166
+ */
167
+ export function validateCredentials(creds: MtlsCredentials): void {
168
+ if (!creds.caCertPem.includes("-----BEGIN CERTIFICATE-----")) {
169
+ throw new FederationProtocolError("credentials: caCertPem is not PEM-encoded");
170
+ }
171
+ if (!creds.clientCertPem.includes("-----BEGIN CERTIFICATE-----")) {
172
+ throw new FederationProtocolError("credentials: clientCertPem is not PEM-encoded");
173
+ }
174
+ if (
175
+ !creds.clientKeyPem.includes("-----BEGIN PRIVATE KEY-----") &&
176
+ !creds.clientKeyPem.includes("-----BEGIN RSA PRIVATE KEY-----") &&
177
+ !creds.clientKeyPem.includes("-----BEGIN EC PRIVATE KEY-----")
178
+ ) {
179
+ throw new FederationProtocolError("credentials: clientKeyPem is not PEM-encoded");
180
+ }
181
+ if (!/^[0-9a-f]{64}$/.test(creds.pinnedFingerprint)) {
182
+ throw new FederationProtocolError(
183
+ `credentials: pinnedFingerprint must be 64-char hex sha256 (got ${creds.pinnedFingerprint.length} chars)`,
184
+ );
185
+ }
186
+ // Sanity: keys parse + cert parses + cert hasn't expired
187
+ try {
188
+ createPublicKey(creds.clientCertPem);
189
+ createPrivateKey(creds.clientKeyPem);
190
+ } catch (cause) {
191
+ throw new FederationProtocolError("credentials: client cert/key did not parse", cause);
192
+ }
193
+ try {
194
+ const cert = new X509Certificate(creds.clientCertPem);
195
+ const now = Date.now();
196
+ if (Date.parse(cert.validTo) < now) {
197
+ throw new FederationProtocolError(`credentials: client cert expired at ${cert.validTo}`);
198
+ }
199
+ } catch (cause) {
200
+ if (cause instanceof FederationProtocolError) throw cause;
201
+ throw new FederationProtocolError("credentials: client cert parse failed", cause);
202
+ }
203
+ }
204
+
205
+ /** Compute the SHA256 fingerprint of a PEM-encoded cert (hex, no separators). */
206
+ export function fingerprintCert(certPem: string): string {
207
+ const cert = new X509Certificate(certPem);
208
+ return cert.fingerprint256.replaceAll(":", "").toLowerCase();
209
+ }
210
+
211
+ /** Default transport — real `node:https` POST with mTLS + cert pinning. */
212
+ function defaultTransport(timeoutMs: number): FederationTransport {
213
+ return async (url, envelope, creds) => {
214
+ const u = new URL(url);
215
+ if (u.protocol !== "https:") {
216
+ throw new FederationProtocolError(
217
+ `federation transport requires https://, got ${u.protocol}`,
218
+ );
219
+ }
220
+ const body = encodeFederationEnvelope(envelope);
221
+ const opts: RequestOptions = {
222
+ method: "POST",
223
+ protocol: u.protocol,
224
+ hostname: u.hostname,
225
+ port: u.port || 443,
226
+ path: `${u.pathname}${u.search}`,
227
+ headers: {
228
+ "Content-Type": "application/json",
229
+ "Content-Length": Buffer.byteLength(body, "utf8").toString(),
230
+ "X-Crewhaus-Federation-Version": FEDERATION_VERSION,
231
+ },
232
+ ca: creds.caCertPem,
233
+ cert: creds.clientCertPem,
234
+ key: creds.clientKeyPem,
235
+ rejectUnauthorized: true,
236
+ timeout: timeoutMs,
237
+ checkServerIdentity: (_host, cert) => {
238
+ // Strict pin: the peer's leaf cert fingerprint must match exactly.
239
+ const fp = cert.fingerprint256?.replaceAll(":", "").toLowerCase() ?? "";
240
+ if (fp !== creds.pinnedFingerprint) {
241
+ return new Error(
242
+ `cert-pin mismatch: peer fingerprint ${fp} != pinned ${creds.pinnedFingerprint}`,
243
+ );
244
+ }
245
+ return undefined;
246
+ },
247
+ };
248
+
249
+ return new Promise((resolve, reject) => {
250
+ const req = httpsRequest(opts, (res) => {
251
+ const chunks: Buffer[] = [];
252
+ res.on("data", (c) => chunks.push(c as Buffer));
253
+ res.on("end", () =>
254
+ resolve({
255
+ status: res.statusCode ?? 0,
256
+ body: Buffer.concat(chunks).toString("utf8"),
257
+ }),
258
+ );
259
+ });
260
+ req.on("error", (err) =>
261
+ reject(new FederationProtocolError(`federation transport error: ${err.message}`, err)),
262
+ );
263
+ req.on("timeout", () => {
264
+ req.destroy(
265
+ new FederationProtocolError(`federation transport timeout after ${timeoutMs}ms`),
266
+ );
267
+ });
268
+ req.write(body);
269
+ req.end();
270
+ });
271
+ };
272
+ }
273
+
274
+ /** Re-export for callers that need to spawn their own agent (rare). */
275
+ export type { HttpsAgent };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Test helpers for federation-protocol — read pre-generated fixture
3
+ * certs from disk so tests don't shell to openssl on every run.
4
+ *
5
+ * The fixture certs are self-signed RSA 2048, valid for 10 years, with
6
+ * CN=fed-fixture (and CN=fed-other for the "wrong key" pair). Generated
7
+ * once via:
8
+ *
9
+ * openssl req -x509 -newkey rsa:2048 -nodes \
10
+ * -keyout fixtures-key.pem -out fixtures-cert.pem \
11
+ * -days 3650 -subj "/CN=fed-fixture" -sha256
12
+ *
13
+ * Test-only secrets — no production deployment uses these.
14
+ *
15
+ * Earlier iterations shelled to openssl on every test run; that timed
16
+ * out on CI runners with slow entropy and racy `openssl x509 ...
17
+ * -fingerprint -sha256` invocations. Static fixtures are deterministic
18
+ * + fast (~1 ms read).
19
+ */
20
+ import { readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ import { fingerprintCert } from "./index";
24
+
25
+ export type FixtureCertSet = {
26
+ readonly caCertPem: string;
27
+ readonly clientCertPem: string;
28
+ readonly clientKeyPem: string;
29
+ readonly pinnedFingerprint: string;
30
+ /** No-op — kept for backwards compat with the old openssl-based helper. */
31
+ readonly cleanup: () => void;
32
+ };
33
+
34
+ // `tsc -b` also compiles this file into `dist/`, but the PEM fixtures live
35
+ // next to the source. Map the dist path back so the dist copy finds them too.
36
+ const FIXTURES_DIR = import.meta.dir.replace(/([/\\])dist$/, "$1src");
37
+
38
+ /**
39
+ * Returns the canonical fixture cert pair (CN=fed-fixture). The
40
+ * `_commonName` arg is accepted for backwards compat with the
41
+ * generate-on-the-fly helper this replaced; it has no effect.
42
+ */
43
+ export function makeFixtureCertSet(_commonName = "fed-fixture"): FixtureCertSet {
44
+ return readFixtures("fixtures-cert.pem", "fixtures-key.pem");
45
+ }
46
+
47
+ /**
48
+ * Returns a second, unrelated cert pair (CN=fed-other) — used by the
49
+ * "wrong public key" / "tampered cert" tests to assert pinning rejects
50
+ * keys that don't match the configured fingerprint.
51
+ */
52
+ export function makeFixtureCertSetOther(): FixtureCertSet {
53
+ return readFixtures("fixtures-other-cert.pem", "fixtures-other-key.pem");
54
+ }
55
+
56
+ function readFixtures(certFile: string, keyFile: string): FixtureCertSet {
57
+ const certPem = readFileSync(join(FIXTURES_DIR, certFile), "utf8");
58
+ const keyPem = readFileSync(join(FIXTURES_DIR, keyFile), "utf8");
59
+ return {
60
+ caCertPem: certPem,
61
+ clientCertPem: certPem,
62
+ clientKeyPem: keyPem,
63
+ pinnedFingerprint: fingerprintCert(certPem),
64
+ cleanup: () => undefined,
65
+ };
66
+ }