@serfab/cadre-host 0.7.1 → 0.9.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/README.md +296 -101
- package/dist/auth/trust-circle.d.ts +45 -7
- package/dist/auth/trust-circle.js +116 -59
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +4 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/bin/host.js +426 -112
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +136 -0
- package/dist/donation/donation-service.js +306 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +42 -0
- package/dist/donation/donation-store.js +141 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/grant-service.d.ts +71 -0
- package/dist/donation/grant-service.js +163 -0
- package/dist/donation/grant-service.js.map +1 -0
- package/dist/donation/grant-store.d.ts +38 -0
- package/dist/donation/grant-store.js +135 -0
- package/dist/donation/grant-store.js.map +1 -0
- package/dist/donation/index.d.ts +18 -0
- package/dist/donation/index.js +16 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +175 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/browser.js +16 -17
- package/dist/installer/browser.js.map +1 -1
- package/dist/installer/config.d.ts +51 -0
- package/dist/installer/config.js +14 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -2
- package/dist/installer/index.js +17 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/service-host/launchd.js +1 -1
- package/dist/installer/service-host/launchd.js.map +1 -1
- package/dist/installer/service-host/systemd.js +1 -1
- package/dist/installer/service-host/systemd.js.map +1 -1
- package/dist/installer/wizard.d.ts +4 -0
- package/dist/installer/wizard.js +5 -1
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/index.d.ts +1 -1
- package/dist/nat/nat-service.d.ts +45 -3
- package/dist/nat/nat-service.js +116 -3
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/secrets/index.d.ts +7 -0
- package/dist/nat/secrets/index.js +28 -2
- package/dist/nat/secrets/index.js.map +1 -1
- package/dist/nat/types.d.ts +3 -1
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
- package/dist/orchestrator/host-process-orchestrator.js +272 -39
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -2
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +6 -5
- package/dist/orchestrator/state-store.js +10 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +45 -10
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/owner/index.d.ts +11 -0
- package/dist/owner/index.js +11 -0
- package/dist/owner/index.js.map +1 -0
- package/dist/owner/owner-node-client.d.ts +95 -0
- package/dist/owner/owner-node-client.js +167 -0
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/push/index.d.ts +67 -0
- package/dist/push/index.js +101 -0
- package/dist/push/index.js.map +1 -0
- package/dist/server/error-handler.d.ts +17 -0
- package/dist/server/error-handler.js +45 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.js +37 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +17 -0
- package/dist/server/routes/grants-admin.js +32 -0
- package/dist/server/routes/grants-admin.js.map +1 -0
- package/dist/server/routes/grants.d.ts +28 -0
- package/dist/server/routes/grants.js +163 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -3
- package/dist/server/routes/nodes.js +39 -24
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +6 -1
- package/dist/server/routes/settings.js +5 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +8 -4
- package/dist/server/routes/status.js +9 -4
- package/dist/server/routes/status.js.map +1 -1
- package/dist/server/static.d.ts +12 -0
- package/dist/server/static.js +24 -7
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +14 -0
- package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/update/apply.d.ts +23 -1
- package/dist/update/apply.js +46 -4
- package/dist/update/apply.js.map +1 -1
- package/dist/update/index.d.ts +14 -3
- package/dist/update/index.js +35 -2
- package/dist/update/index.js.map +1 -1
- package/dist/update/manifest.d.ts +28 -9
- package/dist/update/manifest.js +56 -29
- package/dist/update/manifest.js.map +1 -1
- package/dist/update/release-key.d.ts +18 -7
- package/dist/update/release-key.js +29 -14
- package/dist/update/release-key.js.map +1 -1
- package/dist/update/sign.d.ts +46 -0
- package/dist/update/sign.js +53 -0
- package/dist/update/sign.js.map +1 -0
- package/package.json +9 -4
- package/service/README.md +95 -86
- package/src/__tests__/cli-invite.smoke.test.ts +6 -1
- package/src/__tests__/cli-nat.smoke.test.ts +6 -1
- package/src/__tests__/cli.smoke.test.ts +6 -1
- package/src/__tests__/host-owner.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-owner.test.ts +198 -0
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
- package/src/auth/__tests__/trust-circle.test.ts +91 -24
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/bin/host.ts +1258 -905
- package/src/donation/__tests__/donation-service.test.ts +290 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -0
- package/src/donation/__tests__/grant-store.test.ts +113 -0
- package/src/donation/donation-service.ts +392 -0
- package/src/donation/donation-store.ts +164 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +49 -0
- package/src/donation/types.ts +220 -0
- package/src/index.ts +66 -3
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +94 -0
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +61 -1
- package/src/installer/index.ts +23 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/installer/wizard.ts +12 -1
- package/src/nat/__tests__/ddns-updater.test.ts +1 -1
- package/src/nat/__tests__/nat-service.test.ts +192 -0
- package/src/nat/__tests__/port-mapper.test.ts +1 -1
- package/src/nat/__tests__/secrets.test.ts +23 -1
- package/src/nat/index.ts +1 -1
- package/src/nat/nat-service.ts +131 -5
- package/src/nat/secrets/index.ts +32 -2
- package/src/nat/types.ts +3 -1
- package/src/orchestrator/host-process-orchestrator.ts +322 -54
- package/src/orchestrator/index.ts +9 -2
- package/src/orchestrator/state-store.ts +21 -3
- package/src/orchestrator/types.ts +48 -2
- package/src/owner/__tests__/owner-node-client.test.ts +194 -0
- package/src/owner/index.ts +12 -0
- package/src/owner/owner-node-client.ts +233 -0
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +26 -4
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +189 -0
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +23 -2
- package/src/server/error-handler.ts +47 -0
- package/src/server/index.ts +62 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +186 -0
- package/src/server/routes/nodes.ts +49 -26
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- package/src/server/static.ts +24 -8
- package/src/update/__tests__/apply.test.ts +58 -1
- package/src/update/__tests__/manifest.test.ts +41 -6
- package/src/update/__tests__/release-key.test.ts +42 -0
- package/src/update/__tests__/sign.test.ts +78 -0
- package/src/update/__tests__/update-service.integration.test.ts +44 -9
- package/src/update/apply.ts +46 -4
- package/src/update/index.ts +41 -3
- package/src/update/manifest.ts +214 -172
- package/src/update/release-key.ts +31 -15
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
package/src/update/manifest.ts
CHANGED
|
@@ -1,172 +1,214 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fetch + verify the signed update manifest.
|
|
3
|
-
*
|
|
4
|
-
* The manifest is a JSON envelope `{ manifest, sig }` where `sig` is a
|
|
5
|
-
* detached Ed25519 signature of `canonicalJson(manifest)`. cadre-host only
|
|
6
|
-
* trusts manifests whose signature matches the embedded release public key.
|
|
7
|
-
*
|
|
8
|
-
* Canonical serialization is
|
|
9
|
-
*
|
|
10
|
-
* the shape is fixed (`UpdateManifest`).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { sign as cryptoSign, verify as cryptoVerify, type KeyObject } from 'node:crypto';
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
throw new
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
return
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Fetch + verify the signed update manifest.
|
|
3
|
+
*
|
|
4
|
+
* The manifest is a JSON envelope `{ manifest, sig }` where `sig` is a
|
|
5
|
+
* detached Ed25519 signature of `canonicalJson(manifest)`. cadre-host only
|
|
6
|
+
* trusts manifests whose signature matches the embedded release public key.
|
|
7
|
+
*
|
|
8
|
+
* Canonical serialization (`canonicalJson`) is shared from `@serfab/cadre-core`
|
|
9
|
+
* so the seed-signing path and this manifest-signing path agree on the signed
|
|
10
|
+
* byte representation; the shape we sign here is fixed (`UpdateManifest`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { sign as cryptoSign, verify as cryptoVerify, type KeyObject } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
import { canonicalJson } from '@serfab/cadre-core';
|
|
16
|
+
|
|
17
|
+
import { ed25519FromRaw, getReleasePublicKey, getReleasePublicKeyBase64 } from './release-key.js';
|
|
18
|
+
import {
|
|
19
|
+
UpdateErrorException,
|
|
20
|
+
type SignedManifest,
|
|
21
|
+
type UpdateManifest,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
import { parseVersion } from './version.js';
|
|
24
|
+
|
|
25
|
+
/** npm package naming rule — slightly looser than the official one (we don't enforce length here other than the 214-char total). */
|
|
26
|
+
const NPM_PACKAGE_NAME_RE = /^(?:@[a-z0-9-_.]+\/)?[a-z0-9-_.]+$/;
|
|
27
|
+
|
|
28
|
+
/** Default release-manifest URL. Overridable via env var on the caller. */
|
|
29
|
+
export const DEFAULT_MANIFEST_URL = 'https://releases.serfab.io/cadre-host/latest.json';
|
|
30
|
+
|
|
31
|
+
export interface FetchManifestOptions {
|
|
32
|
+
url: string;
|
|
33
|
+
fetcher?: typeof fetch;
|
|
34
|
+
/** Total fetch timeout (connect + read) in ms; default 10000. */
|
|
35
|
+
readTimeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch the manifest endpoint and validate the signature against the embedded
|
|
40
|
+
* release public key (or `CADRE_HOST_UPDATE_DEV_KEY` if set).
|
|
41
|
+
*
|
|
42
|
+
* Throws `UpdateErrorException` on any failure (bad JSON, bad signature,
|
|
43
|
+
* etc.). Network errors are *thrown* — the caller decides whether to log
|
|
44
|
+
* them at debug (the cadence path) or surface them (the apply path).
|
|
45
|
+
*/
|
|
46
|
+
export async function fetchManifest(opts: FetchManifestOptions): Promise<UpdateManifest> {
|
|
47
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
48
|
+
const readTimeout = opts.readTimeoutMs ?? 10_000;
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const timer = setTimeout(() => controller.abort(), readTimeout);
|
|
51
|
+
let response: Response;
|
|
52
|
+
try {
|
|
53
|
+
response = await fetcher(opts.url, { signal: controller.signal });
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`manifest endpoint returned ${response.status}`);
|
|
59
|
+
}
|
|
60
|
+
let body: unknown;
|
|
61
|
+
try {
|
|
62
|
+
body = await response.json();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new UpdateErrorException(
|
|
65
|
+
'manifest_invalid',
|
|
66
|
+
`manifest is not valid JSON: ${(err as Error).message}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return verifyManifest(body);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Verify a parsed signed-manifest body. Pure (no network), so unit tests
|
|
74
|
+
* exercise this directly.
|
|
75
|
+
*/
|
|
76
|
+
export function verifyManifest(
|
|
77
|
+
body: unknown,
|
|
78
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
79
|
+
): UpdateManifest {
|
|
80
|
+
if (!isSignedManifest(body)) {
|
|
81
|
+
throw new UpdateErrorException('manifest_invalid', 'manifest envelope is malformed');
|
|
82
|
+
}
|
|
83
|
+
if (!isUpdateManifest(body.manifest)) {
|
|
84
|
+
throw new UpdateErrorException('manifest_invalid', 'manifest payload is missing required fields');
|
|
85
|
+
}
|
|
86
|
+
validateManifestFields(body.manifest);
|
|
87
|
+
|
|
88
|
+
const sig = parseSignature(body.sig);
|
|
89
|
+
const canonical = Buffer.from(canonicalJson(body.manifest), 'utf8');
|
|
90
|
+
const key = getReleasePublicKey(env);
|
|
91
|
+
|
|
92
|
+
let ok: boolean;
|
|
93
|
+
try {
|
|
94
|
+
ok = cryptoVerify(null, canonical, key, sig);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
throw new UpdateErrorException(
|
|
97
|
+
'signature_invalid',
|
|
98
|
+
`signature verification threw: ${(err as Error).message}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (!ok) {
|
|
102
|
+
throw new UpdateErrorException('signature_invalid', 'manifest signature did not verify');
|
|
103
|
+
}
|
|
104
|
+
return body.manifest;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sign a manifest with the release private key, producing the `{ manifest, sig }`
|
|
109
|
+
* envelope published as `latest.json`. This is the production signing operation:
|
|
110
|
+
* it canonicalizes with the same `canonicalJson` that `verifyManifest` consumes
|
|
111
|
+
* and emits a detached `ed25519:<base64>` signature. The private key never ships
|
|
112
|
+
* in the binary — signing runs offline on the release operator's machine (see
|
|
113
|
+
* `scripts/sign-manifest.mjs`).
|
|
114
|
+
*/
|
|
115
|
+
export function signManifest(manifest: UpdateManifest, privateKey: KeyObject): SignedManifest {
|
|
116
|
+
const canonical = Buffer.from(canonicalJson(manifest), 'utf8');
|
|
117
|
+
const sig = cryptoSign(null, canonical, privateKey).toString('base64');
|
|
118
|
+
return { manifest, sig: `ed25519:${sig}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Used by `apply.ts` to detect which key the signer matches. */
|
|
122
|
+
export function publicKeyMatchesEmbedded(rawBase64: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
123
|
+
return rawBase64 === getReleasePublicKeyBase64(env);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Wrapper for `getReleasePublicKey`, re-exported for parity with `ed25519FromRaw`. */
|
|
127
|
+
export { ed25519FromRaw };
|
|
128
|
+
|
|
129
|
+
/** ---- internals ---- */
|
|
130
|
+
|
|
131
|
+
function parseSignature(sig: string): Buffer {
|
|
132
|
+
const match = /^ed25519:(.+)$/.exec(sig);
|
|
133
|
+
if (!match) {
|
|
134
|
+
throw new UpdateErrorException('signature_invalid', `unsupported signature scheme: ${sig.slice(0, 16)}…`);
|
|
135
|
+
}
|
|
136
|
+
const decoded = Buffer.from(match[1]!, 'base64');
|
|
137
|
+
if (decoded.length !== 64) {
|
|
138
|
+
throw new UpdateErrorException('signature_invalid', `Ed25519 signature must be 64 bytes, got ${decoded.length}`);
|
|
139
|
+
}
|
|
140
|
+
return decoded;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isSignedManifest(v: unknown): v is SignedManifest {
|
|
144
|
+
if (!v || typeof v !== 'object') return false;
|
|
145
|
+
const obj = v as Record<string, unknown>;
|
|
146
|
+
return typeof obj.sig === 'string' && obj.manifest !== undefined && typeof obj.manifest === 'object';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Field-level validation that goes beyond the structural `isUpdateManifest`
|
|
151
|
+
* type guard: enforces semver on version strings, ISO-8601 round-tripping on
|
|
152
|
+
* the publish timestamp, and the npm naming regex on the package name. A
|
|
153
|
+
* signed-but-malformed manifest would otherwise surface as a confusing
|
|
154
|
+
* `compareVersions` throw further downstream.
|
|
155
|
+
*
|
|
156
|
+
* Exported so the offline signing tool (`sign.ts` / `sign-manifest.mjs`) runs
|
|
157
|
+
* the *exact same* rules before it signs — the signer can never emit a
|
|
158
|
+
* manifest that `verifyManifest` would later reject as `manifest_invalid`.
|
|
159
|
+
*/
|
|
160
|
+
export function validateManifestFields(m: UpdateManifest): void {
|
|
161
|
+
try {
|
|
162
|
+
parseVersion(m.version);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
throw new UpdateErrorException(
|
|
165
|
+
'manifest_invalid',
|
|
166
|
+
`manifest version "${m.version}" is not a valid semver: ${(err as Error).message}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (m.minPreviousVersion !== undefined) {
|
|
170
|
+
try {
|
|
171
|
+
parseVersion(m.minPreviousVersion);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
throw new UpdateErrorException(
|
|
174
|
+
'manifest_invalid',
|
|
175
|
+
`manifest minPreviousVersion "${m.minPreviousVersion}" is not a valid semver: ${(err as Error).message}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const parsed = new Date(m.publishedAt);
|
|
180
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== m.publishedAt) {
|
|
181
|
+
throw new UpdateErrorException(
|
|
182
|
+
'manifest_invalid',
|
|
183
|
+
`manifest publishedAt "${m.publishedAt}" is not a round-trippable ISO-8601 string`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const pkg = m.channels.npm.package;
|
|
187
|
+
if (!NPM_PACKAGE_NAME_RE.test(pkg) || pkg.length > 214) {
|
|
188
|
+
throw new UpdateErrorException(
|
|
189
|
+
'manifest_invalid',
|
|
190
|
+
`manifest channels.npm.package "${pkg}" is not a valid npm package name`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isUpdateManifest(v: unknown): v is UpdateManifest {
|
|
196
|
+
if (!v || typeof v !== 'object') return false;
|
|
197
|
+
const o = v as Record<string, unknown>;
|
|
198
|
+
if (o.v !== 1) return false;
|
|
199
|
+
if (typeof o.version !== 'string' || o.version.length === 0) return false;
|
|
200
|
+
if (typeof o.publishedAt !== 'string') return false;
|
|
201
|
+
if (!o.channels || typeof o.channels !== 'object') return false;
|
|
202
|
+
const npm = (o.channels as Record<string, unknown>).npm as Record<string, unknown> | undefined;
|
|
203
|
+
if (!npm || typeof npm.package !== 'string' || typeof npm.tag !== 'string') return false;
|
|
204
|
+
if (o.releaseNotesUrl !== undefined && typeof o.releaseNotesUrl !== 'string') return false;
|
|
205
|
+
if (o.minPreviousVersion !== undefined && typeof o.minPreviousVersion !== 'string') return false;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Canonical JSON serializer used to define the signed byte representation.
|
|
211
|
+
* The implementation lives in `@serfab/cadre-core` (cadre-host depends on
|
|
212
|
+
* cadre-core); re-exported here so existing import sites stay stable.
|
|
213
|
+
*/
|
|
214
|
+
export { canonicalJson } from '@serfab/cadre-core';
|
|
@@ -2,27 +2,25 @@
|
|
|
2
2
|
* Production Ed25519 public key used to verify the update manifest.
|
|
3
3
|
*
|
|
4
4
|
* The signing key lives offline with the release operator; only the public
|
|
5
|
-
* half ever ships in the binary.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* by the release build. CI keys differ from prod keys; for dev/CI overrides
|
|
10
|
-
* see `CADRE_HOST_UPDATE_DEV_KEY`.
|
|
5
|
+
* half ever ships in the binary. The release operator generates the keypair
|
|
6
|
+
* and embeds the public half with `scripts/release-keygen.mjs --write-source`;
|
|
7
|
+
* see the "Release signing & key management" runbook in `docs/cadre-host.md`.
|
|
8
|
+
* For dev/CI/staging overrides see `CADRE_HOST_UPDATE_DEV_KEY`.
|
|
11
9
|
*/
|
|
12
10
|
|
|
13
|
-
import {
|
|
11
|
+
import type { KeyObject } from 'node:crypto';
|
|
12
|
+
import { createPublicKey } from 'node:crypto';
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
|
-
* Raw 32-byte Ed25519 public key, base64-encoded.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Raw 32-byte Ed25519 public key, base64-encoded. This is the real production
|
|
16
|
+
* public key embedded by the release operator (see the "Release signing"
|
|
17
|
+
* runbook in `docs/cadre-host.md`). Before a real key was embedded the source
|
|
18
|
+
* shipped an all-zeros placeholder; `isPlaceholderReleaseKey` still detects that
|
|
19
|
+
* state and the publish guard refuses to ship it. Mismatch with the real signer
|
|
20
|
+
* surfaces as `signature_invalid`, which the UI treats as "update available but
|
|
19
21
|
* unverifiable" rather than auto-applying.
|
|
20
|
-
*
|
|
21
|
-
* The real key generation/publication procedure is tracked in the
|
|
22
|
-
* release-tooling backlog; until then, the dev override below is the only
|
|
23
|
-
* supported path for CI + local manifest testing.
|
|
24
22
|
*/
|
|
25
|
-
const PROD_KEY_BASE64 = '
|
|
23
|
+
const PROD_KEY_BASE64 = 'XyRVnOY9DVgU6xdMgJIguOsc9B2L1o2KoU9626Nk+OE=';
|
|
26
24
|
|
|
27
25
|
/**
|
|
28
26
|
* Public key (32 raw bytes, base64) — env override beats the embedded one.
|
|
@@ -39,6 +37,24 @@ export function getReleasePublicKey(env: NodeJS.ProcessEnv = process.env): KeyOb
|
|
|
39
37
|
return ed25519FromRaw(getReleasePublicKeyBase64(env));
|
|
40
38
|
}
|
|
41
39
|
|
|
40
|
+
/**
|
|
41
|
+
* True when the *effective* release key (after the `CADRE_HOST_UPDATE_DEV_KEY`
|
|
42
|
+
* override) decodes to 32 zero bytes — i.e. the all-zeros placeholder that
|
|
43
|
+
* ships in source until the release operator embeds a real public key.
|
|
44
|
+
*
|
|
45
|
+
* Consumers:
|
|
46
|
+
* - the publish guard (`scripts/publish-package.js`) refuses to ship a build
|
|
47
|
+
* whose embedded key is still the placeholder, so a real release can never
|
|
48
|
+
* silently ship the dead key;
|
|
49
|
+
* - `UpdateService.check()` logs a diagnostic breadcrumb so a misbuilt binary
|
|
50
|
+
* is recognizable from logs rather than only as opaque `signature_invalid`.
|
|
51
|
+
*/
|
|
52
|
+
export function isPlaceholderReleaseKey(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
53
|
+
const raw = Buffer.from(getReleasePublicKeyBase64(env), 'base64');
|
|
54
|
+
if (raw.length !== 32) return false;
|
|
55
|
+
return raw.every((b) => b === 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
/**
|
|
43
59
|
* Build an Ed25519 KeyObject from a raw 32-byte base64 public key by wrapping
|
|
44
60
|
* it in the SubjectPublicKeyInfo DER prefix Node expects.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed signing core for the release-manifest pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The offline `.mjs` CLI wrappers (`scripts/sign-manifest.mjs`) stay thin by
|
|
5
|
+
* delegating manifest construction, field validation, and the sign + self-verify
|
|
6
|
+
* round-trip to this module — keeping the logic typed and unit-testable instead
|
|
7
|
+
* of hand-rolled in a script. None of this ships in the running binary's hot
|
|
8
|
+
* path: it is only reached by the operator's offline signing tool (and tests).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createPublicKey, type KeyObject } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
import { signManifest, validateManifestFields, verifyManifest } from './manifest.js';
|
|
14
|
+
import type { SignedManifest, UpdateManifest } from './types.js';
|
|
15
|
+
|
|
16
|
+
/** Flat input shape the signing CLI collects from flags or a JSON file. */
|
|
17
|
+
export interface ManifestFields {
|
|
18
|
+
/** Released version (semver). */
|
|
19
|
+
version: string;
|
|
20
|
+
/** npm package the release publishes under. */
|
|
21
|
+
package: string;
|
|
22
|
+
/** npm dist-tag (e.g. `latest`). */
|
|
23
|
+
tag: string;
|
|
24
|
+
/** ISO-8601 publish timestamp (must round-trip exactly). */
|
|
25
|
+
publishedAt: string;
|
|
26
|
+
/** Optional human-facing release-notes URL. */
|
|
27
|
+
releaseNotesUrl?: string;
|
|
28
|
+
/** Optional minimum previous version that can step directly to this release. */
|
|
29
|
+
minPreviousVersion?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Construct a well-formed `UpdateManifest` from flat fields and run the exact
|
|
34
|
+
* `validateManifestFields` rules `verifyManifest` will later apply — so the
|
|
35
|
+
* signing tool fails loudly on a bad semver / npm name / non-ISO timestamp
|
|
36
|
+
* rather than emitting a manifest the verifier would reject as `manifest_invalid`.
|
|
37
|
+
*/
|
|
38
|
+
export function buildManifest(fields: ManifestFields): UpdateManifest {
|
|
39
|
+
const manifest: UpdateManifest = {
|
|
40
|
+
v: 1,
|
|
41
|
+
version: fields.version,
|
|
42
|
+
publishedAt: fields.publishedAt,
|
|
43
|
+
channels: { npm: { package: fields.package, tag: fields.tag } },
|
|
44
|
+
...(fields.releaseNotesUrl !== undefined ? { releaseNotesUrl: fields.releaseNotesUrl } : {}),
|
|
45
|
+
...(fields.minPreviousVersion !== undefined ? { minPreviousVersion: fields.minPreviousVersion } : {}),
|
|
46
|
+
};
|
|
47
|
+
validateManifestFields(manifest);
|
|
48
|
+
return manifest;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Derive the raw 32-byte Ed25519 public key (base64) from a private key by
|
|
53
|
+
* stripping the 12-byte SPKI DER header — the same form `release-key.ts`
|
|
54
|
+
* embeds and `ed25519FromRaw` consumes.
|
|
55
|
+
*/
|
|
56
|
+
export function derivePublicKeyBase64(privateKey: KeyObject): string {
|
|
57
|
+
const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
|
|
58
|
+
return Buffer.from(spki.subarray(12)).toString('base64');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sign a manifest and immediately verify the result against the public key
|
|
63
|
+
* *derived from the same private key*, independent of whatever key the running
|
|
64
|
+
* environment trusts. Throws if the self-check fails, so the signing tool never
|
|
65
|
+
* emits an envelope it could not itself verify.
|
|
66
|
+
*/
|
|
67
|
+
export function signAndSelfVerify(manifest: UpdateManifest, privateKey: KeyObject): SignedManifest {
|
|
68
|
+
const signed = signManifest(manifest, privateKey);
|
|
69
|
+
const publicRawB64 = derivePublicKeyBase64(privateKey);
|
|
70
|
+
// verifyManifest honors the env override; point it at the derived key only so
|
|
71
|
+
// the self-check is against this key pair, not the embedded/dev key.
|
|
72
|
+
verifyManifest(signed, { CADRE_HOST_UPDATE_DEV_KEY: publicRawB64 } as NodeJS.ProcessEnv);
|
|
73
|
+
return signed;
|
|
74
|
+
}
|