@ouro.bot/cli 0.1.0-alpha.771 → 0.1.0-alpha.772
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.json +8 -0
- package/deploy/unraid/README.txt +37 -9
- package/deploy/unraid/sanctuary-acceptance-contract.json +1 -1
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +242 -31
- package/deploy/unraid/sanctuary-unit16-run.sh +19 -2
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/sanctuary-acceptance-adapter.js +111 -1
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +245 -49
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.772",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Make Sanctuary Unraid key rotation crash-resumable with exact transaction and rejection proofs.",
|
|
8
|
+
"Require durable zero-playback journal evidence before live callback injection.",
|
|
9
|
+
"Reject malformed Unraid rotation checkpoints before mutation with exact slot, attestation, revocation-order, and phase-state validation."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
4
12
|
{
|
|
5
13
|
"version": "0.1.0-alpha.771",
|
|
6
14
|
"changes": [
|
package/deploy/unraid/README.txt
CHANGED
|
@@ -2440,6 +2440,9 @@ Packaged Unit 16 acceptance execution:
|
|
|
2440
2440
|
failure. It never reads or changes Unraid autostart configuration. Every command
|
|
2441
2441
|
also receives a freshly generated, redacted typed container-inspect snapshot;
|
|
2442
2442
|
raw container environment or credential values are never captured.
|
|
2443
|
+
Callback injection requires two stable zero observations from a durable callback playback journal under
|
|
2444
|
+
`state/approvals`; the journal is keyed only by the full callback-coordinate
|
|
2445
|
+
digest and stores no raw update, callback data, user, chat, message, or query ID.
|
|
2443
2446
|
Evidence-snapshot keeps the bundle mount read-only and overlays only the exact
|
|
2444
2447
|
canonical `state/acceptance` directory as writable. It first stops the exact
|
|
2445
2448
|
healthy staging container under recovery responsibility, then opens the
|
|
@@ -2459,17 +2462,40 @@ Audit and safety verification:
|
|
|
2459
2462
|
stop and restart mutations; only the separate write key may perform the one
|
|
2460
2463
|
typed approved restart action.
|
|
2461
2464
|
The packaged `unraid-key-rotate` command is the only canonical key-rotation
|
|
2462
|
-
authority.
|
|
2463
|
-
|
|
2465
|
+
authority. Its host launcher stops the exact production container and assumes
|
|
2466
|
+
trap-backed recovery responsibility before key mutation. It inventories the
|
|
2467
|
+
two occupied canonical names by exact immutable ID, creates collision-safe
|
|
2468
|
+
`Butler RO Rotation <suffix>` and
|
|
2464
2469
|
`Butler RW Rotation <suffix>` keys, stores and capability-probes both through
|
|
2465
|
-
the Sanctuary machine vault, and
|
|
2470
|
+
the Sanctuary machine vault, and on the uninterrupted path requires an exact
|
|
2471
|
+
four-key inventory. Because
|
|
2472
|
+
Unraid 7.2.3 cannot create a role-free key through its CLI, the root broker
|
|
2473
|
+
creates each key as provisional `GUEST` with the intended scope plus only
|
|
2474
|
+
`API_KEY:UPDATE_ANY`, then immediately uses that in-memory credential to
|
|
2475
|
+
self-downgrade the same immutable ID to no roles and the exact intended scope.
|
|
2476
|
+
Disk and live-auth proofs must pass before the credential may reach the vault.
|
|
2477
|
+
The provisional credential never reaches argv, logs, evidence, the bundle, or
|
|
2478
|
+
the model. It then
|
|
2466
2479
|
revokes both old IDs and proves each old credential receives a 401 or 403.
|
|
2467
2480
|
Only after an exact inventory contains the temporary pair alone does it create,
|
|
2468
2481
|
store, and probe the canonical `Butler RO` and `Butler RW` pair. It requires an
|
|
2469
|
-
exact four-key temporary-plus-canonical inventory
|
|
2482
|
+
exact four-key temporary-plus-canonical inventory on the uninterrupted path,
|
|
2483
|
+
revokes and rejection-probes
|
|
2470
2484
|
both temporary IDs, and finally requires exactly the canonical pair. Every
|
|
2471
2485
|
unexpected key, duplicate, malformed permission set, role, name, inventory
|
|
2472
|
-
delta, or probe response fails closed.
|
|
2486
|
+
delta, or probe response fails closed. A resumed path may observe fewer keys
|
|
2487
|
+
only when the transaction checkpoint plus a durable rejection receipt proves
|
|
2488
|
+
that the missing immutable ID was already revoked; it never treats absence
|
|
2489
|
+
alone as success.
|
|
2490
|
+
|
|
2491
|
+
A failed invocation resumes from the durable redacted transaction checkpoint
|
|
2492
|
+
instead of reminting known keys. The checkpoint binds every created immutable
|
|
2493
|
+
ID to its name, scope, vault field, temporary/canonical class, and attestation
|
|
2494
|
+
state. Recovery reconciles exact live inventory and rejection proofs, skips
|
|
2495
|
+
already-attested vault writes after canonical fields advance, and never
|
|
2496
|
+
recreates a temporary key already known revoked. Root-owned recovery ownership
|
|
2497
|
+
survives broker response loss and is cleared only after the harness has
|
|
2498
|
+
checkpointed the immutable ID and the vault has read back the exact binding.
|
|
2473
2499
|
|
|
2474
2500
|
Record only IDs, names, and permission sets in evidence; never raw key values.
|
|
2475
2501
|
Raw credentials must not appear in argv, shell history, stdout, logs, or
|
|
@@ -2477,7 +2503,9 @@ Audit and safety verification:
|
|
|
2477
2503
|
socket in process memory. The narrow revocation-retry recovery records under
|
|
2478
2504
|
`/mnt/user/appdata/ouro-butler/acceptance/revoked-key-proof` are the sole file
|
|
2479
2505
|
exception: they are root-owned mode 0600 beneath a root-owned mode 0700
|
|
2480
|
-
directory, excluded from backup/evidence,
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2506
|
+
directory, excluded from backup/evidence, and readable only by the root broker.
|
|
2507
|
+
Recovery state is retained across failed retries.
|
|
2508
|
+
Creation intent is fsynced before the CLI side effect so an exact provisional
|
|
2509
|
+
key can be adopted after process death. Raw recovery proof is durably deleted
|
|
2510
|
+
only after the exact authentication rejection and its redacted replayable
|
|
2511
|
+
receipt are fsynced. Never substitute names for IDs when revoking keys.
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"telegram-poller-quiescence": { "operation": "quiesce_telegram_poller", "authority": "fixed-managed-telegram-poller-stop-and-count", "modelReachable": false, "timeoutMs": 15000 },
|
|
32
32
|
"telegram-vault-store": { "operation": "store_telegram_bootstrap", "authority": "fixed-runtime-vault-merge", "modelReachable": false, "timeoutMs": 15000 },
|
|
33
33
|
"cursor-snapshot": { "operation": "snapshot", "authority": "fixed-telegram-state-read", "modelReachable": false, "timeoutMs": 15000 },
|
|
34
|
-
"callback-live": { "operation": "inject_callbacks_concurrently|inject_callback_replay", "authority": "fixed-live-telegram-callback-probe", "modelReachable": false, "timeoutMs": 15000 },
|
|
34
|
+
"callback-live": { "operation": "callback_playback_preflight|inject_callbacks_concurrently|inject_callback_replay", "authority": "fixed-live-telegram-callback-probe", "modelReachable": false, "timeoutMs": 15000 },
|
|
35
35
|
"key-inventory": { "operation": "inventory_keys", "authority": "fixed-unraid-key-directory-read", "modelReachable": false, "timeoutMs": 15000 },
|
|
36
36
|
"key-create": { "operation": "create_key", "authority": "fixed-unraid-cli-create-and-vault-store", "modelReachable": false, "timeoutMs": 15000 },
|
|
37
37
|
"key-store": { "operation": "store_key", "authority": "fixed-machine-vault-readback", "modelReachable": false, "timeoutMs": 15000 },
|
|
@@ -149,27 +149,113 @@ function runUnraid(args) {
|
|
|
149
149
|
return object(JSON.parse(result.stdout), "Unraid API response")
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function
|
|
152
|
+
function permissionObjects(values) {
|
|
153
|
+
const grouped = new Map()
|
|
154
|
+
for (const value of values) {
|
|
155
|
+
const [resource, action] = value.split(":")
|
|
156
|
+
const actions = grouped.get(resource) ?? []
|
|
157
|
+
actions.push(action)
|
|
158
|
+
grouped.set(resource, actions)
|
|
159
|
+
}
|
|
160
|
+
return [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right))
|
|
161
|
+
.map(([resource, actions]) => ({ resource, actions: actions.sort() }))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function createUnraidKey(name, requested, dependencies = {
|
|
165
|
+
runUnraid, inventoryRecords, persistRecovery, readRecovery: optionalRecoveryRecord,
|
|
166
|
+
persistIntent: persistCreateIntent, readIntent: optionalCreateIntent, removeIntent: removeCreateIntent,
|
|
167
|
+
fetchImpl: fetch,
|
|
168
|
+
}) {
|
|
153
169
|
const before = dependencies.inventoryRecords()
|
|
154
170
|
const beforeIds = new Set(before.map((record) => record.id))
|
|
171
|
+
const provisionalPermissions = [...requested, "API_KEY:UPDATE_ANY"].sort()
|
|
172
|
+
let recoveryId = null
|
|
173
|
+
let cliIdentity = null
|
|
155
174
|
try {
|
|
156
|
-
const
|
|
157
|
-
|
|
175
|
+
const occupied = before.filter((record) => record.name === name)
|
|
176
|
+
const existingIntent = dependencies.readIntent?.(name) ?? null
|
|
177
|
+
if (existingIntent === null) {
|
|
178
|
+
if (occupied.length !== 0) throw new Error("existing provisional key has no durable creation intent")
|
|
179
|
+
dependencies.persistIntent(name, provisionalPermissions)
|
|
180
|
+
} else if (existingIntent.name !== name || JSON.stringify(existingIntent.permissions) !== JSON.stringify(provisionalPermissions)) {
|
|
181
|
+
throw new Error("Unraid key creation intent is invalid")
|
|
182
|
+
}
|
|
183
|
+
let created
|
|
184
|
+
let provisional
|
|
185
|
+
let resumed = false
|
|
186
|
+
let resumedFinal = false
|
|
187
|
+
if (occupied.length === 1) {
|
|
188
|
+
provisional = occupied[0]
|
|
189
|
+
let recovery = dependencies.readRecovery?.(provisional.id) ?? null
|
|
190
|
+
const provisionalExact = JSON.stringify(flattened(provisional)) === JSON.stringify(provisionalPermissions)
|
|
191
|
+
&& JSON.stringify(provisional.roles) === JSON.stringify(["GUEST"])
|
|
192
|
+
if (recovery === null && provisionalExact) {
|
|
193
|
+
dependencies.persistRecovery(provisional)
|
|
194
|
+
recovery = provisional
|
|
195
|
+
}
|
|
196
|
+
if (!recovery || recovery.id !== provisional.id || recovery.name !== provisional.name || recovery.key !== provisional.key
|
|
197
|
+
|| JSON.stringify(flattened(recovery)) !== JSON.stringify(provisionalPermissions)
|
|
198
|
+
|| JSON.stringify(recovery.roles) !== JSON.stringify(["GUEST"])
|
|
199
|
+
|| !(provisionalExact
|
|
200
|
+
|| (JSON.stringify(flattened(provisional)) === JSON.stringify(requested) && provisional.roles.length === 0))) {
|
|
201
|
+
throw new Error("existing provisional key is not exact owned recovery")
|
|
202
|
+
}
|
|
203
|
+
resumedFinal = provisional.roles.length === 0
|
|
204
|
+
created = { id: provisional.id, key: provisional.key, name: provisional.name }
|
|
205
|
+
resumed = true
|
|
206
|
+
} else if (occupied.length === 0) {
|
|
207
|
+
created = object(dependencies.runUnraid(["apikey", "--name", name, "--create", "--roles", "GUEST", "--permissions", provisionalPermissions.join(","), "--json"]), "created Unraid key")
|
|
208
|
+
exactKeys(created, ["id", "key", "name"], "created Unraid key")
|
|
209
|
+
cliIdentity = { id: text(created.id, "created key id", KEY_ID), key: text(created.key, "created key descriptor"), name: created.name }
|
|
210
|
+
} else {
|
|
211
|
+
throw new Error("created provisional key name is ambiguous")
|
|
212
|
+
}
|
|
158
213
|
const id = text(created.id, "created key id", KEY_ID)
|
|
159
214
|
const key = text(created.key, "created key descriptor")
|
|
160
|
-
if (created.name !== name || beforeIds.has(id)) throw new Error("created key identity is invalid")
|
|
161
|
-
const matches = dependencies.inventoryRecords().filter((record) => record.id === id)
|
|
215
|
+
if (created.name !== name || (!resumed && beforeIds.has(id))) throw new Error("created key identity is invalid")
|
|
216
|
+
const matches = resumed ? [provisional] : dependencies.inventoryRecords().filter((record) => record.id === id)
|
|
162
217
|
if (matches.length !== 1 || matches[0].name !== name || matches[0].key !== key
|
|
163
|
-
|| JSON.stringify(flattened(matches[0])) !== JSON.stringify(
|
|
164
|
-
|
|
218
|
+
|| (!resumedFinal && (JSON.stringify(flattened(matches[0])) !== JSON.stringify(provisionalPermissions)
|
|
219
|
+
|| JSON.stringify(matches[0].roles) !== JSON.stringify(["GUEST"])))) {
|
|
220
|
+
throw new Error("created provisional key record does not match the exact CLI result")
|
|
165
221
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
222
|
+
if (!resumed) dependencies.persistRecovery(matches[0])
|
|
223
|
+
recoveryId = id
|
|
224
|
+
const headers = { "content-type": "application/json", "x-api-key": key }
|
|
225
|
+
if (!resumedFinal) try {
|
|
226
|
+
const updateResponse = await dependencies.fetchImpl(GRAPHQL_ENDPOINT, {
|
|
227
|
+
method: "POST", headers,
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
query: "mutation AcceptanceApiKeyDowngrade($input: UpdateApiKeyInput!) { apiKey { update(input: $input) { id name roles permissions { resource actions } } } }",
|
|
230
|
+
variables: { input: { id, roles: [], permissions: permissionObjects(requested) } },
|
|
231
|
+
}),
|
|
232
|
+
signal: AbortSignal.timeout(10_000),
|
|
233
|
+
})
|
|
234
|
+
const updateEnvelope = object(await updateResponse.json(), "Unraid key downgrade response")
|
|
235
|
+
if (!updateResponse.ok || updateEnvelope.errors || !updateEnvelope.data) throw new Error("Unraid key downgrade was rejected")
|
|
236
|
+
} catch {
|
|
237
|
+
// A lost response is accepted only when disk and live-auth attestation below prove convergence.
|
|
172
238
|
}
|
|
239
|
+
const finalized = dependencies.inventoryRecords().filter((record) => record.id === id)
|
|
240
|
+
if (finalized.length !== 1 || finalized[0].name !== name || finalized[0].key !== key
|
|
241
|
+
|| JSON.stringify(flattened(finalized[0])) !== JSON.stringify(requested) || finalized[0].roles.length !== 0) {
|
|
242
|
+
throw new Error("created key did not converge to its exact final scope")
|
|
243
|
+
}
|
|
244
|
+
const probeResponse = await dependencies.fetchImpl(GRAPHQL_ENDPOINT, {
|
|
245
|
+
method: "POST", headers,
|
|
246
|
+
body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
|
|
247
|
+
signal: AbortSignal.timeout(10_000),
|
|
248
|
+
})
|
|
249
|
+
const probeEnvelope = object(await probeResponse.json(), "Unraid key readiness response")
|
|
250
|
+
if (!probeResponse.ok || probeEnvelope.errors || !probeEnvelope.data) throw new Error("created key readiness probe failed")
|
|
251
|
+
return finalized[0]
|
|
252
|
+
} catch (error) {
|
|
253
|
+
const candidates = dependencies.inventoryRecords().filter((record) => record.name === name
|
|
254
|
+
&& !beforeIds.has(record.id)
|
|
255
|
+
&& JSON.stringify(flattened(record)) === JSON.stringify(provisionalPermissions)
|
|
256
|
+
&& JSON.stringify(record.roles) === JSON.stringify(["GUEST"])
|
|
257
|
+
&& (cliIdentity === null || (record.id === cliIdentity.id && record.key === cliIdentity.key && record.name === cliIdentity.name)))
|
|
258
|
+
if (candidates.length === 1 && candidates[0].id !== recoveryId) dependencies.persistRecovery(candidates[0])
|
|
173
259
|
throw error
|
|
174
260
|
}
|
|
175
261
|
}
|
|
@@ -570,27 +656,149 @@ function containerRestartSnapshot(expectedImage) {
|
|
|
570
656
|
}
|
|
571
657
|
|
|
572
658
|
function recoveryPath(id) { return `${RECOVERY_ROOT}/${id}.json` }
|
|
659
|
+
function createIntentPath(name) { return `${RECOVERY_ROOT}/create-${createHash("sha256").update(name).digest("hex")}.json` }
|
|
660
|
+
function rejectionReceiptPath(id) { return `${RECOVERY_ROOT}/${id}.rejected.json` }
|
|
573
661
|
|
|
574
|
-
function
|
|
662
|
+
function persistPrivateJson(target, value) {
|
|
575
663
|
mkdirSync(RECOVERY_ROOT, { recursive: true, mode: 0o700 })
|
|
576
664
|
chmodSync(RECOVERY_ROOT, 0o700)
|
|
577
665
|
chownSync(RECOVERY_ROOT, 0, 0)
|
|
578
|
-
const target = recoveryPath(record.id)
|
|
579
666
|
const temporary = `${target}.tmp-${process.pid}`
|
|
580
667
|
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
|
|
581
|
-
try { writeFileSync(fd, `${JSON.stringify(
|
|
582
|
-
chownSync(temporary, 0, 0)
|
|
668
|
+
try { chownSync(temporary, 0, 0); writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd) } finally { closeSync(fd) }
|
|
583
669
|
renameSync(temporary, target)
|
|
584
670
|
const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
|
|
585
671
|
try { fsyncSync(directory) } finally { closeSync(directory) }
|
|
586
672
|
}
|
|
587
673
|
|
|
674
|
+
function optionalPrivateJson(target) {
|
|
675
|
+
try { return object(readPrivateJson(target), "private acceptance record") }
|
|
676
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function persistCreateIntent(name, requested) {
|
|
680
|
+
mkdirSync(RECOVERY_ROOT, { recursive: true, mode: 0o700 })
|
|
681
|
+
chmodSync(RECOVERY_ROOT, 0o700)
|
|
682
|
+
chownSync(RECOVERY_ROOT, 0, 0)
|
|
683
|
+
const target = createIntentPath(name)
|
|
684
|
+
const fd = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
|
|
685
|
+
try {
|
|
686
|
+
chownSync(target, 0, 0)
|
|
687
|
+
writeFileSync(fd, `${JSON.stringify({ schemaVersion: 1, name, permissions: requested })}\n`)
|
|
688
|
+
fsyncSync(fd)
|
|
689
|
+
} finally { closeSync(fd) }
|
|
690
|
+
const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
|
|
691
|
+
try { fsyncSync(directory) } finally { closeSync(directory) }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function optionalCreateIntent(name) {
|
|
695
|
+
const value = optionalPrivateJson(createIntentPath(name))
|
|
696
|
+
if (value === null) return null
|
|
697
|
+
exactKeys(value, ["schemaVersion", "name", "permissions"], "Unraid key creation intent")
|
|
698
|
+
if (value.schemaVersion !== 1 || value.name !== name) throw new Error("Unraid key creation intent is invalid")
|
|
699
|
+
return { name, permissions: permissions(value.permissions) }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function removePrivateFile(target) {
|
|
703
|
+
try { unlinkSync(target) } catch (error) { if (error?.code !== "ENOENT") throw error }
|
|
704
|
+
const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
|
|
705
|
+
try { fsyncSync(directory) } finally { closeSync(directory) }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function removeCreateIntent(name) { removePrivateFile(createIntentPath(name)) }
|
|
709
|
+
|
|
710
|
+
function optionalRejectionReceipt(id) {
|
|
711
|
+
const value = optionalPrivateJson(rejectionReceiptPath(id))
|
|
712
|
+
if (value === null) return null
|
|
713
|
+
exactKeys(value, ["schemaVersion", "id", "status", "proofDigest"], "revoked key rejection receipt")
|
|
714
|
+
if (value.schemaVersion !== 1 || value.id !== id || (value.status !== 401 && value.status !== 403)
|
|
715
|
+
|| value.proofDigest !== createHash("sha256").update(`revoked-key-rejected\0${id}\0${value.status}`).digest("hex")) {
|
|
716
|
+
throw new Error("revoked key rejection receipt is invalid")
|
|
717
|
+
}
|
|
718
|
+
return { valid: false, status: value.status, id }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function persistRejectionReceipt(id, status) {
|
|
722
|
+
persistPrivateJson(rejectionReceiptPath(id), {
|
|
723
|
+
schemaVersion: 1, id, status,
|
|
724
|
+
proofDigest: createHash("sha256").update(`revoked-key-rejected\0${id}\0${status}`).digest("hex"),
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function probeRevokedUnraidKey(id, dependencies = {
|
|
729
|
+
readReceipt: optionalRejectionReceipt,
|
|
730
|
+
readRecovery: optionalRecoveryRecord,
|
|
731
|
+
persistReceipt: persistRejectionReceipt,
|
|
732
|
+
removeRecovery,
|
|
733
|
+
fetchImpl: fetch,
|
|
734
|
+
}) {
|
|
735
|
+
text(id, "key id", KEY_ID)
|
|
736
|
+
const receipt = dependencies.readReceipt(id)
|
|
737
|
+
if (receipt !== null) {
|
|
738
|
+
const staleRecovery = dependencies.readRecovery(id)
|
|
739
|
+
if (staleRecovery !== null) {
|
|
740
|
+
if (staleRecovery.id !== id) throw new Error("recovery key id is invalid")
|
|
741
|
+
dependencies.removeRecovery(id)
|
|
742
|
+
}
|
|
743
|
+
return receipt
|
|
744
|
+
}
|
|
745
|
+
const record = dependencies.readRecovery(id)
|
|
746
|
+
if (record === null || record.id !== id) throw new Error("recovery key id is invalid")
|
|
747
|
+
const response = await dependencies.fetchImpl(GRAPHQL_ENDPOINT, {
|
|
748
|
+
method: "POST", headers: { "content-type": "application/json", "x-api-key": record.key },
|
|
749
|
+
body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
|
|
750
|
+
signal: AbortSignal.timeout(10_000),
|
|
751
|
+
})
|
|
752
|
+
if (response.status !== 401 && response.status !== 403) throw new Error("revoked key still authenticates")
|
|
753
|
+
dependencies.persistReceipt(id, response.status)
|
|
754
|
+
dependencies.removeRecovery(id)
|
|
755
|
+
return { valid: false, status: response.status, id }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function optionalRecoveryRecord(id) {
|
|
759
|
+
try { return normalizeRecord(readPrivateJson(recoveryPath(id))) }
|
|
760
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function persistRecovery(record) {
|
|
764
|
+
persistPrivateJson(recoveryPath(record.id), record)
|
|
765
|
+
}
|
|
766
|
+
|
|
588
767
|
function removeRecovery(id) {
|
|
589
768
|
unlinkSync(recoveryPath(id))
|
|
590
769
|
const directory = openSync(RECOVERY_ROOT, constants.O_RDONLY)
|
|
591
770
|
try { fsyncSync(directory) } finally { closeSync(directory) }
|
|
592
771
|
}
|
|
593
772
|
|
|
773
|
+
function acknowledgeStoredUnraidKey(id, dependencies = {
|
|
774
|
+
readRecovery: optionalRecoveryRecord,
|
|
775
|
+
inventoryRecords,
|
|
776
|
+
removeRecovery,
|
|
777
|
+
removeIntent: removeCreateIntent,
|
|
778
|
+
}) {
|
|
779
|
+
text(id, "key id", KEY_ID)
|
|
780
|
+
const matches = dependencies.inventoryRecords().filter((record) => record.id === id)
|
|
781
|
+
if (matches.length !== 1 || !KEY_NAME.test(matches[0].name)) throw new Error("Unraid key final storage acknowledgement is invalid")
|
|
782
|
+
const expected = matches[0].name.startsWith("Butler RO") ? RO_PERMISSIONS : [...RO_PERMISSIONS, "DOCKER:UPDATE_ANY"].sort()
|
|
783
|
+
if (matches[0].roles.length !== 0 || JSON.stringify(flattened(matches[0])) !== JSON.stringify(expected)) {
|
|
784
|
+
throw new Error("Unraid key final storage acknowledgement is invalid")
|
|
785
|
+
}
|
|
786
|
+
const recovery = dependencies.readRecovery(id)
|
|
787
|
+
if (recovery === null) {
|
|
788
|
+
dependencies.removeIntent?.(matches[0].name)
|
|
789
|
+
return { acknowledged: true, id }
|
|
790
|
+
}
|
|
791
|
+
const provisional = [...expected, "API_KEY:UPDATE_ANY"].sort()
|
|
792
|
+
if (recovery.id !== id || recovery.name !== matches[0].name || recovery.key !== matches[0].key
|
|
793
|
+
|| JSON.stringify(recovery.roles) !== JSON.stringify(["GUEST"])
|
|
794
|
+
|| JSON.stringify(flattened(recovery)) !== JSON.stringify(provisional)) {
|
|
795
|
+
throw new Error("Unraid key recovery ownership is invalid")
|
|
796
|
+
}
|
|
797
|
+
dependencies.removeRecovery(id)
|
|
798
|
+
dependencies.removeIntent?.(matches[0].name)
|
|
799
|
+
return { acknowledged: true, id }
|
|
800
|
+
}
|
|
801
|
+
|
|
594
802
|
function permissions(value) {
|
|
595
803
|
if (!Array.isArray(value) || value.length === 0) throw new Error("permissions are invalid")
|
|
596
804
|
const result = value.map((item) => text(item, "permission", PERMISSION)).sort()
|
|
@@ -1556,7 +1764,12 @@ async function dispatch(request, dependencies = {
|
|
|
1556
1764
|
if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
|
|
1557
1765
|
const name = text(payload.name, "key name", KEY_NAME)
|
|
1558
1766
|
const requested = permissions(payload.permissions)
|
|
1559
|
-
return createUnraidKey(name, requested)
|
|
1767
|
+
return await createUnraidKey(name, requested)
|
|
1768
|
+
}
|
|
1769
|
+
if (operation === "acknowledge_key_storage") {
|
|
1770
|
+
exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
|
|
1771
|
+
if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
|
|
1772
|
+
return acknowledgeStoredUnraidKey(text(payload.keyId, "key id", KEY_ID))
|
|
1560
1773
|
}
|
|
1561
1774
|
if (operation === "revoke_key") {
|
|
1562
1775
|
exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
|
|
@@ -1574,17 +1787,7 @@ async function dispatch(request, dependencies = {
|
|
|
1574
1787
|
if (operation === "probe_revoked_key") {
|
|
1575
1788
|
exactKeys(payload, ["operation", "targetServerId", "keyId"], operation)
|
|
1576
1789
|
if (payload.targetServerId !== TARGET_SERVER) throw new Error("target server is invalid")
|
|
1577
|
-
|
|
1578
|
-
const record = normalizeRecord(readPrivateJson(recoveryPath(id)))
|
|
1579
|
-
if (record.id !== id) throw new Error("recovery key id is invalid")
|
|
1580
|
-
const response = await fetch(GRAPHQL_ENDPOINT, {
|
|
1581
|
-
method: "POST", headers: { "content-type": "application/json", "x-api-key": record.key },
|
|
1582
|
-
body: JSON.stringify({ query: "query AcceptanceAuthProbe { info { os { hostname } } }", variables: {} }),
|
|
1583
|
-
signal: AbortSignal.timeout(10_000),
|
|
1584
|
-
})
|
|
1585
|
-
if (response.status !== 401 && response.status !== 403) throw new Error("revoked key still authenticates")
|
|
1586
|
-
removeRecovery(id)
|
|
1587
|
-
return { valid: false, status: response.status, id }
|
|
1790
|
+
return await probeRevokedUnraidKey(text(payload.keyId, "key id", KEY_ID))
|
|
1588
1791
|
}
|
|
1589
1792
|
if (operation === "request_reboot") {
|
|
1590
1793
|
exactKeys(payload, ["operation", "targetId", "idempotencyKey", "preflightDigest", "processBindingDigest"], operation)
|
|
@@ -1721,7 +1924,11 @@ function writeClosedInventory(file) {
|
|
|
1721
1924
|
|
|
1722
1925
|
function createBrokerServer(dispatchRequest = dispatch) {
|
|
1723
1926
|
const dispatchDrain = createDispatchDrain()
|
|
1927
|
+
const connections = new Set()
|
|
1724
1928
|
const server = createServer({ allowHalfOpen: true }, (connection) => {
|
|
1929
|
+
connections.add(connection)
|
|
1930
|
+
connection.setTimeout(30_000, () => connection.destroy())
|
|
1931
|
+
connection.once("close", () => connections.delete(connection))
|
|
1725
1932
|
let input = ""
|
|
1726
1933
|
connection.setEncoding("utf8")
|
|
1727
1934
|
connection.on("data", (chunk) => {
|
|
@@ -1737,7 +1944,8 @@ function createBrokerServer(dispatchRequest = dispatch) {
|
|
|
1737
1944
|
} catch { connection.end(`${JSON.stringify({ ok: false, error: "host operation failed" })}\n`) }
|
|
1738
1945
|
})
|
|
1739
1946
|
})
|
|
1740
|
-
|
|
1947
|
+
const destroyConnections = () => { for (const connection of connections) connection.destroy() }
|
|
1948
|
+
return { server, dispatchDrain, destroyConnections }
|
|
1741
1949
|
}
|
|
1742
1950
|
|
|
1743
1951
|
async function main() {
|
|
@@ -1754,7 +1962,7 @@ async function main() {
|
|
|
1754
1962
|
const snapshotFd = openSync(initialSnapshot, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
|
|
1755
1963
|
try { writeFileSync(snapshotFd, `${JSON.stringify(snapshot)}\n`); fsyncSync(snapshotFd) } finally { closeSync(snapshotFd) }
|
|
1756
1964
|
chownSync(initialSnapshot, 0, 0)
|
|
1757
|
-
const { server, dispatchDrain } = createBrokerServer()
|
|
1965
|
+
const { server, dispatchDrain, destroyConnections } = createBrokerServer()
|
|
1758
1966
|
server.listen(socket, () => {
|
|
1759
1967
|
chownSync(socket, 0, 10001)
|
|
1760
1968
|
chmodSync(socket, 0o660)
|
|
@@ -1764,6 +1972,7 @@ async function main() {
|
|
|
1764
1972
|
if (shuttingDown) return
|
|
1765
1973
|
shuttingDown = true
|
|
1766
1974
|
server.close()
|
|
1975
|
+
destroyConnections()
|
|
1767
1976
|
await dispatchDrain.stopAndDrain()
|
|
1768
1977
|
await interactiveRestartDriver.stopAndDrain()
|
|
1769
1978
|
let failed = false
|
|
@@ -1781,6 +1990,7 @@ if (process.argv[1]?.endsWith("sanctuary-unit16-host-broker.mjs")) {
|
|
|
1781
1990
|
}
|
|
1782
1991
|
|
|
1783
1992
|
export {
|
|
1993
|
+
acknowledgeStoredUnraidKey,
|
|
1784
1994
|
assertStableContainerProcess,
|
|
1785
1995
|
attestHealthProbeProcessAbsent,
|
|
1786
1996
|
armRestartAfterResponseClosed,
|
|
@@ -1805,6 +2015,7 @@ export {
|
|
|
1805
2015
|
observeRebootPreflight,
|
|
1806
2016
|
parseVaultStatus,
|
|
1807
2017
|
optionalStoppedContainerExact,
|
|
2018
|
+
probeRevokedUnraidKey,
|
|
1808
2019
|
queryGraphqlAutostart,
|
|
1809
2020
|
readBoundedProcStatus,
|
|
1810
2021
|
driveDuplicateCallbacks,
|
|
@@ -50,9 +50,22 @@ assert_health_probe_cleanup() {
|
|
|
50
50
|
' "$ACCEPTANCE_STATE_ROOT" "$BUNDLE_ROOT/state/health/sanctuary-health.json.lease"
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
terminate_broker() {
|
|
54
|
+
test -n "$BROKER_PID" || return 0
|
|
55
|
+
kill "$BROKER_PID" 2>/dev/null || true
|
|
56
|
+
BROKER_WAIT=0
|
|
57
|
+
while kill -0 "$BROKER_PID" 2>/dev/null && test "$BROKER_WAIT" -lt 10; do
|
|
58
|
+
sleep 1
|
|
59
|
+
BROKER_WAIT=$(( BROKER_WAIT + 1 ))
|
|
60
|
+
done
|
|
61
|
+
if kill -0 "$BROKER_PID" 2>/dev/null; then kill -KILL "$BROKER_PID" 2>/dev/null || true; fi
|
|
62
|
+
wait "$BROKER_PID" 2>/dev/null || true
|
|
63
|
+
BROKER_PID=
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
cleanup_unit16() {
|
|
54
67
|
CLEANUP_STATUS=$?
|
|
55
|
-
|
|
68
|
+
terminate_broker
|
|
56
69
|
if test "$ACCEPTANCE_CANONICAL_PINNED" = yes && ! assert_health_probe_cleanup; then CLEANUP_STATUS=1; fi
|
|
57
70
|
if test "$PRODUCTION_STOPPED" = yes && test "$HOST_REBOOT_COMMIT_STATE" = not_sent; then
|
|
58
71
|
if ! restore_production_container; then CLEANUP_STATUS=1; fi
|
|
@@ -73,7 +86,10 @@ cleanup_unit16() {
|
|
|
73
86
|
if test "$ACCEPTANCE_ALIAS_MOUNTED" = no && test -n "$PRIVATE_ROOT" && test -d "$PRIVATE_ROOT"; then rm -rf -- "$PRIVATE_ROOT"; fi
|
|
74
87
|
return "$CLEANUP_STATUS"
|
|
75
88
|
}
|
|
76
|
-
trap cleanup_unit16 EXIT
|
|
89
|
+
trap cleanup_unit16 EXIT
|
|
90
|
+
trap 'exit 129' HUP
|
|
91
|
+
trap 'exit 130' INT
|
|
92
|
+
trap 'exit 143' TERM
|
|
77
93
|
|
|
78
94
|
case "$MODE" in
|
|
79
95
|
materialize)
|
|
@@ -347,6 +363,7 @@ cmp -s "$EXPECTED_CONFIG" "$CONFIG_PATH" || exit 1
|
|
|
347
363
|
case "$COMMAND" in telegram-bootstrap|callback-inject) test -r /proc/self/fd/3 || exit 2 ;; esac
|
|
348
364
|
prepare_live_facts
|
|
349
365
|
if test "$COMMAND" = telegram-bootstrap; then quiesce_production_telegram_poller; fi
|
|
366
|
+
if test "$COMMAND" = unraid-key-rotate; then stop_exact_production_container; fi
|
|
350
367
|
|
|
351
368
|
case "$COMMAND" in
|
|
352
369
|
telegram-bootstrap) TIME_LIMIT=900; NETWORK=host; INPUT=yes; BUNDLE_MODE=readonly; BROKER=no ;;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>Mendelow Cloud Butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.772</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -32,6 +32,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
35
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
39
|
exports.proveAttemptedRecoveryWithoutRetry = void 0;
|
|
37
40
|
exports.evaluateSanctuaryProviderReadinessContract = evaluateSanctuaryProviderReadinessContract;
|
|
@@ -45,6 +48,8 @@ exports.canonicalDockerIdFromUnraidPrefixedId = canonicalDockerIdFromUnraidPrefi
|
|
|
45
48
|
exports.runSanctuaryProductionBoundaryProbe = runSanctuaryProductionBoundaryProbe;
|
|
46
49
|
exports.createSanctuaryReadOnlyDenialScenarioDriver = createSanctuaryReadOnlyDenialScenarioDriver;
|
|
47
50
|
exports.readDefaultSanctuaryScenarioFacts = readDefaultSanctuaryScenarioFacts;
|
|
51
|
+
exports.callbackPlaybackSnapshot = callbackPlaybackSnapshot;
|
|
52
|
+
exports.recordCallbackPlayback = recordCallbackPlayback;
|
|
48
53
|
exports.executeSanctuaryAcceptanceAdapter = executeSanctuaryAcceptanceAdapter;
|
|
49
54
|
exports.executeSanctuaryAcceptanceVaultProbe = executeSanctuaryAcceptanceVaultProbe;
|
|
50
55
|
exports.executeSanctuaryAcceptanceRevokedProbe = executeSanctuaryAcceptanceRevokedProbe;
|
|
@@ -55,6 +60,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
55
60
|
const node_fs_1 = require("node:fs");
|
|
56
61
|
const node_net_1 = require("node:net");
|
|
57
62
|
const path = __importStar(require("node:path"));
|
|
63
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
58
64
|
const friends_1 = require("@ouro.bot/friends");
|
|
59
65
|
const runtime_1 = require("../../nerves/runtime");
|
|
60
66
|
const telegram_approval_runtime_1 = require("../../senses/telegram-approval-runtime");
|
|
@@ -99,6 +105,7 @@ const NETWORK_TIMEOUT_MS = 10_000;
|
|
|
99
105
|
const KEY_DIRECTORY = "/boot/config/plugins/dynamix.my.servers/keys";
|
|
100
106
|
const SELECTED_KEY_RECORD = "/run/ouro-acceptance/unraid-key.json";
|
|
101
107
|
const TELEGRAM_OFFSET = "/home/ouro/AgentBundles/sanctuary.ouro/state/senses/telegram/offset.json";
|
|
108
|
+
const CALLBACK_PLAYBACK_JOURNAL = "state/approvals/sanctuary-callback-playback.sqlite";
|
|
102
109
|
const TELEGRAM_IDENTITY_KEY = "/home/ouro/AgentBundles/sanctuary.ouro/state/senses/telegram/identity.key";
|
|
103
110
|
const TELEGRAM_AUDIT = `/home/ouro/AgentBundles/sanctuary.ouro/${telegram_audit_ledger_1.TELEGRAM_ACCEPTANCE_AUDIT_RELATIVE_PATH}`;
|
|
104
111
|
const TELEGRAM_AUDIT_HEAD = `/home/ouro/AgentBundles/sanctuary.ouro/${telegram_audit_ledger_1.TELEGRAM_ACCEPTANCE_AUDIT_HEAD_RELATIVE_PATH}`;
|
|
@@ -244,6 +251,7 @@ function createSanctuaryAcceptanceAdapterDependencies(secretFd = 3, options = {}
|
|
|
244
251
|
refreshMachine: runtime_credentials_1.refreshMachineRuntimeCredentialConfig,
|
|
245
252
|
mergeMachine: runtime_credentials_1.mergeMachineRuntimeCredentialConfig,
|
|
246
253
|
callbackProbe: executeSanctuaryAcceptanceCallbackProbe,
|
|
254
|
+
callbackPlaybackSnapshot: (coordinateDigest) => callbackPlaybackSnapshot((0, identity_1.getAgentRoot)(TARGET_ID), coordinateDigest),
|
|
247
255
|
interactiveRuntime: executeSanctuaryInteractiveRuntimeOperation,
|
|
248
256
|
hostRequest: options.hostRequest ?? ((payload) => defaultHostRequest(payload, hostBrokerSocket, adapterTimeoutMs)),
|
|
249
257
|
telegramCredentials: () => (0, telegram_1.loadTelegramSenseCredentials)(TARGET_ID),
|
|
@@ -1912,6 +1920,94 @@ function callbackUpdate(value) {
|
|
|
1912
1920
|
object(update.callback_query, "callback update callback_query");
|
|
1913
1921
|
return update;
|
|
1914
1922
|
}
|
|
1923
|
+
function withCallbackPlaybackJournal(agentRoot, operation) {
|
|
1924
|
+
const databasePath = path.join(agentRoot, CALLBACK_PLAYBACK_JOURNAL);
|
|
1925
|
+
(0, node_fs_1.mkdirSync)(path.dirname(databasePath), { recursive: true, mode: 0o700 });
|
|
1926
|
+
const database = new better_sqlite3_1.default(databasePath);
|
|
1927
|
+
try {
|
|
1928
|
+
(0, node_fs_1.chmodSync)(databasePath, 0o600);
|
|
1929
|
+
database.pragma("journal_mode = DELETE");
|
|
1930
|
+
database.pragma("synchronous = FULL");
|
|
1931
|
+
database.exec(`
|
|
1932
|
+
CREATE TABLE IF NOT EXISTS callback_playback (
|
|
1933
|
+
coordinate_digest TEXT PRIMARY KEY NOT NULL CHECK(length(coordinate_digest) = 64),
|
|
1934
|
+
playback_count INTEGER NOT NULL CHECK(playback_count > 0)
|
|
1935
|
+
)
|
|
1936
|
+
`);
|
|
1937
|
+
return operation(database);
|
|
1938
|
+
}
|
|
1939
|
+
finally {
|
|
1940
|
+
database.close();
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
function callbackPlaybackSnapshot(agentRoot, coordinateDigest) {
|
|
1944
|
+
if (!SHA256.test(coordinateDigest))
|
|
1945
|
+
throw new Error("callback coordinate digest is invalid");
|
|
1946
|
+
return withCallbackPlaybackJournal(agentRoot, (database) => {
|
|
1947
|
+
const rows = database.prepare("SELECT coordinate_digest, playback_count FROM callback_playback ORDER BY coordinate_digest")
|
|
1948
|
+
.all();
|
|
1949
|
+
if (rows.some((row) => !SHA256.test(row.coordinate_digest) || !Number.isSafeInteger(row.playback_count) || row.playback_count <= 0)) {
|
|
1950
|
+
throw new Error("callback playback journal is invalid");
|
|
1951
|
+
}
|
|
1952
|
+
const playbackCount = rows.find((row) => row.coordinate_digest === coordinateDigest)?.playback_count ?? 0;
|
|
1953
|
+
return { playbackCount, coordinateDigest, journalDigest: sha256(JSON.stringify(rows)) };
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
function recordCallbackPlayback(agentRoot, coordinateDigest) {
|
|
1957
|
+
if (!SHA256.test(coordinateDigest))
|
|
1958
|
+
throw new Error("callback coordinate digest is invalid");
|
|
1959
|
+
withCallbackPlaybackJournal(agentRoot, (database) => {
|
|
1960
|
+
database.prepare(`
|
|
1961
|
+
INSERT INTO callback_playback (coordinate_digest, playback_count) VALUES (?, 1)
|
|
1962
|
+
ON CONFLICT(coordinate_digest) DO UPDATE SET playback_count = playback_count + 1
|
|
1963
|
+
`).run(coordinateDigest);
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
function callbackCoordinate(update) {
|
|
1967
|
+
const updateId = update.update_id;
|
|
1968
|
+
if (!Number.isSafeInteger(updateId) || Number(updateId) < 0)
|
|
1969
|
+
throw new Error("callback update_id is invalid");
|
|
1970
|
+
const callback = object(update.callback_query, "callback update callback_query");
|
|
1971
|
+
const from = object(callback.from, "callback update sender");
|
|
1972
|
+
const message = object(callback.message, "callback update message");
|
|
1973
|
+
const chat = object(message.chat, "callback update chat");
|
|
1974
|
+
const callbackId = (value, label) => {
|
|
1975
|
+
if (!Number.isSafeInteger(value) || Number(value) <= 0)
|
|
1976
|
+
throw new Error(`${label} is invalid`);
|
|
1977
|
+
return String(value);
|
|
1978
|
+
};
|
|
1979
|
+
const coordinate = {
|
|
1980
|
+
updateId: Number(updateId),
|
|
1981
|
+
queryId: text(callback.id, "callback query id"),
|
|
1982
|
+
callbackData: text(callback.data, "callback data"),
|
|
1983
|
+
userId: callbackId(from.id, "callback user id"),
|
|
1984
|
+
chatId: callbackId(chat.id, "callback chat id"),
|
|
1985
|
+
messageId: callbackId(message.message_id, "callback message id"),
|
|
1986
|
+
};
|
|
1987
|
+
return {
|
|
1988
|
+
updateId: coordinate.updateId,
|
|
1989
|
+
digest: sha256(`ouroboros.sanctuary.callback-coordinate.v1\0${JSON.stringify(coordinate)}`),
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
function callbackPlaybackPreflight(payload, deps) {
|
|
1993
|
+
exactKeys(payload, ["operation", "update"], "callback playback preflight request");
|
|
1994
|
+
const coordinate = callbackCoordinate(callbackUpdate(payload.update));
|
|
1995
|
+
const offset = object(JSON.parse(fixedFile(deps, TELEGRAM_OFFSET)), "Telegram offset");
|
|
1996
|
+
exactKeys(offset, ["nextUpdateId"], "Telegram offset");
|
|
1997
|
+
if (!Number.isSafeInteger(offset.nextUpdateId) || Number(offset.nextUpdateId) < 0)
|
|
1998
|
+
throw new Error("Telegram offset is invalid");
|
|
1999
|
+
const snapshot = object(dependency(deps.callbackPlaybackSnapshot, "callback playback journal")(coordinate.digest), "callback playback journal snapshot");
|
|
2000
|
+
exactKeys(snapshot, ["coordinateDigest", "journalDigest", "playbackCount"], "callback playback journal snapshot");
|
|
2001
|
+
if (snapshot.coordinateDigest !== coordinate.digest || typeof snapshot.journalDigest !== "string" || !SHA256.test(snapshot.journalDigest)
|
|
2002
|
+
|| !Number.isSafeInteger(snapshot.playbackCount) || Number(snapshot.playbackCount) < 0) {
|
|
2003
|
+
throw new Error("callback playback journal snapshot is invalid");
|
|
2004
|
+
}
|
|
2005
|
+
return {
|
|
2006
|
+
playbackCount: Math.max(Number(snapshot.playbackCount), Number(offset.nextUpdateId) > coordinate.updateId ? 1 : 0),
|
|
2007
|
+
coordinateDigest: coordinate.digest,
|
|
2008
|
+
journalDigest: snapshot.journalDigest,
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
1915
2011
|
async function concurrentCallbackProbe(payload, deps) {
|
|
1916
2012
|
const update = callbackUpdate(payload.update);
|
|
1917
2013
|
if (!Number.isSafeInteger(payload.concurrency) || payload.concurrency < 2 || payload.concurrency > 16) {
|
|
@@ -2026,6 +2122,12 @@ async function storeKey(payload, deps) {
|
|
|
2026
2122
|
if (text(handles[id], "vault-backed Unraid credential") !== text(stored[field], "vault-backed Unraid credential")) {
|
|
2027
2123
|
throw new Error("Unraid key handle does not bind to the active vault field");
|
|
2028
2124
|
}
|
|
2125
|
+
const acknowledged = object(await dependency(deps.hostRequest, "Sanctuary host broker")({
|
|
2126
|
+
operation: "acknowledge_key_storage", targetServerId: TARGET_SERVER_ID, keyId: id,
|
|
2127
|
+
}), "Unraid key storage acknowledgement");
|
|
2128
|
+
if (acknowledged.acknowledged !== true || acknowledged.id !== id) {
|
|
2129
|
+
throw new Error("Unraid key storage acknowledgement failed");
|
|
2130
|
+
}
|
|
2029
2131
|
return { stored: true, keyId: id };
|
|
2030
2132
|
}
|
|
2031
2133
|
async function probeKey(payload, deps) {
|
|
@@ -2232,6 +2334,9 @@ async function executeSanctuaryAcceptanceAdapter(rawPayload, deps = createSanctu
|
|
|
2232
2334
|
case "snapshot":
|
|
2233
2335
|
result = cursorSnapshot(payload, deps);
|
|
2234
2336
|
break;
|
|
2337
|
+
case "callback_playback_preflight":
|
|
2338
|
+
result = callbackPlaybackPreflight(payload, deps);
|
|
2339
|
+
break;
|
|
2235
2340
|
case "inject_callbacks_concurrently":
|
|
2236
2341
|
result = await concurrentCallbackProbe(payload, deps);
|
|
2237
2342
|
break;
|
|
@@ -2418,7 +2523,7 @@ async function executeSanctuaryInteractiveRuntimeOperation(rawPayload, supplied)
|
|
|
2418
2523
|
};
|
|
2419
2524
|
return (0, sanctuary_interactive_control_1.executeSanctuaryInteractiveEngine)(rawPayload, deps);
|
|
2420
2525
|
}
|
|
2421
|
-
async function executeSanctuaryAcceptanceCallbackProbe(rawUpdate,
|
|
2526
|
+
async function executeSanctuaryAcceptanceCallbackProbe(rawUpdate, replay, deps = {
|
|
2422
2527
|
refresh: runtime_credentials_1.refreshRuntimeCredentialConfig,
|
|
2423
2528
|
credentials: telegram_1.loadTelegramSenseCredentials,
|
|
2424
2529
|
identityKey: telegram_1.readOrCreateTelegramIdentityKey,
|
|
@@ -2426,6 +2531,7 @@ async function executeSanctuaryAcceptanceCallbackProbe(rawUpdate, _replay, deps
|
|
|
2426
2531
|
createRuntime: telegram_approval_runtime_1.createTelegramApprovalRuntime,
|
|
2427
2532
|
toolContext: sanctuary_runtime_1.createSanctuaryToolContext,
|
|
2428
2533
|
effects: createAcceptanceProbeEffects,
|
|
2534
|
+
recordCallbackPlayback: (coordinateDigest) => recordCallbackPlayback((0, identity_1.getAgentRoot)(TARGET_ID), coordinateDigest),
|
|
2429
2535
|
}) {
|
|
2430
2536
|
const update = callbackUpdate(rawUpdate);
|
|
2431
2537
|
const refreshed = await deps.refresh(TARGET_ID);
|
|
@@ -2453,7 +2559,11 @@ async function executeSanctuaryAcceptanceCallbackProbe(rawUpdate, _replay, deps
|
|
|
2453
2559
|
effects: effectBoundary?.port ?? unavailableEffects,
|
|
2454
2560
|
});
|
|
2455
2561
|
try {
|
|
2562
|
+
deps.recordCallbackPlayback(callbackCoordinate(update).digest);
|
|
2456
2563
|
const result = await runtime.transport.handleUpdate(update);
|
|
2564
|
+
if (replay && (result.handled !== true || result.accepted !== false || result.reason !== "stale_callback")) {
|
|
2565
|
+
throw new Error("Telegram callback replay did not settle as stale");
|
|
2566
|
+
}
|
|
2457
2567
|
return {
|
|
2458
2568
|
settled: result.handled,
|
|
2459
2569
|
claimed: result.reason === "accepted" || result.reason === "decision_refused",
|
|
@@ -1065,6 +1065,32 @@ async function callbackInject(config, deps) {
|
|
|
1065
1065
|
const base = { schemaVersion: 1, operation: "callback-inject", phase: "preflight", updateDigest: digest(update), concurrency, replay: true };
|
|
1066
1066
|
initializeCheckpoint(root, evidencePath, base);
|
|
1067
1067
|
try {
|
|
1068
|
+
const preflight = async () => {
|
|
1069
|
+
const result = object(await deps.runAdapter(executable, { operation: "callback_playback_preflight", update }), "callback playback preflight");
|
|
1070
|
+
if (JSON.stringify(Object.keys(result).sort()) !== JSON.stringify(["coordinateDigest", "journalDigest", "playbackCount"])) {
|
|
1071
|
+
throw new Error("callback playback preflight shape is invalid");
|
|
1072
|
+
}
|
|
1073
|
+
if (!Number.isSafeInteger(result.playbackCount) || Number(result.playbackCount) < 0) {
|
|
1074
|
+
throw new Error("callback playback preflight count is invalid");
|
|
1075
|
+
}
|
|
1076
|
+
if (typeof result.coordinateDigest !== "string" || !/^[0-9a-f]{64}$/u.test(result.coordinateDigest)) {
|
|
1077
|
+
throw new Error("callback playback preflight coordinate digest is invalid");
|
|
1078
|
+
}
|
|
1079
|
+
if (typeof result.journalDigest !== "string" || !/^[0-9a-f]{64}$/u.test(result.journalDigest)) {
|
|
1080
|
+
throw new Error("callback playback preflight journal digest is invalid");
|
|
1081
|
+
}
|
|
1082
|
+
return { playbackCount: Number(result.playbackCount), coordinateDigest: result.coordinateDigest, journalDigest: result.journalDigest };
|
|
1083
|
+
};
|
|
1084
|
+
const firstPreflight = await preflight();
|
|
1085
|
+
if (firstPreflight.playbackCount !== 0)
|
|
1086
|
+
throw new Error("callback injection requires zero prior playback");
|
|
1087
|
+
const secondPreflight = await preflight();
|
|
1088
|
+
if (secondPreflight.playbackCount !== 0)
|
|
1089
|
+
throw new Error("callback injection requires zero prior playback");
|
|
1090
|
+
if (secondPreflight.coordinateDigest !== firstPreflight.coordinateDigest)
|
|
1091
|
+
throw new Error("callback playback coordinate changed between preflights");
|
|
1092
|
+
if (secondPreflight.journalDigest !== firstPreflight.journalDigest)
|
|
1093
|
+
throw new Error("callback playback journal changed between preflights");
|
|
1068
1094
|
const batch = object(await deps.runAdapter(executable, { operation: "inject_callbacks_concurrently", update, concurrency }), "callback batch result");
|
|
1069
1095
|
if (!Array.isArray(batch.results) || batch.results.length !== concurrency)
|
|
1070
1096
|
throw new Error("callback batch result count mismatch");
|
|
@@ -1094,7 +1120,7 @@ async function callbackInject(config, deps) {
|
|
|
1094
1120
|
}
|
|
1095
1121
|
if (replayResult.claimed || replayResult.mutated)
|
|
1096
1122
|
throw new Error("callback replay was claimed or mutated state");
|
|
1097
|
-
replaceCheckpoint(root, evidencePath, { ...base, phase: "complete", claims, mutations, replayClaimed: false, replayMutated: false, completedAt: deps.now() });
|
|
1123
|
+
replaceCheckpoint(root, evidencePath, { ...base, phase: "complete", coordinateDigest: firstPreflight.coordinateDigest, zeroPlayback: true, claims, mutations, replayClaimed: false, replayMutated: false, completedAt: deps.now() });
|
|
1098
1124
|
}
|
|
1099
1125
|
catch (error) {
|
|
1100
1126
|
failedCheckpoint(root, evidencePath, base, error);
|
|
@@ -1135,99 +1161,267 @@ function sameInventory(left, right) {
|
|
|
1135
1161
|
}
|
|
1136
1162
|
async function rotateOccupiedCanonicalUnraidKeys(input) {
|
|
1137
1163
|
const { root, evidencePath, targetServerId, adapters, desired, oldKeys, initial, deps } = input;
|
|
1164
|
+
const prior = pathEntryExists(evidencePath) ? readCheckpoint(root, evidencePath) : null;
|
|
1165
|
+
if (prior && (prior.schemaVersion !== 1 || prior.operation !== "unraid-key-rotate" || prior.targetServerId !== targetServerId)) {
|
|
1166
|
+
throw new Error("Unraid rotation checkpoint identity mismatch");
|
|
1167
|
+
}
|
|
1168
|
+
const suffix = prior ? text(prior.transactionSuffix, "Unraid rotation transaction suffix") : deps.randomBytes(8).subarray(0, 8).toString("hex");
|
|
1169
|
+
if (!/^[0-9a-f]{16}$/u.test(suffix))
|
|
1170
|
+
throw new Error("Unraid rotation transaction suffix is invalid");
|
|
1171
|
+
const createdKeyIds = prior ? stringArray(prior.createdKeyIds, "createdKeyIds") : [];
|
|
1172
|
+
const revokedKeyIds = prior ? stringArray(prior.revokedKeyIds, "revokedKeyIds") : [];
|
|
1173
|
+
const transactionKeys = prior ? (() => {
|
|
1174
|
+
if (!Array.isArray(prior.transactionKeys))
|
|
1175
|
+
throw new Error("Unraid rotation transaction keys are unavailable");
|
|
1176
|
+
return prior.transactionKeys.map((raw) => {
|
|
1177
|
+
const value = object(raw, "Unraid rotation transaction key");
|
|
1178
|
+
return {
|
|
1179
|
+
id: text(value.id, "transaction key id"), name: text(value.name, "transaction key name"),
|
|
1180
|
+
vaultField: text(value.vaultField, "transaction key vault field"),
|
|
1181
|
+
permissions: stringArray(value.permissions, "transaction key permissions").sort(),
|
|
1182
|
+
temporary: boolean(value.temporary, "transaction key temporary"),
|
|
1183
|
+
attested: boolean(value.attested, "transaction key attested"),
|
|
1184
|
+
};
|
|
1185
|
+
});
|
|
1186
|
+
})() : [];
|
|
1187
|
+
if (new Set(createdKeyIds).size !== createdKeyIds.length || new Set(revokedKeyIds).size !== revokedKeyIds.length) {
|
|
1188
|
+
throw new Error("Unraid rotation checkpoint IDs are ambiguous");
|
|
1189
|
+
}
|
|
1190
|
+
if (new Set(transactionKeys.map((entry) => entry.id)).size !== transactionKeys.length
|
|
1191
|
+
|| new Set(transactionKeys.map((entry) => entry.name)).size !== transactionKeys.length
|
|
1192
|
+
|| !sameStrings(createdKeyIds, transactionKeys.map((entry) => entry.id))) {
|
|
1193
|
+
throw new Error("Unraid rotation transaction key metadata is ambiguous");
|
|
1194
|
+
}
|
|
1195
|
+
const slots = desired.flatMap((key) => [
|
|
1196
|
+
{ ...key, name: `${key.name} Rotation ${suffix}`, temporary: true },
|
|
1197
|
+
{ ...key, temporary: false },
|
|
1198
|
+
]);
|
|
1199
|
+
if (transactionKeys.some((entry) => {
|
|
1200
|
+
const slot = slots.find((candidate) => candidate.name === entry.name);
|
|
1201
|
+
return !slot || entry.temporary !== slot.temporary || entry.vaultField !== slot.vaultField
|
|
1202
|
+
|| !sameStrings(entry.permissions, slot.permissions);
|
|
1203
|
+
}))
|
|
1204
|
+
throw new Error("Unraid rotation checkpoint transaction slots are invalid");
|
|
1205
|
+
const revocableKeyIds = new Set([
|
|
1206
|
+
...oldKeys.map((entry) => entry.id),
|
|
1207
|
+
...transactionKeys.filter((entry) => entry.temporary).map((entry) => entry.id),
|
|
1208
|
+
]);
|
|
1209
|
+
if (revokedKeyIds.some((id) => !revocableKeyIds.has(id))) {
|
|
1210
|
+
throw new Error("Unraid rotation checkpoint contains an unowned revoked key ID");
|
|
1211
|
+
}
|
|
1212
|
+
const temporary = transactionKeys.filter((entry) => entry.temporary);
|
|
1213
|
+
const canonical = transactionKeys.filter((entry) => !entry.temporary);
|
|
1214
|
+
const allOldRevoked = oldKeys.every((entry) => revokedKeyIds.includes(entry.id));
|
|
1215
|
+
const allTemporaryAttested = temporary.length === desired.length && temporary.every((entry) => entry.attested);
|
|
1216
|
+
const allCanonicalAttested = canonical.length === desired.length && canonical.every((entry) => entry.attested);
|
|
1217
|
+
const revokedTemporary = temporary.filter((entry) => revokedKeyIds.includes(entry.id));
|
|
1218
|
+
if (revokedTemporary.some((entry) => !entry.attested)
|
|
1219
|
+
|| (oldKeys.some((entry) => revokedKeyIds.includes(entry.id)) && !allTemporaryAttested)
|
|
1220
|
+
|| (revokedTemporary.length > 0 && !allCanonicalAttested)
|
|
1221
|
+
|| (canonical.length > 0 && !allOldRevoked)) {
|
|
1222
|
+
throw new Error("Unraid rotation checkpoint revocation order is invalid");
|
|
1223
|
+
}
|
|
1224
|
+
const phase = prior ? text(prior.phase, "Unraid rotation phase") : "preflight";
|
|
1225
|
+
const knownPhases = new Set(["preflight", "temporary_key_created", "temporary_key_attested", "key_revoke_attempted", "key_revoked", "canonical_key_created", "canonical_key_attested", "complete"]);
|
|
1226
|
+
const resumePhase = phase === "failed" ? text(prior?.resumePhase, "Unraid rotation resume phase") : phase;
|
|
1227
|
+
if (!knownPhases.has(resumePhase) || (phase !== "failed" && prior?.resumePhase !== undefined)) {
|
|
1228
|
+
throw new Error("Unraid rotation checkpoint phase is invalid");
|
|
1229
|
+
}
|
|
1230
|
+
const stateMatchesPhase = resumePhase === "preflight" ? transactionKeys.length === 0 && revokedKeyIds.length === 0
|
|
1231
|
+
: resumePhase === "temporary_key_created" ? temporary.length > 0 && canonical.length === 0 && revokedKeyIds.length === 0 && temporary.filter((entry) => !entry.attested).length === 1
|
|
1232
|
+
: resumePhase === "temporary_key_attested" ? temporary.length > 0 && canonical.length === 0 && revokedKeyIds.length === 0 && temporary.every((entry) => entry.attested)
|
|
1233
|
+
: resumePhase === "key_revoke_attempted" ? allTemporaryAttested && (canonical.length === 0 || allCanonicalAttested)
|
|
1234
|
+
: resumePhase === "key_revoked" ? revokedKeyIds.length > 0 && allTemporaryAttested && (canonical.length === 0 || allCanonicalAttested)
|
|
1235
|
+
: resumePhase === "canonical_key_created" ? allTemporaryAttested && canonical.length > 0 && sameStrings(revokedKeyIds, oldKeys.map((entry) => entry.id)) && canonical.filter((entry) => !entry.attested).length === 1
|
|
1236
|
+
: resumePhase === "canonical_key_attested" ? allTemporaryAttested && canonical.length > 0 && sameStrings(revokedKeyIds, oldKeys.map((entry) => entry.id)) && canonical.every((entry) => entry.attested)
|
|
1237
|
+
: transactionKeys.length === slots.length && allTemporaryAttested && allCanonicalAttested
|
|
1238
|
+
&& sameStrings(revokedKeyIds, [...oldKeys.map((entry) => entry.id), ...temporary.map((entry) => entry.id)]);
|
|
1239
|
+
if (!stateMatchesPhase)
|
|
1240
|
+
throw new Error("Unraid rotation checkpoint state does not match its phase");
|
|
1138
1241
|
const base = {
|
|
1139
|
-
schemaVersion: 1,
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
targetServerId,
|
|
1143
|
-
initialInventoryDigest: digest(initial),
|
|
1144
|
-
createdKeyIds: [],
|
|
1145
|
-
revokedKeyIds: [],
|
|
1242
|
+
schemaVersion: 1, operation: "unraid-key-rotate", targetServerId,
|
|
1243
|
+
initialInventoryDigest: prior ? text(prior.initialInventoryDigest, "initialInventoryDigest") : digest(initial),
|
|
1244
|
+
transactionSuffix: suffix,
|
|
1146
1245
|
};
|
|
1147
|
-
|
|
1246
|
+
if (!prior)
|
|
1247
|
+
initializeCheckpoint(root, evidencePath, { ...base, phase: "preflight", createdKeyIds, revokedKeyIds, transactionKeys });
|
|
1248
|
+
if (phase === "complete") {
|
|
1249
|
+
const final = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
|
|
1250
|
+
const canonical = transactionKeys.filter((entry) => !entry.temporary)
|
|
1251
|
+
.map(({ id, name, permissions }) => ({ id, name, permissions, roles: [] }));
|
|
1252
|
+
if (canonical.length !== desired.length || !sameInventory(final, canonical)) {
|
|
1253
|
+
throw new Error("completed Unraid rotation inventory drifted");
|
|
1254
|
+
}
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1148
1257
|
const created = [];
|
|
1149
|
-
|
|
1150
|
-
const
|
|
1258
|
+
let activePhase = resumePhase;
|
|
1259
|
+
const checkpoint = (phase) => {
|
|
1260
|
+
activePhase = phase;
|
|
1261
|
+
replaceCheckpoint(root, evidencePath, { ...base, phase, createdKeyIds, revokedKeyIds, transactionKeys });
|
|
1262
|
+
};
|
|
1151
1263
|
const createAndAttest = async (key, name, temporary) => {
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1264
|
+
const live = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
|
|
1265
|
+
const matches = live.filter((entry) => entry.name === name);
|
|
1266
|
+
const transaction = transactionKeys.find((entry) => entry.name === name);
|
|
1267
|
+
if (transaction && revokedKeyIds.includes(transaction.id)) {
|
|
1268
|
+
if (matches.length !== 0)
|
|
1269
|
+
throw new Error("revoked Unraid transaction key inventory mismatch");
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (transaction && temporary && matches.length === 0)
|
|
1273
|
+
return;
|
|
1274
|
+
let id;
|
|
1275
|
+
let handle;
|
|
1276
|
+
if (matches.length === 1) {
|
|
1277
|
+
const existing = matches[0];
|
|
1278
|
+
if (!sameStrings(existing.permissions, key.permissions) || existing.roles.length !== 0
|
|
1279
|
+
|| oldKeys.some((old) => old.id === existing.id)
|
|
1280
|
+
|| (transaction && transaction.id !== existing.id)) {
|
|
1281
|
+
throw new Error("resumable Unraid key scope or identity mismatch");
|
|
1282
|
+
}
|
|
1283
|
+
id = existing.id;
|
|
1284
|
+
handle = `unraid-key:${id}:${key.vaultField}`;
|
|
1285
|
+
if (!transaction) {
|
|
1286
|
+
const recovered = object(await deps.runAdapter(adapters.create, {
|
|
1287
|
+
operation: "create_key", targetServerId, name, permissions: key.permissions,
|
|
1288
|
+
}), "recovered Unraid key");
|
|
1289
|
+
if (recovered.id !== id || recovered.name !== name
|
|
1290
|
+
|| !sameStrings(stringArray(recovered.permissions, "recovered permissions"), key.permissions)
|
|
1291
|
+
|| !Array.isArray(recovered.roles) || recovered.roles.length !== 0) {
|
|
1292
|
+
throw new Error("recovered Unraid key scope or identity mismatch");
|
|
1293
|
+
}
|
|
1294
|
+
handle = text(recovered.key, "recovered key handle");
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
else {
|
|
1298
|
+
const response = object(await deps.runAdapter(adapters.create, {
|
|
1299
|
+
operation: "create_key", targetServerId, name, permissions: key.permissions,
|
|
1300
|
+
}), "created Unraid key");
|
|
1301
|
+
id = text(response.id, "created key id");
|
|
1302
|
+
handle = text(response.key, "created key handle");
|
|
1303
|
+
if (response.name !== name || !sameStrings(stringArray(response.permissions, "created permissions"), key.permissions)
|
|
1304
|
+
|| !Array.isArray(response.roles) || response.roles.length !== 0
|
|
1305
|
+
|| oldKeys.some((old) => old.id === id) || created.some((entry) => entry.id === id)) {
|
|
1306
|
+
throw new Error("created Unraid key scope or identity mismatch");
|
|
1307
|
+
}
|
|
1161
1308
|
}
|
|
1162
1309
|
created.push({ id, name, permissions: key.permissions, roles: [], handle, desired: key, temporary });
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1310
|
+
if (!createdKeyIds.includes(id))
|
|
1311
|
+
createdKeyIds.push(id);
|
|
1312
|
+
const metadata = transaction ?? { id, name, vaultField: key.vaultField, permissions: key.permissions, temporary, attested: false };
|
|
1313
|
+
if (!transaction)
|
|
1314
|
+
transactionKeys.push(metadata);
|
|
1315
|
+
checkpoint(temporary ? "temporary_key_created" : "canonical_key_created");
|
|
1316
|
+
if (!metadata.attested) {
|
|
1317
|
+
const stored = object(await deps.runAdapter(adapters.store, {
|
|
1318
|
+
operation: "store_key", targetServerId, vaultField: key.vaultField, keyId: id, key: handle,
|
|
1319
|
+
}), "key store result");
|
|
1320
|
+
if (stored.stored !== true || stored.keyId !== id)
|
|
1321
|
+
throw new Error("vault did not attest exact key storage");
|
|
1322
|
+
}
|
|
1169
1323
|
const probe = object(await deps.runAdapter(adapters.probe, {
|
|
1170
1324
|
operation: "probe_new_key", targetServerId, id, key: handle,
|
|
1171
1325
|
}), "new key probe");
|
|
1172
1326
|
if (probe.valid !== true)
|
|
1173
1327
|
throw new Error("new Unraid key readiness failed");
|
|
1328
|
+
metadata.attested = true;
|
|
1329
|
+
checkpoint(temporary ? "temporary_key_attested" : "canonical_key_attested");
|
|
1174
1330
|
};
|
|
1175
1331
|
const revokeAndReject = async (id, handle) => {
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1332
|
+
if (revokedKeyIds.includes(id))
|
|
1333
|
+
return;
|
|
1334
|
+
const live = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
|
|
1335
|
+
if (live.some((entry) => entry.id === id)) {
|
|
1336
|
+
const revoked = object(await deps.runAdapter(adapters.revoke, { operation: "revoke_key", targetServerId, id }), "key revoke result");
|
|
1337
|
+
if (revoked.revoked !== true || revoked.id !== id)
|
|
1338
|
+
throw new Error("Unraid revoke did not attest exact key id");
|
|
1339
|
+
checkpoint("key_revoke_attempted");
|
|
1340
|
+
}
|
|
1179
1341
|
const probe = object(await deps.runAdapter(adapters.probe, { operation: "probe_revoked_key", targetServerId, id, key: handle }), "revoked key probe");
|
|
1180
1342
|
if (probe.valid !== false || (probe.status !== 401 && probe.status !== 403))
|
|
1181
1343
|
throw new Error("revoked Unraid key still authenticates");
|
|
1182
1344
|
revokedKeyIds.push(id);
|
|
1183
|
-
|
|
1345
|
+
checkpoint("key_revoked");
|
|
1184
1346
|
};
|
|
1185
1347
|
try {
|
|
1186
1348
|
for (const key of desired)
|
|
1187
1349
|
await createAndAttest(key, `${key.name} Rotation ${suffix}`, true);
|
|
1188
1350
|
const withTemporary = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
|
|
1189
|
-
|
|
1351
|
+
const liveOld = withTemporary.filter((entry) => oldKeys.some((old) => old.id === entry.id));
|
|
1352
|
+
const inventoryAmbiguous = withTemporary.some((entry) => {
|
|
1353
|
+
if (oldKeys.some((old) => old.id === entry.id)) {
|
|
1354
|
+
const expected = desired.find((candidate) => candidate.name === entry.name);
|
|
1355
|
+
return !expected || !sameStrings(expected.permissions, entry.permissions) || entry.roles.length !== 0;
|
|
1356
|
+
}
|
|
1357
|
+
const metadata = transactionKeys.find((candidate) => candidate.id === entry.id);
|
|
1358
|
+
return !metadata || metadata.name !== entry.name || !sameStrings(metadata.permissions, entry.permissions) || entry.roles.length !== 0;
|
|
1359
|
+
});
|
|
1360
|
+
const missingBridge = liveOld.length > 0 && transactionKeys.filter((entry) => entry.temporary && !revokedKeyIds.includes(entry.id))
|
|
1361
|
+
.some((entry) => !withTemporary.some((candidate) => candidate.id === entry.id));
|
|
1362
|
+
if (inventoryAmbiguous || missingBridge) {
|
|
1190
1363
|
throw new Error("temporary Unraid key inventory changed ambiguously");
|
|
1191
1364
|
}
|
|
1192
1365
|
for (const old of oldKeys) {
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1366
|
+
const live = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
|
|
1367
|
+
if (live.some((entry) => entry.id === old.id)) {
|
|
1368
|
+
const secret = object(await deps.runAdapter(old.secretAdapter, { operation: "read_old_key", targetServerId, id: old.id }), "old key handle result");
|
|
1369
|
+
await revokeAndReject(old.id, text(secret.key, "old key handle"));
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
await revokeAndReject(old.id, `unraid-key:${old.id}:legacy`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
let afterOldRevokes = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
|
|
1376
|
+
const existingCanonical = transactionKeys.filter((entry) => !entry.temporary);
|
|
1377
|
+
if (existingCanonical.length === desired.length) {
|
|
1378
|
+
for (const key of transactionKeys.filter((entry) => entry.temporary && !revokedKeyIds.includes(entry.id))) {
|
|
1379
|
+
if (!afterOldRevokes.some((entry) => entry.id === key.id)) {
|
|
1380
|
+
await revokeAndReject(key.id, `unraid-key:${key.id}:${key.vaultField}`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
afterOldRevokes = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
|
|
1195
1384
|
}
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
if (!sameInventory(afterOldRevokes, expectedTemporary))
|
|
1385
|
+
const exactPhase = transactionKeys.filter((entry) => !revokedKeyIds.includes(entry.id))
|
|
1386
|
+
.map(({ id, name, permissions }) => ({ id, name, permissions, roles: [] }));
|
|
1387
|
+
if (existingCanonical.length > desired.length || !sameInventory(afterOldRevokes, exactPhase)) {
|
|
1200
1388
|
throw new Error("old-key revoke inventory is not the exact temporary pair");
|
|
1389
|
+
}
|
|
1201
1390
|
for (const key of desired)
|
|
1202
1391
|
await createAndAttest(key, key.name, false);
|
|
1203
1392
|
const withCanonical = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
|
|
1204
|
-
|
|
1205
|
-
|
|
1393
|
+
if (withCanonical.some((entry) => {
|
|
1394
|
+
const metadata = transactionKeys.find((candidate) => candidate.id === entry.id);
|
|
1395
|
+
return !metadata || metadata.name !== entry.name || !sameStrings(metadata.permissions, entry.permissions) || entry.roles.length !== 0;
|
|
1396
|
+
}) || transactionKeys.filter((entry) => !entry.temporary).some((entry) => !withCanonical.some((candidate) => candidate.id === entry.id))) {
|
|
1206
1397
|
throw new Error("canonical-key mint inventory changed ambiguously");
|
|
1207
|
-
|
|
1208
|
-
|
|
1398
|
+
}
|
|
1399
|
+
for (const key of transactionKeys.filter((entry) => entry.temporary)) {
|
|
1400
|
+
await revokeAndReject(key.id, `unraid-key:${key.id}:${key.vaultField}`);
|
|
1401
|
+
}
|
|
1209
1402
|
const final = inventory(await deps.runAdapter(adapters.inventory, { operation: "inventory_keys", targetServerId }));
|
|
1210
|
-
const canonical =
|
|
1403
|
+
const canonical = transactionKeys.filter((entry) => !entry.temporary)
|
|
1404
|
+
.map(({ id, name, permissions }) => ({ id, name, permissions, roles: [] }));
|
|
1211
1405
|
if (!sameInventory(final, canonical))
|
|
1212
1406
|
throw new Error("final canonical Unraid key inventory mismatch");
|
|
1213
1407
|
replaceCheckpoint(root, evidencePath, {
|
|
1214
1408
|
...base,
|
|
1215
1409
|
phase: "complete",
|
|
1216
|
-
createdKeyIds
|
|
1410
|
+
createdKeyIds,
|
|
1217
1411
|
revokedKeyIds,
|
|
1412
|
+
transactionKeys,
|
|
1218
1413
|
finalInventoryDigest: digest(final),
|
|
1219
1414
|
completedAt: deps.now(),
|
|
1220
1415
|
});
|
|
1221
1416
|
}
|
|
1222
1417
|
catch (error) {
|
|
1223
|
-
|
|
1418
|
+
replaceCheckpoint(root, evidencePath, { ...base, phase: "failed", resumePhase: activePhase, createdKeyIds, revokedKeyIds, transactionKeys, errorCategory: safeErrorCategory(error) });
|
|
1224
1419
|
throw error;
|
|
1225
1420
|
}
|
|
1226
1421
|
}
|
|
1227
1422
|
async function unraidKeyRotate(config, deps) {
|
|
1228
1423
|
const root = privateAllowedRoot(config);
|
|
1229
1424
|
const evidencePath = confinedPath(root, config.evidencePath, "evidencePath");
|
|
1230
|
-
refuseExistingCheckpoint(root, evidencePath);
|
|
1231
1425
|
const targetServerId = text(config.targetServerId, "targetServerId");
|
|
1232
1426
|
const inventoryAdapter = adapter(config.inventoryAdapter, "inventoryAdapter");
|
|
1233
1427
|
const createAdapter = adapter(config.createAdapter, "createAdapter");
|
|
@@ -1257,12 +1451,13 @@ async function unraidKeyRotate(config, deps) {
|
|
|
1257
1451
|
throw new Error("old key IDs must be unique");
|
|
1258
1452
|
const initial = inventory(await deps.runAdapter(inventoryAdapter, { operation: "inventory_keys", targetServerId }));
|
|
1259
1453
|
const occupiedCanonicalContract = targetServerId === "sanctuary-unraid"
|
|
1260
|
-
&& desired.length === 2 &&
|
|
1454
|
+
&& desired.length === 2 && oldKeys.length === 2
|
|
1261
1455
|
&& JSON.stringify(desired.map((key) => key.name).sort()) === JSON.stringify(["Butler RO", "Butler RW"])
|
|
1262
|
-
&& desired.every((key) => {
|
|
1456
|
+
&& (pathEntryExists(evidencePath) || (initial.length === 2 && desired.every((key) => {
|
|
1263
1457
|
const existing = initial.find((entry) => entry.name === key.name);
|
|
1264
|
-
return existing !== undefined && oldKeys.some((old) => old.id === existing.id)
|
|
1265
|
-
|
|
1458
|
+
return existing !== undefined && oldKeys.some((old) => old.id === existing.id)
|
|
1459
|
+
&& existing.roles.length === 0 && sameStrings(existing.permissions, key.permissions);
|
|
1460
|
+
})))
|
|
1266
1461
|
&& [inventoryAdapter, createAdapter, storeAdapter, revokeAdapter, probeAdapter, ...oldKeys.map((old) => old.secretAdapter)]
|
|
1267
1462
|
.every((value) => value === SANCTUARY_ACCEPTANCE_ADAPTER);
|
|
1268
1463
|
if (occupiedCanonicalContract) {
|
|
@@ -1273,6 +1468,7 @@ async function unraidKeyRotate(config, deps) {
|
|
|
1273
1468
|
});
|
|
1274
1469
|
return;
|
|
1275
1470
|
}
|
|
1471
|
+
refuseExistingCheckpoint(root, evidencePath);
|
|
1276
1472
|
for (const key of desired)
|
|
1277
1473
|
if (initial.some((entry) => entry.name === key.name))
|
|
1278
1474
|
throw new Error(`Unraid key ${key.name} already exists`);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.772",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.772",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|