@microck/canonfig 2.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/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/agent/agent-resolution.errors.js +42 -0
- package/dist/agent/agent-resolution.layer.js +204 -0
- package/dist/agent/agent-resolution.service.js +2259 -0
- package/dist/agent/agent-resolution.types.js +1 -0
- package/dist/agent/controlled-executor.js +704 -0
- package/dist/agent/harness-adapters.js +85 -0
- package/dist/cli/cli.js +618 -0
- package/dist/cli/exit-codes.js +28 -0
- package/dist/cli/follower-commands.js +3 -0
- package/dist/cli/render.js +56 -0
- package/dist/cli/source-commands.js +5 -0
- package/dist/domain/brand.js +29 -0
- package/dist/domain/identity.js +31 -0
- package/dist/domain/npm-package-spec.js +186 -0
- package/dist/domain/profile.js +950 -0
- package/dist/domain/recipe-versions.js +297 -0
- package/dist/domain/resource.js +259 -0
- package/dist/domain/synchronization.js +346 -0
- package/dist/enrollment/enrollment.errors.js +43 -0
- package/dist/enrollment/enrollment.layer.js +724 -0
- package/dist/enrollment/enrollment.service.js +3 -0
- package/dist/enrollment/enrollment.types.js +59 -0
- package/dist/enrollment/follower-client.js +585 -0
- package/dist/enrollment/source-server.js +313 -0
- package/dist/machine/linux.layer.js +1183 -0
- package/dist/machine/machine-state.errors.js +52 -0
- package/dist/machine/machine-state.service.js +3 -0
- package/dist/machine/machine-state.types.js +1 -0
- package/dist/machine/macos.layer.js +470 -0
- package/dist/machine/windows.layer.js +879 -0
- package/dist/profile/discovery.js +740 -0
- package/dist/profile/profile-catalog.errors.js +50 -0
- package/dist/profile/profile-catalog.layer.js +20 -0
- package/dist/profile/profile-catalog.service.js +7 -0
- package/dist/profile/profile-codec.js +153 -0
- package/dist/profile/publication.js +298 -0
- package/dist/profile/tool-catalog.js +384 -0
- package/dist/runtime/doctor.js +306 -0
- package/dist/runtime/layers.js +706 -0
- package/dist/runtime/main.js +38 -0
- package/dist/schedule/linux-schedule.js +24 -0
- package/dist/schedule/macos-schedule.js +25 -0
- package/dist/schedule/schedule-manager.errors.js +17 -0
- package/dist/schedule/schedule-manager.layer.js +205 -0
- package/dist/schedule/schedule-manager.service.js +3 -0
- package/dist/schedule/schedule-manager.types.js +114 -0
- package/dist/schedule/windows-schedule.js +25 -0
- package/dist/state/state-repository.errors.js +55 -0
- package/dist/state/state-repository.layer.js +1507 -0
- package/dist/state/state-repository.service.js +3 -0
- package/dist/state/state-repository.types.js +1 -0
- package/dist/state/state-schema.js +298 -0
- package/dist/synchronization/config-codec.js +97 -0
- package/dist/synchronization/executor.js +700 -0
- package/dist/synchronization/follower-orchestration.js +939 -0
- package/dist/synchronization/follower-sync-config.js +81 -0
- package/dist/synchronization/npm-artifact.js +670 -0
- package/dist/synchronization/planner.js +378 -0
- package/dist/synchronization/recovery.js +397 -0
- package/dist/synchronization/resource-executors.js +1198 -0
- package/dist/synchronization/resource-plans.js +645 -0
- package/dist/synchronization/synchronization.errors.js +102 -0
- package/dist/synchronization/synchronization.layer.js +97 -0
- package/dist/synchronization/synchronization.service.js +11 -0
- package/dist/synchronization/synchronization.types.js +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { CertificateFingerprint, ContentDigest, InvitationCode, Timestamp, } from "../domain/brand.js";
|
|
3
|
+
import { FollowerIdentity, SourceIdentity } from "../domain/identity.js";
|
|
4
|
+
import { PublishedResourceSchema, ScheduleDefaultSchema, VerificationInputSchema, } from "../domain/profile.js";
|
|
5
|
+
export const TransportPublishedResourceSchema = Schema.Struct({
|
|
6
|
+
...PublishedResourceSchema.fields,
|
|
7
|
+
verify: VerificationInputSchema,
|
|
8
|
+
});
|
|
9
|
+
export const EnrollFollowerRequestSchema = Schema.Struct({
|
|
10
|
+
code: InvitationCode,
|
|
11
|
+
nonce: Schema.NonEmptyString,
|
|
12
|
+
sourceFingerprint: CertificateFingerprint,
|
|
13
|
+
tlsFingerprint: CertificateFingerprint,
|
|
14
|
+
followerName: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128), Schema.isPattern(/^[^\p{Cc}]+$/u)),
|
|
15
|
+
});
|
|
16
|
+
export const EnrollFollowerResponseSchema = Schema.Struct({
|
|
17
|
+
follower: FollowerIdentity,
|
|
18
|
+
credential: Schema.NonEmptyString,
|
|
19
|
+
source: SourceIdentity,
|
|
20
|
+
tlsFingerprint: CertificateFingerprint,
|
|
21
|
+
authorizedProfiles: Schema.optional(Schema.Array(Schema.Struct({
|
|
22
|
+
id: Schema.NonEmptyString,
|
|
23
|
+
profileId: Schema.NonEmptyString,
|
|
24
|
+
sequence: Schema.Natural,
|
|
25
|
+
digest: ContentDigest,
|
|
26
|
+
publishedAt: Timestamp,
|
|
27
|
+
}))),
|
|
28
|
+
});
|
|
29
|
+
export const AuthenticatedFollowerSchema = Schema.Struct({
|
|
30
|
+
follower: FollowerIdentity,
|
|
31
|
+
});
|
|
32
|
+
export const RevisionSummarySchema = Schema.Struct({
|
|
33
|
+
id: Schema.NonEmptyString,
|
|
34
|
+
profileId: Schema.NonEmptyString,
|
|
35
|
+
sequence: Schema.Natural,
|
|
36
|
+
digest: ContentDigest,
|
|
37
|
+
publishedAt: Timestamp,
|
|
38
|
+
});
|
|
39
|
+
export const RevisionListSchema = Schema.Struct({
|
|
40
|
+
revisions: Schema.Array(RevisionSummarySchema),
|
|
41
|
+
});
|
|
42
|
+
export const RevisionMetadataSchema = Schema.Struct({
|
|
43
|
+
id: Schema.NonEmptyString,
|
|
44
|
+
profileId: Schema.NonEmptyString,
|
|
45
|
+
sequence: Schema.Natural,
|
|
46
|
+
digest: ContentDigest,
|
|
47
|
+
publishedAt: Timestamp,
|
|
48
|
+
resources: Schema.Array(TransportPublishedResourceSchema),
|
|
49
|
+
scheduleDefault: Schema.optional(ScheduleDefaultSchema),
|
|
50
|
+
metadataDigest: ContentDigest,
|
|
51
|
+
signingKeyId: Schema.NonEmptyString,
|
|
52
|
+
signingPublicKey: Schema.NonEmptyString,
|
|
53
|
+
sourceSignature: Schema.NonEmptyString,
|
|
54
|
+
signature: Schema.NonEmptyString,
|
|
55
|
+
});
|
|
56
|
+
export const WireEnrollmentErrorSchema = Schema.Struct({
|
|
57
|
+
error: Schema.NonEmptyString,
|
|
58
|
+
message: Schema.NonEmptyString,
|
|
59
|
+
});
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { createHash, createPublicKey, randomUUID, verify, X509Certificate, } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, open, readFile, rename, unlink, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { connect as tlsConnect } from "node:tls";
|
|
6
|
+
import { Effect, Option, Redacted, Schema } from "effect";
|
|
7
|
+
import { BlobId, CertificateFingerprint, } from "../domain/brand.js";
|
|
8
|
+
import { MachineState } from "../machine/machine-state.service.js";
|
|
9
|
+
import { DuplicateFollowerIdentityError, EnrollmentFingerprintMismatchError, EnrollmentSourceMismatchError, EnrollmentTransportError, InvitationExpiredError, InvitationNotFoundError, InvitationReplayError, InvalidFollowerCredentialError, MalformedEnrollmentRequestError, RevokedFollowerCredentialError, TransportIntegrityError, TransportInterruptedError, TransportMalformedResponseError, TransportResourceNotFoundError, TransportSizeLimitError, TransportUnauthorizedError, } from "./enrollment.errors.js";
|
|
10
|
+
import { AuthenticatedFollowerSchema, EnrollFollowerResponseSchema, RevisionListSchema, RevisionMetadataSchema, WireEnrollmentErrorSchema, } from "./enrollment.types.js";
|
|
11
|
+
import { canonicalJson, digestOf, sha256BytesHex, } from "../profile/profile-codec.js";
|
|
12
|
+
const decode = Schema.decodeUnknownSync;
|
|
13
|
+
const maximumResponseBytes = 64 * 1024;
|
|
14
|
+
const defaultMaximumMetadataBytes = 1024 * 1024;
|
|
15
|
+
const defaultMaximumBlobBytes = 8 * 1024 * 1024;
|
|
16
|
+
const defaultTimeoutMilliseconds = 10_000;
|
|
17
|
+
const checkedEndpoint = (endpoint) => Effect.try({
|
|
18
|
+
try: () => {
|
|
19
|
+
const url = new URL(endpoint);
|
|
20
|
+
const loopback = url.hostname === "127.0.0.1"
|
|
21
|
+
|| url.hostname === "[::1]"
|
|
22
|
+
|| url.hostname === "::1";
|
|
23
|
+
if (url.protocol !== "https:" || !loopback) {
|
|
24
|
+
throw new Error("not a loopback HTTPS URL");
|
|
25
|
+
}
|
|
26
|
+
return url;
|
|
27
|
+
},
|
|
28
|
+
catch: () => new EnrollmentTransportError({
|
|
29
|
+
operation: "validate source endpoint",
|
|
30
|
+
message: "the source endpoint must use loopback HTTPS",
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
const inspectCertificate = (endpoint) => Effect.tryPromise({
|
|
34
|
+
try: () => new Promise((resolveCertificate, rejectCertificate) => {
|
|
35
|
+
const socket = tlsConnect({
|
|
36
|
+
host: endpoint.hostname.replaceAll("[", "").replaceAll("]", ""),
|
|
37
|
+
port: Number(endpoint.port),
|
|
38
|
+
rejectUnauthorized: false,
|
|
39
|
+
minVersion: "TLSv1.2",
|
|
40
|
+
});
|
|
41
|
+
socket.setTimeout(10_000);
|
|
42
|
+
socket.once("secureConnect", () => {
|
|
43
|
+
const peer = socket.getPeerCertificate();
|
|
44
|
+
if (peer.raw === undefined) {
|
|
45
|
+
socket.destroy();
|
|
46
|
+
rejectCertificate(new Error("source did not provide a certificate"));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const raw = peer.raw;
|
|
50
|
+
const fingerprint = decode(CertificateFingerprint)(createHash("sha256").update(raw).digest("hex"));
|
|
51
|
+
const pem = new X509Certificate(raw).toString();
|
|
52
|
+
socket.end();
|
|
53
|
+
resolveCertificate({ pem, fingerprint });
|
|
54
|
+
});
|
|
55
|
+
socket.once("timeout", () => {
|
|
56
|
+
socket.destroy(new Error("TLS connection timed out"));
|
|
57
|
+
});
|
|
58
|
+
socket.once("error", rejectCertificate);
|
|
59
|
+
}),
|
|
60
|
+
catch: () => new EnrollmentTransportError({
|
|
61
|
+
operation: "inspect source certificate",
|
|
62
|
+
message: "the source TLS certificate could not be inspected",
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
const requestJson = (method, endpoint, path, certificate, body, authorization) => Effect.tryPromise({
|
|
66
|
+
try: () => new Promise((resolveResponse, rejectResponse) => {
|
|
67
|
+
const encoded = body === undefined ? undefined : JSON.stringify(body);
|
|
68
|
+
const headers = { accept: "application/json" };
|
|
69
|
+
if (encoded !== undefined) {
|
|
70
|
+
headers["content-type"] = "application/json";
|
|
71
|
+
headers["content-length"] = Buffer.byteLength(encoded);
|
|
72
|
+
}
|
|
73
|
+
if (authorization !== undefined) {
|
|
74
|
+
headers.authorization = `Bearer ${Redacted.value(authorization)}`;
|
|
75
|
+
}
|
|
76
|
+
const request = httpsRequest({
|
|
77
|
+
protocol: "https:",
|
|
78
|
+
hostname: endpoint.hostname.replaceAll("[", "").replaceAll("]", ""),
|
|
79
|
+
port: endpoint.port,
|
|
80
|
+
path,
|
|
81
|
+
method,
|
|
82
|
+
ca: certificate.pem,
|
|
83
|
+
rejectUnauthorized: true,
|
|
84
|
+
minVersion: "TLSv1.2",
|
|
85
|
+
headers,
|
|
86
|
+
}, (response) => {
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
response.on("data", (chunk) => {
|
|
90
|
+
bytes += chunk.byteLength;
|
|
91
|
+
if (bytes > maximumResponseBytes) {
|
|
92
|
+
request.destroy(new Error("response exceeds the size limit"));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
chunks.push(chunk);
|
|
96
|
+
});
|
|
97
|
+
response.on("end", () => {
|
|
98
|
+
try {
|
|
99
|
+
resolveResponse({
|
|
100
|
+
status: response.statusCode ?? 500,
|
|
101
|
+
body: JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
rejectResponse(new Error("source returned malformed JSON"));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
request.setTimeout(10_000, () => {
|
|
110
|
+
request.destroy(new Error("source request timed out"));
|
|
111
|
+
});
|
|
112
|
+
request.once("error", rejectResponse);
|
|
113
|
+
request.end(encoded);
|
|
114
|
+
}),
|
|
115
|
+
catch: () => new EnrollmentTransportError({
|
|
116
|
+
operation: "request source enrollment endpoint",
|
|
117
|
+
message: "the source enrollment endpoint could not be reached",
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
const wireError = (response) => Schema.decodeUnknownEffect(WireEnrollmentErrorSchema)(response.body).pipe(Effect.mapError(() => new EnrollmentTransportError({
|
|
121
|
+
operation: "decode enrollment failure",
|
|
122
|
+
message: "the source returned an invalid enrollment failure",
|
|
123
|
+
})), Effect.flatMap((error) => {
|
|
124
|
+
let enrollmentError;
|
|
125
|
+
switch (error.error) {
|
|
126
|
+
case "InvitationNotFoundError":
|
|
127
|
+
enrollmentError = new InvitationNotFoundError({ message: error.message });
|
|
128
|
+
break;
|
|
129
|
+
case "InvitationExpiredError":
|
|
130
|
+
enrollmentError = new InvitationExpiredError({ message: error.message });
|
|
131
|
+
break;
|
|
132
|
+
case "InvitationReplayError":
|
|
133
|
+
enrollmentError = new InvitationReplayError({ message: error.message });
|
|
134
|
+
break;
|
|
135
|
+
case "EnrollmentSourceMismatchError":
|
|
136
|
+
enrollmentError = new EnrollmentSourceMismatchError({ message: error.message });
|
|
137
|
+
break;
|
|
138
|
+
case "EnrollmentFingerprintMismatchError":
|
|
139
|
+
enrollmentError = new EnrollmentFingerprintMismatchError({
|
|
140
|
+
message: error.message,
|
|
141
|
+
});
|
|
142
|
+
break;
|
|
143
|
+
case "MalformedEnrollmentRequestError":
|
|
144
|
+
enrollmentError = new MalformedEnrollmentRequestError({ message: error.message });
|
|
145
|
+
break;
|
|
146
|
+
case "DuplicateFollowerIdentityError":
|
|
147
|
+
enrollmentError = new DuplicateFollowerIdentityError({ message: error.message });
|
|
148
|
+
break;
|
|
149
|
+
case "InvalidFollowerCredentialError":
|
|
150
|
+
enrollmentError = new InvalidFollowerCredentialError({ message: error.message });
|
|
151
|
+
break;
|
|
152
|
+
case "RevokedFollowerCredentialError":
|
|
153
|
+
enrollmentError = new RevokedFollowerCredentialError({ message: error.message });
|
|
154
|
+
break;
|
|
155
|
+
case "TransportResourceNotFoundError":
|
|
156
|
+
enrollmentError = new TransportResourceNotFoundError({
|
|
157
|
+
resource: "transport-resource",
|
|
158
|
+
});
|
|
159
|
+
break;
|
|
160
|
+
case "TransportUnauthorizedError":
|
|
161
|
+
enrollmentError = new TransportUnauthorizedError({
|
|
162
|
+
resource: "transport-resource",
|
|
163
|
+
});
|
|
164
|
+
break;
|
|
165
|
+
case "TransportSizeLimitError":
|
|
166
|
+
enrollmentError = new TransportSizeLimitError({
|
|
167
|
+
artifact: "transport-response",
|
|
168
|
+
limit: 0,
|
|
169
|
+
});
|
|
170
|
+
break;
|
|
171
|
+
case "TransportIntegrityError":
|
|
172
|
+
enrollmentError = new TransportIntegrityError({
|
|
173
|
+
artifact: "source",
|
|
174
|
+
message: error.message,
|
|
175
|
+
});
|
|
176
|
+
break;
|
|
177
|
+
default:
|
|
178
|
+
enrollmentError = new EnrollmentTransportError({
|
|
179
|
+
operation: "source enrollment request",
|
|
180
|
+
message: `the source rejected the enrollment request (${error.error})`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return Effect.fail(enrollmentError);
|
|
184
|
+
}));
|
|
185
|
+
export const enrollFollower = (input) => Effect.gen(function* () {
|
|
186
|
+
const machine = yield* MachineState;
|
|
187
|
+
const endpoint = yield* checkedEndpoint(input.invitation.endpoint);
|
|
188
|
+
const certificate = yield* inspectCertificate(endpoint);
|
|
189
|
+
if (certificate.fingerprint !== input.invitation.tlsFingerprint) {
|
|
190
|
+
return yield* new EnrollmentFingerprintMismatchError({
|
|
191
|
+
message: "the source TLS fingerprint does not match the invitation",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const response = yield* requestJson("POST", endpoint, "/v1/enrollment", certificate, {
|
|
195
|
+
code: input.invitation.code,
|
|
196
|
+
nonce: input.invitation.nonce,
|
|
197
|
+
sourceFingerprint: input.invitation.sourceFingerprint,
|
|
198
|
+
tlsFingerprint: input.invitation.tlsFingerprint,
|
|
199
|
+
followerName: input.followerName,
|
|
200
|
+
});
|
|
201
|
+
if (response.status !== 201)
|
|
202
|
+
return yield* wireError(response);
|
|
203
|
+
const enrolled = yield* Schema.decodeUnknownEffect(EnrollFollowerResponseSchema)(response.body).pipe(Effect.mapError(() => new EnrollmentTransportError({
|
|
204
|
+
operation: "decode enrollment response",
|
|
205
|
+
message: "the source returned an invalid enrollment response",
|
|
206
|
+
})));
|
|
207
|
+
if (enrolled.source.publicKeyFingerprint !== input.invitation.sourceFingerprint) {
|
|
208
|
+
return yield* new EnrollmentSourceMismatchError({
|
|
209
|
+
message: "the enrolled source identity does not match the invitation",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (enrolled.tlsFingerprint !== input.invitation.tlsFingerprint) {
|
|
213
|
+
return yield* new EnrollmentFingerprintMismatchError({
|
|
214
|
+
message: "the enrolled TLS fingerprint does not match the invitation",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const credentialReference = yield* machine.storeCredential({
|
|
218
|
+
name: `canonfig-follower-${enrolled.follower.id}-${enrolled.source.publicKeyFingerprint}`,
|
|
219
|
+
value: Redacted.make(enrolled.credential),
|
|
220
|
+
}).pipe(Effect.mapError(() => new EnrollmentTransportError({
|
|
221
|
+
operation: "store follower credential",
|
|
222
|
+
message: "secure follower credential storage is unavailable",
|
|
223
|
+
})));
|
|
224
|
+
const credential = Redacted.make(enrolled.credential);
|
|
225
|
+
if (input.finalize !== false) {
|
|
226
|
+
const finalized = yield* requestJson("POST", endpoint, "/v1/enrollment/finalize", certificate, undefined, credential);
|
|
227
|
+
if (finalized.status !== 200) {
|
|
228
|
+
yield* machine.removeCredential(credentialReference).pipe(Effect.ignore);
|
|
229
|
+
return yield* wireError(finalized);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
follower: enrolled.follower,
|
|
234
|
+
credentialReference,
|
|
235
|
+
source: enrolled.source,
|
|
236
|
+
tlsFingerprint: enrolled.tlsFingerprint,
|
|
237
|
+
authorizedProfiles: enrolled.authorizedProfiles,
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
const mutateEnrollment = (input) => Effect.gen(function* () {
|
|
241
|
+
const machine = yield* MachineState;
|
|
242
|
+
const endpoint = yield* checkedEndpoint(input.endpoint);
|
|
243
|
+
const certificate = yield* inspectCertificate(endpoint);
|
|
244
|
+
if (certificate.fingerprint !== input.tlsFingerprint) {
|
|
245
|
+
return yield* new EnrollmentFingerprintMismatchError({
|
|
246
|
+
message: "the source TLS fingerprint does not match the pinned fingerprint",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const credential = yield* machine.loadCredential({
|
|
250
|
+
reference: input.credentialReference,
|
|
251
|
+
}).pipe(Effect.mapError(() => new InvalidFollowerCredentialError({
|
|
252
|
+
message: "the follower credential is unavailable",
|
|
253
|
+
})));
|
|
254
|
+
const response = yield* requestJson("POST", endpoint, input.path, certificate, undefined, credential).pipe(Effect.ensuring(input.removeLocal
|
|
255
|
+
? machine.removeCredential(input.credentialReference).pipe(Effect.ignore)
|
|
256
|
+
: Effect.void));
|
|
257
|
+
if (response.status !== 200)
|
|
258
|
+
return yield* wireError(response);
|
|
259
|
+
});
|
|
260
|
+
export const cancelFollowerEnrollment = (input) => mutateEnrollment({ ...input, path: "/v1/enrollment/cancel", removeLocal: true });
|
|
261
|
+
export const finalizeFollowerEnrollment = (input) => mutateEnrollment({ ...input, path: "/v1/enrollment/finalize", removeLocal: false });
|
|
262
|
+
export const revokeFollowerEnrollment = (input) => mutateEnrollment({ ...input, path: "/v1/enrollment/revoke", removeLocal: true });
|
|
263
|
+
export const authenticateFollower = (input) => Effect.gen(function* () {
|
|
264
|
+
const machine = yield* MachineState;
|
|
265
|
+
const endpoint = yield* checkedEndpoint(input.endpoint);
|
|
266
|
+
const certificate = yield* inspectCertificate(endpoint);
|
|
267
|
+
if (certificate.fingerprint !== input.tlsFingerprint) {
|
|
268
|
+
return yield* new EnrollmentFingerprintMismatchError({
|
|
269
|
+
message: "the source TLS fingerprint does not match the pinned fingerprint",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const credential = yield* machine.loadCredential({
|
|
273
|
+
reference: input.credentialReference,
|
|
274
|
+
}).pipe(Effect.mapError(() => new InvalidFollowerCredentialError({
|
|
275
|
+
message: "the follower credential is unavailable",
|
|
276
|
+
})));
|
|
277
|
+
const response = yield* requestJson("GET", endpoint, "/v1/enrollment/authenticate", certificate, undefined, credential);
|
|
278
|
+
if (response.status !== 200)
|
|
279
|
+
return yield* wireError(response);
|
|
280
|
+
return yield* Schema.decodeUnknownEffect(AuthenticatedFollowerSchema)(response.body).pipe(Effect.mapError(() => new EnrollmentTransportError({
|
|
281
|
+
operation: "decode authentication response",
|
|
282
|
+
message: "the source returned an invalid authentication response",
|
|
283
|
+
})));
|
|
284
|
+
});
|
|
285
|
+
const asJson = (value) => decode(Schema.MutableJson)(JSON.parse(JSON.stringify(value)));
|
|
286
|
+
const transportRequest = (endpoint, path, certificate, credential, maximumBytes, timeoutMilliseconds, signal) => Effect.tryPromise({
|
|
287
|
+
try: () => new Promise((resolveResponse, rejectResponse) => {
|
|
288
|
+
let settled = false;
|
|
289
|
+
const resolveOnce = (response) => {
|
|
290
|
+
if (settled)
|
|
291
|
+
return;
|
|
292
|
+
settled = true;
|
|
293
|
+
resolveResponse(response);
|
|
294
|
+
};
|
|
295
|
+
const rejectOnce = (cause) => {
|
|
296
|
+
if (settled)
|
|
297
|
+
return;
|
|
298
|
+
settled = true;
|
|
299
|
+
rejectResponse(cause);
|
|
300
|
+
};
|
|
301
|
+
if (signal?.aborted === true) {
|
|
302
|
+
rejectOnce(new TransportInterruptedError({
|
|
303
|
+
operation: "request source transport endpoint",
|
|
304
|
+
}));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const request = httpsRequest({
|
|
308
|
+
protocol: "https:",
|
|
309
|
+
hostname: endpoint.hostname.replaceAll("[", "").replaceAll("]", ""),
|
|
310
|
+
port: endpoint.port,
|
|
311
|
+
path,
|
|
312
|
+
method: "GET",
|
|
313
|
+
ca: certificate.pem,
|
|
314
|
+
rejectUnauthorized: true,
|
|
315
|
+
minVersion: "TLSv1.2",
|
|
316
|
+
headers: {
|
|
317
|
+
authorization: `Bearer ${Redacted.value(credential)}`,
|
|
318
|
+
accept: "application/json, application/octet-stream",
|
|
319
|
+
},
|
|
320
|
+
}, (response) => {
|
|
321
|
+
const chunks = [];
|
|
322
|
+
let bytes = 0;
|
|
323
|
+
response.on("data", (chunk) => {
|
|
324
|
+
bytes += chunk.byteLength;
|
|
325
|
+
if (bytes > maximumBytes) {
|
|
326
|
+
rejectOnce(new TransportSizeLimitError({
|
|
327
|
+
artifact: path,
|
|
328
|
+
limit: maximumBytes,
|
|
329
|
+
}));
|
|
330
|
+
response.destroy();
|
|
331
|
+
request.destroy();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
chunks.push(chunk);
|
|
335
|
+
});
|
|
336
|
+
response.on("end", () => {
|
|
337
|
+
const body = Buffer.concat(chunks);
|
|
338
|
+
if ((response.statusCode ?? 500) >= 400) {
|
|
339
|
+
try {
|
|
340
|
+
resolveOnce({
|
|
341
|
+
status: response.statusCode ?? 500,
|
|
342
|
+
body,
|
|
343
|
+
errorBody: JSON.parse(body.toString("utf8")),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
rejectOnce(new TransportMalformedResponseError({
|
|
348
|
+
operation: "decode transport failure",
|
|
349
|
+
message: "the source returned an invalid failure",
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
resolveOnce({
|
|
355
|
+
status: response.statusCode ?? 500,
|
|
356
|
+
body,
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
const abort = () => {
|
|
361
|
+
request.destroy(new TransportInterruptedError({
|
|
362
|
+
operation: "request source transport endpoint",
|
|
363
|
+
}));
|
|
364
|
+
};
|
|
365
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
366
|
+
request.setTimeout(timeoutMilliseconds, () => {
|
|
367
|
+
request.destroy(new TransportInterruptedError({
|
|
368
|
+
operation: "request source transport endpoint",
|
|
369
|
+
}));
|
|
370
|
+
});
|
|
371
|
+
request.once("error", rejectOnce);
|
|
372
|
+
request.once("close", () => signal?.removeEventListener("abort", abort));
|
|
373
|
+
request.end();
|
|
374
|
+
}),
|
|
375
|
+
catch: (cause) => {
|
|
376
|
+
if (cause instanceof TransportInterruptedError
|
|
377
|
+
|| cause instanceof TransportMalformedResponseError
|
|
378
|
+
|| cause instanceof TransportSizeLimitError)
|
|
379
|
+
return cause;
|
|
380
|
+
return new EnrollmentTransportError({
|
|
381
|
+
operation: "request source transport endpoint",
|
|
382
|
+
message: "the source transport endpoint could not be reached",
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
const transportContext = (input) => Effect.gen(function* () {
|
|
387
|
+
const machine = yield* MachineState;
|
|
388
|
+
const endpoint = yield* checkedEndpoint(input.endpoint);
|
|
389
|
+
const certificate = yield* inspectCertificate(endpoint);
|
|
390
|
+
if (certificate.fingerprint !== input.tlsFingerprint) {
|
|
391
|
+
return yield* new EnrollmentFingerprintMismatchError({
|
|
392
|
+
message: "the source TLS fingerprint does not match the pinned fingerprint",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const credential = yield* machine.loadCredential({
|
|
396
|
+
reference: input.credentialReference,
|
|
397
|
+
}).pipe(Effect.mapError(() => new InvalidFollowerCredentialError({
|
|
398
|
+
message: "the follower credential is unavailable",
|
|
399
|
+
})));
|
|
400
|
+
return { endpoint, certificate, credential };
|
|
401
|
+
});
|
|
402
|
+
const transportFailure = (response) => wireError({ status: response.status, body: response.errorBody });
|
|
403
|
+
const decodeJsonBody = (schema, response, operation) => Effect.try({
|
|
404
|
+
try: () => decode(schema)(JSON.parse(Buffer.from(response.body).toString("utf8"))),
|
|
405
|
+
catch: () => new TransportMalformedResponseError({
|
|
406
|
+
operation,
|
|
407
|
+
message: "the source returned malformed transport metadata",
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
const verifyMetadata = (metadata, sourceFingerprint) => Effect.try({
|
|
411
|
+
try: () => {
|
|
412
|
+
const publicKey = createPublicKey(metadata.signingPublicKey);
|
|
413
|
+
const fingerprint = createHash("sha256").update(publicKey.export({
|
|
414
|
+
type: "spki",
|
|
415
|
+
format: "der",
|
|
416
|
+
})).digest("hex");
|
|
417
|
+
if (fingerprint !== sourceFingerprint
|
|
418
|
+
|| metadata.signingKeyId !== `ed25519:${sourceFingerprint}`) {
|
|
419
|
+
throw new Error("source signing identity mismatch");
|
|
420
|
+
}
|
|
421
|
+
const unsigned = {
|
|
422
|
+
id: metadata.id,
|
|
423
|
+
profileId: metadata.profileId,
|
|
424
|
+
sequence: metadata.sequence,
|
|
425
|
+
digest: metadata.digest,
|
|
426
|
+
publishedAt: metadata.publishedAt,
|
|
427
|
+
resources: metadata.resources,
|
|
428
|
+
scheduleDefault: metadata.scheduleDefault,
|
|
429
|
+
signingKeyId: metadata.signingKeyId,
|
|
430
|
+
signingPublicKey: metadata.signingPublicKey,
|
|
431
|
+
sourceSignature: metadata.sourceSignature,
|
|
432
|
+
};
|
|
433
|
+
if (digestOf(asJson(unsigned)) !== metadata.metadataDigest) {
|
|
434
|
+
throw new Error("revision metadata digest mismatch");
|
|
435
|
+
}
|
|
436
|
+
const payload = canonicalJson(asJson({
|
|
437
|
+
...unsigned,
|
|
438
|
+
metadataDigest: metadata.metadataDigest,
|
|
439
|
+
}));
|
|
440
|
+
if (!metadata.signature.startsWith("ed25519:")
|
|
441
|
+
|| !verify(null, Buffer.from(payload), publicKey, Buffer.from(metadata.signature.slice("ed25519:".length), "base64url"))) {
|
|
442
|
+
throw new Error("revision metadata signature mismatch");
|
|
443
|
+
}
|
|
444
|
+
return metadata;
|
|
445
|
+
},
|
|
446
|
+
catch: (cause) => new TransportIntegrityError({
|
|
447
|
+
artifact: "revision-metadata",
|
|
448
|
+
message: cause instanceof Error
|
|
449
|
+
? cause.message
|
|
450
|
+
: "revision metadata verification failed",
|
|
451
|
+
}),
|
|
452
|
+
});
|
|
453
|
+
export const listRevisions = (input) => Effect.gen(function* () {
|
|
454
|
+
const context = yield* transportContext(input);
|
|
455
|
+
const response = yield* transportRequest(context.endpoint, "/v1/transport/revisions", context.certificate, context.credential, defaultMaximumMetadataBytes, input.timeoutMilliseconds ?? defaultTimeoutMilliseconds, input.signal);
|
|
456
|
+
if (response.status !== 200)
|
|
457
|
+
return yield* transportFailure(response);
|
|
458
|
+
return yield* decodeJsonBody(RevisionListSchema, response, "decode revision list");
|
|
459
|
+
});
|
|
460
|
+
export const getRevisionMetadata = (input) => Effect.gen(function* () {
|
|
461
|
+
const context = yield* transportContext(input);
|
|
462
|
+
const response = yield* transportRequest(context.endpoint, `/v1/transport/revisions/${encodeURIComponent(input.revisionId)}`, context.certificate, context.credential, input.maximumMetadataBytes ?? defaultMaximumMetadataBytes, input.timeoutMilliseconds ?? defaultTimeoutMilliseconds, input.signal);
|
|
463
|
+
if (response.status !== 200)
|
|
464
|
+
return yield* transportFailure(response);
|
|
465
|
+
const metadata = yield* decodeJsonBody(RevisionMetadataSchema, response, "decode revision metadata");
|
|
466
|
+
return yield* verifyMetadata(metadata, input.sourceFingerprint);
|
|
467
|
+
});
|
|
468
|
+
export const retrieveBlob = (input) => Effect.gen(function* () {
|
|
469
|
+
const context = yield* transportContext(input);
|
|
470
|
+
const response = yield* transportRequest(context.endpoint, `/v1/transport/blobs/${input.blobId}`, context.certificate, context.credential, input.maximumBlobBytes ?? defaultMaximumBlobBytes, input.timeoutMilliseconds ?? defaultTimeoutMilliseconds, input.signal);
|
|
471
|
+
if (response.status !== 200)
|
|
472
|
+
return yield* transportFailure(response);
|
|
473
|
+
if (sha256BytesHex(response.body) !== input.blobId) {
|
|
474
|
+
return yield* new TransportIntegrityError({
|
|
475
|
+
artifact: input.blobId,
|
|
476
|
+
message: "blob digest mismatch",
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
return response.body;
|
|
480
|
+
});
|
|
481
|
+
const atomicWrite = (path, bytes) => {
|
|
482
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
483
|
+
return writeFile(temporary, bytes, { flag: "wx", mode: 0o600 })
|
|
484
|
+
.then(async () => {
|
|
485
|
+
const file = await open(temporary, "r");
|
|
486
|
+
try {
|
|
487
|
+
await file.sync().catch((cause) => {
|
|
488
|
+
if (process.platform !== "win32"
|
|
489
|
+
|| (cause.code !== "EPERM" && cause.code !== "EINVAL")) {
|
|
490
|
+
throw cause;
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
await file.close();
|
|
496
|
+
}
|
|
497
|
+
await rename(temporary, path);
|
|
498
|
+
})
|
|
499
|
+
.catch(async (cause) => {
|
|
500
|
+
await unlink(temporary).catch(() => undefined);
|
|
501
|
+
throw cause;
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
const filesystemCause = (cause) => {
|
|
505
|
+
if (!(cause instanceof Error))
|
|
506
|
+
return "unknown filesystem failure";
|
|
507
|
+
const decodedCode = Schema.decodeUnknownOption(Schema.Struct({ code: Schema.String }))(cause);
|
|
508
|
+
const code = Option.isSome(decodedCode) ? decodedCode.value.code : cause.name;
|
|
509
|
+
const message = cause.message
|
|
510
|
+
.replace(/(https?:\/\/)[^@\s/]+@/giu, "$1[REDACTED]@")
|
|
511
|
+
.replace(/(token|password|credential)=([^&\s]+)/giu, "$1=[REDACTED]")
|
|
512
|
+
.replace(/\s+/gu, " ")
|
|
513
|
+
.slice(0, 1024);
|
|
514
|
+
return `${code}: ${message}`;
|
|
515
|
+
};
|
|
516
|
+
const cacheFailure = (operation, cause) => new EnrollmentTransportError({
|
|
517
|
+
operation,
|
|
518
|
+
message: `the follower transport cache is unavailable (${filesystemCause(cause)})`,
|
|
519
|
+
});
|
|
520
|
+
const ensureCacheDirectory = async (path) => {
|
|
521
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
522
|
+
if (process.platform !== "win32")
|
|
523
|
+
await chmod(path, 0o700);
|
|
524
|
+
};
|
|
525
|
+
export const atomicCacheWrite = atomicWrite;
|
|
526
|
+
const cachedBlob = async (directory, id) => {
|
|
527
|
+
const path = join(directory, id);
|
|
528
|
+
try {
|
|
529
|
+
const bytes = await readFile(path);
|
|
530
|
+
if (sha256BytesHex(bytes) === id)
|
|
531
|
+
return { id: decode(BlobId)(id), path };
|
|
532
|
+
await unlink(path);
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
export const fetchRevision = (input) => Effect.gen(function* () {
|
|
540
|
+
const metadata = yield* getRevisionMetadata(input);
|
|
541
|
+
const blobDirectory = join(input.cacheDirectory, "blobs");
|
|
542
|
+
const revisionDirectory = join(input.cacheDirectory, "revisions");
|
|
543
|
+
yield* Effect.tryPromise({
|
|
544
|
+
try: () => Promise.all([
|
|
545
|
+
ensureCacheDirectory(blobDirectory),
|
|
546
|
+
ensureCacheDirectory(revisionDirectory),
|
|
547
|
+
]),
|
|
548
|
+
catch: (cause) => cacheFailure("create follower transport cache", cause),
|
|
549
|
+
});
|
|
550
|
+
const ids = [...new Set(metadata.resources.flatMap((resource) => resource.blobs))];
|
|
551
|
+
const blobs = [];
|
|
552
|
+
let downloadedBlobs = 0;
|
|
553
|
+
let reusedBlobs = 0;
|
|
554
|
+
for (const id of ids) {
|
|
555
|
+
const existing = yield* Effect.promise(() => cachedBlob(blobDirectory, id));
|
|
556
|
+
if (existing !== undefined) {
|
|
557
|
+
blobs.push(existing);
|
|
558
|
+
reusedBlobs += 1;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const bytes = yield* retrieveBlob({
|
|
562
|
+
endpoint: input.endpoint,
|
|
563
|
+
tlsFingerprint: input.tlsFingerprint,
|
|
564
|
+
credentialReference: input.credentialReference,
|
|
565
|
+
sourceFingerprint: input.sourceFingerprint,
|
|
566
|
+
blobId: id,
|
|
567
|
+
timeoutMilliseconds: input.timeoutMilliseconds,
|
|
568
|
+
maximumBlobBytes: input.maximumBlobBytes,
|
|
569
|
+
signal: input.signal,
|
|
570
|
+
});
|
|
571
|
+
const path = join(blobDirectory, id);
|
|
572
|
+
yield* Effect.tryPromise({
|
|
573
|
+
try: () => atomicWrite(path, bytes),
|
|
574
|
+
catch: (cause) => cacheFailure("cache verified blob", cause),
|
|
575
|
+
});
|
|
576
|
+
blobs.push({ id, path });
|
|
577
|
+
downloadedBlobs += 1;
|
|
578
|
+
}
|
|
579
|
+
const metadataPath = join(revisionDirectory, `${createHash("sha256").update(metadata.id).digest("hex")}.json`);
|
|
580
|
+
yield* Effect.tryPromise({
|
|
581
|
+
try: () => atomicWrite(metadataPath, Buffer.from(JSON.stringify(metadata))),
|
|
582
|
+
catch: (cause) => cacheFailure("cache verified revision metadata", cause),
|
|
583
|
+
});
|
|
584
|
+
return { metadata, blobs, downloadedBlobs, reusedBlobs };
|
|
585
|
+
});
|