@dsh-enhanced/plugin-control-plane 0.1.31 → 0.1.34

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 (199) hide show
  1. package/README.md +352 -4
  2. package/bin/dsh-local-release-adapter.js +80 -16
  3. package/bin/dsh-npm-registry-adapter.js +613 -0
  4. package/bin/dsh-source-adoption-authority.js +12 -0
  5. package/bin/dsh-source-approval-authority.js +16 -0
  6. package/bin/dsh-source-release-authority.js +12 -0
  7. package/bin/dsh-systemd-host-attestor.js +707 -0
  8. package/bin/dsh-task-observation-authority.js +15 -0
  9. package/lib/adapter-process.d.ts +23 -0
  10. package/lib/adapter-process.d.ts.map +1 -0
  11. package/lib/adapter-process.js +305 -0
  12. package/lib/adapter-process.js.map +1 -0
  13. package/lib/adoption-coordinator.d.ts +63 -0
  14. package/lib/adoption-coordinator.d.ts.map +1 -0
  15. package/lib/adoption-coordinator.js +241 -0
  16. package/lib/adoption-coordinator.js.map +1 -0
  17. package/lib/adoption-handoff.d.ts +16 -0
  18. package/lib/adoption-handoff.d.ts.map +1 -0
  19. package/lib/adoption-handoff.js +11 -0
  20. package/lib/adoption-handoff.js.map +1 -0
  21. package/lib/attestation.d.ts.map +1 -1
  22. package/lib/attestation.js +49 -1
  23. package/lib/attestation.js.map +1 -1
  24. package/lib/catalog.d.ts.map +1 -1
  25. package/lib/catalog.js +29 -2
  26. package/lib/catalog.js.map +1 -1
  27. package/lib/cli.d.ts +33 -9
  28. package/lib/cli.d.ts.map +1 -1
  29. package/lib/cli.js +624 -178
  30. package/lib/cli.js.map +1 -1
  31. package/lib/deployment-readiness.d.ts +38 -0
  32. package/lib/deployment-readiness.d.ts.map +1 -0
  33. package/lib/deployment-readiness.js +154 -0
  34. package/lib/deployment-readiness.js.map +1 -0
  35. package/lib/effect-blocked-replay.d.ts +81 -0
  36. package/lib/effect-blocked-replay.d.ts.map +1 -0
  37. package/lib/effect-blocked-replay.js +259 -0
  38. package/lib/effect-blocked-replay.js.map +1 -0
  39. package/lib/errors.d.ts +6 -0
  40. package/lib/errors.d.ts.map +1 -0
  41. package/lib/errors.js +9 -0
  42. package/lib/errors.js.map +1 -0
  43. package/lib/foreground-deployment-runtime.d.ts +20 -0
  44. package/lib/foreground-deployment-runtime.d.ts.map +1 -0
  45. package/lib/foreground-deployment-runtime.js +79 -0
  46. package/lib/foreground-deployment-runtime.js.map +1 -0
  47. package/lib/foreground-deployment.d.ts +17 -0
  48. package/lib/foreground-deployment.d.ts.map +1 -0
  49. package/lib/foreground-deployment.js +43 -0
  50. package/lib/foreground-deployment.js.map +1 -0
  51. package/lib/host-attestor.d.ts +1 -1
  52. package/lib/host-attestor.d.ts.map +1 -1
  53. package/lib/host-attestor.js +51 -57
  54. package/lib/host-attestor.js.map +1 -1
  55. package/lib/index.d.ts +25 -2
  56. package/lib/index.d.ts.map +1 -1
  57. package/lib/index.js +11 -2
  58. package/lib/index.js.map +1 -1
  59. package/lib/npm-publish.d.ts +25 -0
  60. package/lib/npm-publish.d.ts.map +1 -0
  61. package/lib/npm-publish.js +280 -0
  62. package/lib/npm-publish.js.map +1 -0
  63. package/lib/owner-task-gap-types.d.ts +21 -0
  64. package/lib/owner-task-gap-types.d.ts.map +1 -0
  65. package/lib/owner-task-gap-types.js +2 -0
  66. package/lib/owner-task-gap-types.js.map +1 -0
  67. package/lib/owner-task-gaps.d.ts +24 -0
  68. package/lib/owner-task-gaps.d.ts.map +1 -0
  69. package/lib/owner-task-gaps.js +74 -0
  70. package/lib/owner-task-gaps.js.map +1 -0
  71. package/lib/post-activation.d.ts +33 -0
  72. package/lib/post-activation.d.ts.map +1 -0
  73. package/lib/post-activation.js +219 -0
  74. package/lib/post-activation.js.map +1 -0
  75. package/lib/registry-fetch.d.ts +50 -0
  76. package/lib/registry-fetch.d.ts.map +1 -0
  77. package/lib/registry-fetch.js +267 -0
  78. package/lib/registry-fetch.js.map +1 -0
  79. package/lib/release.d.ts +4 -4
  80. package/lib/release.d.ts.map +1 -1
  81. package/lib/release.js +142 -56
  82. package/lib/release.js.map +1 -1
  83. package/lib/replay-endpoint-protocol.d.ts +65 -0
  84. package/lib/replay-endpoint-protocol.d.ts.map +1 -0
  85. package/lib/replay-endpoint-protocol.js +183 -0
  86. package/lib/replay-endpoint-protocol.js.map +1 -0
  87. package/lib/replay-endpoint.d.ts +7 -0
  88. package/lib/replay-endpoint.d.ts.map +1 -0
  89. package/lib/replay-endpoint.js +193 -0
  90. package/lib/replay-endpoint.js.map +1 -0
  91. package/lib/replay-grant.d.ts +62 -0
  92. package/lib/replay-grant.d.ts.map +1 -0
  93. package/lib/replay-grant.js +208 -0
  94. package/lib/replay-grant.js.map +1 -0
  95. package/lib/replay-journal.d.ts +25 -0
  96. package/lib/replay-journal.d.ts.map +1 -0
  97. package/lib/replay-journal.js +275 -0
  98. package/lib/replay-journal.js.map +1 -0
  99. package/lib/runtime-observer-protocol.d.ts +63 -0
  100. package/lib/runtime-observer-protocol.d.ts.map +1 -0
  101. package/lib/runtime-observer-protocol.js +280 -0
  102. package/lib/runtime-observer-protocol.js.map +1 -0
  103. package/lib/runtime-observer.d.ts +8 -0
  104. package/lib/runtime-observer.d.ts.map +1 -0
  105. package/lib/runtime-observer.js +182 -0
  106. package/lib/runtime-observer.js.map +1 -0
  107. package/lib/service.d.ts +145 -1
  108. package/lib/service.d.ts.map +1 -1
  109. package/lib/service.js +734 -6
  110. package/lib/service.js.map +1 -1
  111. package/lib/source-adoption-authority.d.ts +65 -0
  112. package/lib/source-adoption-authority.d.ts.map +1 -0
  113. package/lib/source-adoption-authority.js +271 -0
  114. package/lib/source-adoption-authority.js.map +1 -0
  115. package/lib/source-adoption-runner.d.ts +28 -0
  116. package/lib/source-adoption-runner.d.ts.map +1 -0
  117. package/lib/source-adoption-runner.js +197 -0
  118. package/lib/source-adoption-runner.js.map +1 -0
  119. package/lib/source-approval-authority.d.ts +50 -0
  120. package/lib/source-approval-authority.d.ts.map +1 -0
  121. package/lib/source-approval-authority.js +412 -0
  122. package/lib/source-approval-authority.js.map +1 -0
  123. package/lib/source-approval-client.d.ts +32 -0
  124. package/lib/source-approval-client.d.ts.map +1 -0
  125. package/lib/source-approval-client.js +175 -0
  126. package/lib/source-approval-client.js.map +1 -0
  127. package/lib/source-build.d.ts +60 -0
  128. package/lib/source-build.d.ts.map +1 -0
  129. package/lib/source-build.js +311 -0
  130. package/lib/source-build.js.map +1 -0
  131. package/lib/source-context.d.ts +24 -0
  132. package/lib/source-context.d.ts.map +1 -0
  133. package/lib/source-context.js +99 -0
  134. package/lib/source-context.js.map +1 -0
  135. package/lib/source-job-types.d.ts +87 -0
  136. package/lib/source-job-types.d.ts.map +1 -0
  137. package/lib/source-job-types.js +2 -0
  138. package/lib/source-job-types.js.map +1 -0
  139. package/lib/source-jobs.d.ts +113 -0
  140. package/lib/source-jobs.d.ts.map +1 -0
  141. package/lib/source-jobs.js +535 -0
  142. package/lib/source-jobs.js.map +1 -0
  143. package/lib/source-release-authority.d.ts +35 -0
  144. package/lib/source-release-authority.d.ts.map +1 -0
  145. package/lib/source-release-authority.js +271 -0
  146. package/lib/source-release-authority.js.map +1 -0
  147. package/lib/source-release-client.d.ts +10 -0
  148. package/lib/source-release-client.d.ts.map +1 -0
  149. package/lib/source-release-client.js +24 -0
  150. package/lib/source-release-client.js.map +1 -0
  151. package/lib/source-release-runner.d.ts +31 -0
  152. package/lib/source-release-runner.d.ts.map +1 -0
  153. package/lib/source-release-runner.js +125 -0
  154. package/lib/source-release-runner.js.map +1 -0
  155. package/lib/source-versioning.d.ts +24 -0
  156. package/lib/source-versioning.d.ts.map +1 -0
  157. package/lib/source-versioning.js +213 -0
  158. package/lib/source-versioning.js.map +1 -0
  159. package/lib/source-workspace.d.ts +117 -0
  160. package/lib/source-workspace.d.ts.map +1 -0
  161. package/lib/source-workspace.js +458 -0
  162. package/lib/source-workspace.js.map +1 -0
  163. package/lib/sqlite.d.ts +1 -1
  164. package/lib/sqlite.d.ts.map +1 -1
  165. package/lib/sqlite.js +509 -7
  166. package/lib/sqlite.js.map +1 -1
  167. package/lib/store.d.ts +229 -6
  168. package/lib/store.d.ts.map +1 -1
  169. package/lib/store.js +2431 -383
  170. package/lib/store.js.map +1 -1
  171. package/lib/task-observation-authority.d.ts +29 -0
  172. package/lib/task-observation-authority.d.ts.map +1 -0
  173. package/lib/task-observation-authority.js +370 -0
  174. package/lib/task-observation-authority.js.map +1 -0
  175. package/lib/task-observation-client.d.ts +4 -0
  176. package/lib/task-observation-client.d.ts.map +1 -0
  177. package/lib/task-observation-client.js +20 -0
  178. package/lib/task-observation-client.js.map +1 -0
  179. package/lib/task-observation-runtime.d.ts +59 -0
  180. package/lib/task-observation-runtime.d.ts.map +1 -0
  181. package/lib/task-observation-runtime.js +331 -0
  182. package/lib/task-observation-runtime.js.map +1 -0
  183. package/lib/task-observation-store.d.ts +17 -0
  184. package/lib/task-observation-store.d.ts.map +1 -0
  185. package/lib/task-observation-store.js +115 -0
  186. package/lib/task-observation-store.js.map +1 -0
  187. package/lib/task-observation-types.d.ts +67 -0
  188. package/lib/task-observation-types.d.ts.map +1 -0
  189. package/lib/task-observation-types.js +2 -0
  190. package/lib/task-observation-types.js.map +1 -0
  191. package/lib/trust.d.ts +3 -0
  192. package/lib/trust.d.ts.map +1 -1
  193. package/lib/trust.js +0 -0
  194. package/lib/trust.js.map +1 -1
  195. package/lib/types.d.ts +275 -8
  196. package/lib/types.d.ts.map +1 -1
  197. package/lib/version.d.ts +1 -1
  198. package/lib/version.js +1 -1
  199. package/package.json +40 -5
