@serfab/cadre-host 0.7.1 → 0.8.1

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.
Files changed (135) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +44 -6
  3. package/dist/auth/trust-circle.js +115 -58
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/authority/authority-node-client.d.ts +77 -0
  8. package/dist/authority/authority-node-client.js +143 -0
  9. package/dist/authority/authority-node-client.js.map +1 -0
  10. package/dist/authority/index.d.ts +11 -0
  11. package/dist/authority/index.js +11 -0
  12. package/dist/authority/index.js.map +1 -0
  13. package/dist/bin/host.js +210 -97
  14. package/dist/bin/host.js.map +1 -1
  15. package/dist/index.d.ts +10 -6
  16. package/dist/index.js +6 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/installer/browser.js +16 -17
  19. package/dist/installer/browser.js.map +1 -1
  20. package/dist/installer/config.d.ts +25 -0
  21. package/dist/installer/config.js +1 -1
  22. package/dist/installer/config.js.map +1 -1
  23. package/dist/installer/index.d.ts +5 -2
  24. package/dist/installer/index.js +14 -10
  25. package/dist/installer/index.js.map +1 -1
  26. package/dist/installer/service-host/launchd.js +1 -1
  27. package/dist/installer/service-host/launchd.js.map +1 -1
  28. package/dist/installer/service-host/systemd.js +1 -1
  29. package/dist/installer/service-host/systemd.js.map +1 -1
  30. package/dist/nat/index.d.ts +1 -1
  31. package/dist/nat/nat-service.d.ts +45 -3
  32. package/dist/nat/nat-service.js +116 -3
  33. package/dist/nat/nat-service.js.map +1 -1
  34. package/dist/nat/secrets/index.d.ts +7 -0
  35. package/dist/nat/secrets/index.js +28 -2
  36. package/dist/nat/secrets/index.js.map +1 -1
  37. package/dist/nat/types.d.ts +3 -1
  38. package/dist/nat/types.js.map +1 -1
  39. package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
  40. package/dist/orchestrator/host-process-orchestrator.js +246 -39
  41. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  42. package/dist/orchestrator/index.d.ts +3 -2
  43. package/dist/orchestrator/index.js +1 -1
  44. package/dist/orchestrator/index.js.map +1 -1
  45. package/dist/orchestrator/state-store.d.ts +6 -5
  46. package/dist/orchestrator/state-store.js +10 -2
  47. package/dist/orchestrator/state-store.js.map +1 -1
  48. package/dist/orchestrator/types.d.ts +45 -10
  49. package/dist/orchestrator/types.js.map +1 -1
  50. package/dist/push/index.d.ts +67 -0
  51. package/dist/push/index.js +101 -0
  52. package/dist/push/index.js.map +1 -0
  53. package/dist/server/error-handler.d.ts +2 -0
  54. package/dist/server/error-handler.js +4 -0
  55. package/dist/server/error-handler.js.map +1 -1
  56. package/dist/server/routes/nodes.d.ts +4 -3
  57. package/dist/server/routes/nodes.js +38 -24
  58. package/dist/server/routes/nodes.js.map +1 -1
  59. package/dist/server/static.d.ts +12 -0
  60. package/dist/server/static.js +24 -7
  61. package/dist/server/static.js.map +1 -1
  62. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  63. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  64. package/dist/ui/index.html +1 -1
  65. package/dist/update/apply.d.ts +23 -1
  66. package/dist/update/apply.js +46 -4
  67. package/dist/update/apply.js.map +1 -1
  68. package/dist/update/index.d.ts +14 -3
  69. package/dist/update/index.js +35 -2
  70. package/dist/update/index.js.map +1 -1
  71. package/dist/update/manifest.d.ts +28 -9
  72. package/dist/update/manifest.js +56 -29
  73. package/dist/update/manifest.js.map +1 -1
  74. package/dist/update/release-key.d.ts +18 -7
  75. package/dist/update/release-key.js +28 -15
  76. package/dist/update/release-key.js.map +1 -1
  77. package/dist/update/sign.d.ts +46 -0
  78. package/dist/update/sign.js +53 -0
  79. package/dist/update/sign.js.map +1 -0
  80. package/package.json +9 -4
  81. package/service/README.md +95 -86
  82. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  83. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  84. package/src/__tests__/cli.smoke.test.ts +6 -1
  85. package/src/__tests__/host-authority.smoke.test.ts +123 -0
  86. package/src/__tests__/orchestrator-authority.test.ts +198 -0
  87. package/src/__tests__/orchestrator-push.test.ts +145 -0
  88. package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
  89. package/src/auth/__tests__/trust-circle.test.ts +90 -23
  90. package/src/auth/trust-circle.ts +363 -304
  91. package/src/auth/types.ts +4 -1
  92. package/src/authority/__tests__/authority-node-client.test.ts +166 -0
  93. package/src/authority/authority-node-client.ts +199 -0
  94. package/src/authority/index.ts +12 -0
  95. package/src/bin/host.ts +1038 -905
  96. package/src/index.ts +34 -3
  97. package/src/installer/__tests__/installer.smoke.test.ts +64 -0
  98. package/src/installer/browser.ts +40 -42
  99. package/src/installer/config.ts +27 -1
  100. package/src/installer/index.ts +18 -12
  101. package/src/installer/service-host/launchd.ts +1 -1
  102. package/src/installer/service-host/systemd.ts +1 -1
  103. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  104. package/src/nat/__tests__/nat-service.test.ts +192 -0
  105. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  106. package/src/nat/__tests__/secrets.test.ts +23 -1
  107. package/src/nat/index.ts +1 -1
  108. package/src/nat/nat-service.ts +131 -5
  109. package/src/nat/secrets/index.ts +32 -2
  110. package/src/nat/types.ts +3 -1
  111. package/src/orchestrator/host-process-orchestrator.ts +294 -54
  112. package/src/orchestrator/index.ts +9 -2
  113. package/src/orchestrator/state-store.ts +21 -3
  114. package/src/orchestrator/types.ts +48 -2
  115. package/src/push/__tests__/push.test.ts +90 -0
  116. package/src/push/index.ts +146 -0
  117. package/src/server/__tests__/error-handler.test.ts +4 -4
  118. package/src/server/__tests__/nodes-route.test.ts +90 -10
  119. package/src/server/__tests__/publishers.test.ts +1 -1
  120. package/src/server/__tests__/static.test.ts +145 -0
  121. package/src/server/__tests__/status-route.test.ts +2 -2
  122. package/src/server/error-handler.ts +4 -0
  123. package/src/server/routes/nodes.ts +48 -26
  124. package/src/server/static.ts +24 -8
  125. package/src/update/__tests__/apply.test.ts +58 -1
  126. package/src/update/__tests__/manifest.test.ts +41 -6
  127. package/src/update/__tests__/release-key.test.ts +29 -0
  128. package/src/update/__tests__/sign.test.ts +78 -0
  129. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  130. package/src/update/apply.ts +46 -4
  131. package/src/update/index.ts +41 -3
  132. package/src/update/manifest.ts +214 -172
  133. package/src/update/release-key.ts +30 -16
  134. package/src/update/sign.ts +74 -0
  135. package/dist/ui/assets/index-C9uChk2P.js +0 -14
