@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.
Files changed (193) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +45 -7
  3. package/dist/auth/trust-circle.js +116 -59
  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/bin/host.js +426 -112
  8. package/dist/bin/host.js.map +1 -1
  9. package/dist/donation/donation-service.d.ts +136 -0
  10. package/dist/donation/donation-service.js +306 -0
  11. package/dist/donation/donation-service.js.map +1 -0
  12. package/dist/donation/donation-store.d.ts +42 -0
  13. package/dist/donation/donation-store.js +141 -0
  14. package/dist/donation/donation-store.js.map +1 -0
  15. package/dist/donation/grant-service.d.ts +71 -0
  16. package/dist/donation/grant-service.js +163 -0
  17. package/dist/donation/grant-service.js.map +1 -0
  18. package/dist/donation/grant-store.d.ts +38 -0
  19. package/dist/donation/grant-store.js +135 -0
  20. package/dist/donation/grant-store.js.map +1 -0
  21. package/dist/donation/index.d.ts +18 -0
  22. package/dist/donation/index.js +16 -0
  23. package/dist/donation/index.js.map +1 -0
  24. package/dist/donation/types.d.ts +175 -0
  25. package/dist/donation/types.js +40 -0
  26. package/dist/donation/types.js.map +1 -0
  27. package/dist/index.d.ts +12 -6
  28. package/dist/index.js +8 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/installer/browser.js +16 -17
  31. package/dist/installer/browser.js.map +1 -1
  32. package/dist/installer/config.d.ts +51 -0
  33. package/dist/installer/config.js +14 -1
  34. package/dist/installer/config.js.map +1 -1
  35. package/dist/installer/index.d.ts +7 -2
  36. package/dist/installer/index.js +17 -10
  37. package/dist/installer/index.js.map +1 -1
  38. package/dist/installer/service-host/launchd.js +1 -1
  39. package/dist/installer/service-host/launchd.js.map +1 -1
  40. package/dist/installer/service-host/systemd.js +1 -1
  41. package/dist/installer/service-host/systemd.js.map +1 -1
  42. package/dist/installer/wizard.d.ts +4 -0
  43. package/dist/installer/wizard.js +5 -1
  44. package/dist/installer/wizard.js.map +1 -1
  45. package/dist/nat/index.d.ts +1 -1
  46. package/dist/nat/nat-service.d.ts +45 -3
  47. package/dist/nat/nat-service.js +116 -3
  48. package/dist/nat/nat-service.js.map +1 -1
  49. package/dist/nat/secrets/index.d.ts +7 -0
  50. package/dist/nat/secrets/index.js +28 -2
  51. package/dist/nat/secrets/index.js.map +1 -1
  52. package/dist/nat/types.d.ts +3 -1
  53. package/dist/nat/types.js.map +1 -1
  54. package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
  55. package/dist/orchestrator/host-process-orchestrator.js +272 -39
  56. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  57. package/dist/orchestrator/index.d.ts +3 -2
  58. package/dist/orchestrator/index.js +1 -1
  59. package/dist/orchestrator/index.js.map +1 -1
  60. package/dist/orchestrator/state-store.d.ts +6 -5
  61. package/dist/orchestrator/state-store.js +10 -2
  62. package/dist/orchestrator/state-store.js.map +1 -1
  63. package/dist/orchestrator/types.d.ts +45 -10
  64. package/dist/orchestrator/types.js.map +1 -1
  65. package/dist/owner/index.d.ts +11 -0
  66. package/dist/owner/index.js +11 -0
  67. package/dist/owner/index.js.map +1 -0
  68. package/dist/owner/owner-node-client.d.ts +95 -0
  69. package/dist/owner/owner-node-client.js +167 -0
  70. package/dist/owner/owner-node-client.js.map +1 -0
  71. package/dist/push/index.d.ts +67 -0
  72. package/dist/push/index.js +101 -0
  73. package/dist/push/index.js.map +1 -0
  74. package/dist/server/error-handler.d.ts +17 -0
  75. package/dist/server/error-handler.js +45 -0
  76. package/dist/server/error-handler.js.map +1 -1
  77. package/dist/server/index.d.ts +26 -2
  78. package/dist/server/index.js +37 -16
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/routes/grants-admin.d.ts +17 -0
  81. package/dist/server/routes/grants-admin.js +32 -0
  82. package/dist/server/routes/grants-admin.js.map +1 -0
  83. package/dist/server/routes/grants.d.ts +28 -0
  84. package/dist/server/routes/grants.js +163 -0
  85. package/dist/server/routes/grants.js.map +1 -0
  86. package/dist/server/routes/nodes.d.ts +5 -3
  87. package/dist/server/routes/nodes.js +39 -24
  88. package/dist/server/routes/nodes.js.map +1 -1
  89. package/dist/server/routes/settings.d.ts +6 -1
  90. package/dist/server/routes/settings.js +5 -2
  91. package/dist/server/routes/settings.js.map +1 -1
  92. package/dist/server/routes/status.d.ts +8 -4
  93. package/dist/server/routes/status.js +9 -4
  94. package/dist/server/routes/status.js.map +1 -1
  95. package/dist/server/static.d.ts +12 -0
  96. package/dist/server/static.js +24 -7
  97. package/dist/server/static.js.map +1 -1
  98. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  99. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  100. package/dist/ui/index.html +1 -1
  101. package/dist/update/apply.d.ts +23 -1
  102. package/dist/update/apply.js +46 -4
  103. package/dist/update/apply.js.map +1 -1
  104. package/dist/update/index.d.ts +14 -3
  105. package/dist/update/index.js +35 -2
  106. package/dist/update/index.js.map +1 -1
  107. package/dist/update/manifest.d.ts +28 -9
  108. package/dist/update/manifest.js +56 -29
  109. package/dist/update/manifest.js.map +1 -1
  110. package/dist/update/release-key.d.ts +18 -7
  111. package/dist/update/release-key.js +29 -14
  112. package/dist/update/release-key.js.map +1 -1
  113. package/dist/update/sign.d.ts +46 -0
  114. package/dist/update/sign.js +53 -0
  115. package/dist/update/sign.js.map +1 -0
  116. package/package.json +9 -4
  117. package/service/README.md +95 -86
  118. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  119. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  120. package/src/__tests__/cli.smoke.test.ts +6 -1
  121. package/src/__tests__/host-owner.smoke.test.ts +123 -0
  122. package/src/__tests__/orchestrator-owner.test.ts +198 -0
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +145 -0
  125. package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
  126. package/src/auth/__tests__/trust-circle.test.ts +91 -24
  127. package/src/auth/trust-circle.ts +363 -304
  128. package/src/auth/types.ts +4 -1
  129. package/src/bin/host.ts +1258 -905
  130. package/src/donation/__tests__/donation-service.test.ts +290 -0
  131. package/src/donation/__tests__/grant-service.test.ts +213 -0
  132. package/src/donation/__tests__/grant-store.test.ts +113 -0
  133. package/src/donation/donation-service.ts +392 -0
  134. package/src/donation/donation-store.ts +164 -0
  135. package/src/donation/grant-service.ts +200 -0
  136. package/src/donation/grant-store.ts +152 -0
  137. package/src/donation/index.ts +49 -0
  138. package/src/donation/types.ts +220 -0
  139. package/src/index.ts +66 -3
  140. package/src/installer/__tests__/config.test.ts +27 -1
  141. package/src/installer/__tests__/installer.smoke.test.ts +94 -0
  142. package/src/installer/__tests__/wizard.test.ts +5 -1
  143. package/src/installer/browser.ts +40 -42
  144. package/src/installer/config.ts +61 -1
  145. package/src/installer/index.ts +23 -12
  146. package/src/installer/service-host/launchd.ts +1 -1
  147. package/src/installer/service-host/systemd.ts +1 -1
  148. package/src/installer/wizard.ts +12 -1
  149. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  150. package/src/nat/__tests__/nat-service.test.ts +192 -0
  151. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  152. package/src/nat/__tests__/secrets.test.ts +23 -1
  153. package/src/nat/index.ts +1 -1
  154. package/src/nat/nat-service.ts +131 -5
  155. package/src/nat/secrets/index.ts +32 -2
  156. package/src/nat/types.ts +3 -1
  157. package/src/orchestrator/host-process-orchestrator.ts +322 -54
  158. package/src/orchestrator/index.ts +9 -2
  159. package/src/orchestrator/state-store.ts +21 -3
  160. package/src/orchestrator/types.ts +48 -2
  161. package/src/owner/__tests__/owner-node-client.test.ts +194 -0
  162. package/src/owner/index.ts +12 -0
  163. package/src/owner/owner-node-client.ts +233 -0
  164. package/src/push/__tests__/push.test.ts +90 -0
  165. package/src/push/index.ts +146 -0
  166. package/src/server/__tests__/error-handler.test.ts +26 -4
  167. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  168. package/src/server/__tests__/grants-route.test.ts +189 -0
  169. package/src/server/__tests__/nodes-route.test.ts +90 -10
  170. package/src/server/__tests__/publishers.test.ts +1 -1
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/static.test.ts +145 -0
  174. package/src/server/__tests__/status-route.test.ts +23 -2
  175. package/src/server/error-handler.ts +47 -0
  176. package/src/server/index.ts +62 -17
  177. package/src/server/routes/grants-admin.ts +41 -0
  178. package/src/server/routes/grants.ts +186 -0
  179. package/src/server/routes/nodes.ts +49 -26
  180. package/src/server/routes/settings.ts +11 -3
  181. package/src/server/routes/status.ts +18 -8
  182. package/src/server/static.ts +24 -8
  183. package/src/update/__tests__/apply.test.ts +58 -1
  184. package/src/update/__tests__/manifest.test.ts +41 -6
  185. package/src/update/__tests__/release-key.test.ts +42 -0
  186. package/src/update/__tests__/sign.test.ts +78 -0
  187. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  188. package/src/update/apply.ts +46 -4
  189. package/src/update/index.ts +41 -3
  190. package/src/update/manifest.ts +214 -172
  191. package/src/update/release-key.ts +31 -15
  192. package/src/update/sign.ts +74 -0
  193. package/dist/ui/assets/index-C9uChk2P.js +0 -14
@@ -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,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
- * **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
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 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
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
+ }