@@ -0,0 +1,707 @@
1
+ #!/usr/bin/node
2
+ // Owner-operated supervisor boundary. This executable never runs in the Host Fiber.
3
+ import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'
4
+ import { closeSync, constants as F, existsSync, fsyncSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, readSync, realpathSync, statfsSync } from 'node:fs'
5
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
6
+ import { DatabaseSync } from 'node:sqlite'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ export const SYSTEMD_HOST_ATTESTOR_VERSION = 'dsh-systemd-host-attestor-5'
10
+ const DIGEST = /^[a-f0-9]{64}$/u
11
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u
12
+ const UNIT_PROPERTIES = ['FragmentPath', 'DropInPaths', 'ExecStart', 'Environment', 'WorkingDirectory', 'User', 'Group', 'Type', 'KillMode']
13
+ const STATUS_PROPERTIES = ['Id', 'LoadState', 'ActiveState', 'SubState', 'MainPID', 'ControlPID', 'InvocationID', 'NRestarts', 'ControlGroup']
14
+ const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(',')}]`
15
+ : value !== null && typeof value === 'object' ? `{${Object.entries(value).filter(([, item]) => item !== undefined)
16
+ .sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}` : JSON.stringify(value)
17
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex')
18
+ const digest = value => hash(canonical(value))
19
+ const fail = message => { throw new Error(`systemd Host attestor: ${message}`) }
20
+ const within = (root, path) => path === root || path.startsWith(`${root}/`)
21
+ function object(value, keys, label) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)
23
+ || Object.keys(value).sort().join('\0') !== [...keys].sort().join('\0')) fail(`${label} fields are invalid`)
24
+ return value
25
+ }
26
+ function text(value, label, pattern = /^[^\r\n]*$/u) {
27
+ if (typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value) > 16384 || !pattern.test(value)) fail(`${label} is invalid`)
28
+ return value
29
+ }
30
+ function integer(value, label, min = 0, max = Number.MAX_SAFE_INTEGER) {
31
+ if (!Number.isSafeInteger(value) || value < min || value > max) fail(`${label} is invalid`)
32
+ return value
33
+ }
34
+ function canonicalPath(path, label) {
35
+ text(path, label)
36
+ if (!isAbsolute(path) || path === '/' || resolve(path) !== path || realpathSync(path) !== path) fail(`${label} is not canonical`)
37
+ return path
38
+ }
39
+ function privateDirectory(path) {
40
+ canonicalPath(path, 'private directory'); const stat = lstatSync(path)
41
+ if (!stat.isDirectory() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0) fail('directory is not owner-private')
42
+ }
43
+ function syncDirectory(path) {
44
+ const fd = openSync(path, F.O_RDONLY | F.O_DIRECTORY)
45
+ try { fsyncSync(fd) } finally { closeSync(fd) }
46
+ }
47
+ function descriptorBytes(fd, maximum) {
48
+ const before = fstatSync(fd, { bigint: true })
49
+ if (!before.isFile() || before.nlink !== 1n || before.size > BigInt(maximum) || (before.mode & 0o022n) !== 0n
50
+ || ![0n, BigInt(process.getuid())].includes(before.uid)) fail('unsafe file descriptor')
51
+ const bytes = Buffer.alloc(Number(before.size)); let offset = 0
52
+ while (offset < bytes.length) { const count = readSync(fd, bytes, offset, bytes.length - offset, offset); if (!count) fail('short file read'); offset += count }
53
+ const after = fstatSync(fd, { bigint: true })
54
+ if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size
55
+ || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) fail('file changed during read')
56
+ return bytes
57
+ }
58
+ function readSafe(path, maximum = 65536, privateMode = false) {
59
+ canonicalPath(path, 'file'); const before = lstatSync(path, { bigint: true })
60
+ if (privateMode) {
61
+ privateDirectory(dirname(path))
62
+ if (before.uid !== BigInt(process.getuid()) || (before.mode & 0o077n) !== 0n) fail('file is not owner-private')
63
+ }
64
+ const fd = openSync(path, F.O_RDONLY | F.O_NOFOLLOW)
65
+ try {
66
+ const opened = fstatSync(fd, { bigint: true }); const bytes = descriptorBytes(fd, maximum); const after = lstatSync(path, { bigint: true })
67
+ if (before.dev !== opened.dev || before.ino !== opened.ino || after.dev !== opened.dev || after.ino !== opened.ino) fail('file pathname changed')
68
+ return bytes
69
+ } finally { closeSync(fd) }
70
+ }
71
+ function pin(spec, label, executable = false) {
72
+ object(spec, ['path', 'sha256'], label); text(spec.sha256, `${label} digest`, DIGEST)
73
+ if (hash(readSafe(spec.path, 268435456)) !== spec.sha256) fail(`${label} digest changed`)
74
+ if (executable && (lstatSync(spec.path).mode & 0o111) === 0) fail(`${label} is not executable`)
75
+ return spec
76
+ }
77
+ function baselinePin(spec) {
78
+ object(spec, ['path', 'sha256'], 'baseline pin')
79
+ if (spec.sha256 !== null) return pin(spec, 'baseline file')
80
+ text(spec.path, 'absent baseline path')
81
+ if (!isAbsolute(spec.path) || resolve(spec.path) !== spec.path) fail('absent baseline path is invalid')
82
+ canonicalPath(dirname(spec.path), 'baseline parent')
83
+ try { lstatSync(spec.path) } catch (error) { if (error.code === 'ENOENT') return spec; throw error }
84
+ fail('originally absent baseline file exists')
85
+ }
86
+ function runningHash(path) {
87
+ const match = path.match(/^\/proc\/self\/fd\/(\d+)$/u)
88
+ return hash(match ? descriptorBytes(Number(match[1]), 268435456) : readSafe(path, 268435456))
89
+ }
90
+ function parseJson(bytes, label) { try { return JSON.parse(bytes.toString('utf8')) } catch { fail(`${label} JSON is invalid`) } }
91
+
92
+ function assertAbsentProfile(path) {
93
+ text(path, 'absent profile')
94
+ if (!isAbsolute(path) || resolve(path) !== path || path === '/') fail('absent profile path is invalid')
95
+ canonicalPath(dirname(path), 'profile parent')
96
+ try { lstatSync(path) } catch (error) { if (error.code === 'ENOENT') return; throw error }
97
+ fail('originally absent profile still exists')
98
+ }
99
+ function validateReadiness(ready, profilePath, withReload) {
100
+ object(ready, ['client', 'observer', 'deploymentFiles', ...(withReload ? ['reloadOperationId'] : [])], 'readiness')
101
+ pin(ready.client, 'observer client')
102
+ if (!Array.isArray(ready.deploymentFiles) || !ready.deploymentFiles.length || ready.deploymentFiles.length > 128) fail('deployment pins required')
103
+ for (const spec of ready.deploymentFiles) pin(spec, 'deployment file')
104
+ if (new Set(ready.deploymentFiles.map(spec => spec.path)).size !== ready.deploymentFiles.length) fail('duplicate deployment pin')
105
+ if (ready.observer?.profilePath !== profilePath) fail('observer profile differs')
106
+ }
107
+ function loadConfig(environment, request) {
108
+ const config = parseJson(readSafe(environment.DSH_SYSTEMD_HOST_ATTESTOR_CONFIG, 65536, true), 'config')
109
+ object(config, ['schemaVersion', 'authority', 'keyId', 'privateKeyPath', 'stateRoot', 'executable', 'interpreter', 'processHelper',
110
+ 'systemctl', 'scope', 'unit', 'unitProperties', 'profileFiles', 'authorization', 'timeoutMs', 'stableWindowMs', 'pollIntervalMs',
111
+ ...([2, 3].includes(config.schemaVersion) ? ['readiness'] : [])], 'config')
112
+ if (![1, 2, 3].includes(config.schemaVersion)) fail('config schema is unsupported')
113
+ text(config.authority, 'authority', ID); text(config.keyId, 'keyId', ID)
114
+ integer(config.timeoutMs, 'timeout', 1000, 60000); integer(config.stableWindowMs, 'stable window', 50, 10000)
115
+ integer(config.pollIntervalMs, 'poll interval', 25, 1000)
116
+ if (config.stableWindowMs + config.pollIntervalMs >= config.timeoutMs) fail('window exceeds deadline')
117
+ if (!['user', 'system'].includes(config.scope)) fail('unsupported supervisor scope')
118
+ const auth = object(config.authorization, ['installationId', 'ledger', 'profile', 'plan', 'activation', config.schemaVersion === 2 ? 'hostGeneration' : 'previousHostGeneration', 'requestDigest', 'notBefore', 'expiresAt'], 'authorization')
119
+ text(auth.requestDigest, 'authorized request digest', DIGEST)
120
+ if (digest(request) !== auth.requestDigest) fail('exact request is not authorized')
121
+ object(auth.ledger, ['id', 'path'], 'ledger'); object(auth.profile, ['name', 'path'], 'profile')
122
+ object(auth.plan, ['id', 'digest'], 'plan'); object(auth.activation, ['id', 'fence'], 'activation')
123
+ text(auth.installationId, 'installation', /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u)
124
+ for (const value of [auth.ledger.id, auth.plan.id, auth.activation.id]) text(value, 'identity', ID)
125
+ text(auth.plan.digest, 'plan digest', DIGEST); text(auth.profile.name, 'profile name', /^[a-z0-9][a-z0-9-]{0,63}$/u)
126
+ if (config.schemaVersion === 3 && request.requirements?.action === 'stop') assertAbsentProfile(auth.profile.path)
127
+ else canonicalPath(auth.profile.path, 'profile')
128
+ canonicalPath(auth.ledger.path, 'ledger')
129
+ if (basename(auth.profile.path) !== auth.profile.name || basename(dirname(auth.profile.path)) !== 'profiles') fail('profile path and name differ')
130
+ integer(auth.activation.fence, 'fence', 1)
131
+ integer(config.schemaVersion === 2 ? auth.hostGeneration : auth.previousHostGeneration, 'authorized generation', config.schemaVersion === 2 ? 1 : 0, Number.MAX_SAFE_INTEGER - 1)
132
+ integer(auth.notBefore, 'authorization start'); integer(auth.expiresAt, 'authorization expiry', auth.notBefore + 1)
133
+ if (config.unit !== `dsh-profile-${auth.profile.name}.service`) fail('unit does not bind the profile')
134
+ object(config.unitProperties, UNIT_PROPERTIES, 'unit properties')
135
+ for (const property of UNIT_PROPERTIES) text(config.unitProperties[property], property)
136
+ if (!['simple', 'exec', 'notify'].includes(config.unitProperties.Type) || config.unitProperties.KillMode !== 'control-group') fail('unsupported unit lifecycle')
137
+ const stopping = config.schemaVersion === 3 && request.requirements?.action === 'stop'
138
+ if (!Array.isArray(config.profileFiles) || config.profileFiles.length < (stopping ? 0 : 3) || config.profileFiles.length > (stopping ? 0 : 32)) fail('profile pins are invalid')
139
+ const paths = config.profileFiles.map(spec => {
140
+ if (config.schemaVersion === 3) baselinePin(spec); else pin(spec, 'profile file')
141
+ if (!within(auth.profile.path, spec.path)) fail('profile pin is outside profile')
142
+ return spec.path
143
+ })
144
+ if (new Set(paths).size !== paths.length || (!stopping && !['package.json', 'pnpm-lock.yaml', 'cordis.patch.yml'].every(name => paths.includes(join(auth.profile.path, name))))) fail('profile manifest, lockfile and patch pins are required')
145
+ privateDirectory(config.stateRoot)
146
+ for (const path of [environment.DSH_SYSTEMD_HOST_ATTESTOR_CONFIG, config.privateKeyPath, config.stateRoot]) {
147
+ if (within(auth.profile.path, path)) fail('attestor authority must be outside the candidate profile')
148
+ }
149
+ const privateKey = createPrivateKey(readSafe(config.privateKeyPath, 16384, true))
150
+ if (privateKey.asymmetricKeyType !== 'ed25519') fail('signing key must be Ed25519')
151
+ pin(config.executable, 'attestor executable', true); pin(config.interpreter, 'attestor interpreter', true); pin(config.processHelper, 'process helper')
152
+ if (runningHash(process.argv[1]) !== config.executable.sha256 || runningHash(process.execPath) !== config.interpreter.sha256) fail('running attestor identity differs')
153
+ object(config.systemctl, ['path', 'sha256', 'interpreter'], 'systemctl')
154
+ pin({ path: config.systemctl.path, sha256: config.systemctl.sha256 }, 'systemctl', true)
155
+ if (config.systemctl.interpreter !== null) pin(config.systemctl.interpreter, 'systemctl interpreter', true)
156
+ object(request, ['schemaVersion', 'kind', 'operationId', 'requestedAt', 'receiptTtlMs', 'installationId', 'ledger', 'plan', 'activation', 'profile', 'issuer', 'phase', 'requirements', 'predecessor'], 'request')
157
+ if (request.schemaVersion !== 2 || request.kind !== 'dsh-host-attestation-request'
158
+ || request.phase !== (config.schemaVersion === 1 ? 'reload' : config.schemaVersion === 2 ? 'readiness' : 'rollback')) fail('phase does not match configured authority')
159
+ if (request.predecessor !== null) {
160
+ const prior = object(request.predecessor, ['operationId', 'receiptId', 'phase', 'receiptDigest', 'hostGeneration'], 'predecessor')
161
+ text(prior.operationId, 'predecessor operation', ID); text(prior.receiptId, 'predecessor receipt', ID)
162
+ text(prior.receiptDigest, 'predecessor receipt digest', DIGEST)
163
+ integer(prior.hostGeneration, 'predecessor generation', 1)
164
+ if (!['reload', 'readiness', 'effect-blocked-replay', 'shadow', 'canary', 'soak', 'health'].includes(prior.phase)
165
+ || prior.operationId === request.operationId) fail('predecessor phase or operation is invalid')
166
+ }
167
+ if (config.schemaVersion === 1) {
168
+ if (request.predecessor !== null) fail('reload cannot have a predecessor')
169
+ object(request.requirements, ['kind', 'previousHostGeneration'], 'requirements')
170
+ if (request.requirements.kind !== 'reload' || request.requirements.previousHostGeneration !== auth.previousHostGeneration) fail('generation is not authorized')
171
+ } else if (config.schemaVersion === 3) {
172
+ const requirements = object(request.requirements, ['kind', 'previousHostGeneration', 'action', 'baselineFiles', 'minimumChecks'], 'rollback requirements')
173
+ if (requirements.kind !== 'rollback' || !['restore', 'stop'].includes(requirements.action)
174
+ || requirements.previousHostGeneration !== auth.previousHostGeneration
175
+ || canonical(requirements.baselineFiles) !== canonical(config.profileFiles)) fail('rollback baseline or action is not authorized')
176
+ if (request.predecessor !== null && request.predecessor.hostGeneration > requirements.previousHostGeneration) fail('rollback predecessor generation is ahead of recovery')
177
+ integer(requirements.minimumChecks, 'minimum checks', 1, 256)
178
+ if (stopping) { if (config.readiness !== null) fail('stop cannot request runtime readiness') }
179
+ else {
180
+ if (config.profileFiles.length !== 3) fail('rollback requires exactly three baseline pins')
181
+ validateReadiness(config.readiness, auth.profile.path, false)
182
+ }
183
+ } else {
184
+ object(request.requirements, ['kind', 'minimumChecks'], 'requirements')
185
+ if (request.requirements.kind !== 'readiness') fail('readiness requirements required')
186
+ integer(request.requirements.minimumChecks, 'minimum checks', 1, 256)
187
+ const ready = object(config.readiness, ['client', 'observer', 'deploymentFiles', 'reloadOperationId'], 'readiness')
188
+ if (request.predecessor === null || request.predecessor.phase !== 'reload'
189
+ || request.predecessor.operationId !== ready.reloadOperationId
190
+ || request.predecessor.hostGeneration !== auth.hostGeneration) fail('readiness predecessor does not bind the authorized reload')
191
+ text(ready.reloadOperationId, 'reload operation', ID); pin(ready.client, 'observer client')
192
+ if (!Array.isArray(ready.deploymentFiles) || !ready.deploymentFiles.length || ready.deploymentFiles.length > 128) fail('deployment pins required')
193
+ for (const spec of ready.deploymentFiles) pin(spec, 'deployment file')
194
+ if (new Set(ready.deploymentFiles.map(spec => spec.path)).size !== ready.deploymentFiles.length) fail('duplicate deployment pin')
195
+ if (ready.observer?.profilePath !== auth.profile.path) fail('observer profile differs')
196
+ }
197
+ text(request.operationId, 'operation', ID)
198
+ if (request.operationId.length > 152) fail('operation is too long for receipt identity')
199
+ integer(request.requestedAt, 'requestedAt'); integer(request.receiptTtlMs, 'receipt TTL', 1, 3600000)
200
+ for (const field of ['installationId', 'ledger', 'profile', 'plan', 'activation']) if (canonical(request[field]) !== canonical(auth[field])) fail(`${field} is not authorized`)
201
+ object(request.issuer, ['mode', 'id', 'version', 'path', 'sha256', 'interpreter', 'authority', 'keyId'], 'issuer')
202
+ text(request.issuer.id, 'issuer ID', ID)
203
+ if (request.issuer.mode !== 'configured-executable' || request.issuer.version !== SYSTEMD_HOST_ATTESTOR_VERSION
204
+ || request.issuer.path !== config.executable.path || request.issuer.sha256 !== config.executable.sha256
205
+ || canonical(request.issuer.interpreter) !== canonical(config.interpreter)
206
+ || request.issuer.authority !== config.authority || request.issuer.keyId !== config.keyId) fail('issuer differs from owner configuration')
207
+ assertCurrent(config, request)
208
+ return { config, privateKey }
209
+ }
210
+ function assertCurrent(config, request) {
211
+ const now = Date.now(); const auth = config.authorization
212
+ if (request.requestedAt < auth.notBefore || request.requestedAt > now || now > auth.expiresAt) fail('authorization expired or not yet valid')
213
+ }
214
+ function assertProfile(config) {
215
+ if (config.schemaVersion === 3 && config.readiness === null) assertAbsentProfile(config.authorization.profile.path)
216
+ for (const spec of config.profileFiles) { if (config.schemaVersion === 3) baselinePin(spec); else pin(spec, 'profile file') }
217
+ for (const spec of config.readiness?.deploymentFiles ?? []) pin(spec, 'deployment file')
218
+ }
219
+ function remaining(deadline) { const value = Math.floor(deadline - performance.now()); if (value <= 0) fail('observation deadline exceeded; outcome needs reconciliation'); return value }
220
+ function normalizeExecStart(value) {
221
+ // systemctl appends per-invocation runtime fields after ignore_errors.
222
+ const match = value.match(/^(\{ path=[^\r\n]* ; argv\[\]=[^\r\n]* ; ignore_errors=(?:yes|no)) ; (?:start_time=[^\r\n]* )?\}$/u)
223
+ if (!match || value.split('{ path=').length !== 2) fail('unsupported ExecStart representation')
224
+ return `${match[1]} ; }`
225
+ }
226
+ async function command(config, execute, args, deadline) {
227
+ const specs = [{ path: config.systemctl.path, sha256: config.systemctl.sha256 }, ...(config.systemctl.interpreter ? [config.systemctl.interpreter] : [])]
228
+ const descriptors = []
229
+ try {
230
+ for (const spec of specs) {
231
+ pin(spec, 'systemctl command', true)
232
+ const fd = openSync(spec.path, F.O_RDONLY | F.O_NOFOLLOW); descriptors.push(fd)
233
+ if (hash(descriptorBytes(fd, 268435456)) !== spec.sha256) fail('command inode changed')
234
+ }
235
+ const env = { LANG: 'C', LC_ALL: 'C', SYSTEMD_PAGER: '', SYSTEMD_COLORS: '0' }
236
+ if (config.scope === 'user') {
237
+ const runtime = `/run/user/${process.getuid()}`; privateDirectory(runtime)
238
+ env.XDG_RUNTIME_DIR = runtime; env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${runtime}/bus`
239
+ }
240
+ const result = await execute({ command: descriptors.length === 2 ? '/proc/self/fd/4' : '/proc/self/fd/3',
241
+ args: [...(descriptors.length === 2 ? ['/proc/self/fd/3'] : []), `--${config.scope}`, ...args], env,
242
+ stdio: ['pipe', 'pipe', 'ignore', ...descriptors], stdin: undefined, maximumOutput: 65536, timeoutMs: remaining(deadline) })
243
+ for (let index = 0; index < descriptors.length; index++) if (hash(descriptorBytes(descriptors[index], 268435456)) !== specs[index].sha256) fail('command mutated')
244
+ remaining(deadline)
245
+ return result
246
+ } finally { for (const fd of descriptors) closeSync(fd) }
247
+ }
248
+ async function observe(config, execute, deadline, allowRemoved = false) {
249
+ const keys = [...STATUS_PROPERTIES, ...UNIT_PROPERTIES, ...(config.schemaVersion === 3 ? ['Job'] : [])]
250
+ const source = await command(config, execute, ['show', config.unit, '--no-pager', ...keys.map(key => `--property=${key}`)], deadline)
251
+ const fields = {}
252
+ for (const line of source.trimEnd().split('\n')) {
253
+ const separator = line.indexOf('='); const key = line.slice(0, separator)
254
+ if (separator < 0 || !keys.includes(key) || Object.hasOwn(fields, key)) fail('invalid or duplicate supervisor property')
255
+ fields[key] = line.slice(separator + 1)
256
+ }
257
+ const removed = allowRemoved && config.schemaVersion === 3 && config.readiness === null && fields.LoadState === 'not-found'
258
+ // systemd omits service-only properties once a transient unit is unloaded.
259
+ if (removed) for (const key of UNIT_PROPERTIES) if (!Object.hasOwn(fields, key)) fields[key] = ''
260
+ object(fields, keys, 'supervisor observation')
261
+ if (fields.Id !== config.unit || (!removed && fields.LoadState !== 'loaded')) fail('unit identity or load state changed')
262
+ if (removed) {
263
+ if (fields.ActiveState !== 'inactive' || fields.SubState !== 'dead' || fields.MainPID !== '0'
264
+ || fields.ControlPID !== '0' || fields.ControlGroup !== '' || !['', '0'].includes(fields.Job)) fail('removed unit retains runtime state')
265
+ } else {
266
+ fields.ExecStart = normalizeExecStart(fields.ExecStart)
267
+ for (const key of UNIT_PROPERTIES) if (fields[key] !== config.unitProperties[key]) fail('effective unit configuration drifted')
268
+ }
269
+ for (const key of ['MainPID', 'ControlPID', 'NRestarts']) {
270
+ if (!/^(0|[1-9]\d*)$/u.test(fields[key])) fail('invalid supervisor counter')
271
+ fields[key] = integer(Number(fields[key]), key)
272
+ }
273
+ if (!(config.schemaVersion === 3 && fields.ControlGroup === '' && fields.MainPID === 0 && fields.ControlPID === 0)
274
+ && (!/^\/[A-Za-z0-9_.@:/\\-]+$/u.test(fields.ControlGroup) || fields.ControlGroup.includes('..'))) fail('invalid unit cgroup')
275
+ const memberships = readFileSync('/proc/self/cgroup', 'utf8').trim().split('\n')
276
+ let ownHierarchyObserved = false
277
+ for (const line of memberships) {
278
+ const match = line.match(/^\d+:([^:]*):(.*)$/u)
279
+ if (match && (match[1] === '' || match[1].split(',').includes('name=systemd'))) {
280
+ if (!match[2].startsWith('/') || match[2].includes('..')) fail('cannot establish attestor cgroup membership')
281
+ ownHierarchyObserved = true
282
+ if (fields.ControlGroup && within(fields.ControlGroup, match[2])) fail('attestor must run outside the target service cgroup')
283
+ }
284
+ }
285
+ if (!ownHierarchyObserved) fail('cannot establish attestor cgroup membership')
286
+ if (config.schemaVersion === 3 && !['', '0'].includes(fields.Job)) fail('unit has an unsettled supervisor job')
287
+ assertProfile(config)
288
+ return fields
289
+ }
290
+ function active(observation) {
291
+ return observation.ActiveState === 'active' && observation.SubState === 'running' && observation.MainPID > 0
292
+ && observation.ControlPID === 0 && /^[a-f0-9]{32}$/u.test(observation.InvocationID) && !/^0+$/u.test(observation.InvocationID)
293
+ }
294
+ function journal(config) {
295
+ const path = join(config.stateRoot, 'reload.sqlite')
296
+ for (const suffix of ['', '-wal', '-shm', '-journal']) if (existsSync(path + suffix)) readSafe(path + suffix, 67108864, true)
297
+ const oldMask = process.umask(0o077); let db
298
+ try { db = new DatabaseSync(path) } finally { process.umask(oldMask) }
299
+ try {
300
+ db.exec(`PRAGMA busy_timeout=1000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
301
+ CREATE TABLE IF NOT EXISTS reloads (
302
+ operation_id TEXT PRIMARY KEY, request_digest TEXT NOT NULL, config_digest TEXT NOT NULL,
303
+ scope_id TEXT NOT NULL, generation INTEGER NOT NULL, activation_id TEXT NOT NULL,
304
+ prior TEXT NOT NULL, observation TEXT, receipt TEXT, request TEXT, config TEXT,
305
+ UNIQUE(scope_id, generation), UNIQUE(scope_id, activation_id));`)
306
+ transaction(db, () => {
307
+ const columns = db.prepare('PRAGMA table_info(reloads)').all().map(column => column.name)
308
+ for (const name of ['request', 'config']) if (!columns.includes(name)) db.exec(`ALTER TABLE reloads ADD COLUMN ${name} TEXT`)
309
+ db.exec(`CREATE TABLE IF NOT EXISTS readiness (
310
+ operation_id TEXT PRIMARY KEY, request_digest TEXT NOT NULL, config_digest TEXT NOT NULL,
311
+ reload_id TEXT NOT NULL UNIQUE, observation TEXT, receipt TEXT);`)
312
+ })
313
+ syncDirectory(config.stateRoot)
314
+ return db
315
+ } catch { db.close(); fail('private supervisor journal could not be opened') }
316
+ }
317
+ function transaction(db, action) {
318
+ db.exec('BEGIN IMMEDIATE')
319
+ try { const result = action(); db.exec('COMMIT'); return result } catch (error) { db.exec('ROLLBACK'); throw error }
320
+ }
321
+ function cachedReceipt(row, request, config, privateKey) {
322
+ if (row.request_digest !== digest(request) || row.config_digest !== digest(config)) fail('operation identity changed')
323
+ if (row.receipt === null) return undefined
324
+ const receipt = parseJson(Buffer.from(row.receipt), 'cached receipt'); const { signature, ...unsigned } = receipt
325
+ if (receipt.requestDigest !== row.request_digest || !verify(null, Buffer.from(canonical(unsigned)), createPublicKey(privateKey), Buffer.from(signature, 'base64'))) fail('cached receipt is invalid')
326
+ if (Date.now() > receipt.expiresAt) fail('cached receipt expired; owner reconciliation required')
327
+ return receipt
328
+ }
329
+ function boundReload(db, request, config, privateKey) {
330
+ const row = db.prepare('SELECT * FROM reloads WHERE scope_id = ? ORDER BY generation DESC LIMIT 1')
331
+ .get(digest({ installation: request.installationId }))
332
+ if (!row || row.operation_id !== config.readiness.reloadOperationId || row.generation !== config.authorization.hostGeneration
333
+ || !row.receipt || !row.observation || !row.request || !row.config) fail('latest reload is unresolved, superseded or lacks retained context')
334
+ const priorRequest = parseJson(Buffer.from(row.request), 'reload request')
335
+ const priorConfig = parseJson(Buffer.from(row.config), 'reload config')
336
+ for (const field of ['installationId', 'ledger', 'profile', 'plan', 'activation']) {
337
+ if (canonical(priorRequest[field]) !== canonical(request[field])) fail('reload context differs')
338
+ }
339
+ for (const field of ['authority', 'keyId', 'privateKeyPath', 'stateRoot', 'scope', 'unit', 'unitProperties', 'profileFiles', 'systemctl']) {
340
+ if (canonical(priorConfig[field]) !== canonical(config[field])) fail('reload deployment differs')
341
+ }
342
+ const receipt = cachedReceipt(row, priorRequest, priorConfig, privateKey)
343
+ const observation = parseJson(Buffer.from(row.observation), 'reload observation')
344
+ if (priorRequest.phase !== 'reload' || priorRequest.operationId !== row.operation_id
345
+ || receipt.phase !== 'reload' || receipt.outcome !== 'passed' || receipt.hostGeneration !== row.generation
346
+ || receipt.operationId !== row.operation_id || receipt.installationId !== request.installationId
347
+ || receipt.planId !== request.plan.id || receipt.planDigest !== request.plan.digest
348
+ || receipt.activationId !== request.activation.id || receipt.fence !== request.activation.fence
349
+ || receipt.authority !== config.authority || receipt.keyId !== config.keyId
350
+ || receipt.evidenceDigest !== digest(receipt.evidence) || receipt.evidence.kind !== 'reload'
351
+ || receipt.evidence.reloaded !== true || receipt.evidence.currentHostGeneration !== row.generation
352
+ || receipt.evidence.previousHostGeneration !== priorRequest.requirements.previousHostGeneration
353
+ || receipt.evidence.probeDigest !== digest(observation) || observation.requestDigest !== row.request_digest
354
+ || observation.configDigest !== row.config_digest || !active(observation.successor)) fail('reload evidence binding is invalid')
355
+ if (canonical(request.predecessor) !== canonical({ operationId: receipt.operationId, receiptId: receipt.receiptId,
356
+ phase: receipt.phase, receiptDigest: digest(receipt), hostGeneration: receipt.hostGeneration })) fail('readiness predecessor differs from the signed reload')
357
+ return { operationId: row.operation_id, generation: row.generation, receiptDigest: digest(receipt), successor: observation.successor }
358
+ }
359
+
360
+ async function attestReadiness(request, config, privateKey, db, execute, deadline) {
361
+ const ready = config.readiness
362
+ const bytes = readSafe(ready.client.path, 1048576)
363
+ if (hash(bytes) !== ready.client.sha256) fail('observer client changed')
364
+ const client = await import(`data:text/javascript;base64,${bytes.toString('base64')}`)
365
+ client.validateRuntimeObserverConfig(ready.observer)
366
+ const observerDigest = client.runtimeConfigDigest(ready.observer)
367
+ // Pin the channel key as well as its configured path for this entire attempt.
368
+ const channelDigest = hash(readSafe(ready.observer.keyPath, 32, true))
369
+ const reload = boundReload(db, request, config, privateKey)
370
+ const reserved = transaction(db, () => {
371
+ const row = db.prepare('SELECT * FROM readiness WHERE operation_id = ?').get(request.operationId)
372
+ if (row) { cachedReceipt(row, request, config, privateKey); return row }
373
+ db.prepare('INSERT INTO readiness(operation_id, request_digest, config_digest, reload_id) VALUES (?, ?, ?, ?)')
374
+ .run(request.operationId, digest(request), digest(config), reload.operationId)
375
+ return db.prepare('SELECT * FROM readiness WHERE operation_id = ?').get(request.operationId)
376
+ })
377
+ const cached = cachedReceipt(reserved, request, config, privateKey)
378
+ const retained = cached ? parseJson(Buffer.from(reserved.observation), 'readiness observation') : undefined
379
+ if (retained && (cached.evidence.probeDigest !== digest(retained) || canonical(retained.reload) !== canonical(reload))) fail('retained readiness binding differs')
380
+ let stable; let started; let failures = 0; const samples = []; const challenges = new Set()
381
+ for (;;) {
382
+ assertCurrent(config, request); remaining(deadline)
383
+ if (canonical(boundReload(db, request, config, privateKey)) !== canonical(reload)) fail('reload changed during readiness')
384
+ const before = await observe(config, execute, deadline)
385
+ if (canonical(before) !== canonical(reload.successor)) fail('supervisor no longer matches the reload successor')
386
+ if (hash(readSafe(ready.observer.keyPath, 32, true)) !== channelDigest) fail('observer key changed')
387
+ const queryStarted = Date.now()
388
+ const sample = await client.queryRuntimeObserver({ ...ready.observer, signal: AbortSignal.timeout(remaining(deadline)) })
389
+ if (sample.profilePath !== request.profile.path || sample.observerConfigDigest !== observerDigest
390
+ || sample.processId !== before.MainPID || sample.invocationId !== before.InvocationID
391
+ || sample.observedAt < queryStarted || sample.observedAt > Date.now() || challenges.has(sample.challenge)
392
+ || sample.entries.length !== ready.observer.targets.length) fail('observer identity or freshness differs')
393
+ challenges.add(sample.challenge)
394
+ let sampleFailed = false
395
+ for (const target of ready.observer.targets) {
396
+ const entry = sample.entries.find(item => item.entryId === target.entryId)
397
+ if (!entry || entry.module !== target.module || entry.configDigest !== target.configDigest
398
+ || canonical(entry.services.map(service => service.name).sort()) !== canonical([...target.services].sort())) fail('candidate identity differs')
399
+ if (entry.active === true) {
400
+ if (!entry.instance || [...entry.dependencies, ...entry.services].some(service => !service.instance)) fail('active candidate is not ready')
401
+ } else if (entry.active === false) {
402
+ // A disabled or pending Fiber has no instance by contract. It is a
403
+ // signed negative result only after the complete authenticated identity
404
+ // remains stable for the full window; malformed identities still abort.
405
+ sampleFailed = true
406
+ } else fail('candidate active state is invalid')
407
+ }
408
+ const { challenge: _challenge, observedAt: _observedAt, ...identity } = sample
409
+ if (stable === undefined) { stable = identity; started = performance.now() }
410
+ else if (canonical(identity) !== canonical(stable)) fail('runtime instance changed during readiness')
411
+ if (retained && canonical(identity) !== canonical(retained.runtime)) fail('cached readiness runtime changed')
412
+ samples.push({ challenge: sample.challenge, observedAt: sample.observedAt, digest: digest(sample) })
413
+ if (sampleFailed) failures++
414
+ const after = await observe(config, execute, deadline)
415
+ if (canonical(after) !== canonical(reload.successor)) fail('supervisor changed during runtime query')
416
+ if (samples.length >= Math.max(2, request.requirements.minimumChecks) && performance.now() - started >= config.stableWindowMs) break
417
+ if (samples.length >= 256) fail('readiness sample limit exceeded')
418
+ await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
419
+ }
420
+ assertCurrent(config, request); assertProfile(config); remaining(deadline)
421
+ if (hash(readSafe(ready.observer.keyPath, 32, true)) !== channelDigest) fail('observer key changed')
422
+ const observation = { schemaVersion: 1, requestDigest: digest(request), configDigest: digest(config), reload,
423
+ channelDigest, runtime: stable, samples, stableWindowMs: config.stableWindowMs, observedAt: Date.now() }
424
+ const evidence = { kind: 'readiness', checks: samples.length, failures, probeDigest: digest(observation) }
425
+ const unsigned = { schemaVersion: 2, receiptId: `receipt:${request.operationId}`, authority: config.authority, keyId: config.keyId,
426
+ installationId: request.installationId, planId: request.plan.id, planDigest: request.plan.digest,
427
+ activationId: request.activation.id, fence: request.activation.fence, operationId: request.operationId,
428
+ requestDigest: digest(request), phase: 'readiness', outcome: failures === 0 ? 'passed' : 'failed', hostGeneration: reload.generation,
429
+ evidence, evidenceDigest: digest(evidence), observedAt: observation.observedAt,
430
+ expiresAt: Math.min(observation.observedAt + request.receiptTtlMs, config.authorization.expiresAt) }
431
+ if (unsigned.expiresAt <= unsigned.observedAt) fail('authorization expired before signing')
432
+ return transaction(db, () => {
433
+ assertCurrent(config, request); remaining(deadline)
434
+ if (canonical(boundReload(db, request, config, privateKey)) !== canonical(reload)) fail('reload superseded before signing')
435
+ const row = db.prepare('SELECT * FROM readiness WHERE operation_id = ?').get(request.operationId)
436
+ const replay = cachedReceipt(row, request, config, privateKey)
437
+ if (replay) {
438
+ const prior = parseJson(Buffer.from(row.observation), 'readiness observation')
439
+ if (replay.evidence.probeDigest !== digest(prior) || canonical(prior.runtime) !== canonical(stable)
440
+ || canonical(prior.reload) !== canonical(reload) || prior.channelDigest !== channelDigest) fail('concurrent readiness binding differs')
441
+ return replay
442
+ }
443
+ const receipt = { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('base64') }
444
+ db.prepare('UPDATE readiness SET observation = ?, receipt = ? WHERE operation_id = ? AND receipt IS NULL')
445
+ .run(canonical(observation), canonical(receipt), request.operationId)
446
+ return cachedReceipt(db.prepare('SELECT * FROM readiness WHERE operation_id = ?').get(request.operationId), request, config, privateKey)
447
+ })
448
+ }
449
+ function assertRetiredProcess(prior) {
450
+ if (prior.MainPID === 0) return
451
+ try { process.kill(prior.MainPID, 0) } catch (error) { if (error.code === 'ESRCH') return; throw error }
452
+ fail('prior Host process still exists')
453
+ }
454
+ function visibleCgroupMount(path) {
455
+ const unified = statfsSync('/sys/fs/cgroup').type === 0x63677270
456
+ const root = unified ? '/sys/fs/cgroup' : '/sys/fs/cgroup/systemd'
457
+ if (!unified && statfsSync(root).type !== 0x27e0eb) fail('systemd cgroup mount is unavailable')
458
+ const own = readFileSync('/proc/self/cgroup', 'utf8').trim().split('\n')
459
+ .map(line => line.match(/^\d+:([^:]*):(.*)$/u)).find(match => match && (unified ? match[1] === '' : match[1].split(',').includes('name=systemd')))?.[2]
460
+ if (!own || !own.startsWith('/') || own.includes('..')) fail('cannot map attestor cgroup hierarchy')
461
+ const parent = dirname(path)
462
+ if (own === '/' && parent === '/') fail('root-only cgroup namespace cannot prove target visibility')
463
+ const members = readFileSync(join(root, own, unified ? 'cgroup.procs' : 'tasks'), 'utf8').trim().split('\n')
464
+ if (!members.includes(String(process.pid))) fail('cgroup mount does not map the attestor process')
465
+ const anchor = join(root, parent)
466
+ if (realpathSync(anchor) !== anchor || !lstatSync(anchor).isDirectory()
467
+ || statfsSync(anchor).type !== statfsSync(root).type) fail('target cgroup parent is not visible on the supervisor hierarchy')
468
+ return { root, unified }
469
+ }
470
+ function assertEmptyCgroup(path) {
471
+ if (!path) return
472
+ // Only actual kernel cgroup mounts count as evidence. cgroup v2 populated
473
+ // covers descendants; legacy systemd uses its named v1 hierarchy instead.
474
+ const { root, unified } = visibleCgroupMount(path)
475
+ if (unified) {
476
+ let events
477
+ try { events = readFileSync(join(root, path, 'cgroup.events'), 'utf8') }
478
+ catch (error) { if (error.code === 'ENOENT') return; throw error }
479
+ if (events.length > 4096 || events.split('\n').filter(line => line === 'populated 0').length !== 1
480
+ || events.split('\n').some(line => line.startsWith('populated ') && line !== 'populated 0')) fail('stopped service retains cgroup processes')
481
+ } else {
482
+ let visited = 0
483
+ const visit = directory => {
484
+ if (++visited > 256) fail('systemd cgroup subtree exceeds bound')
485
+ let entries
486
+ try { entries = readdirSync(directory, { withFileTypes: true }) }
487
+ catch (error) { if (error.code === 'ENOENT') return; throw error }
488
+ if (readFileSync(join(directory, 'tasks'), 'utf8').trim()) fail('stopped service retains cgroup tasks')
489
+ for (const entry of entries) if (entry.isDirectory()) visit(join(directory, entry.name))
490
+ }
491
+ visit(join(root, path))
492
+ }
493
+ }
494
+ function latestRecovery(db, row) {
495
+ const latest = db.prepare('SELECT operation_id FROM reloads WHERE scope_id = ? ORDER BY generation DESC LIMIT 1').get(row.scope_id)
496
+ if (!latest || latest.operation_id !== row.operation_id) fail('recovery generation was superseded')
497
+ }
498
+ async function recoveryRuntime(config, request, successor, execute, deadline, retained) {
499
+ const ready = config.readiness
500
+ const bytes = readSafe(ready.client.path, 1048576)
501
+ if (hash(bytes) !== ready.client.sha256) fail('observer client changed')
502
+ const client = await import(`data:text/javascript;base64,${bytes.toString('base64')}`)
503
+ client.validateRuntimeObserverConfig(ready.observer)
504
+ const observerDigest = client.runtimeConfigDigest(ready.observer)
505
+ const channelDigest = hash(readSafe(ready.observer.keyPath, 32, true))
506
+ let runtime; let started; const samples = []; const challenges = new Set()
507
+ for (;;) {
508
+ assertCurrent(config, request); remaining(deadline)
509
+ if (canonical(await observe(config, execute, deadline)) !== canonical(successor)) fail('recovery supervisor changed')
510
+ if (hash(readSafe(ready.observer.keyPath, 32, true)) !== channelDigest) fail('observer key changed')
511
+ const queriedAt = Date.now()
512
+ // After restart the observer may still be starting. No observation failure
513
+ // becomes proof: only a complete later authenticated stable window counts.
514
+ let sample
515
+ try { sample = await client.queryRuntimeObserver({ ...ready.observer, signal: AbortSignal.timeout(remaining(deadline)) }) }
516
+ catch (error) {
517
+ if (runtime !== undefined) throw error
518
+ remaining(deadline)
519
+ await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
520
+ continue
521
+ }
522
+ if (sample.profilePath !== request.profile.path || sample.observerConfigDigest !== observerDigest
523
+ || sample.processId !== successor.MainPID || sample.invocationId !== successor.InvocationID
524
+ || sample.observedAt < queriedAt || sample.observedAt > Date.now() || challenges.has(sample.challenge)
525
+ || sample.entries.length !== ready.observer.targets.length) fail('restored runtime identity differs')
526
+ challenges.add(sample.challenge)
527
+ for (const target of ready.observer.targets) {
528
+ const entry = sample.entries.find(item => item.entryId === target.entryId)
529
+ if (!entry || entry.module !== target.module || entry.configDigest !== target.configDigest || entry.active !== true
530
+ || !entry.instance || [...entry.dependencies, ...entry.services].some(service => !service.instance)
531
+ || canonical(entry.services.map(service => service.name).sort()) !== canonical([...target.services].sort())) fail('restored candidate is not ready')
532
+ }
533
+ const { challenge: _challenge, observedAt: _observedAt, ...identity } = sample
534
+ if (runtime === undefined) { runtime = identity; started = performance.now() }
535
+ else if (canonical(identity) !== canonical(runtime)) fail('restored runtime changed during observation')
536
+ if (retained && (canonical(identity) !== canonical(retained.runtime) || channelDigest !== retained.channelDigest)) fail('cached recovery runtime changed')
537
+ samples.push({ challenge: sample.challenge, observedAt: sample.observedAt, digest: digest(sample) })
538
+ if (canonical(await observe(config, execute, deadline)) !== canonical(successor)) fail('recovery supervisor changed during query')
539
+ if (samples.length >= Math.max(2, request.requirements.minimumChecks) && performance.now() - started >= config.stableWindowMs) break
540
+ if (samples.length >= 256) fail('recovery runtime sample limit exceeded')
541
+ await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
542
+ }
543
+ if (hash(readSafe(ready.observer.keyPath, 32, true)) !== channelDigest) fail('observer key changed')
544
+ return { runtime, channelDigest, samples }
545
+ }
546
+ async function attestRollback(request, config, privateKey, db, execute, deadline) {
547
+ const existing = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
548
+ if (existing) { cachedReceipt(existing, request, config, privateKey); latestRecovery(db, existing) }
549
+ const observed = await observe(config, execute, deadline, Boolean(existing))
550
+ if (observed.ControlGroup) visibleCgroupMount(observed.ControlGroup)
551
+ const scopeId = digest({ installation: request.installationId })
552
+ const reserved = transaction(db, () => {
553
+ const row = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
554
+ if (row) { cachedReceipt(row, request, config, privateKey); latestRecovery(db, row); return { row, dispatch: false } }
555
+ const latest = db.prepare('SELECT * FROM reloads WHERE scope_id = ? ORDER BY generation DESC LIMIT 1').get(scopeId)
556
+ if (latest && (latest.receipt === null || latest.generation !== request.requirements.previousHostGeneration)) fail('previous generation is unresolved or stale')
557
+ const generation = request.requirements.previousHostGeneration + 1
558
+ const activationId = digest({ purpose: 'rollback', plan: request.plan, activation: request.activation })
559
+ db.prepare('INSERT INTO reloads(operation_id, request_digest, config_digest, scope_id, generation, activation_id, prior, request, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
560
+ .run(request.operationId, digest(request), digest(config), scopeId, generation, activationId, canonical(observed), canonical(request), canonical(config))
561
+ return { row: db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId), dispatch: true }
562
+ })
563
+ const row = reserved.row; const prior = parseJson(Buffer.from(row.prior), 'prior recovery observation')
564
+ const cached = cachedReceipt(row, request, config, privateKey)
565
+ const retained = cached ? parseJson(Buffer.from(row.observation), 'cached recovery observation') : undefined
566
+ if (retained && cached.evidence.probeDigest !== digest(retained)) fail('cached recovery observation differs')
567
+ if (reserved.dispatch) {
568
+ syncDirectory(config.stateRoot); assertCurrent(config, request); assertProfile(config); latestRecovery(db, row)
569
+ await command(config, execute, [request.requirements.action === 'restore' ? 'restart' : 'stop', config.unit, '--no-ask-password', '--job-mode=fail'], deadline)
570
+ }
571
+ let successor; let stableAt; let samples = 0
572
+ for (;;) {
573
+ assertCurrent(config, request); remaining(deadline); latestRecovery(db, row)
574
+ const current = await observe(config, execute, deadline, true)
575
+ const settled = request.requirements.action === 'restore'
576
+ ? active(current) && current.InvocationID !== prior.InvocationID && current.MainPID !== prior.MainPID
577
+ : current.ActiveState === 'inactive' && current.SubState === 'dead' && current.MainPID === 0 && current.ControlPID === 0
578
+ if (settled) {
579
+ assertRetiredProcess(prior)
580
+ if (request.requirements.action === 'stop') { assertEmptyCgroup(prior.ControlGroup); assertEmptyCgroup(current.ControlGroup) }
581
+ if (successor === undefined) { successor = current; stableAt = performance.now() }
582
+ else if (canonical(successor) !== canonical(current)) fail('recovered supervisor changed during observation')
583
+ if (retained && canonical(current) !== canonical(retained.successor)) fail('cached recovery supervisor changed')
584
+ samples++
585
+ if (samples >= Math.max(2, request.requirements.minimumChecks) && performance.now() - stableAt >= config.stableWindowMs) break
586
+ } else if (successor !== undefined || cached) fail('recovered supervisor is no longer settled')
587
+ if (samples >= 256) fail('recovery supervisor sample limit exceeded')
588
+ await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
589
+ }
590
+ const runtime = request.requirements.action === 'restore'
591
+ ? await recoveryRuntime(config, request, successor, execute, deadline, retained?.runtimeObservation) : null
592
+ assertRetiredProcess(prior); assertProfile(config); assertCurrent(config, request); remaining(deadline)
593
+ const observation = { schemaVersion: 1, requestDigest: digest(request), configDigest: digest(config), prior, successor,
594
+ samples, runtimeObservation: runtime, stableWindowMs: config.stableWindowMs, observedAt: Date.now() }
595
+ const evidence = { kind: 'rollback', action: request.requirements.action, previousHostGeneration: request.requirements.previousHostGeneration,
596
+ currentHostGeneration: row.generation, checks: runtime?.samples.length ?? samples, failures: 0, profileRestored: true, probeDigest: digest(observation) }
597
+ const unsigned = { schemaVersion: 2, receiptId: `receipt:${request.operationId}`, authority: config.authority, keyId: config.keyId,
598
+ installationId: request.installationId, planId: request.plan.id, planDigest: request.plan.digest,
599
+ activationId: request.activation.id, fence: request.activation.fence, operationId: request.operationId,
600
+ requestDigest: digest(request), phase: 'rollback', outcome: 'passed', hostGeneration: row.generation,
601
+ evidence, evidenceDigest: digest(evidence), observedAt: observation.observedAt,
602
+ expiresAt: Math.min(observation.observedAt + request.receiptTtlMs, config.authorization.expiresAt) }
603
+ if (unsigned.expiresAt <= unsigned.observedAt) fail('authorization expired before signing')
604
+ return transaction(db, () => {
605
+ latestRecovery(db, row); assertCurrent(config, request); remaining(deadline)
606
+ const current = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
607
+ const replay = cachedReceipt(current, request, config, privateKey)
608
+ if (replay) {
609
+ const priorObservation = parseJson(Buffer.from(current.observation), 'concurrent recovery observation')
610
+ if (replay.evidence.probeDigest !== digest(priorObservation) || canonical(priorObservation.successor) !== canonical(successor)
611
+ || canonical(priorObservation.runtimeObservation?.runtime) !== canonical(runtime?.runtime)
612
+ || priorObservation.runtimeObservation?.channelDigest !== runtime?.channelDigest) fail('concurrent recovery binding differs')
613
+ return replay
614
+ }
615
+ const receipt = { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('base64') }
616
+ db.prepare('UPDATE reloads SET observation = ?, receipt = ? WHERE operation_id = ? AND receipt IS NULL')
617
+ .run(canonical(observation), canonical(receipt), request.operationId)
618
+ return cachedReceipt(db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId), request, config, privateKey)
619
+ })
620
+ }
621
+ async function attest(request, config, privateKey) {
622
+ const helperBytes = readSafe(config.processHelper.path, 1048576)
623
+ if (hash(helperBytes) !== config.processHelper.sha256) fail('process helper changed')
624
+ const { executeControlledProcess: execute } = await import(`data:text/javascript;base64,${helperBytes.toString('base64')}`)
625
+ if (typeof execute !== 'function') fail('process helper contract is unavailable')
626
+ const deadline = performance.now() + config.timeoutMs
627
+ const db = journal(config)
628
+ try {
629
+ if (request.phase === 'rollback') return await attestRollback(request, config, privateKey, db, execute, deadline)
630
+ if (request.phase === 'readiness') return await attestReadiness(request, config, privateKey, db, execute, deadline)
631
+ const existing = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
632
+ if (existing) { const receipt = cachedReceipt(existing, request, config, privateKey); if (receipt) return receipt }
633
+ const observed = await observe(config, execute, deadline)
634
+ if (!existing && !active(observed)) fail('target service is not stably active before restart')
635
+ // Control Plane generations are installation-wide, including when its
636
+ // profiles alternate. The retained request/config digests bind each unit.
637
+ const scopeId = digest({ installation: request.installationId })
638
+ const activationId = digest({ plan: request.plan, activation: request.activation })
639
+ const reserved = transaction(db, () => {
640
+ const row = db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId)
641
+ if (row) { cachedReceipt(row, request, config, privateKey); return { row, dispatch: false } }
642
+ const latest = db.prepare('SELECT * FROM reloads WHERE scope_id = ? ORDER BY generation DESC LIMIT 1').get(scopeId)
643
+ if (latest && (latest.receipt === null || latest.generation !== request.requirements.previousHostGeneration)) fail('previous generation is unresolved or stale')
644
+ const generation = request.requirements.previousHostGeneration + 1
645
+ db.prepare('INSERT INTO reloads(operation_id, request_digest, config_digest, scope_id, generation, activation_id, prior, request, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
646
+ .run(request.operationId, digest(request), digest(config), scopeId, generation, activationId, canonical(observed), canonical(request), canonical(config))
647
+ return { row: db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId), dispatch: true }
648
+ })
649
+ const replay = cachedReceipt(reserved.row, request, config, privateKey); if (replay) return replay
650
+ const prior = parseJson(Buffer.from(reserved.row.prior), 'prior observation')
651
+ if (reserved.dispatch) {
652
+ // Reservation is durable before crossing the supervisor boundary. Any
653
+ // later failure is ambiguous; replay must never call restart again.
654
+ syncDirectory(config.stateRoot); assertCurrent(config, request); assertProfile(config)
655
+ await command(config, execute, ['restart', config.unit, '--no-ask-password', '--job-mode=fail'], deadline)
656
+ }
657
+ let successor; let stableAt = 0; let samples = 0
658
+ for (;;) {
659
+ assertCurrent(config, request); remaining(deadline)
660
+ const current = await observe(config, execute, deadline)
661
+ if (active(current) && current.InvocationID !== prior.InvocationID && current.MainPID !== prior.MainPID) {
662
+ if (successor === undefined) { successor = current; stableAt = performance.now(); samples = 1 }
663
+ else if (canonical(current) !== canonical(successor)) fail('successor changed during the stability window')
664
+ else samples++
665
+ if (performance.now() - stableAt >= config.stableWindowMs && samples >= 2) break
666
+ } else if (successor !== undefined) fail('successor left the active state')
667
+ await new Promise(resolveDelay => setTimeout(resolveDelay, Math.min(config.pollIntervalMs, remaining(deadline))))
668
+ }
669
+ assertCurrent(config, request); assertProfile(config); remaining(deadline)
670
+ const observation = { schemaVersion: 1, requestDigest: digest(request), configDigest: digest(config), prior, successor, samples,
671
+ stableWindowMs: config.stableWindowMs, observedAt: Date.now() }
672
+ const evidence = { kind: 'reload', reloaded: true, previousHostGeneration: request.requirements.previousHostGeneration,
673
+ currentHostGeneration: reserved.row.generation, probeDigest: digest(observation) }
674
+ const unsigned = { schemaVersion: 2, receiptId: `receipt:${request.operationId}`, authority: config.authority, keyId: config.keyId,
675
+ installationId: request.installationId, planId: request.plan.id, planDigest: request.plan.digest,
676
+ activationId: request.activation.id, fence: request.activation.fence, operationId: request.operationId,
677
+ requestDigest: digest(request), phase: 'reload', outcome: 'passed', hostGeneration: reserved.row.generation,
678
+ evidence, evidenceDigest: digest(evidence), observedAt: observation.observedAt,
679
+ expiresAt: Math.min(observation.observedAt + request.receiptTtlMs, config.authorization.expiresAt) }
680
+ if (unsigned.expiresAt <= unsigned.observedAt) fail('authorization expired before signing')
681
+ const receipt = { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('base64') }
682
+ return transaction(db, () => {
683
+ db.prepare('UPDATE reloads SET observation = ?, receipt = ? WHERE operation_id = ? AND receipt IS NULL')
684
+ .run(canonical(observation), canonical(receipt), request.operationId)
685
+ return cachedReceipt(db.prepare('SELECT * FROM reloads WHERE operation_id = ?').get(request.operationId), request, config, privateKey)
686
+ })
687
+ } finally { db.close() }
688
+ }
689
+
690
+ export async function runSystemdHostAttestor(argv = process.argv.slice(2), environment = process.env) {
691
+ if (argv.length === 1 && argv[0] === '--version') return SYSTEMD_HOST_ATTESTOR_VERSION
692
+ if (process.platform !== 'linux' || argv.length !== 1 || argv[0] !== 'attest') fail('Linux attest command required')
693
+ let bytes = 0; const chunks = []
694
+ for await (const chunk of process.stdin) { bytes += chunk.length; if (bytes > 65536) fail('request exceeds byte limit'); chunks.push(chunk) }
695
+ const request = parseJson(Buffer.concat(chunks), 'request')
696
+ if (!['reload', 'readiness', 'rollback'].includes(request?.phase)) fail('only reload, readiness and rollback requests are supported')
697
+ const { config, privateKey } = loadConfig(environment, request)
698
+ return attest(request, config, privateKey)
699
+ }
700
+ if (process.argv[1] && (/^\/proc\/self\/fd\/\d+$/u.test(process.argv[1]) || realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))) {
701
+ try { const result = await runSystemdHostAttestor(); process.stdout.write(`${typeof result === 'string' ? result : JSON.stringify(result)}\n`) }
702
+ catch (error) {
703
+ const reason = error instanceof Error && error.message.startsWith('systemd Host attestor:') ? error.message
704
+ : 'systemd Host attestor refused the request; inspect owner configuration and durable observations before retrying'
705
+ process.stderr.write(`${reason}\n`); process.exitCode = 1
706
+ }
707
+ }