@@ -23,6 +23,7 @@ import {
23
23
  DEFAULT_MANIFEST_URL,
24
24
  fetchManifest,
25
25
  } from './manifest.js';
26
+ import { isPlaceholderReleaseKey } from './release-key.js';
26
27
  import { UpdateStateStore } from './store.js';
27
28
  import {
28
29
  UpdateErrorException,
@@ -94,6 +95,31 @@ export class UpdateService {
94
95
  this.checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
95
96
  this.settings = opts.settings ?? { autoApply: false };
96
97
  this.manifestUrl = resolveManifestUrl(opts.manifestUrl, this.settings.manifestUrl);
98
+ this.recoverInterruptedApply();
99
+ }
100
+
101
+ /**
102
+ * If `update-state.json` still records an in-flight apply at construction
103
+ * time, the previous cadre-host process died mid-apply (OOM, power loss,
104
+ * sigkill). Without intervention, every subsequent `apply()` would short-
105
+ * circuit on `apply_in_progress` forever. We clear the marker and surface a
106
+ * `lastError` so the UI banner can flag that an apply was interrupted —
107
+ * the npm install may or may not have completed, so the operator should
108
+ * verify with `npm ls -g`.
109
+ */
110
+ private recoverInterruptedApply(): void {
111
+ const state = this.store.load();
112
+ if (!state.applyInProgress) return;
113
+ const { fromVersion, toVersion } = state.applyInProgress;
114
+ log('clearing stale applyInProgress (%s → %s); previous apply was interrupted', fromVersion, toVersion);
115
+ this.store.update({
116
+ applyInProgress: undefined,
117
+ lastError: {
118
+ code: 'apply_failed',
119
+ message: `previous apply (${fromVersion} → ${toVersion}) was interrupted (cadre-host restarted before it completed); the install may or may not have succeeded — verify with 'npm ls -g'`,
120
+ at: new Date().toISOString(),
121
+ },
122
+ });
97
123
  }
98
124
 
99
125
  /** Read the persisted state for UI consumption. */
@@ -128,6 +154,12 @@ export class UpdateService {
128
154
  * debug; signature failures are recorded as `lastError` so the UI can warn.
129
155
  */
130
156
  async check(): Promise<void> {
157
+ if (isPlaceholderReleaseKey()) {
158
+ // A build that shipped the all-zeros placeholder rejects every authentic
159
+ // manifest as signature_invalid; surface that here so it's diagnosable
160
+ // from logs rather than only as an opaque verification failure.
161
+ log('release verification key is the all-zeros placeholder; production manifests will fail signature_invalid (set CADRE_HOST_UPDATE_DEV_KEY for dev/CI only)');
162
+ }
131
163
  let manifest: UpdateManifest;
132
164
  try {
133
165
  manifest = await fetchManifest({
@@ -294,10 +326,16 @@ function resolveManifestUrl(explicit: string | undefined, fromSettings: string |
294
326
  return explicit ?? fromSettings ?? DEFAULT_MANIFEST_URL;
295
327
  }
296
328
 
297
- export type { UpdateState, UpdateApplyResult, UpdateSettings, UpdateManifest } from './types.js';
329
+ export type { UpdateState, UpdateApplyResult, UpdateSettings, UpdateManifest, SignedManifest } from './types.js';
298
330
  export { UpdateErrorException, type UpdateErrorCode } from './types.js';
299
331
  export { compareVersions, parseVersion } from './version.js';
300
- export { fetchManifest, verifyManifest, canonicalJson, signManifestForTesting } from './manifest.js';
332
+ export { fetchManifest, verifyManifest, canonicalJson, signManifest, validateManifestFields } from './manifest.js';
301
333
  export { UpdateStateStore } from './store.js';
302
334
  export { applyUpdate, defaultNpmExecutor, type NpmExecutor, type ServiceRestarter } from './apply.js';
303
- export { getReleasePublicKey, getReleasePublicKeyBase64, ed25519FromRaw } from './release-key.js';
335
+ export {
336
+ getReleasePublicKey,
337
+ getReleasePublicKeyBase64,
338
+ ed25519FromRaw,
339
+ isPlaceholderReleaseKey,
340
+ } from './release-key.js';
341
+ export { buildManifest, derivePublicKeyBase64, signAndSelfVerify, type ManifestFields } from './sign.js';
@@ -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 a tiny local impl — depending on
9
- * `json-canonicalize` (or similar) for a single call site is overkill, and
10
- * the shape is fixed (`UpdateManifest`).
11
- */
12
-
13
- import { sign as cryptoSign, verify as cryptoVerify, type KeyObject } from 'node:crypto';
14
-
15
- import { ed25519FromRaw, getReleasePublicKey, getReleasePublicKeyBase64 } from './release-key.js';
16
- import {
17
- UpdateErrorException,
18
- type SignedManifest,
19
- type UpdateManifest,
20
- } from './types.js';
21
-
22
- /** Default release-manifest URL. Overridable via env var on the caller. */
23
- export const DEFAULT_MANIFEST_URL = 'https://releases.serfab.io/cadre-host/latest.json';
24
-
25
- export interface FetchManifestOptions {
26
- url: string;
27
- fetcher?: typeof fetch;
28
- /** Total fetch timeout (connect + read) in ms; default 10000. */
29
- readTimeoutMs?: number;
30
- }
31
-
32
- /**
33
- * Fetch the manifest endpoint and validate the signature against the embedded
34
- * release public key (or `CADRE_HOST_UPDATE_DEV_KEY` if set).
35
- *
36
- * Throws `UpdateErrorException` on any failure (bad JSON, bad signature,
37
- * etc.). Network errors are *thrown* — the caller decides whether to log
38
- * them at debug (the cadence path) or surface them (the apply path).
39
- */
40
- export async function fetchManifest(opts: FetchManifestOptions): Promise<UpdateManifest> {
41
- const fetcher = opts.fetcher ?? fetch;
42
- const readTimeout = opts.readTimeoutMs ?? 10_000;
43
- const controller = new AbortController();
44
- const timer = setTimeout(() => controller.abort(), readTimeout);
45
- let response: Response;
46
- try {
47
- response = await fetcher(opts.url, { signal: controller.signal });
48
- } finally {
49
- clearTimeout(timer);
50
- }
51
- if (!response.ok) {
52
- throw new Error(`manifest endpoint returned ${response.status}`);
53
- }
54
- let body: unknown;
55
- try {
56
- body = await response.json();
57
- } catch (err) {
58
- throw new UpdateErrorException(
59
- 'manifest_invalid',
60
- `manifest is not valid JSON: ${(err as Error).message}`,
61
- );
62
- }
63
- return verifyManifest(body);
64
- }
65
-
66
- /**
67
- * Verify a parsed signed-manifest body. Pure (no network), so unit tests
68
- * exercise this directly.
69
- */
70
- export function verifyManifest(
71
- body: unknown,
72
- env: NodeJS.ProcessEnv = process.env,
73
- ): UpdateManifest {
74
- if (!isSignedManifest(body)) {
75
- throw new UpdateErrorException('manifest_invalid', 'manifest envelope is malformed');
76
- }
77
- if (!isUpdateManifest(body.manifest)) {
78
- throw new UpdateErrorException('manifest_invalid', 'manifest payload is missing required fields');
79
- }
80
-
81
- const sig = parseSignature(body.sig);
82
- const canonical = Buffer.from(canonicalJson(body.manifest), 'utf8');
83
- const key = getReleasePublicKey(env);
84
-
85
- let ok: boolean;
86
- try {
87
- ok = cryptoVerify(null, canonical, key, sig);
88
- } catch (err) {
89
- throw new UpdateErrorException(
90
- 'signature_invalid',
91
- `signature verification threw: ${(err as Error).message}`,
92
- );
93
- }
94
- if (!ok) {
95
- throw new UpdateErrorException('signature_invalid', 'manifest signature did not verify');
96
- }
97
- return body.manifest;
98
- }
99
-
100
- /** Exposed for test signers. */
101
- export function signManifestForTesting(manifest: UpdateManifest, privateKey: KeyObject): SignedManifest {
102
- const canonical = Buffer.from(canonicalJson(manifest), 'utf8');
103
- const sig = cryptoSign(null, canonical, privateKey).toString('base64');
104
- return { manifest, sig: `ed25519:${sig}` };
105
- }
106
-
107
- /** Used by `apply.ts` to detect which key the signer matches. */
108
- export function publicKeyMatchesEmbedded(rawBase64: string, env: NodeJS.ProcessEnv = process.env): boolean {
109
- return rawBase64 === getReleasePublicKeyBase64(env);
110
- }
111
-
112
- /** Wrapper for `getReleasePublicKey`, re-exported for parity with `ed25519FromRaw`. */
113
- export { ed25519FromRaw };
114
-
115
- /** ---- internals ---- */
116
-
117
- function parseSignature(sig: string): Buffer {
118
- const match = /^ed25519:(.+)$/.exec(sig);
119
- if (!match) {
120
- throw new UpdateErrorException('signature_invalid', `unsupported signature scheme: ${sig.slice(0, 16)}…`);
121
- }
122
- const decoded = Buffer.from(match[1]!, 'base64');
123
- if (decoded.length !== 64) {
124
- throw new UpdateErrorException('signature_invalid', `Ed25519 signature must be 64 bytes, got ${decoded.length}`);
125
- }
126
- return decoded;
127
- }
128
-
129
- function isSignedManifest(v: unknown): v is SignedManifest {
130
- if (!v || typeof v !== 'object') return false;
131
- const obj = v as Record<string, unknown>;
132
- return typeof obj.sig === 'string' && obj.manifest !== undefined && typeof obj.manifest === 'object';
133
- }
134
-
135
- function isUpdateManifest(v: unknown): v is UpdateManifest {
136
- if (!v || typeof v !== 'object') return false;
137
- const o = v as Record<string, unknown>;
138
- if (o.v !== 1) return false;
139
- if (typeof o.version !== 'string' || o.version.length === 0) return false;
140
- if (typeof o.publishedAt !== 'string') return false;
141
- if (!o.channels || typeof o.channels !== 'object') return false;
142
- const npm = (o.channels as Record<string, unknown>).npm as Record<string, unknown> | undefined;
143
- if (!npm || typeof npm.package !== 'string' || typeof npm.tag !== 'string') return false;
144
- if (o.releaseNotesUrl !== undefined && typeof o.releaseNotesUrl !== 'string') return false;
145
- if (o.minPreviousVersion !== undefined && typeof o.minPreviousVersion !== 'string') return false;
146
- return true;
147
- }
148
-
149
- /**
150
- * Canonical JSON: recursively sort object keys, no whitespace, no trailing
151
- * newline. Matches a typical RFC 8785 subset for the fixed shape we sign.
152
- * Arrays preserve order.
153
- */
154
- export function canonicalJson(value: unknown): string {
155
- if (value === null) return 'null';
156
- if (typeof value === 'number') {
157
- if (!Number.isFinite(value)) throw new Error('cannot serialize non-finite number');
158
- return JSON.stringify(value);
159
- }
160
- if (typeof value === 'string' || typeof value === 'boolean') {
161
- return JSON.stringify(value);
162
- }
163
- if (Array.isArray(value)) {
164
- return '[' + value.map((v) => canonicalJson(v)).join(',') + ']';
165
- }
166
- if (typeof value === 'object') {
167
- const obj = value as Record<string, unknown>;
168
- const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
169
- return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}';
170
- }
171
- throw new Error(`cannot serialize value of type ${typeof value}`);
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,23 @@
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
- * **Provisional key.** The placeholder below is regenerated whenever the
8
- * release tooling is rotated — the key in this file is replaced atomically
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 { createPublicKey, KeyObject } from 'node:crypto';
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. Placeholder pending key
17
- * generation by the release op. Mismatch with the real signer will surface
18
- * as `signature_invalid`, which the UI treats as "update available but
19
- * 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.
15
+ * Raw 32-byte Ed25519 public key, base64-encoded. The all-zeros value below is
16
+ * the placeholder that ships in source until the release operator embeds a real
17
+ * key (`isPlaceholderReleaseKey` reports it; the publish guard refuses to ship
18
+ * it). Mismatch with the real signer surfaces as `signature_invalid`, which the
19
+ * UI treats as "update available but unverifiable" rather than auto-applying.
24
20
  */
25
- const PROD_KEY_BASE64 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
21
+ const PROD_KEY_BASE64 = 'XyRVnOY9DVgU6xdMgJIguOsc9B2L1o2KoU9626Nk+OE=';
26
22
 
27
23
  /**
28
24
  * Public key (32 raw bytes, base64) — env override beats the embedded one.
@@ -39,6 +35,24 @@ export function getReleasePublicKey(env: NodeJS.ProcessEnv = process.env): KeyOb
39
35
  return ed25519FromRaw(getReleasePublicKeyBase64(env));
40
36
  }
41
37
 
38
+ /**
39
+ * True when the *effective* release key (after the `CADRE_HOST_UPDATE_DEV_KEY`
40
+ * override) decodes to 32 zero bytes — i.e. the all-zeros placeholder that
41
+ * ships in source until the release operator embeds a real public key.
42
+ *
43
+ * Consumers:
44
+ * - the publish guard (`scripts/publish-package.js`) refuses to ship a build
45
+ * whose embedded key is still the placeholder, so a real release can never
46
+ * silently ship the dead key;
47
+ * - `UpdateService.check()` logs a diagnostic breadcrumb so a misbuilt binary
48
+ * is recognizable from logs rather than only as opaque `signature_invalid`.
49
+ */
50
+ export function isPlaceholderReleaseKey(env: NodeJS.ProcessEnv = process.env): boolean {
51
+ const raw = Buffer.from(getReleasePublicKeyBase64(env), 'base64');
52
+ if (raw.length !== 32) return false;
53
+ return raw.every((b) => b === 0);
54
+ }
55
+
42
56
  /**
43
57
  * Build an Ed25519 KeyObject from a raw 32-byte base64 public key by wrapping
44
58
  * 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
+ }