claim-step6-artifacts: claims run sign-policy and publish-identity, --out byte-exact files, claims install-console (dev cohort-1 trial step 6)
Squashed from claim-step6-artifacts into main.
30 files changed,
+3151
−39
claims_sums_key_test.go+16 −0docs/site/cli.md+4 −0docs/superpowers/notes/census-proposal-claim-step6-artifacts.md+88 −0internal/claimactivation/artifacts.go+235 −0internal/claimactivation/artifacts_elf_test.go+129 −0internal/claimactivation/artifacts_test.go+505 −0internal/claimactivation/runner.go+70 −0internal/claimactivation/runner_test.go+17 −0internal/host/activation_artifacts_linux.go+466 −0internal/host/activation_artifacts_linux_test.go+417 −0internal/host/activation_artifacts_rootrunner_linux_test.go+141 −0internal/host/activation_command.go+3 −1internal/host/activation_command_linux.go+52 −3internal/host/activation_console_rootrunner_linux_test.go+180 −20internal/host/guardarm.go+23 −9internal/hostfloor/armedpolicy_test.go+98 −0internal/hostfloor/floor.go+36 −5internal/provision/claim_step6_artifacts_census_test.go+18 −0internal/provision/plan14_root_reads_test.go+23 −0internal/provision/plan9_b8_census_test.go+1 −1internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv+12 −0internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix2.tsv+7 −0internal/provision/testdata/plan14-root-reads-claim-step6-artifacts.tsv+22 −0internal/publicrelease/verifysums.go+47 −0internal/publicrelease/verifysums_test.go+42 −0internal/release/artifactbuild_test.go+200 −0internal/release/elfbounds.go+129 −0internal/release/program.go+48 −0internal/release/systemidentity.go+20 −0internal/release/systemidentity_sign_test.go+102 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 303.738s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.702s
ok github.com/carlosframework/platform/cmd/moveprobe 93.235s
ok github.com/carlosframework/platform/cmd/zddprobe 0.704s
ok github.com/carlosframework/platform/internal/activator 250.449s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.018s
ok github.com/carlosframework/platform/internal/appconfig 2.470s
ok github.com/carlosframework/platform/internal/apperr 0.072s
ok github.com/carlosframework/platform/internal/applog 0.132s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.008s
ok github.com/carlosframework/platform/internal/boxid 1.035s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.013s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.699s
ok github.com/carlosframework/platform/internal/changelog 0.317s
ok github.com/carlosframework/platform/internal/claimactivation 0.769s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 401.303s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.023s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.309s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 44.824s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.131s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.150s
ok github.com/carlosframework/platform/internal/host 180.736s
ok github.com/carlosframework/platform/internal/hostactors 5.826s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.014s
ok github.com/carlosframework/platform/internal/hostbox 2.604s
ok github.com/carlosframework/platform/internal/hostclaim 0.534s
ok github.com/carlosframework/platform/internal/hostenv 0.014s
ok github.com/carlosframework/platform/internal/hostevidence 0.014s
ok github.com/carlosframework/platform/internal/hostexec 4.902s
ok github.com/carlosframework/platform/internal/hostfence 0.033s
ok github.com/carlosframework/platform/internal/hostfiles 10.496s
ok github.com/carlosframework/platform/internal/hostfloor 0.017s
ok github.com/carlosframework/platform/internal/hostguard 1.028s
ok github.com/carlosframework/platform/internal/hostops 1.595s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.014s
ok github.com/carlosframework/platform/internal/hostquarantine 0.015s
ok github.com/carlosframework/platform/internal/hostrelocate 0.468s
ok github.com/carlosframework/platform/internal/hostresolve 0.143s
ok github.com/carlosframework/platform/internal/hostwrite 0.014s
ok github.com/carlosframework/platform/internal/infraview 0.015s
ok github.com/carlosframework/platform/internal/instancectl 5.248s
ok github.com/carlosframework/platform/internal/instanceexec 2.731s
ok github.com/carlosframework/platform/internal/instancefs 0.018s
ok github.com/carlosframework/platform/internal/instancekey 0.016s
ok github.com/carlosframework/platform/internal/instanceupdates 0.022s
ok github.com/carlosframework/platform/internal/layout 1.526s
ok github.com/carlosframework/platform/internal/lbhealth 0.002s
ok github.com/carlosframework/platform/internal/lease 9.827s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.006s
ok github.com/carlosframework/platform/internal/ledger 0.010s
ok github.com/carlosframework/platform/internal/mail 0.150s
ok github.com/carlosframework/platform/internal/maintenance 0.085s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.525s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.069s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.078s
ok github.com/carlosframework/platform/internal/moves/archive 0.778s
ok github.com/carlosframework/platform/internal/objstore 0.178s
ok github.com/carlosframework/platform/internal/ops 0.168s
ok github.com/carlosframework/platform/internal/park 0.209s
ok github.com/carlosframework/platform/internal/privatefile 0.006s
ok github.com/carlosframework/platform/internal/provision 34.999s
ok github.com/carlosframework/platform/internal/publicrelease 0.009s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.463s
ok github.com/carlosframework/platform/internal/relay 7.254s
ok github.com/carlosframework/platform/internal/release 23.991s
ok github.com/carlosframework/platform/internal/rollout 3.798s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.058s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/runs 0.574s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.510s
ok github.com/carlosframework/platform/internal/seeds 0.015s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.376s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.093s
ok github.com/carlosframework/platform/internal/status 0.060s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.186s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.542s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49348770 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.021s
=== RUN TestT4StorageProbeMinio
2026/10/18 09:13:34 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF94D3F15582D8, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/claims_sums_key_test.go b/claims_sums_key_test.go| new file mode 100644 |
| index 000000000..861b63dbd |
| --- /dev/null |
| +++ b/claims_sums_key_test.go |
| @@ -0,0 +1,16 @@ |
| +package main |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/publicrelease" |
| +) |
| + |
| +// carlos system claims run publish-identity verifies a release's |
| +// SHA256SUMS.txt with publicrelease.Default's key. It must be the key |
| +// carlos update trusts for the same file. |
| +func TestClaimsSumsKeyIsTheCLIUpdateKey(t *testing.T) { |
| + if publicrelease.Default().PubKey != cliUpdatePubKey { |
| + t.Fatalf("publicrelease key %q, cliUpdatePubKey %q", publicrelease.Default().PubKey, cliUpdatePubKey) |
| + } |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 81aff3dd5..b477aeeaa 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2158,6 +2158,10 @@ The member command remains `carlos ops host resolve`. |
| `install-control` installs only instance-control and reloads systemd. `console-config` uses a separate console environment file and leaves host execution claims off. Neither installer starts its service. |
| +Two `run` phases make the files `carlos system guard-arm` takes. `sign-policy` signs the armed policy for a floor and writes nothing to the bucket. `publish-identity` publishes a release's signed identity. It takes the router's hash from the release's signed checksum list, and the console's from the console app's signed release, chosen by its build. The console must be built with the release tag as its version, or the phase refuses it. Build it stripped, with -s -w, as releases are built. Each phase writes its file to `--out`, a new path, readable by root only. Pass these files to `guard-arm` exactly as written. A JSON tool would change the signed bytes. |
| + |
| +`install-console` copies the console build that the published identity names into `/opt/carlos/system/console/console` and `console.prev`, owned by root. It refuses a source whose bytes don't match the identity, and it doesn't touch `console.service`. Pass that path to `guard-arm --console`. |
| + |
| After starting the guarded console, run `console-report` on its box as root. It checks the signed roster, guard, systemd process and running executable, then records that process in the existing private box report. It refuses stale box evidence and leaves the report’s timestamp unchanged. Run it again after a console restart and immediately before floor publication; the next agent report may replace the process entry. |
| #### carlos system journal-migrate |
diff --git a/docs/superpowers/notes/census-proposal-claim-step6-artifacts.md b/docs/superpowers/notes/census-proposal-claim-step6-artifacts.md| new file mode 100644 |
| index 000000000..968c30d15 |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-claim-step6-artifacts.md |
| @@ -0,0 +1,88 @@ |
| +# Census proposal: claim trial step 6 artefacts |
| + |
| +Status: RULED (platform-1e, 2026-10-18): accepted as proposed (`rev3=census-ruling-claim-step6-artifacts`). Branch `claim-step6-artifacts`, cut from main at 3b69c2bd (v0.28.1). Paul approved the build on 2026-10-18. |
| + |
| +## What changed |
| + |
| +Step 6 of the dev cohort-1 trial runs `carlos system guard-arm`. Two of its inputs had no producer, and the protected console copy had no installer. This branch adds all three. Each is an operator command that does nothing unless someone runs it (ruling 6). |
| + |
| +- **`claims run` phase `sign-policy`** (`internal/claimactivation/artifacts.go`, `Runner.signPolicy`). It signs an armed `hostfloor.Policy` with `hostfloor.ArmedPolicy` and writes nothing to the bucket. `hostfloor.Authorize` is now the one signer of the floor pair: `hostfloor.Publish` calls it too, so the later `publish-floor` writes the same bytes. Tests pin that equality in hostfloor, in the runner (sign-policy then publish-floor) and in the root-runner witness. The phase refuses a floor the deployment is not bound to: the roster's preparing or published digest, a published pair, or half of one. |
| +- **`claims run` phase `publish-identity`** (`Runner.publishIdentity`, `identityRelease`, `consoleArtifact`, `readConsole`). Nothing is typed (controller ruling C2). |
| + - The router's hash is the one in the release's `SHA256SUMS.txt`, verified with `publicrelease.VerifySums` against the vendor key the binary embeds. A root-package test pins that key to update.go's `cliUpdatePubKey`. |
| + - The console's hash comes from the console channel's root-verified pointer, its signed manifest digest for the explicit version, and that manifest. The artefact is then read from the bucket, hashed, and its `version.Build` stamp (`release.ArtifactBuild`) must equal the tag. |
| + - The identity is signed with `release.SignSystemIdentity` and published create-only through the existing `release.PublishSystemIdentity`, via the runner's `checkedStore`. `checkedStore.Put` is unchanged. The phase then reads the identity back and verifies it. |
| +- **`claims run --out`** (`internal/host`, `writeActivationOutput`, `writeActivationOutputAt`). The two phases write their file there byte for byte (controller ruling C4): mode 0600, created exclusively in a checked root-owned directory, then read back. |
| +- **`claims install-console`** (`runInstallConsole`, `installProtectedConsole`, `consoleDir`, `consoleSlotHolds`, `readConsoleSource`). A root box installer. It copies a console build into `/opt/carlos/system/console/console` and `.prev` as 0755 root:root, after these checks: |
| + - the staged identity verifies and is byte-equal to the published one; |
| + - the identity names this box's build; |
| + - the source's sha256 is the identity's console sha256. |
| + |
| + `.prev` equals current (ruling C3, on install-channel's A2-2 precedent). It does not touch `console.service`. |
| +- **guard-arm** (`RunSystemGuardArm`): its parse of `--policy` and `--identity` moved unchanged into `guardArmInputs`, so tests feed it the phases' files. It reads the same files, now in a different order. |
| + |
| +## Root-read census (TestPlan14RootReadCensus) |
| + |
| +Overlay `internal/provision/testdata/plan14-root-reads-claim-step6-artifacts.tsv`, loaded last, after `plan14-root-reads-box-publish-record.tsv`. It adds no accepted-legacy row, so the seal (`fc584e11...1b5d`) stands. |
| + |
| +| Unit | Executing identity | Class / tasks | Basis | |
| +|---|---|---|---| |
| +| `internal/claimactivation/runner.go:Runner.Run` (changed) | operator CLI, uid 0, on the trusted signing host (`claims run`) | authority F,G,J4 | as ruled for activation-tools; sign-policy has no writable key, publish-identity only its tag's identity key | |
| +| `internal/claimactivation/artifacts.go:Runner.signPolicy` (new) | operator CLI, uid 0, on the signing host | authority F,G,J4 | reads the floor pair only to refuse; writes nothing | |
| +| `internal/claimactivation/artifacts.go:readConsole` (new) | operator CLI, uid 0, on the signing host | authority F,G,J4 | reads the console artefact named by a verified pointer and digest-checked manifest, bounded; sha256 and stamp checked by its caller | |
| +| `internal/release/program.go:ArtifactBuild` (new) | operator CLI, uid 0, on the signing host (publish-identity) | authority G,J4 | parses build info of bytes already hashed against a signed manifest; one plain `-X version.Build` flag or nothing. No box path calls it | |
| +| `internal/host/activation_command_linux.go:runSystemClaims` (changed) | operator CLI, uid 0 (signing host for `run`, box for installers) | authority G,J4 | as ruled; `--out` only for the two phases and checked before signing; install-console checks the staged identity against the published one | |
| +| `internal/host/activation_artifacts_linux.go:writeActivationOutputAt` (new) | operator CLI, uid 0, on the signing host | authority G,J4 | checked parent; refuses any existing entry; exclusive 0600 create, fsync, exact readback | |
| +| `internal/host/activation_artifacts_linux.go:consoleDir` (new) | box installer, uid 0 (`claims install-console` on the console box) | authority G,J4 | no-follow walk from `/`; root-owned, not group or other writable, other-searchable; creates 0755 | |
| +| `internal/host/activation_artifacts_linux.go:consoleSlotHolds` (new) | box installer, uid 0 | authority G,J4 | absent, or root:root 0755 one-link regular file holding the sha256; anything else refuses | |
| +| `internal/host/activation_artifacts_linux.go:installProtectedConsole` (new) | box installer, uid 0 | authority G,J4 | source verified before anything is created; staged rename; readback through `hostfloor.OpenProtected` | |
| +| `internal/host/activation_artifacts_linux.go:readConsoleSource` (new) | box installer, uid 0 | authority G,J4 | no symlink on the path, regular, one link, at most 512 MiB, must hash to the sha256 | |
| +| `internal/host/guardarm.go:RunSystemGuardArm` (changed) | `carlos system guard-arm`, offline CLI, uid 0 | authority F,J4 | as ruled in p3; parse moved into `guardArmInputs`, same files read | |
| + |
| +Source envelopes re-pinned: `internal/claimactivation`, `internal/host`, `internal/hostfloor` (`Authorize`, `ArmedPolicy`), `internal/publicrelease` (`VerifySums`), `internal/release` (`SignSystemIdentity`, `ArtifactBuild`). The root package envelope is unchanged: only a test file was added there. |
| + |
| +No box agent, update, recovery or edge path reaches a new unit. guard-exec, `Disk.VerifyFile`, `Disk.Running`, `hostfloor.Publish`'s two writes and the box report path are byte for byte main's, apart from Publish calling `Authorize`. |
| + |
| +## Record-write census (TestPlan9B8RecordWriteCensus) |
| + |
| +No new bucket writer, so no 9B8 entry is added. The test passes unchanged. The one new bucket write is the identity's create-only PUT. It goes through the existing `release.PublishSystemIdentity` and the runner's `checkedStore.Put` (already `nonrecord`, digest `26c6e1f7...0bfd6`, unchanged), which admits only the plan's identity key. sign-policy admits no key. The installer and `--out` write local files only, through `safefile`, and no record key or record path is involved. |
| + |
| +## Tests behind the claims |
| + |
| +- `internal/hostfloor/armedpolicy_test.go`: the armed policy's floor and authorization are byte-equal to what `Publish` stores; deterministic re-signing; invalid floors refused. |
| +- `internal/claimactivation/artifacts_test.go`: production `S3Store` against leasetest's S3 server, roster made by the runner's own phases, the console shipped and promoted with pointer digests on. |
| + - sign-policy: sends no write request; its output passes guard-arm's parse; it refuses a different or half-published floor, a roster prepared for another floor, and a stale revision. |
| + - publish-identity: one create-only PUT (If-None-Match `*`); the stored bytes are the output; the identity passes `CheckImage` and `CheckComponent`; a replay is a no-op; another console under the same tag is refused and nothing moves; a history version resolves through its signed history digest. |
| + - Refusals, each with no write: another sums signer, sums changed after signing, no linux-arm64 router, a console hash that is a release file, no sums key, protocol 0, a build sha as tag, a version not on the channel, another channel or account, a pointer without digests, a swapped manifest, a swapped artefact, a console stamped with its build sha, an unstamped console, the CLI shipped as the console. |
| +- `internal/claimactivation/runner_test.go`: sign-policy then publish-floor through the runner; the published pair equals the policy. |
| +- `internal/host/activation_artifacts_linux_test.go`: the `--out` files round-trip into `guardArmInputs`; a re-indented policy is refused; exclusive create, 0600, dry run writes nothing. The installer's identity checks; the claims runner uses the vendor key. |
| +- `internal/release`: `ArtifactBuild` on real built binaries; `SignSystemIdentity` create-only and refusals. |
| +- Root runner (`20261018T070118-3fa2a1`, PASS): |
| + - `TestRootActivationConsoleRuntimePayload` produces the identity through publish-identity over the production S3 store, the policy through the real binary's sign-policy and `--out`, and the console copies through the real binary's install-console. There is no hand-published identity and no hand-made console copy. guard-arm, guard-exec and the console then start under the guard drop-in as the service user, from the protected copy's bytes, and the floor publish-floor writes is the policy's. |
| + - `TestRootActivationConsoleInstall` covers the installer as real root. |
| + |
| +## Fix round 1 (RULED, `rev3=census-ruling-claim-step6-artifacts-fix1`) |
| + |
| +Astra's review blocked the merge on four findings. The controller ruled F1, F3 and F4 FIX, and accepted F2 as a trial gap. The rows above stay as ruled. The changes are in a second overlay, `internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv`, loaded after the first. RULED (platform-1e, 2026-10-18): accepted as proposed, the overlay's rows and the 9B8 entry alike; the seal stands. |
| + |
| +- **F1, bounded ELF parsing.** `release.ArtifactBuild` runs `release.CheckELFBounds` (new, `internal/release/elfbounds.go`) before `buildinfo.Read`. The check reads only fixed-offset header fields and has no read site. It accepts only ELF64 little-endian EM_AARCH64, with at most 256 sections and 64 segments. Every table and range must lie inside the file, the tables must not overlap each other or the ELF header, sections with contents must not overlap, and no section may be SHF_COMPRESSED. An unstripped Go binary is refused, because its DWARF is compressed. Release and console builds are stripped (`-s -w`). |
| +- **F2, recorded flag, not linked value: trial gap.** `ArtifactBuild` reads the recorded `-X` flag. Release binaries are stripped, so there is no symbol table to read the linked value from. The chain rests on the release key. Owed before production cohorts: trusted build attestation tied to the artefact digest. |
| +- **F3, sums bound to the tag.** `sumsNameRelease` (claimactivation, no read site) requires `carlos_<X.Y.Z>_amd64.deb` and `carlos_<X.Y.Z>_arm64.deb` for exactly the tag's X.Y.Z, and no other `carlos_*.deb`. |
| +- **F4, `--out` reserved before the phase.** In `internal/host`: |
| + - `reserveActivationOutputAt` creates the file exclusively, 0600, and holds it open before the phase can write. |
| + - `activationOutput.finish` writes, syncs and reads back through that descriptor. |
| + - `activationOutput.abandon` removes the file only while `activationOutput.ours` shows the entry is still that file (same device and inode). |
| + - A failure after any write attempt keeps the file and says so. |
| + - `writeActivationOutputAt` is gone (`@remove`). |
| + |
| +Root-read rows: `ArtifactBuild` and `runSystemClaims` are changed; `reserveActivationOutputAt`, `activationOutput.finish` and `activationOutput.ours` are new, all authority G,J4, executing as the operator CLI, uid 0, on the trusted signing host. The `internal/claimactivation`, `internal/host` and `internal/release` envelopes are re-pinned. The seal stands. |
| + |
| +Record-write census (9B8): one new entry, `internal/host/activation_artifacts_linux.go:putAttempts.Put`, proposed `nonrecord` (`internal/provision/claim_step6_artifacts_census_test.go`, digest `0c8bbda8...b848`). It wraps the store claims run hands the two phases, only to record that a write was attempted. Every Put it passes on comes from the runner's checkedStore and its allow-list, and it adds no key, etag or body. |
| + |
| +## Fix round 2 (RULED, `rev3=census-ruling-claim-step6-artifacts-fix2`) |
| + |
| +Astra's re-review found F1 incomplete and a new LOW (F5). The controller ruled both FIX. Overlay `internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix2.tsv`, loaded after fix1. RULED (platform-1e, 2026-10-18): accepted as proposed; the 9B8 inventory and the seal are unchanged. |
| + |
| +- **F1, section names.** `release.CheckELFBounds` also caps the section-name table: type SHT_STRTAB, 1 byte to 64 KiB, every section's name offset inside it, and every name at most 256 bytes up to its NUL. debug/elf copies each section's name, so the names it can copy are at most 256 x 256 bytes. No read site is added, `ArtifactBuild`'s digest is unchanged, and the `internal/release` envelope is re-pinned. A real stripped `cmd/console` and carlos linux-arm64 build pass. A 32 MiB name repeated across every section is refused through publish-identity with no write. |
| +- **F5, never remove a reservation.** `activationOutput.abandon` is gone. On any failure the reserved `--out` file stays, and the error names it, says whether the phase wrote to the bucket first, and asks for a new `--out` path. `reserveActivationOutputAt` is now a wrapper with no read site (`@remove`). Its reads moved unchanged into `reserveOutput` (new row, authority G,J4, operator CLI uid 0 on the trusted signing host), which also names the full path in its errors. The `internal/host` envelope is re-pinned. |
| + |
| +The 9B8 inventory is unchanged: `putAttempts.Put` is byte for byte as ruled. The seal stands. |
diff --git a/internal/claimactivation/artifacts.go b/internal/claimactivation/artifacts.go| new file mode 100644 |
| index 000000000..6be8667e6 |
| --- /dev/null |
| +++ b/internal/claimactivation/artifacts.go |
| @@ -0,0 +1,235 @@ |
| +package claimactivation |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "reflect" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/publicrelease" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// signPolicy is the sign-policy phase: the armed policy guard-arm takes, |
| +// signed by hostfloor.ArmedPolicy, the signer hostfloor.Publish uses, so the |
| +// later publish-floor phase writes the same floor bytes and authorization. |
| +// It writes nothing to the bucket. It refuses a floor other than the one the |
| +// deployment is already bound to: the roster's preparing or published |
| +// digest, or a published floor pair, or half of one. A box armed for another |
| +// floor would refuse every guarded launch once the real floor is published. |
| +func (r Runner) signPolicy(ctx context.Context, d hostactors.Directory, o hostactors.Observed, floor hostfloor.Floor, dry bool, result *Result) error { |
| + policy, err := hostfloor.ArmedPolicy(r.Signer, d.Bucket, floor) |
| + if err != nil { |
| + return ErrActivation |
| + } |
| + verified, err := policy.Verify(d.PublicKey, d.Bucket) |
| + if err != nil { |
| + return ErrActivation |
| + } |
| + if o.Roster.FloorSHA256 != "" && o.Roster.FloorSHA256 != verified.Digest { |
| + return ErrActivation |
| + } |
| + _, _, rawErr := d.Store.Get(ctx, hostfloor.Key) |
| + _, _, authErr := d.Store.Get(ctx, hostfloor.AuthKey) |
| + switch { |
| + case errors.Is(rawErr, os.ErrNotExist) && errors.Is(authErr, os.ErrNotExist): |
| + case rawErr != nil && !errors.Is(rawErr, os.ErrNotExist): |
| + return rawErr |
| + case authErr != nil && !errors.Is(authErr, os.ErrNotExist): |
| + return authErr |
| + default: |
| + published, e := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) |
| + if e != nil || published.Digest != verified.Digest { |
| + return ErrActivation |
| + } |
| + } |
| + result.FloorSHA256 = verified.Digest |
| + if dry { |
| + return nil |
| + } |
| + out, err := json.Marshal(policy) |
| + if err != nil { |
| + return err |
| + } |
| + result.Output = out |
| + result.OutputSHA256 = hostfloor.Digest(out) |
| + return nil |
| +} |
| + |
| +// publishIdentity is the publish-identity phase. It builds the release's |
| +// identity from signed inputs only, signs it with the root key, publishes it |
| +// create-only (the same identity again is a no-op, a different one under |
| +// the tag is refused) and reads it back through the deployment key. |
| +func (r Runner) publishIdentity(ctx context.Context, d hostactors.Directory, in IdentityInput, dry bool, result *Result) error { |
| + rel, err := r.identityRelease(ctx, d, in) |
| + if err != nil { |
| + return err |
| + } |
| + wire, err := release.SignSystemIdentity(r.Signer, rel) |
| + if err != nil { |
| + return ErrActivation |
| + } |
| + want, err := release.VerifySystemIdentity(d.PublicKey, wire) |
| + if err != nil { |
| + return ErrActivation |
| + } |
| + if err = release.PublishSystemIdentity(ctx, d.Store, d.PublicKey, wire); err != nil { |
| + return err |
| + } |
| + key, _ := release.IdentityKey(rel.ReleaseTag) |
| + stored, _, err := d.Store.Get(ctx, key) |
| + if err != nil { |
| + return err |
| + } |
| + got, err := release.VerifySystemIdentity(d.PublicKey, stored) |
| + if err != nil || got.Current.ReleaseTag != rel.ReleaseTag || !reflect.DeepEqual(got.Current, want.Current) { |
| + return ErrActivation |
| + } |
| + result.IdentityKey = key |
| + result.Release = &got.Current |
| + if dry { |
| + return nil |
| + } |
| + result.Output = stored |
| + result.OutputSHA256 = hostfloor.Digest(stored) |
| + return nil |
| +} |
| + |
| +// identityRelease is the release an identity names, from the plan's signed |
| +// inputs (controller ruling C2: no typed hashes). The router's sha256 is the |
| +// one the vendor-signed SHA256SUMS.txt lists for carlos-linux-arm64. The |
| +// console's comes from consoleArtifact. The console sha256 may not be any |
| +// file in the release's checksum list (the 2026-10-06 outage shipped the CLI |
| +// as the console). version and release_tag are both the tag: a release |
| +// build is stamped with its tag, and guard-arm and the console's own report |
| +// compare the running build's stamp with the identity's version. |
| +func (r Runner) identityRelease(ctx context.Context, d hostactors.Directory, in IdentityInput) (release.SystemRelease, error) { |
| + tag := in.ReleaseTag |
| + if _, ok := release.ReleaseTag(tag); !ok || in.MovesProtocol < 1 || r.SumsKey == "" { |
| + return release.SystemRelease{}, ErrActivation |
| + } |
| + sums, err := publicrelease.VerifySums(r.SumsKey, []byte(in.Sums), in.SumsSignature) |
| + if err != nil { |
| + return release.SystemRelease{}, ErrActivation |
| + } |
| + // The sums carry no tag of their own: a release's signed list names its |
| + // debs carlos_<X.Y.Z>_amd64.deb and carlos_<X.Y.Z>_arm64.deb, so both |
| + // must be listed for exactly this tag, and no deb for another version |
| + // may be, or another release's genuine list would pass under this tag |
| + // (controller ruling F3). |
| + if !sumsNameRelease(sums, tag) { |
| + return release.SystemRelease{}, ErrActivation |
| + } |
| + router, ok := sums[publicrelease.AssetName(IdentityPlatform)] |
| + if !ok { |
| + return release.SystemRelease{}, ErrActivation |
| + } |
| + console, err := r.consoleArtifact(ctx, d, in.Console, tag) |
| + if err != nil { |
| + return release.SystemRelease{}, err |
| + } |
| + for _, sha := range sums { |
| + if sha == console { |
| + return release.SystemRelease{}, ErrActivation |
| + } |
| + } |
| + return release.SystemRelease{ |
| + Version: tag, Source: "release:" + tag, ReleaseTag: tag, MovesProtocol: in.MovesProtocol, |
| + Artifacts: map[string]release.SystemArtifact{IdentityPlatform: {Key: release.SystemArtifactKey(tag, IdentityPlatform), SHA256: router}}, |
| + ConsoleArtifacts: map[string]release.SystemArtifact{IdentityPlatform: {Key: "bootstrap/console/" + tag + "/carlos-" + IdentityPlatform, SHA256: console}}, |
| + }, nil |
| +} |
| + |
| +// consoleArtifact is the console's sha256 for IdentityPlatform: the one the |
| +// console build's manifest records, where the manifest must hash to the |
| +// digest the console channel's root-verified pointer signs for that exact |
| +// version. A version the pointer does not name, or names without a signed |
| +// digest, is refused. The artefact itself is then read from the bucket, |
| +// must hash to that sha256 and must be linked with version.Build equal to |
| +// the release tag. The console's own floor report (Disk.Running) compares |
| +// its build stamp with the identity's version, and the identity is |
| +// create-only under its tag, so a console stamped otherwise is refused here |
| +// rather than published. The stamp read is the build info's recorded flag, |
| +// behind release.CheckELFBounds; the linked value is not read (stripped |
| +// binaries have no symbols). Owed before production cohorts: trusted build |
| +// attestation tied to the artefact digest (controller ruling F2). |
| +// sumsNameRelease reports whether a verified checksum list is tag's: it |
| +// lists carlos_<X.Y.Z>_amd64.deb and carlos_<X.Y.Z>_arm64.deb for the tag's |
| +// X.Y.Z, and no other carlos_*.deb. |
| +func sumsNameRelease(sums map[string]string, tag string) bool { |
| + version := strings.TrimPrefix(tag, "v") |
| + want := map[string]bool{"carlos_" + version + "_amd64.deb": true, "carlos_" + version + "_arm64.deb": true} |
| + for name := range want { |
| + if _, ok := sums[name]; !ok { |
| + return false |
| + } |
| + } |
| + for name := range sums { |
| + if strings.HasPrefix(name, "carlos_") && strings.HasSuffix(name, ".deb") && !want[name] { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func (r Runner) consoleArtifact(ctx context.Context, d hostactors.Directory, c ConsoleRelease, tag string) (string, error) { |
| + if !release.ValidAccount(c.Account) || !release.ValidApp(c.App) || !release.ValidChannel(c.Channel) || !release.ValidVersion(c.Version) || c.Version == "dev" { |
| + return "", ErrActivation |
| + } |
| + pointer, err := release.Channel(ctx, d.Store, d.PublicKey, c.Account, c.App, c.Channel) |
| + if err != nil { |
| + return "", err |
| + } |
| + digest := pointer.DigestOf(c.Version) |
| + if digest == "" { |
| + return "", ErrActivation |
| + } |
| + m, _, err := release.CheckManifestDigest(ctx, d.Store, c.Account, c.App, c.Version, digest) |
| + if err != nil { |
| + return "", err |
| + } |
| + if m.Kind != "binary" || m.App != c.App || m.Version != c.Version || m.Program != "" && m.Program != ConsoleProgram { |
| + return "", ErrActivation |
| + } |
| + a, ok := m.Artifacts[IdentityPlatform] |
| + if !ok { |
| + return "", ErrActivation |
| + } |
| + body, err := readConsole(ctx, d.Store, release.ArtifactKey(c.Account, c.App, c.Version, a.File)) |
| + if err != nil { |
| + return "", err |
| + } |
| + stamp := release.ArtifactBuild |
| + if r.ConsoleBuild != nil { |
| + stamp = r.ConsoleBuild |
| + } |
| + if hostfloor.Digest(body) != a.SHA256 || stamp(body) != tag { |
| + return "", ErrActivation |
| + } |
| + return a.SHA256, nil |
| +} |
| + |
| +// consoleMaxBytes bounds a console artefact, as Disk.VerifyFile bounds what |
| +// it hashes. |
| +const consoleMaxBytes = 512 << 20 |
| + |
| +func readConsole(ctx context.Context, s release.Store, key string) ([]byte, error) { |
| + rc, _, err := release.OpenObject(ctx, s, key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rc.Close() |
| + body, err := io.ReadAll(io.LimitReader(rc, consoleMaxBytes+1)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(body) > consoleMaxBytes { |
| + return nil, ErrActivation |
| + } |
| + return body, nil |
| +} |
diff --git a/internal/claimactivation/artifacts_elf_test.go b/internal/claimactivation/artifacts_elf_test.go| new file mode 100644 |
| index 000000000..664709ffc |
| --- /dev/null |
| +++ b/internal/claimactivation/artifacts_elf_test.go |
| @@ -0,0 +1,129 @@ |
| +package claimactivation |
| + |
| +import ( |
| + "encoding/binary" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// realConsole builds a real stripped linux-arm64 program at |
| +// github.com/carlosframework/platform/cmd/console with an |
| +// internal/version.Build variable stamped tag, the shape a release console |
| +// has. |
| +func realConsole(t *testing.T, tag string) []byte { |
| + t.Helper() |
| + dir := t.TempDir() |
| + for name, body := range map[string]string{ |
| + "go.mod": "module github.com/carlosframework/platform\n\ngo 1.21\n", |
| + "internal/version/version.go": "package version\n\nvar Build = \"dev\"\n", |
| + "cmd/console/main.go": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/carlosframework/platform/internal/version\"\n)\n\nfunc main() { fmt.Println(version.Build) }\n", |
| + } { |
| + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + out := filepath.Join(dir, "console") |
| + cmd := exec.Command("go", "build", "-ldflags", "-s -w -X "+release.BuildStampFlag+"="+tag, "-o", out, "./cmd/console") |
| + cmd.Dir = dir |
| + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false", "GOWORK=off", "GOPROXY=off") |
| + if output, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatal(string(output), err) |
| + } |
| + b, err := os.ReadFile(out) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +// F1: publish-identity reads a console's build info only after the bounded |
| +// ELF pre-check, with the production reader (no test seam). A real stripped |
| +// console publishes; a crafted one, its section-name table flagged |
| +// compressed or its section count oversized, is refused with no write. |
| +func TestPublishIdentityBoundsTheConsoleBinary(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("builds Go binaries") |
| + } |
| + console := realConsole(t, "v0.28.2") |
| + le := binary.LittleEndian |
| + mutate := func(change func(c []byte)) []byte { |
| + c := append([]byte(nil), console...) |
| + change(c) |
| + return c |
| + } |
| + shoff := le.Uint64(console[40:]) |
| + shstrndx := uint64(le.Uint16(console[62:])) |
| + for name, tc := range map[string]struct { |
| + body []byte |
| + ok bool |
| + }{ |
| + "real stripped console": {console, true}, |
| + "compressed section-name table": {mutate(func(c []byte) { |
| + h := c[shoff+shstrndx*64:] |
| + le.PutUint64(h[8:], le.Uint64(h[8:])|0x800) |
| + }), false}, |
| + "oversized shnum": {mutate(func(c []byte) { le.PutUint16(c[60:], 65000) }), false}, |
| + // Astra's re-review: every section named by one 32 MiB string, which |
| + // debug/elf would copy once per section. |
| + "repeated 32 MiB name": {repeatedName(console, 32<<20), false}, |
| + "console stamped another tag": {realConsole(t, "v0.28.1"), false}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + // The crafted inputs are the pre-check's to refuse; the wrong tag |
| + // passes it and is refused on its stamp. |
| + if bounded := release.CheckELFBounds(tc.body) == nil; bounded != (tc.ok || name == "console stamped another tag") { |
| + t.Fatal("pre-check verdict", bounded) |
| + } |
| + w := newArtifactsWorld(t) |
| + w.r.ConsoleBuild = nil |
| + w.ship(t, "eee5555", tc.body, ConsoleProgram) |
| + if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "eee5555"); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := w.identityPlan(t, "eee5555") |
| + mark := w.mark() |
| + result, err := w.r.Run(t.Context(), p, false) |
| + if tc.ok { |
| + if err != nil || len(result.Output) == 0 { |
| + t.Fatal("real console refused", err) |
| + } |
| + return |
| + } |
| + if err == nil { |
| + t.Fatal("crafted console accepted") |
| + } |
| + for _, r := range w.requests()[mark:] { |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| + t.Fatalf("refusal wrote: %+v", r) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// repeatedName appends a name table holding one name of size bytes to a |
| +// real build and points the section-name table, and every section's name, |
| +// at it. |
| +func repeatedName(b []byte, size int) []byte { |
| + c := append(append([]byte(nil), b...), make([]byte, size+1)...) |
| + for i := len(b); i < len(b)+size; i++ { |
| + c[i] = 'a' |
| + } |
| + le := binary.LittleEndian |
| + shoff, shnum, idx := le.Uint64(c[40:]), uint64(le.Uint16(c[60:])), uint64(le.Uint16(c[62:])) |
| + h := c[shoff+idx*64:] |
| + le.PutUint64(h[24:], uint64(len(b))) |
| + le.PutUint64(h[32:], uint64(size+1)) |
| + for i := uint64(0); i < shnum; i++ { |
| + le.PutUint32(c[shoff+i*64:], 0) |
| + } |
| + return c |
| +} |
diff --git a/internal/claimactivation/artifacts_test.go b/internal/claimactivation/artifacts_test.go| new file mode 100644 |
| index 000000000..a6349b2e2 |
| --- /dev/null |
| +++ b/internal/claimactivation/artifacts_test.go |
| @@ -0,0 +1,505 @@ |
| +package claimactivation |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "sort" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const artifactsBucket = "carlos-trial-bucket" |
| + |
| +// s3Seen is one request the fake bucket answered. |
| +type s3Seen struct{ Method, Key, IfNoneMatch, IfMatch string } |
| + |
| +// artifactsWorld is the trusted signing host against a real S3Store: the |
| +// production client over leasetest's S3 server, which honours If-None-Match |
| +// and If-Match. The roster is initialized and configured through the |
| +// runner's own phases, and the console release is shipped and promoted |
| +// through release.Ship and Signer.Promote with pointer digests on, as |
| +// carlos ship and carlos promote leave it. |
| +type artifactsWorld struct { |
| + r Runner |
| + store release.Store |
| + model *leasetest.VersionedBucket |
| + sumsKey ed25519.PrivateKey |
| + mu sync.Mutex |
| + seen []s3Seen |
| + console []byte |
| + router []byte |
| +} |
| + |
| +func (w *artifactsWorld) requests() []s3Seen { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + return append([]s3Seen(nil), w.seen...) |
| +} |
| + |
| +// mark is a point in the request log; writesSince lists every request |
| +// after it that could change the bucket. |
| +func (w *artifactsWorld) mark() int { return len(w.requests()) } |
| + |
| +func (w *artifactsWorld) writesSince(mark int) []s3Seen { |
| + var out []s3Seen |
| + for _, r := range w.requests()[mark:] { |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| + out = append(out, r) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// fakeConsoleBuild stands in for release.ArtifactBuild over the fixture's |
| +// console bytes, which are not Go binaries: "console build <v> stamp <tag>" |
| +// is a build stamped <tag>. ArtifactBuild itself is tested on real binaries |
| +// in internal/release, and the root-runner witness runs the production |
| +// reader over a real console image. |
| +func fakeConsoleBuild(b []byte) string { |
| + _, stamp, ok := strings.Cut(string(b), " stamp ") |
| + if !ok { |
| + return "" |
| + } |
| + return stamp |
| +} |
| + |
| +func sha(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } |
| + |
| +func newArtifactsWorld(t *testing.T) *artifactsWorld { |
| + t.Helper() |
| + w := &artifactsWorld{model: leasetest.NewVersionedBucket(), console: []byte("console build abc1234 stamp v0.28.2"), router: []byte("carlos v0.28.2 linux-arm64")} |
| + w.model.AddBucket(artifactsBucket) |
| + next, _ := leasetest.NewS3Server(w.model) |
| + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { |
| + _, key, _ := strings.Cut(strings.TrimPrefix(req.URL.Path, "/"), "/") |
| + w.mu.Lock() |
| + w.seen = append(w.seen, s3Seen{req.Method, key, req.Header.Get("If-None-Match"), req.Header.Get("If-Match")}) |
| + w.mu.Unlock() |
| + next.ServeHTTP(rw, req) |
| + })) |
| + t.Cleanup(server.Close) |
| + for k, v := range map[string]string{ |
| + "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", |
| + "AWS_REGION": "eu-west-1", "AWS_EC2_METADATA_DISABLED": "true", |
| + "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent", |
| + "CARLOS_S3_ENDPOINT": server.URL, |
| + } { |
| + t.Setenv(k, v) |
| + } |
| + s3, err := release.NewS3StoreIn(t.Context(), artifactsBucket, "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + w.store = s3 |
| + pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| + sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader) |
| + w.sumsKey = sumsKey |
| + w.r = Runner{Directory: hostactors.Directory{Store: s3, PublicKey: pub, Bucket: artifactsBucket}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: fakeConsoleBuild} |
| + ctx := t.Context() |
| + if _, err = w.r.Run(ctx, Plan{Phase: "initialize", Bucket: artifactsBucket}, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = w.r.Run(ctx, Plan{Phase: "configure", Bucket: artifactsBucket, ExpectedRevision: 1, Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console", "cohort1"}}}}, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = release.WriteDigestMarker(ctx, s3, w.r.Signer, "bab", "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + w.ship(t, "abc1234", w.console, "") |
| + if err = w.r.Signer.Promote(ctx, s3, "bab", "console", "canary/console", "abc1234"); err != nil { |
| + t.Fatal(err) |
| + } |
| + return w |
| +} |
| + |
| +func (w *artifactsWorld) ship(t *testing.T, version string, body []byte, program string) { |
| + t.Helper() |
| + m := release.Manifest{App: "console", Version: version, Kind: "binary", Program: program, Artifacts: map[string]release.Artifact{IdentityPlatform: {}}} |
| + if err := release.Ship(t.Context(), w.store, "bab", m, map[string][]byte{IdentityPlatform: body}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (w *artifactsWorld) revision(t *testing.T) uint64 { |
| + t.Helper() |
| + o, err := w.r.Directory.Read(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return o.Roster.Revision |
| +} |
| + |
| +// sums is a release's SHA256SUMS.txt and its .sig, signed by key, one line |
| +// per file in name order, as the release recipe writes it. |
| +func sums(key ed25519.PrivateKey, files map[string][]byte) (string, string) { |
| + names := make([]string, 0, len(files)) |
| + for name := range files { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + var b strings.Builder |
| + for _, name := range names { |
| + b.WriteString(sha(files[name]) + " " + name + "\n") |
| + } |
| + return b.String(), base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b.String()))) + "\n" |
| +} |
| + |
| +// releaseFiles is a release's eight signed files, real-shaped: six carlos |
| +// builds (Windows zipped) and the two debs, for version X.Y.Z. |
| +func releaseFiles(version string, router []byte) map[string][]byte { |
| + files := map[string][]byte{"carlos-linux-arm64": router} |
| + for _, name := range []string{"carlos-darwin-amd64", "carlos-darwin-arm64", "carlos-linux-amd64", "carlos-windows-amd64.zip", "carlos-windows-arm64.zip", "carlos_" + version + "_amd64.deb", "carlos_" + version + "_arm64.deb"} { |
| + files[name] = []byte(name + " " + version) |
| + } |
| + return files |
| +} |
| + |
| +func (w *artifactsWorld) identityPlan(t *testing.T, version string) Plan { |
| + text, sig := sums(w.sumsKey, releaseFiles("0.28.2", w.router)) |
| + return Plan{Phase: "publish-identity", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Identity: &IdentityInput{ |
| + ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: text, SumsSignature: sig, |
| + Console: ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: version}}} |
| +} |
| + |
| +func TestSignPolicyWritesNothingAndSignsTheFloor(t *testing.T) { |
| + w := newArtifactsWorld(t) |
| + ctx := t.Context() |
| + floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"} |
| + p := Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor} |
| + mark := w.mark() |
| + preview, err := w.r.Run(ctx, p, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !preview.DryRun || len(preview.Writes) != 0 || preview.Output != nil || preview.OutputSHA256 != "" || len(preview.FloorSHA256) != 64 { |
| + t.Fatalf("preview %+v", preview) |
| + } |
| + result, err := w.r.Run(ctx, p, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(result.Writes) != 0 || result.FloorSHA256 != preview.FloorSHA256 || result.OutputSHA256 != hostfloor.Digest(result.Output) { |
| + t.Fatalf("result %+v", result) |
| + } |
| + if got := w.writesSince(mark); len(got) != 0 { |
| + t.Fatalf("sign-policy wrote to the bucket: %+v", got) |
| + } |
| + // The output is the file guard-arm reads: its parser, unmarshal then |
| + // Policy.Verify against the deployment key and bucket. |
| + var policy hostfloor.Policy |
| + if err = json.Unmarshal(result.Output, &policy); err != nil || policy.Mode != "armed" { |
| + t.Fatal("not an armed policy", err) |
| + } |
| + verified, err := policy.Verify(w.r.Directory.PublicKey, artifactsBucket) |
| + if err != nil || verified.Floor != floor || verified.Digest != result.FloorSHA256 { |
| + t.Fatal(verified, err) |
| + } |
| + // The JSON the command prints never carries the policy. |
| + printed, _ := json.Marshal(result) |
| + if bytes.Contains(printed, []byte("authorization")) { |
| + t.Fatalf("policy embedded in the printed result: %s", printed) |
| + } |
| + |
| + // Once the deployment is bound to a floor, only that floor is signed. |
| + if err = hostfloor.Publish(ctx, w.store, w.r.Signer, artifactsBucket, floor); err != nil { |
| + t.Fatal(err) |
| + } |
| + again, err := w.r.Run(ctx, p, false) |
| + if err != nil || !bytes.Equal(again.Output, result.Output) { |
| + t.Fatal("signing the published floor again", err) |
| + } |
| + other := hostfloor.Floor{Min: 1, MinRelease: "v0.28.3"} |
| + p.Floor = &other |
| + if _, err = w.r.Run(ctx, p, false); err == nil { |
| + t.Fatal("signed a floor other than the published one") |
| + } |
| +} |
| + |
| +func TestSignPolicyRefusesAHalfPublishedOrOtherFloor(t *testing.T) { |
| + floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"} |
| + t.Run("authorization alone", func(t *testing.T) { |
| + w := newArtifactsWorld(t) |
| + _, auth, err := hostfloor.Authorize(w.r.Signer, artifactsBucket, floor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = w.store.Put(t.Context(), hostfloor.AuthKey, auth, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = w.r.Run(t.Context(), Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor}, false); err == nil { |
| + t.Fatal("signed beside half a floor pair") |
| + } |
| + }) |
| + t.Run("roster prepared for another floor", func(t *testing.T) { |
| + w := newArtifactsWorld(t) |
| + ctx := t.Context() |
| + o, _ := w.r.Directory.Read(ctx) |
| + next := hostactors.Clone(o.Roster) |
| + next.Publication = "preparing" |
| + next.FloorSHA256 = strings.Repeat("e", 64) |
| + if _, err := w.r.Directory.Replace(ctx, w.r.Signer, o, next); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := w.r.Run(ctx, Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor}, false); err == nil { |
| + t.Fatal("signed a floor the roster is not preparing") |
| + } |
| + }) |
| + t.Run("stale revision and bad floors", func(t *testing.T) { |
| + w := newArtifactsWorld(t) |
| + for _, p := range []Plan{ |
| + {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t) - 1, Floor: &floor}, |
| + {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t)}, |
| + {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &hostfloor.Floor{Min: 0, MinRelease: "v0.28.2"}}, |
| + {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &hostfloor.Floor{Min: 1, MinRelease: "26065378"}}, |
| + } { |
| + if _, err := w.r.Run(t.Context(), p, false); err == nil { |
| + t.Fatal("accepted", p) |
| + } |
| + } |
| + }) |
| +} |
| + |
| +func TestPublishIdentityFromSignedInputsOnly(t *testing.T) { |
| + w := newArtifactsWorld(t) |
| + ctx := t.Context() |
| + p := w.identityPlan(t, "abc1234") |
| + before := w.mark() |
| + preview, err := w.r.Run(ctx, p, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := w.writesSince(before); len(got) != 0 { |
| + t.Fatalf("dry run wrote: %+v", got) |
| + } |
| + if len(preview.Writes) != 1 || preview.Writes[0].Key != "control/system/router/releases/v0.28.2.json" || preview.Output != nil { |
| + t.Fatalf("preview %+v", preview) |
| + } |
| + mark := w.mark() |
| + result, err := w.r.Run(ctx, p, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + identityPuts := 0 |
| + for _, r := range w.requests()[mark:] { |
| + switch { |
| + case r.Method == http.MethodPut && r.Key == result.IdentityKey: |
| + identityPuts++ |
| + if r.IfNoneMatch != "*" || r.IfMatch != "" { |
| + t.Fatalf("identity PUT not create-only: %+v", r) |
| + } |
| + case r.Method != http.MethodGet && r.Method != http.MethodHead: |
| + t.Fatalf("publish-identity sent %s %s", r.Method, r.Key) |
| + } |
| + } |
| + if identityPuts != 1 || len(result.Writes) != 1 { |
| + t.Fatal("identity writes", identityPuts, result.Writes) |
| + } |
| + stored, _, err := w.store.Get(ctx, result.IdentityKey) |
| + if err != nil || !bytes.Equal(stored, result.Output) || result.OutputSHA256 != hostfloor.Digest(stored) { |
| + t.Fatal("output is not the stored identity", err) |
| + } |
| + // The file is what guard-arm reads with --identity. |
| + got, err := release.VerifySystemIdentity(w.r.Directory.PublicKey, result.Output) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := got.Current |
| + if c.Version != "v0.28.2" || c.ReleaseTag != "v0.28.2" || c.Source != "release:v0.28.2" || c.MovesProtocol != 1 || |
| + c.Artifacts[IdentityPlatform] != (release.SystemArtifact{Key: "bootstrap/router/v0.28.2/carlos-linux-arm64", SHA256: sha(w.router)}) || |
| + c.ConsoleArtifacts[IdentityPlatform] != (release.SystemArtifact{Key: "bootstrap/console/v0.28.2/carlos-linux-arm64", SHA256: sha(w.console)}) || |
| + len(c.Artifacts) != 1 || len(c.ConsoleArtifacts) != 1 { |
| + t.Fatalf("identity names %+v", c) |
| + } |
| + floor := hostfloor.Verified{Floor: hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"}} |
| + if hostfloor.CheckImage(w.r.Directory.PublicKey, floor, result.Output, "v0.28.2", IdentityPlatform, sha(w.router)) != nil || |
| + hostfloor.CheckComponent(w.r.Directory.PublicKey, floor, result.Output, "v0.28.2", IdentityPlatform, sha(w.console), "console") != nil { |
| + t.Fatal("guard-arm's checks refuse the published identity") |
| + } |
| + |
| + // The same plan again is a no-op that hands back the stored bytes. |
| + p.ExpectedRevision = w.revision(t) |
| + // Signed within the same second the bytes are identical, and the |
| + // checked store reports the matching create as the resumed write; later |
| + // they differ and PublishSystemIdentity keeps the stored identity. |
| + // Either way the stored bytes are what comes back. |
| + replay, err := w.r.Run(ctx, p, false) |
| + if err != nil || len(replay.Writes) > 1 || !bytes.Equal(replay.Output, stored) { |
| + t.Fatal("replay", replay, err) |
| + } |
| + if now, _, _ := w.store.Get(ctx, result.IdentityKey); !bytes.Equal(now, stored) { |
| + t.Fatal("replay replaced the identity") |
| + } |
| + // Another console build under the same tag is refused, and nothing moves. |
| + other := []byte("console build def5678 stamp v0.28.2") |
| + w.ship(t, "def5678", other, ConsoleProgram) |
| + if err = w.r.Signer.Promote(ctx, w.store, "bab", "console", "canary/console", "def5678"); err != nil { |
| + t.Fatal(err) |
| + } |
| + p = w.identityPlan(t, "def5678") |
| + mark = w.mark() |
| + if _, err = w.r.Run(ctx, p, false); err == nil { |
| + t.Fatal("a different identity replaced the published one") |
| + } |
| + if now, _, _ := w.store.Get(ctx, result.IdentityKey); !bytes.Equal(now, stored) { |
| + t.Fatal("refused publish changed the identity") |
| + } |
| + for _, r := range w.writesSince(mark) { |
| + if r.Key != result.IdentityKey || r.IfNoneMatch != "*" { |
| + t.Fatalf("refused publish sent %+v", r) |
| + } |
| + } |
| + // The older build stays selectable by version through the pointer's |
| + // signed history digests. |
| + p = w.identityPlan(t, "abc1234") |
| + if replay, err = w.r.Run(ctx, p, false); err != nil || !bytes.Equal(replay.Output, stored) { |
| + t.Fatal("history version", err) |
| + } |
| +} |
| + |
| +func TestPublishIdentityRefusesUnsignedOrWrongInputs(t *testing.T) { |
| + for name, tc := range map[string]struct { |
| + setup func(t *testing.T, w *artifactsWorld) |
| + mutate func(w *artifactsWorld, p *Plan) |
| + }{ |
| + "sums signed by another key": {mutate: func(w *artifactsWorld, p *Plan) { |
| + _, other, _ := ed25519.GenerateKey(rand.Reader) |
| + p.Identity.Sums, p.Identity.SumsSignature = sums(other, releaseFiles("0.28.2", w.router)) |
| + }}, |
| + "sums changed after signing": {mutate: func(w *artifactsWorld, p *Plan) { |
| + p.Identity.Sums = strings.Replace(p.Identity.Sums, sha(w.router), strings.Repeat("0", 64), 1) |
| + }}, |
| + "no linux-arm64 router": {mutate: func(w *artifactsWorld, p *Plan) { |
| + files := releaseFiles("0.28.2", w.router) |
| + delete(files, "carlos-linux-arm64") |
| + p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| + }}, |
| + "console is a release file": {mutate: func(w *artifactsWorld, p *Plan) { |
| + files := releaseFiles("0.28.2", w.router) |
| + files["carlos-darwin-arm64"] = w.console |
| + p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| + }}, |
| + // F3: a genuine, validly signed list for another release is not this |
| + // tag's, whatever its router hash. |
| + "v0.28.1's real-shaped sums under v0.28.2": {mutate: func(w *artifactsWorld, p *Plan) { |
| + p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.1", w.router)) |
| + }}, |
| + "amd64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) { |
| + files := releaseFiles("0.28.2", w.router) |
| + delete(files, "carlos_0.28.2_amd64.deb") |
| + p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| + }}, |
| + "arm64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) { |
| + files := releaseFiles("0.28.2", w.router) |
| + delete(files, "carlos_0.28.2_arm64.deb") |
| + p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| + }}, |
| + "another release's deb beside": {mutate: func(w *artifactsWorld, p *Plan) { |
| + files := releaseFiles("0.28.2", w.router) |
| + files["carlos_0.28.1_arm64.deb"] = []byte("old deb") |
| + p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| + }}, |
| + "debs for 0.28.20": {mutate: func(w *artifactsWorld, p *Plan) { |
| + p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.20", w.router)) |
| + }}, |
| + "no sums key": {mutate: func(w *artifactsWorld, p *Plan) { w.r.SumsKey = "" }}, |
| + "moves protocol zero": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.MovesProtocol = 0 }}, |
| + "build as tag": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.ReleaseTag = "26065378" }}, |
| + "version not on the channel": {mutate: func(w *artifactsWorld, p *Plan) { |
| + p.Identity.Console.Version = "fff0000" |
| + }}, |
| + "other channel": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Channel = "stable/console" }}, |
| + "other account": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Account = "bap" }}, |
| + "no account": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Account = "" }}, |
| + "pointer without digests": {setup: func(t *testing.T, w *artifactsWorld) { |
| + if err := release.RemoveDigestMarker(t.Context(), w.store, "bab"); err != nil { |
| + t.Fatal(err) |
| + } |
| + w.ship(t, "aaa1111", []byte("console build aaa1111 stamp v0.28.2"), "") |
| + if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "edge/console", "aaa1111"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The pointer verifies and names the version; it signs no digest. |
| + ptr, err := release.Channel(t.Context(), w.store, w.r.Directory.PublicKey, "bab", "console", "edge/console") |
| + if err != nil || ptr.Version != "aaa1111" || ptr.DigestOf("aaa1111") != "" { |
| + t.Fatal("fixture pointer", ptr, err) |
| + } |
| + }, mutate: func(w *artifactsWorld, p *Plan) { |
| + p.Identity.Console.Channel, p.Identity.Console.Version = "edge/console", "aaa1111" |
| + }}, |
| + "manifest swapped after promotion": {setup: func(t *testing.T, w *artifactsWorld) { |
| + key := release.ManifestKey("bab", "console", "abc1234") |
| + raw, _, err := w.store.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m release.Manifest |
| + _ = json.Unmarshal(raw, &m) |
| + a := m.Artifacts[IdentityPlatform] |
| + a.SHA256 = strings.Repeat("d", 64) |
| + m.Artifacts[IdentityPlatform] = a |
| + swapped, _ := json.Marshal(m) |
| + w.model.Put(artifactsBucket, key, swapped) |
| + }}, |
| + "console stamped with its build sha": {setup: func(t *testing.T, w *artifactsWorld) { |
| + w.ship(t, "ccc3333", []byte("console build ccc3333 stamp ccc3333"), ConsoleProgram) |
| + if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "ccc3333"); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "ccc3333" }}, |
| + "console with no stamp": {setup: func(t *testing.T, w *artifactsWorld) { |
| + w.ship(t, "ddd4444", []byte("console build ddd4444"), ConsoleProgram) |
| + if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "ddd4444"); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "ddd4444" }}, |
| + "artefact swapped after promotion": {setup: func(t *testing.T, w *artifactsWorld) { |
| + m, err := release.GetManifest(t.Context(), w.store, "bab", "console", "abc1234") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + w.model.Put(artifactsBucket, release.ArtifactKey("bab", "console", "abc1234", m.Artifacts[IdentityPlatform].File), []byte("console build evil stamp v0.28.2")) |
| + }}, |
| + "production stamp reader on fixture bytes": {mutate: func(w *artifactsWorld, p *Plan) { w.r.ConsoleBuild = nil }}, |
| + "the CLI shipped as the console": {setup: func(t *testing.T, w *artifactsWorld) { |
| + w.ship(t, "bbb2222", []byte("carlos cli stamp v0.28.2"), "github.com/carlosframework/platform") |
| + if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "bbb2222"); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "bbb2222" }}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + w := newArtifactsWorld(t) |
| + if tc.setup != nil { |
| + tc.setup(t, w) |
| + } |
| + p := w.identityPlan(t, "abc1234") |
| + if tc.mutate != nil { |
| + tc.mutate(w, &p) |
| + } |
| + mark := w.mark() |
| + for _, dry := range []bool{true, false} { |
| + result, err := w.r.Run(t.Context(), p, dry) |
| + if err == nil { |
| + t.Fatalf("accepted (dry %v): %+v", dry, result) |
| + } |
| + t.Logf("dry %v refused: %v", dry, err) |
| + } |
| + if got := w.writesSince(mark); len(got) != 0 { |
| + t.Fatalf("refusal wrote: %+v", got) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/claimactivation/runner.go b/internal/claimactivation/runner.go| index 1a7e8d04d..d89f734fe 100644 |
| --- a/internal/claimactivation/runner.go |
| +++ b/internal/claimactivation/runner.go |
| @@ -32,6 +32,30 @@ type Plan struct { |
| Floor *hostfloor.Floor `json:"floor,omitempty"` |
| Binding *hostactors.CensusKeyBinding `json:"binding,omitempty"` |
| Proof []byte `json:"proof,omitempty"` |
| + Identity *IdentityInput `json:"identity,omitempty"` |
| +} |
| + |
| +// IdentityInput is publish-identity's plan: a release tag, its moves |
| +// protocol, the release's signed checksum list (SHA256SUMS.txt and its .sig, |
| +// byte for byte) and the console release whose binary the identity names. |
| +// No sha256 is typed: the router's comes from the signed sums, the |
| +// console's from the console app's signed channel pointer and the manifest |
| +// digest it signs. |
| +type IdentityInput struct { |
| + ReleaseTag string `json:"release_tag"` |
| + MovesProtocol int `json:"moves_protocol"` |
| + Sums string `json:"sha256sums"` |
| + SumsSignature string `json:"sha256sums_sig"` |
| + Console ConsoleRelease `json:"console"` |
| +} |
| + |
| +// ConsoleRelease selects one shipped console build by its version (the |
| +// console build sha) on a channel whose signed pointer names it. |
| +type ConsoleRelease struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| } |
| type BoxInput struct { |
| Reference string `json:"reference"` |
| @@ -54,12 +78,40 @@ type Result struct { |
| DryRun bool `json:"dry_run"` |
| Revision uint64 `json:"revision"` |
| Writes []Write `json:"writes"` |
| + // sign-policy and publish-identity only. FloorSHA256 is the policy's |
| + // floor digest; IdentityKey and Release are the identity's key and the |
| + // release it names, as read back. Output is the exact file the phase |
| + // produces (the armed policy, or the published identity's stored bytes), |
| + // set only when the phase is applied. It is never part of the JSON: the |
| + // command writes it to --out unchanged, because re-indenting either would |
| + // change bytes guard-arm verifies. |
| + FloorSHA256 string `json:"floor_sha256,omitempty"` |
| + IdentityKey string `json:"identity_key,omitempty"` |
| + Release *release.SystemRelease `json:"release,omitempty"` |
| + OutputSHA256 string `json:"output_sha256,omitempty"` |
| + Output []byte `json:"-"` |
| } |
| type Runner struct { |
| Directory hostactors.Directory |
| Signer release.Signer |
| + // SumsKey verifies a release's SHA256SUMS.txt (base64 ed25519). The |
| + // command sets the vendor key the carlos binary embeds; an empty key |
| + // refuses publish-identity. |
| + SumsKey string |
| + // ConsoleBuild reads a console artefact's version.Build stamp; nil is |
| + // release.ArtifactBuild. A test seam only. |
| + ConsoleBuild func([]byte) string |
| } |
| +// IdentityPlatform is the one platform publish-identity names: the dev |
| +// trial's boxes are linux-arm64. |
| +const IdentityPlatform = "linux-arm64" |
| + |
| +// ConsoleProgram is the Go main package a console release must report when |
| +// its manifest records one (console release defences D2). It refuses the |
| +// 2026-10-06 shape, the carlos CLI shipped as the console. |
| +const ConsoleProgram = "github.com/carlosframework/platform/cmd/console" |
| + |
| func (r Runner) Run(ctx context.Context, p Plan, dry bool) (Result, error) { |
| result := Result{DryRun: dry, Writes: []Write{}} |
| if p.Bucket == "" || p.Bucket != r.Directory.Bucket || !bytes.Equal(r.Signer.RootPublicKey(), r.Directory.PublicKey) { |
| @@ -102,6 +154,14 @@ func (r Runner) Run(ctx context.Context, p Plan, dry bool) (Result, error) { |
| allowed[hostactors.Key] = true |
| allowed[hostfloor.Key] = true |
| allowed[hostfloor.AuthKey] = true |
| + case "publish-identity": |
| + if p.Identity != nil { |
| + if key, e := release.IdentityKey(p.Identity.ReleaseTag); e == nil { |
| + allowed[key] = true |
| + } |
| + } |
| + case "sign-policy": |
| + // Writes nothing: the policy goes to the operator's file. |
| } |
| store := &checkedStore{allowed: allowed, Store: d.Store, pub: d.PublicKey, bucket: d.Bucket, expected: o.ETag, dry: dry, overlay: map[string]overlayObject{}, writes: &result.Writes} |
| d.Store = store |
| @@ -214,6 +274,16 @@ func (r Runner) Run(ctx context.Context, p Plan, dry bool) (Result, error) { |
| return result, ErrActivation |
| } |
| _, err = d.BindCensusKey(ctx, r.Signer, o, *p.Binding, p.Proof) |
| + case "sign-policy": |
| + if p.Floor == nil { |
| + return result, ErrActivation |
| + } |
| + err = r.signPolicy(ctx, d, o, *p.Floor, dry, &result) |
| + case "publish-identity": |
| + if p.Identity == nil { |
| + return result, ErrActivation |
| + } |
| + err = r.publishIdentity(ctx, d, *p.Identity, dry, &result) |
| default: |
| return result, ErrActivation |
| } |
diff --git a/internal/claimactivation/runner_test.go b/internal/claimactivation/runner_test.go| index c05b53f21..32fba1e74 100644 |
| --- a/internal/claimactivation/runner_test.go |
| +++ b/internal/claimactivation/runner_test.go |
| @@ -1,6 +1,7 @@ |
| package claimactivation |
| import ( |
| + "bytes" |
| "context" |
| "crypto/ed25519" |
| "crypto/rand" |
| @@ -289,9 +290,25 @@ func TestActivationFloorWaitsForGuardAndComputeReport(t *testing.T) { |
| if o.Roster.Publication != "preparing" { |
| t.Fatal("preview published") |
| } |
| + // The policy a box arms with, signed offline before publication, |
| + // carries exactly the floor and authorization publish-floor writes. |
| + signed, err := r.Run(ctx, Plan{Phase: "sign-policy", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Floor: &floor}, false) |
| + if err != nil || len(signed.Writes) != 0 || signed.FloorSHA256 != digest { |
| + t.Fatal("sign-policy", signed, err) |
| + } |
| + var armed hostfloor.Policy |
| + if err = json.Unmarshal(signed.Output, &armed); err != nil { |
| + t.Fatal(err) |
| + } |
| if _, err = r.Run(ctx, p, false); err != nil { |
| t.Fatal(err) |
| } |
| + for key, want := range map[string][]byte{hostfloor.Key: armed.Floor, hostfloor.AuthKey: armed.Authorization} { |
| + got, _, err := store.Get(ctx, key) |
| + if err != nil || !bytes.Equal(got, want) { |
| + t.Fatalf("published %s differs from the armed policy: %s vs %s (%v)", key, got, want, err) |
| + } |
| + } |
| o, _ = r.Directory.Read(ctx) |
| if o.Roster.Publication != "published" { |
| t.Fatal("floor not published") |
diff --git a/internal/host/activation_artifacts_linux.go b/internal/host/activation_artifacts_linux.go| new file mode 100644 |
| index 000000000..2708a9d11 |
| --- /dev/null |
| +++ b/internal/host/activation_artifacts_linux.go |
| @@ -0,0 +1,466 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + |
| + "github.com/carlosframework/platform/internal/claimactivation" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/safefile" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// activationOutput is a claims phase's --out file, reserved before the phase |
| +// can write to the bucket (controller ruling F4): an exclusive create, mode |
| +// 0600, in a checked root-owned directory, held open. A second invocation |
| +// naming the same path fails its own reservation and publishes nothing. The |
| +// phase's bytes are then written through the held descriptor, synced and |
| +// read back. A dry run only checks the path is free. |
| +// |
| +// A reservation is never removed (controller ruling F5): on any failure the |
| +// file stays, empty or partial, and the error names it and asks for a new |
| +// --out path. Removing it safely would need its identity check and the |
| +// unlink to be one step, which a directory another root writer can change |
| +// does not offer. |
| +type activationOutput struct { |
| + dir *os.File |
| + ownDir bool |
| + name string |
| + path string |
| + f *os.File |
| + dev, ino uint64 |
| +} |
| + |
| +// reserveActivationOutput reserves path: absolute and clean, in a directory |
| +// activationDir accepts (root-owned, not writable by group or others). |
| +func reserveActivationOutput(path string, dry bool) (*activationOutput, error) { |
| + if !filepath.IsAbs(path) || filepath.Clean(path) != path { |
| + return nil, errActivation |
| + } |
| + dir, err := activationDir(filepath.Dir(path), false) |
| + if err != nil { |
| + return nil, err |
| + } |
| + o, err := reserveOutput(dir, filepath.Base(path), path, dry) |
| + if err != nil { |
| + dir.Close() |
| + return nil, err |
| + } |
| + o.ownDir, o.path = true, path |
| + return o, nil |
| +} |
| + |
| +// reserveActivationOutputAt reserves name under an already checked |
| +// directory. Any existing entry is refused without following it. |
| +func reserveActivationOutputAt(dir *os.File, name string, dry bool) (*activationOutput, error) { |
| + return reserveOutput(dir, name, name, dry) |
| +} |
| + |
| +// reserveOutput is the reservation, naming path in its errors. |
| +func reserveOutput(dir *os.File, name, path string, dry bool) (*activationOutput, error) { |
| + if name != filepath.Base(name) || name == "." || name == ".." || name == "/" { |
| + return nil, errActivation |
| + } |
| + var st unix.Stat_t |
| + err := unix.Fstatat(int(dir.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW) |
| + if err == nil { |
| + return nil, errActivation |
| + } |
| + if err != unix.ENOENT { |
| + return nil, err |
| + } |
| + o := &activationOutput{dir: dir, name: name, path: path} |
| + if dry { |
| + return o, nil |
| + } |
| + f, err := safefile.OpenFileAt(int(dir.Fd()), name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) |
| + if err != nil { |
| + return nil, err |
| + } |
| + err = f.Chmod(0o600) |
| + if err == nil { |
| + err = unix.Fstat(int(f.Fd()), &st) |
| + } |
| + if err == nil && st.Uid != uint32(os.Geteuid()) { |
| + err = errActivation |
| + } |
| + if err == nil { |
| + err = unix.Fsync(int(dir.Fd())) |
| + } |
| + o.f, o.dev, o.ino = f, uint64(st.Dev), st.Ino |
| + if err != nil { |
| + f.Close() |
| + return nil, outputKept(err, path, false) |
| + } |
| + return o, nil |
| +} |
| + |
| +// ours reports whether the directory entry is still the file this |
| +// reservation created. |
| +func (o *activationOutput) ours() bool { |
| + var st unix.Stat_t |
| + return unix.Fstatat(int(o.dir.Fd()), o.name, &st, unix.AT_SYMLINK_NOFOLLOW) == nil && uint64(st.Dev) == o.dev && st.Ino == o.ino |
| +} |
| + |
| +// finish writes raw through the held descriptor, syncs it and the |
| +// directory, and reads it back: the exact bytes, mode 0600, one link, and |
| +// the entry still this file. |
| +func (o *activationOutput) finish(raw []byte) error { |
| + if o.f == nil { |
| + return nil |
| + } |
| + if len(raw) == 0 { |
| + return errActivation |
| + } |
| + if _, err := o.f.WriteAt(raw, 0); err != nil { |
| + return err |
| + } |
| + if err := o.f.Sync(); err != nil { |
| + return err |
| + } |
| + got := make([]byte, len(raw)+1) |
| + n, err := o.f.ReadAt(got, 0) |
| + if err != nil && !errors.Is(err, io.EOF) { |
| + return err |
| + } |
| + var st unix.Stat_t |
| + if err = unix.Fstat(int(o.f.Fd()), &st); err != nil { |
| + return err |
| + } |
| + if n != len(raw) || !bytes.Equal(got[:n], raw) || st.Mode&0o7777 != 0o600 || st.Nlink != 1 || !o.ours() { |
| + return errActivation |
| + } |
| + return unix.Fsync(int(o.dir.Fd())) |
| +} |
| + |
| +// close releases the reservation and keeps whatever the file holds. |
| +func (o *activationOutput) close() { |
| + if o.f != nil { |
| + o.f.Close() |
| + o.f = nil |
| + } |
| + if o.ownDir { |
| + o.dir.Close() |
| + o.ownDir = false |
| + } |
| +} |
| + |
| +// putAttempts wraps a phase's store and records whether any write was |
| +// attempted. A write that failed may still have committed, so an attempt, |
| +// not a success, is what the error after a failure reports. |
| +type putAttempts struct { |
| + release.Store |
| + attempted atomic.Bool |
| +} |
| + |
| +func (s *putAttempts) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { |
| + s.attempted.Store(true) |
| + return s.Store.Put(ctx, key, raw, etag) |
| +} |
| + |
| +func (s *putAttempts) Delete(ctx context.Context, key string) error { |
| + s.attempted.Store(true) |
| + return s.Store.Delete(ctx, key) |
| +} |
| + |
| +// runProducingPhase runs sign-policy or publish-identity with its output |
| +// already reserved (out), on a runner whose store is store. On success the |
| +// output holds the phase's file. On any failure the reserved file is kept |
| +// and the error names it, asks for a new --out path and says whether the |
| +// phase wrote to the bucket first, because the bucket may then already hold |
| +// what it published. |
| +func runProducingPhase(ctx context.Context, r claimactivation.Runner, store *putAttempts, plan claimactivation.Plan, out *activationOutput, dry bool) (claimactivation.Result, error) { |
| + r.Directory.Store = store |
| + result, err := r.Run(ctx, plan, dry) |
| + if err == nil { |
| + err = out.finish(result.Output) |
| + } |
| + if err == nil { |
| + out.close() |
| + return result, nil |
| + } |
| + out.close() |
| + if dry { |
| + return result, err |
| + } |
| + return result, outputKept(err, out.path, store.attempted.Load()) |
| +} |
| + |
| +// outputKept is a failed phase's error once its reserved --out file stays. |
| +func outputKept(err error, path string, wrote bool) error { |
| + if wrote { |
| + return fmt.Errorf("%w; the phase wrote to the bucket before it failed, so %s was kept. run it again with a new --out path", err, path) |
| + } |
| + return fmt.Errorf("%w; %s was kept. run it again with a new --out path", err, path) |
| +} |
| + |
| +// consoleInstallDir is where install-console puts the protected console |
| +// executable and its .prev: under root's /opt/carlos/system, beside the |
| +// guard, outside system/router and every restore set. |
| +const consoleInstallDir = "/opt/carlos/system/console" |
| + |
| +// consoleMaxBytes bounds a console artefact, as Disk.VerifyFile bounds what |
| +// it hashes. |
| +const consoleMaxBytes = 512 << 20 |
| + |
| +// consoleInstallTarget checks the staged identity for install-console and |
| +// returns its release tag and the console sha256 for platform. The staged |
| +// bytes must verify under the deployment key, be exactly the identity |
| +// published at control/system/router/releases/<tag>.json, and name this |
| +// build: guard-arm compares the running build with the identity's version. |
| +func consoleInstallTarget(identity, published []byte, pub ed25519.PublicKey, build, platform string) (string, string, error) { |
| + p, err := release.VerifySystemIdentity(pub, identity) |
| + if err != nil || !bytes.Equal(identity, published) || p.Current.Version != build { |
| + return "", "", errActivation |
| + } |
| + a, ok := p.Current.ConsoleArtifacts[platform] |
| + if !ok { |
| + return "", "", errActivation |
| + } |
| + return p.Current.ReleaseTag, a.SHA256, nil |
| +} |
| + |
| +// consoleInstalled is what one install-console run did. |
| +type consoleInstalled struct { |
| + Path, Previous string |
| + Already bool |
| +} |
| + |
| +// installProtectedConsole copies the console at source into dir as console |
| +// and console.prev, for guard-arm's --console. Both copies are the same |
| +// bytes, the build the identity names: the identity carries one console |
| +// sha256 per platform and guard-arm checks <path> and <path>.prev against |
| +// it, so at arming .prev can only equal current. That is install-channel's |
| +// A2-2 precedent for carlos and carlos.prev (controller ruling C3). A later |
| +// guarded update can leave an older build in .prev, because guard-exec |
| +// accepts any identity persisted beside the guard. |
| +// |
| +// dir and every ancestor are root-owned, not writable by group or others, |
| +// and searchable by others (console.service runs guard-exec as the service |
| +// user, which must open the file); missing components are created 0755 and |
| +// no component may be a symlink. The source is opened with no symlink |
| +// followed anywhere on its path and must be a regular file with one link. |
| +// Its bytes must hash to sha before anything is created. Each slot is |
| +// written through a private staging directory and renamed into place, mode |
| +// 0755 and owned by root, .prev first, then read back as guard-arm reads it |
| +// (hostfloor.OpenProtected) and hashed again. A slot that already holds |
| +// other bytes, a symlink or anything but a regular file is refused and left |
| +// as it is; slots that both hold the build are a no-op, after the source is |
| +// verified again. console.service is |
| +// not touched: guard-arm installs its drop-in. The dry run writes nothing. |
| +func installProtectedConsole(dir, source, sha string, dry bool) (consoleInstalled, error) { |
| + got := consoleInstalled{Path: filepath.Join(dir, "console"), Previous: filepath.Join(dir, "console.prev")} |
| + if len(sha) != 64 || !filepath.IsAbs(source) || filepath.Clean(source) != source { |
| + return got, errActivation |
| + } |
| + if os.Geteuid() != 0 { |
| + return got, errActivation |
| + } |
| + // The source is verified before anything is created or locked. |
| + body, err := readConsoleSource(source, sha) |
| + if err != nil { |
| + return got, err |
| + } |
| + d, err := consoleDir(dir, !dry) |
| + if dry && errors.Is(err, unix.ENOENT) { |
| + return got, nil |
| + } |
| + if err != nil { |
| + return got, err |
| + } |
| + defer d.Close() |
| + if !dry { |
| + if err = unix.Flock(int(d.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { |
| + return got, err |
| + } |
| + } |
| + holds := map[string]bool{} |
| + for _, name := range []string{"console.prev", "console"} { |
| + h, err := consoleSlotHolds(d, name, sha) |
| + if err != nil { |
| + return got, err |
| + } |
| + holds[name] = h |
| + } |
| + if holds["console.prev"] && holds["console"] { |
| + got.Already = true |
| + return got, nil |
| + } |
| + if dry { |
| + return got, nil |
| + } |
| + for _, name := range []string{"console.prev", "console"} { |
| + if holds[name] { |
| + continue |
| + } |
| + if err = safefile.WriteAt(int(d.Fd()), name, body, 0o755); err != nil { |
| + return got, err |
| + } |
| + } |
| + for _, path := range []string{got.Previous, got.Path} { |
| + f, err := hostfloor.OpenProtected(path) |
| + if err != nil { |
| + return got, err |
| + } |
| + var st unix.Stat_t |
| + err = unix.Fstat(int(f.Fd()), &st) |
| + var hash string |
| + if err == nil { |
| + hash, err = hostfloor.DigestReader(io.LimitReader(f, consoleMaxBytes+1)) |
| + } |
| + f.Close() |
| + if err != nil { |
| + return got, err |
| + } |
| + if hash != sha || st.Mode&0o7777 != 0o755 || st.Nlink != 1 || st.Uid != 0 || st.Gid != 0 { |
| + return got, errActivation |
| + } |
| + } |
| + return got, nil |
| +} |
| + |
| +// consoleSlotHolds reports whether name in d is absent (false) or a root |
| +// owned regular file, one link, mode 0755, holding sha (true). Anything else |
| +// is refused. |
| +func consoleSlotHolds(d *os.File, name, sha string) (bool, error) { |
| + var st unix.Stat_t |
| + err := unix.Fstatat(int(d.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW) |
| + if err == unix.ENOENT { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Uid != 0 || st.Gid != 0 || st.Mode&0o7777 != 0o755 { |
| + return false, errActivation |
| + } |
| + f, err := safefile.OpenFileAt(int(d.Fd()), name, os.O_RDONLY, 0) |
| + if err != nil { |
| + return false, err |
| + } |
| + defer f.Close() |
| + hash, err := hostfloor.DigestReader(io.LimitReader(f, consoleMaxBytes+1)) |
| + if err != nil { |
| + return false, err |
| + } |
| + if hash != sha { |
| + return false, errActivation |
| + } |
| + return true, nil |
| +} |
| + |
| +// readConsoleSource reads the source console, no symlink followed on its |
| +// path, a regular file with one link, at most consoleMaxBytes, and refuses |
| +// it unless it hashes to sha. |
| +func readConsoleSource(source, sha string) ([]byte, error) { |
| + f, err := safefile.OpenFile(source, os.O_RDONLY, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer f.Close() |
| + body, err := io.ReadAll(io.LimitReader(f, consoleMaxBytes+1)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sum := sha256.Sum256(body) |
| + if len(body) > consoleMaxBytes || hex.EncodeToString(sum[:]) != sha { |
| + return nil, errActivation |
| + } |
| + return body, nil |
| +} |
| + |
| +// consoleDir opens dir component by component from /, never following a |
| +// symlink. Every component must be root-owned, not writable by group or |
| +// others and searchable by others; missing ones are created 0755 when |
| +// create is set, and the parent is synced. The last must be exactly 0755. |
| +func consoleDir(dir string, create bool) (*os.File, error) { |
| + if os.Geteuid() != 0 || !filepath.IsAbs(dir) || filepath.Clean(dir) != dir || dir == "/" { |
| + return nil, errActivation |
| + } |
| + fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + check := func(fd int, last bool) error { |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + return err |
| + } |
| + if st.Uid != 0 || st.Mode&0o022 != 0 || st.Mode&0o001 == 0 || last && (st.Mode&0o7777 != 0o755 || st.Gid != 0) { |
| + return errActivation |
| + } |
| + return nil |
| + } |
| + names := strings.Split(strings.TrimPrefix(dir, "/"), "/") |
| + for i, name := range names { |
| + if err = check(fd, false); err != nil { |
| + unix.Close(fd) |
| + return nil, err |
| + } |
| + next, e := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + if e == unix.ENOENT && create { |
| + e = unix.Mkdirat(fd, name, 0o755) |
| + if e == nil { |
| + e = unix.Fchmodat(fd, name, 0o755, 0) |
| + } |
| + if e == nil || e == unix.EEXIST { |
| + if e = unix.Fsync(fd); e == nil { |
| + next, e = unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + } |
| + } |
| + } |
| + unix.Close(fd) |
| + if e != nil { |
| + return nil, e |
| + } |
| + fd = next |
| + if i == len(names)-1 { |
| + if err = check(fd, true); err != nil { |
| + unix.Close(fd) |
| + return nil, err |
| + } |
| + } |
| + } |
| + return os.NewFile(uintptr(fd), dir), nil |
| +} |
| + |
| +// runInstallConsole is install-console's work after its flags: the staged |
| +// identity checked against the published one in store, then the protected |
| +// copies. |
| +func runInstallConsole(ctx context.Context, store release.Store, pub ed25519.PublicKey, identityPath, source, dir, build, platform string, dry bool) (string, string, consoleInstalled, error) { |
| + identity, err := activationRead(identityPath, false) |
| + if err != nil { |
| + return "", "", consoleInstalled{}, err |
| + } |
| + p, err := release.VerifySystemIdentity(pub, identity) |
| + if err != nil { |
| + return "", "", consoleInstalled{}, errActivation |
| + } |
| + key, err := release.IdentityKey(p.Current.ReleaseTag) |
| + if err != nil { |
| + return "", "", consoleInstalled{}, errActivation |
| + } |
| + published, _, err := store.Get(ctx, key) |
| + if err != nil { |
| + return "", "", consoleInstalled{}, err |
| + } |
| + tag, sha, err := consoleInstallTarget(identity, published, pub, build, platform) |
| + if err != nil { |
| + return "", "", consoleInstalled{}, err |
| + } |
| + got, err := installProtectedConsole(dir, source, sha, dry) |
| + return tag, sha, got, err |
| +} |
diff --git a/internal/host/activation_artifacts_linux_test.go b/internal/host/activation_artifacts_linux_test.go| new file mode 100644 |
| index 000000000..2ff4b715d |
| --- /dev/null |
| +++ b/internal/host/activation_artifacts_linux_test.go |
| @@ -0,0 +1,417 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/claimactivation" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/publicrelease" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func artifactSHA(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } |
| + |
| +// artifactWorld is a fresh deployment ready for sign-policy and |
| +// publish-identity: roster initialized and configured, the console shipped |
| +// and promoted with pointer digests on. |
| +type artifactWorld struct { |
| + r claimactivation.Runner |
| + store release.Store |
| + pub ed25519.PublicKey |
| + router []byte |
| + sums string |
| + sig string |
| +} |
| + |
| +func newArtifactWorld(t *testing.T) *artifactWorld { |
| + t.Helper() |
| + ctx := t.Context() |
| + store := release.NewDirStore(t.TempDir()) |
| + pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| + sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader) |
| + w := &artifactWorld{store: store, pub: pub, router: []byte("carlos v0.28.2"), |
| + r: claimactivation.Runner{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: func([]byte) string { return "v0.28.2" }}} |
| + w.run(t, claimactivation.Plan{Phase: "initialize"}) |
| + w.run(t, claimactivation.Plan{Phase: "configure", Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console"}}}}) |
| + if err := release.WriteDigestMarker(ctx, store, w.r.Signer, "bab", "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := release.Manifest{App: "console", Version: "abc1234", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-arm64": {}}} |
| + if err := release.Ship(ctx, store, "bab", m, map[string][]byte{"linux-arm64": []byte("console build")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := w.r.Signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234"); err != nil { |
| + t.Fatal(err) |
| + } |
| + w.sums = artifactSHA(w.router) + " carlos-linux-arm64\n" + artifactSHA([]byte("amd64 deb")) + " carlos_0.28.2_amd64.deb\n" + artifactSHA([]byte("arm64 deb")) + " carlos_0.28.2_arm64.deb\n" |
| + w.sig = base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(w.sums))) |
| + return w |
| +} |
| + |
| +// plan fills in the bucket and the current roster revision. |
| +func (w *artifactWorld) plan(t *testing.T, p claimactivation.Plan) claimactivation.Plan { |
| + t.Helper() |
| + p.Bucket = "bucket" |
| + if o, err := w.r.Directory.Read(t.Context()); err == nil { |
| + p.ExpectedRevision = o.Roster.Revision |
| + } |
| + return p |
| +} |
| + |
| +func (w *artifactWorld) run(t *testing.T, p claimactivation.Plan) claimactivation.Result { |
| + t.Helper() |
| + result, err := w.r.Run(t.Context(), w.plan(t, p), false) |
| + if err != nil { |
| + t.Fatal(p.Phase, err) |
| + } |
| + return result |
| +} |
| + |
| +func (w *artifactWorld) policyPlan(t *testing.T) claimactivation.Plan { |
| + floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"} |
| + return w.plan(t, claimactivation.Plan{Phase: "sign-policy", Floor: &floor}) |
| +} |
| + |
| +func (w *artifactWorld) identityPlan(t *testing.T) claimactivation.Plan { |
| + return w.plan(t, claimactivation.Plan{Phase: "publish-identity", Identity: &claimactivation.IdentityInput{ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: w.sums, SumsSignature: w.sig, |
| + Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}}) |
| +} |
| + |
| +// artifactPhases runs sign-policy and publish-identity on a fresh deployment |
| +// and returns their outputs, the deployment key and the router build's hash. |
| +func artifactPhases(t *testing.T) (policy, identity []byte, pub ed25519.PublicKey, router []byte) { |
| + t.Helper() |
| + w := newArtifactWorld(t) |
| + policy = w.run(t, w.policyPlan(t)).Output |
| + identity = w.run(t, w.identityPlan(t)).Output |
| + return policy, identity, w.pub, w.router |
| +} |
| + |
| +// writeOut reserves name under dir and writes raw to it, as claims run |
| +// --out does. |
| +func writeOut(dir *os.File, name string, raw []byte, dry bool) error { |
| + out, err := reserveActivationOutputAt(dir, name, dry) |
| + if err != nil { |
| + return err |
| + } |
| + err = out.finish(raw) |
| + out.close() |
| + return err |
| +} |
| + |
| +func openDir(t *testing.T) (*os.File, string) { |
| + t.Helper() |
| + path := t.TempDir() |
| + dir, err := os.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { dir.Close() }) |
| + return dir, path |
| +} |
| + |
| +// C4: the phases' files are written byte for byte, 0600 and exclusively, |
| +// and guard-arm's own parse accepts exactly those bytes. A re-indented |
| +// policy (what jq would print) is refused, which is why nothing may pass |
| +// through jq. |
| +func TestActivationOutputRoundTripsIntoGuardArm(t *testing.T) { |
| + policy, identity, pub, router := artifactPhases(t) |
| + dir, dirPath := openDir(t) |
| + var err error |
| + for name, raw := range map[string][]byte{"policy.json": policy, "identity.json": identity} { |
| + if err = writeOut(dir, name, raw, true); err != nil { |
| + t.Fatal("dry run", name, err) |
| + } |
| + if _, err = os.Lstat(filepath.Join(dirPath, name)); !os.IsNotExist(err) { |
| + t.Fatal("dry run wrote", name, err) |
| + } |
| + if err = writeOut(dir, name, raw, false); err != nil { |
| + t.Fatal(name, err) |
| + } |
| + st, err := os.Lstat(filepath.Join(dirPath, name)) |
| + if err != nil || st.Mode() != 0o600 { |
| + t.Fatal(name, st, err) |
| + } |
| + if err = writeOut(dir, name, raw, false); err == nil { |
| + t.Fatal("replaced an existing", name) |
| + } |
| + if err = writeOut(dir, name, raw, true); err == nil { |
| + t.Fatal("dry run accepted an existing", name) |
| + } |
| + } |
| + policyFile, _ := os.ReadFile(filepath.Join(dirPath, "policy.json")) |
| + identityFile, _ := os.ReadFile(filepath.Join(dirPath, "identity.json")) |
| + if !bytes.Equal(policyFile, policy) || !bytes.Equal(identityFile, identity) { |
| + t.Fatal("files are not the phase output") |
| + } |
| + p, f, err := guardArmInputs(policyFile, identityFile, pub, "bucket", "v0.28.2", "linux-arm64", artifactSHA(router)) |
| + if err != nil || p.Mode != "armed" || f.Floor.MinRelease != "v0.28.2" { |
| + t.Fatal("guard-arm refused the written files", err) |
| + } |
| + var indented bytes.Buffer |
| + if err = json.Indent(&indented, policyFile, "", " "); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err = guardArmInputs(indented.Bytes(), identityFile, pub, "bucket", "v0.28.2", "linux-arm64", artifactSHA(router)); err == nil { |
| + t.Fatal("guard-arm accepted a re-indented policy") |
| + } |
| + for name, args := range map[string][]string{ |
| + "other build": {"v0.28.1", "linux-arm64", artifactSHA(router)}, |
| + "other platform": {"v0.28.2", "linux-amd64", artifactSHA(router)}, |
| + "other image": {"v0.28.2", "linux-arm64", strings.Repeat("0", 64)}, |
| + } { |
| + if _, _, err = guardArmInputs(policyFile, identityFile, pub, "bucket", args[0], args[1], args[2]); err == nil { |
| + t.Fatal("guard-arm accepted", name) |
| + } |
| + } |
| + for _, name := range []string{"", ".", "..", "a/b"} { |
| + if err = writeOut(dir, name, policy, false); err == nil { |
| + t.Fatalf("wrote %q", name) |
| + } |
| + } |
| +} |
| + |
| +func TestConsoleInstallTargetNeedsThePublishedIdentity(t *testing.T) { |
| + _, identity, pub, _ := artifactPhases(t) |
| + tag, sha, err := consoleInstallTarget(identity, identity, pub, "v0.28.2", "linux-arm64") |
| + if err != nil || tag != "v0.28.2" || sha != artifactSHA([]byte("console build")) { |
| + t.Fatal(tag, sha, err) |
| + } |
| + other, _, _ := ed25519.GenerateKey(rand.Reader) |
| + for name, tc := range map[string]struct { |
| + staged, published []byte |
| + pub ed25519.PublicKey |
| + build, platform string |
| + }{ |
| + "not published": {identity, nil, pub, "v0.28.2", "linux-arm64"}, |
| + "other bytes": {identity, append(append([]byte(nil), identity...), '\n'), pub, "v0.28.2", "linux-arm64"}, |
| + "other key": {identity, identity, other, "v0.28.2", "linux-arm64"}, |
| + "other build": {identity, identity, pub, "v0.28.1", "linux-arm64"}, |
| + "other platform": {identity, identity, pub, "v0.28.2", "linux-amd64"}, |
| + "not identity": {[]byte("{}"), []byte("{}"), pub, "v0.28.2", "linux-arm64"}, |
| + } { |
| + if _, _, err := consoleInstallTarget(tc.staged, tc.published, tc.pub, tc.build, tc.platform); err == nil { |
| + t.Fatal("accepted", name) |
| + } |
| + } |
| +} |
| + |
| +func TestInstallConsoleRefusesWithoutRoot(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("runs as non-root") |
| + } |
| + src := filepath.Join(t.TempDir(), "console") |
| + if err := os.WriteFile(src, []byte("console build"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := filepath.Join(t.TempDir(), "protected") |
| + for _, dry := range []bool{true, false} { |
| + if _, err := installProtectedConsole(dir, src, artifactSHA([]byte("console build")), dry); err == nil { |
| + t.Fatal("installed without root") |
| + } |
| + } |
| + if _, err := os.Lstat(dir); !os.IsNotExist(err) { |
| + t.Fatal("refusal created the directory", err) |
| + } |
| +} |
| + |
| +// claims run verifies release checksum lists with the vendor key the |
| +// binary embeds, never a key from its inputs. |
| +func TestActivationRunnerUsesTheVendorSumsKey(t *testing.T) { |
| + pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| + r := activationRunner(release.NewDirStore(t.TempDir()), pub, "bucket", key) |
| + if r.SumsKey != publicrelease.Default().PubKey || r.SumsKey != "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=" { |
| + t.Fatal("sums key", r.SumsKey) |
| + } |
| +} |
| + |
| +func TestClaimsUsageNamesTheArtifactCommands(t *testing.T) { |
| + for _, want := range []string{"install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K", "[--out FILE]"} { |
| + if !strings.Contains(activationClaimsUsage, want) { |
| + t.Fatal("usage lacks", want) |
| + } |
| + } |
| +} |
| + |
| +// gatedStore holds its first Put until release is closed, after telling |
| +// reached, and counts Puts. |
| +type gatedStore struct { |
| + release.Store |
| + reached, release chan struct{} |
| + once sync.Once |
| + puts atomic.Int32 |
| +} |
| + |
| +func (s *gatedStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { |
| + s.puts.Add(1) |
| + s.once.Do(func() { close(s.reached); <-s.release }) |
| + return s.Store.Put(ctx, key, raw, etag) |
| +} |
| + |
| +// F4: two invocations naming the same --out. The first has reserved the |
| +// path and is inside its identity PUT; the second fails its reservation |
| +// before its phase runs and sends the bucket nothing. The first then |
| +// finishes and the file is its published identity. |
| +func TestActivationOutputCompetingInvocations(t *testing.T) { |
| + w := newArtifactWorld(t) |
| + dir, dirPath := openDir(t) |
| + winner := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})} |
| + loser := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})} |
| + close(loser.release) |
| + plan := w.identityPlan(t) |
| + invoke := func(store *gatedStore) (claimactivation.Result, *putAttempts, error) { |
| + attempts := &putAttempts{Store: store} |
| + out, err := reserveActivationOutputAt(dir, "identity.json", false) |
| + if err != nil { |
| + return claimactivation.Result{}, attempts, err |
| + } |
| + result, err := runProducingPhase(t.Context(), w.r, attempts, plan, out, false) |
| + return result, attempts, err |
| + } |
| + type outcome struct { |
| + result claimactivation.Result |
| + err error |
| + } |
| + done := make(chan outcome) |
| + go func() { |
| + result, _, err := invoke(winner) |
| + done <- outcome{result, err} |
| + }() |
| + <-winner.reached |
| + if _, attempts, err := invoke(loser); err == nil || attempts.attempted.Load() || loser.puts.Load() != 0 { |
| + t.Fatal("the loser published or was not refused", err, loser.puts.Load()) |
| + } |
| + close(winner.release) |
| + got := <-done |
| + if got.err != nil { |
| + t.Fatal(got.err) |
| + } |
| + file, err := os.ReadFile(filepath.Join(dirPath, "identity.json")) |
| + stored, _, serr := w.store.Get(t.Context(), "control/system/router/releases/v0.28.2.json") |
| + if err != nil || serr != nil || !bytes.Equal(file, stored) || !bytes.Equal(file, got.result.Output) { |
| + t.Fatal("winner's file is not the published identity", err, serr) |
| + } |
| +} |
| + |
| +// F5: a failure keeps the reservation, never removes it, and the error |
| +// names it and asks for a new --out path. A failure before any bucket write |
| +// says only that. Nothing is removed even when the entry was replaced: the |
| +// write refuses and the replacement stays. |
| +func TestActivationOutputFailureKeepsTheReservation(t *testing.T) { |
| + w := newArtifactWorld(t) |
| + dir, dirPath := openDir(t) |
| + path := filepath.Join(dirPath, "policy.json") |
| + stale := w.policyPlan(t) |
| + stale.ExpectedRevision-- |
| + out, err := reserveOutput(dir, "policy.json", path, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + attempts := &putAttempts{Store: w.store} |
| + _, err = runProducingPhase(t.Context(), w.r, attempts, stale, out, false) |
| + if err == nil || attempts.attempted.Load() || !strings.HasSuffix(err.Error(), "; "+path+" was kept. run it again with a new --out path") { |
| + t.Fatal("stale plan", err) |
| + } |
| + if st, err := os.Lstat(path); err != nil || st.Size() != 0 || st.Mode() != 0o600 { |
| + t.Fatal("reservation not kept", st, err) |
| + } |
| + if _, err = reserveOutput(dir, "policy.json", path, false); err == nil { |
| + t.Fatal("rerun on the kept path accepted") |
| + } |
| + // The dry run reserves nothing, so it has nothing to keep. |
| + other := filepath.Join(dirPath, "dry.json") |
| + out, err = reserveOutput(dir, "dry.json", other, true) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: w.store}, stale, out, true); err == nil || strings.Contains(err.Error(), "was kept") { |
| + t.Fatal("dry run", err) |
| + } |
| + if _, err = os.Lstat(other); !os.IsNotExist(err) { |
| + t.Fatal("dry run created its output", err) |
| + } |
| + // A replaced entry: the write refuses and nothing is removed. |
| + replaced := filepath.Join(dirPath, "identity.json") |
| + out, err = reserveOutput(dir, "identity.json", replaced, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = os.Rename(replaced, replaced+".moved"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = os.WriteFile(replaced, []byte("someone else's"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: w.store}, w.policyPlan(t), out, false) |
| + if err == nil || !strings.HasSuffix(err.Error(), replaced+" was kept. run it again with a new --out path") { |
| + t.Fatal("finished into a replaced entry", err) |
| + } |
| + if b, err := os.ReadFile(replaced); err != nil || string(b) != "someone else's" { |
| + t.Fatal("the replacement changed", err) |
| + } |
| + if _, err = os.Lstat(replaced + ".moved"); err != nil { |
| + t.Fatal("the reservation was removed", err) |
| + } |
| +} |
| + |
| +// failingReadback commits Puts to key and then fails every Get of key, as |
| +// when a create reaches the bucket and its readback is lost. |
| +type failingReadback struct { |
| + release.Store |
| + key string |
| + sent atomic.Bool |
| +} |
| + |
| +func (s *failingReadback) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { |
| + tag, err := s.Store.Put(ctx, key, raw, etag) |
| + if key == s.key { |
| + s.sent.Store(true) |
| + } |
| + return tag, err |
| +} |
| + |
| +func (s *failingReadback) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == s.key && s.sent.Load() { |
| + return nil, "", errors.New("readback lost") |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +// A failure after the identity reached the bucket keeps the reserved file |
| +// and says so. |
| +func TestActivationOutputKeptAfterPublication(t *testing.T) { |
| + w := newArtifactWorld(t) |
| + dir, dirPath := openDir(t) |
| + out, err := reserveOutput(dir, "identity.json", filepath.Join(dirPath, "identity.json"), false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := "control/system/router/releases/v0.28.2.json" |
| + _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: &failingReadback{Store: w.store, key: key}}, w.identityPlan(t), out, false) |
| + if err == nil || !strings.Contains(err.Error(), "the phase wrote to the bucket before it failed, so "+out.path+" was kept. run it again with a new --out path") { |
| + t.Fatal("failure after publication", err) |
| + } |
| + if _, err = os.Lstat(out.path); err != nil { |
| + t.Fatal("reservation removed after publication", err) |
| + } |
| + if _, _, err = w.store.Get(t.Context(), key); err != nil { |
| + t.Fatal("identity not published", err) |
| + } |
| +} |
diff --git a/internal/host/activation_artifacts_rootrunner_linux_test.go b/internal/host/activation_artifacts_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..c1482ba14 |
| --- /dev/null |
| +++ b/internal/host/activation_artifacts_rootrunner_linux_test.go |
| @@ -0,0 +1,141 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "path/filepath" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +// TestRootActivationConsoleInstall is install-console's copy step as real |
| +// root: modes, ownership, checked ancestry, symlinks, extra links, partial |
| +// state, dry runs and refusals that leave everything as it was. |
| +func TestRootActivationConsoleInstall(t *testing.T) { |
| + d := activationRootFixture(t) |
| + check := func(err error) { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // The fixture root is created 0700; the service user must be able to |
| + // search every ancestor of the protected copy. |
| + check(os.Chmod(d, 0o755)) |
| + body := []byte("console build abc1234") |
| + sha := artifactSHA(body) |
| + source := filepath.Join(d, "src", "console") |
| + check(os.MkdirAll(filepath.Dir(source), 0o755)) |
| + check(os.WriteFile(source, body, 0o755)) |
| + check(os.Chown(source, 1000, 1000)) // a carlos-owned versions tree |
| + other := filepath.Join(d, "src", "other") |
| + check(os.WriteFile(other, []byte("console build def5678"), 0o755)) |
| + absent := func(p string) { |
| + t.Helper() |
| + if _, err := os.Lstat(p); !os.IsNotExist(err) { |
| + t.Fatal("present", p, err) |
| + } |
| + } |
| + slot := func(p string) { |
| + t.Helper() |
| + got, err := os.ReadFile(p) |
| + st, serr := os.Lstat(p) |
| + if err != nil || serr != nil || !bytes.Equal(got, body) { |
| + t.Fatal("slot", p, err, serr) |
| + } |
| + sys := st.Sys().(*syscall.Stat_t) |
| + if st.Mode() != 0o755 || sys.Uid != 0 || sys.Gid != 0 || sys.Nlink != 1 { |
| + t.Fatalf("slot %s mode %v uid %d gid %d links %d", p, st.Mode(), sys.Uid, sys.Gid, sys.Nlink) |
| + } |
| + } |
| + dir := filepath.Join(d, "opt", "system", "console") |
| + |
| + // Bytes that do not match, and a symlinked source, are refused before |
| + // anything is created. |
| + link := filepath.Join(d, "src", "link") |
| + check(os.Symlink(source, link)) |
| + check(os.Symlink(filepath.Join(d, "src"), filepath.Join(d, "srclink"))) |
| + for _, src := range []string{other, link, filepath.Join(d, "srclink", "console"), "relative/console", source + "/../src/console"} { |
| + for _, dry := range []bool{true, false} { |
| + if _, err := installProtectedConsole(dir, src, sha, dry); err == nil { |
| + t.Fatal("accepted source", src, dry) |
| + } |
| + } |
| + } |
| + absent(filepath.Join(d, "opt")) |
| + |
| + // A dry run with nothing there writes nothing. |
| + if _, err := installProtectedConsole(dir, source, sha, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + absent(filepath.Join(d, "opt")) |
| + |
| + got, err := installProtectedConsole(dir, source, sha, false) |
| + if err != nil || got.Already || got.Path != filepath.Join(dir, "console") || got.Previous != filepath.Join(dir, "console.prev") { |
| + t.Fatal(got, err) |
| + } |
| + slot(got.Path) |
| + slot(got.Previous) |
| + for _, p := range []string{filepath.Join(d, "opt"), filepath.Join(d, "opt", "system"), dir} { |
| + st, err := os.Stat(p) |
| + if err != nil || st.Mode().Perm() != 0o755 || st.Sys().(*syscall.Stat_t).Uid != 0 { |
| + t.Fatal("directory", p, st.Mode(), err) |
| + } |
| + } |
| + entries, _ := os.ReadDir(dir) |
| + if len(entries) != 2 { |
| + t.Fatal("staging left behind", entries) |
| + } |
| + if again, err := installProtectedConsole(dir, source, sha, false); err != nil || !again.Already { |
| + t.Fatal("rerun", again, err) |
| + } |
| + if again, err := installProtectedConsole(dir, source, sha, true); err != nil || !again.Already { |
| + t.Fatal("dry rerun", again, err) |
| + } |
| + |
| + // A run cut off after .prev: the next run completes it. |
| + check(os.Remove(got.Path)) |
| + if again, err := installProtectedConsole(dir, source, sha, false); err != nil || again.Already { |
| + t.Fatal("partial", again, err) |
| + } |
| + slot(got.Path) |
| + |
| + // Anything else in a slot is refused and left as it is. |
| + refuse := func(name string, setup, undo func()) { |
| + t.Helper() |
| + setup() |
| + for _, dry := range []bool{true, false} { |
| + if _, err := installProtectedConsole(dir, source, sha, dry); err == nil { |
| + t.Fatal("accepted", name, dry) |
| + } |
| + } |
| + undo() |
| + slot(got.Previous) |
| + } |
| + foreign := []byte("someone else's console") |
| + refuse("other bytes", func() { check(os.WriteFile(got.Path+".x", foreign, 0o755)); check(os.Rename(got.Path+".x", got.Path)) }, func() { |
| + if b, _ := os.ReadFile(got.Path); !bytes.Equal(b, foreign) { |
| + t.Fatal("refusal replaced the slot") |
| + } |
| + check(os.Remove(got.Path)) |
| + }) |
| + refuse("symlink", func() { check(os.Symlink(source, got.Path)) }, func() { check(os.Remove(got.Path)) }) |
| + refuse("extra link", func() { check(os.Link(got.Previous, got.Path)) }, func() { check(os.Remove(got.Path)) }) |
| + refuse("group writable", func() { check(os.Chmod(got.Previous, 0o775)) }, func() { check(os.Chmod(got.Previous, 0o755)) }) |
| + refuse("not root's", func() { check(os.Chown(got.Previous, 1000, 1000)) }, func() { check(os.Chown(got.Previous, 0, 0)) }) |
| + |
| + // Ancestry: a writable or unsearchable directory, or a symlinked one. |
| + refuse("group-writable directory", func() { check(os.Chmod(dir, 0o775)) }, func() { check(os.Chmod(dir, 0o755)) }) |
| + refuse("private directory", func() { check(os.Chmod(dir, 0o700)) }, func() { check(os.Chmod(dir, 0o755)) }) |
| + refuse("world-writable ancestor", func() { check(os.Chmod(filepath.Join(d, "opt"), 0o777)) }, func() { check(os.Chmod(filepath.Join(d, "opt"), 0o755)) }) |
| + refuse("carlos-owned ancestor", func() { check(os.Chown(filepath.Join(d, "opt"), 1000, 1000)) }, func() { check(os.Chown(filepath.Join(d, "opt"), 0, 0)) }) |
| + moved := filepath.Join(d, "opt", "system", "real") |
| + refuse("symlinked directory", func() { check(os.Rename(dir, moved)); check(os.Symlink(moved, dir)) }, func() { check(os.Remove(dir)); check(os.Rename(moved, dir)) }) |
| + if again, err := installProtectedConsole(dir, source, sha, false); err != nil { |
| + t.Fatal("restored state", again, err) |
| + } |
| + slot(got.Path) |
| + slot(got.Previous) |
| +} |
diff --git a/internal/host/activation_command.go b/internal/host/activation_command.go| index 1c3b6ebe1..501726fdb 100644 |
| --- a/internal/host/activation_command.go |
| +++ b/internal/host/activation_command.go |
| @@ -14,13 +14,15 @@ func RunSystemClaims(args []string) { |
| const activationClaimsUsage = `usage: carlos system claims <command> [flags] |
| inspect --bucket B --aws-region R --public-key K |
| - run --plan FILE --signing-key FILE --public-key K --aws-region R |
| + run --plan FILE --signing-key FILE --public-key K --aws-region R [--out FILE] |
| evidence-key --roster FILE --expect-revision N --bucket B --box BOX --enrollment E --public-key K |
| fence-credentials --source FILE --bucket B --aws-region R --role ARN |
| console-config --roster FILE --expect-revision N --bucket B --console-id ID --public-key K [--enable-console-runtime] |
| console-report --bucket B --aws-region R --expect-revision N --console-id ID --public-key K |
| + install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K |
| install-control |
| For commands that write, add --dry-run to validate and print the planned operation without writing. |
| Run signing phases on the trusted operator host. Run box installers as root. |
| +The sign-policy and publish-identity phases write their file to --out, a new path. Pass it to guard-arm as it is. |
| ` |
diff --git a/internal/host/activation_command_linux.go b/internal/host/activation_command_linux.go| index 4293bea40..dc967f819 100644 |
| --- a/internal/host/activation_command_linux.go |
| +++ b/internal/host/activation_command_linux.go |
| @@ -13,7 +13,10 @@ import ( |
| "github.com/carlosframework/platform/internal/claimactivation" |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/publicrelease" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/version" |
| + "runtime" |
| ) |
| func runSystemClaims(args []string, out io.Writer) error { |
| @@ -40,6 +43,8 @@ func runSystemClaims(args []string, out io.Writer) error { |
| source := fs.String("source", "", "") |
| bucket := fs.String("bucket", "", "") |
| role := fs.String("role", "", "") |
| + outPath := fs.String("out", "", "") |
| + identityPath := fs.String("identity", "", "") |
| if err := fs.Parse(args[1:]); err != nil || len(fs.Args()) != 0 { |
| return errActivation |
| } |
| @@ -187,16 +192,35 @@ func runSystemClaims(args []string, out io.Writer) error { |
| if !bytes.Equal(key.Public().(ed25519.PublicKey), pub) { |
| return errActivation |
| } |
| + // sign-policy and publish-identity produce a file, written to --out |
| + // byte for byte; every other phase takes no --out. |
| + produces := plan.Phase == "sign-policy" || plan.Phase == "publish-identity" |
| + if produces != (*outPath != "") { |
| + return errActivation |
| + } |
| store, err := release.NewS3StoreIn(ctx, plan.Bucket, *region) |
| if err != nil { |
| return err |
| } |
| - runner := claimactivation.Runner{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: plan.Bucket}, Signer: release.RootSigner(key)} |
| - result, err := runner.Run(ctx, plan, *dry) |
| + runner := activationRunner(store, pub, plan.Bucket, key) |
| + var result claimactivation.Result |
| + if produces { |
| + // Reserved before the phase can write to the bucket (F4). |
| + out, e := reserveActivationOutput(*outPath, *dry) |
| + if e != nil { |
| + return e |
| + } |
| + result, err = runProducingPhase(ctx, runner, &putAttempts{Store: store}, plan, out, *dry) |
| + } else { |
| + result, err = runner.Run(ctx, plan, *dry) |
| + } |
| if err != nil { |
| return err |
| } |
| - return encode(result) |
| + return encode(struct { |
| + claimactivation.Result |
| + Out string `json:"out,omitempty"` |
| + }{result, *outPath}) |
| case "evidence-key": |
| if *rosterPath == "" || *revision == 0 || *bucket == "" || *box == "" || *enrollment == "" { |
| return errActivation |
| @@ -243,6 +267,23 @@ func runSystemClaims(args []string, out io.Writer) error { |
| return err |
| } |
| return encode(map[string]any{"dry_run": *dry, "source": *source, "target": target, "mode": "0600", "bucket": *bucket, "aws_region": *region, "role": *role}) |
| + case "install-console": |
| + if *identityPath == "" || *source == "" || *bucket == "" || *region == "" { |
| + return errActivation |
| + } |
| + pub, err := release.ParsePublicKey(*pubValue, "public-key") |
| + if err != nil { |
| + return errActivation |
| + } |
| + store, err := release.NewS3StoreIn(ctx, *bucket, *region) |
| + if err != nil { |
| + return err |
| + } |
| + tag, sha, got, err := runInstallConsole(ctx, store, pub, *identityPath, *source, consoleInstallDir, version.Build, runtime.GOOS+"-"+runtime.GOARCH, *dry) |
| + if err != nil { |
| + return err |
| + } |
| + return encode(map[string]any{"dry_run": *dry, "release_tag": tag, "sha256": sha, "source": *source, "path": got.Path, "previous": got.Previous, "already_installed": got.Already, "mode": "0755"}) |
| case "install-control": |
| if err := verifyOfflineRootMigration(ctx); err != nil { |
| return err |
| @@ -255,3 +296,11 @@ func runSystemClaims(args []string, out io.Writer) error { |
| return errActivation |
| } |
| } |
| + |
| +// activationRunner is claims run's runner. Its sums key is the vendor's |
| +// release key that the carlos binary embeds (publicrelease.Default, the same |
| +// key update.go's cliUpdatePubKey holds), so publish-identity accepts only a |
| +// checksum list the vendor signed. |
| +func activationRunner(store release.Store, pub ed25519.PublicKey, bucket string, key ed25519.PrivateKey) claimactivation.Runner { |
| + return claimactivation.Runner{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket}, Signer: release.RootSigner(key), SumsKey: publicrelease.Default().PubKey} |
| +} |
diff --git a/internal/host/activation_console_rootrunner_linux_test.go b/internal/host/activation_console_rootrunner_linux_test.go| index c421943d6..55383db44 100644 |
| --- a/internal/host/activation_console_rootrunner_linux_test.go |
| +++ b/internal/host/activation_console_rootrunner_linux_test.go |
| @@ -5,6 +5,7 @@ package host |
| import ( |
| "bytes" |
| "crypto/ed25519" |
| + "debug/buildinfo" |
| "encoding/base64" |
| "encoding/json" |
| "encoding/xml" |
| @@ -20,6 +21,7 @@ import ( |
| "reflect" |
| "strings" |
| "sync" |
| + "syscall" |
| "testing" |
| "time" |
| @@ -36,6 +38,41 @@ import ( |
| var activationConsoleImage, activationCarlosImage []byte |
| +// activationImagesBuild is how the two embedded images are built, from the |
| +// worktree root, before `go test -c -tags rootrunner,activationconsole` |
| +// (controller ruling C5). Both are stripped (-s -w), as release builds are: |
| +// an unstripped Go binary carries compressed DWARF sections, which the |
| +// bounded ELF pre-check refuses (F1). Both are stamped v1.0.0 because the identity |
| +// names that version: guard-arm compares the carlos build with it, and the |
| +// console's own floor report (Disk.Running) compares the console's build |
| +// with it, so publish-identity refuses a console stamped otherwise. |
| +// checkActivationImages asserts both images match this command. |
| +const activationImagesBuild = `GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-carlos-runner.bin . |
| +GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-console-runner.bin ./cmd/console` |
| + |
| +func checkActivationImages(t *testing.T) { |
| + t.Helper() |
| + for _, img := range []struct { |
| + raw []byte |
| + path, ldflag string |
| + }{ |
| + {activationCarlosImage, "github.com/carlosframework/platform", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0"}, |
| + {activationConsoleImage, "github.com/carlosframework/platform/cmd/console", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0"}, |
| + } { |
| + info, err := buildinfo.Read(bytes.NewReader(img.raw)) |
| + if err != nil { |
| + t.Fatal("embedded image has no build info; build it with:\n"+activationImagesBuild, err) |
| + } |
| + settings := map[string]string{} |
| + for _, s := range info.Settings { |
| + settings[s.Key] = s.Value |
| + } |
| + if info.Path != img.path || settings["GOOS"] != "linux" || settings["GOARCH"] != "arm64" || settings["CGO_ENABLED"] != "0" || settings["-ldflags"] != img.ldflag { |
| + t.Fatalf("embedded image %s (%v) is not the one this command builds:\n%s", info.Path, settings, activationImagesBuild) |
| + } |
| + } |
| +} |
| + |
| // This test allows only loopback HTTP to its S3 and console fixtures. The |
| // executable, guard, systemd, proc observation, HTTP auth and reset are real. |
| // Sophie uses the runner’s real AWS identity; Leo, box telemetry and initial |
| @@ -44,6 +81,7 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| if os.Geteuid() != 0 || len(activationConsoleImage) == 0 || len(activationCarlosImage) == 0 { |
| t.Fatal("requires root and embedded activation binaries") |
| } |
| + checkActivationImages(t) |
| uid, gid := generationRunnerRegistryUser(t) |
| instance := status.InstanceID() |
| if instance == "" { |
| @@ -120,37 +158,132 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), DeploymentBucket: bucket, Namespaces: []string{"platform"}, Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: next.Boxes["sophie"].Enrollment}} |
| phase(claimactivation.Plan{Phase: "invite-console", Console: &c}) |
| phase(claimactivation.Plan{Phase: "enroll-console", Console: &c}) |
| - rel := release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey("v1.0.0", "linux-arm64"), SHA256: hostfloor.Digest(activationCarlosImage)}}, ConsoleArtifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: "bootstrap/console/v1.0.0/carlos-linux-arm64", SHA256: hostfloor.Digest(activationConsoleImage)}}} |
| - _, err = release.PromoteSystem(ctx, store, signer, "router", "stable", rel, release.SystemPromoteOptions{}) |
| + // The environment every real carlos command below runs with, as host.env |
| + // would give it on a box. In-process S3 clients get the same endpoint. |
| + environment := []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "CARLOS_DEPLOYMENT_BUCKET=" + bucket, "AWS_REGION=eu-west-1", "AWS_DEFAULT_REGION=eu-west-1", "AWS_ACCESS_KEY_ID=fixture", "AWS_SECRET_ACCESS_KEY=fixture", "AWS_EC2_METADATA_DISABLED=true", "AWS_CONFIG_FILE=/nonexistent", "AWS_SHARED_CREDENTIALS_FILE=/nonexistent", "CARLOS_S3_ENDPOINT=" + endpoint, "CARLOS_RELEASE_PUBKEY=" + base64.StdEncoding.EncodeToString(pub), "CARLOS_RELEASE_KEY=" + base64.StdEncoding.EncodeToString(key.Seed()), "CARLOS_DATA_DIR=" + d + "/data"} |
| + for _, kv := range environment { |
| + if k, v, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "AWS_") || k == "CARLOS_S3_ENDPOINT" { |
| + t.Setenv(k, v) |
| + } |
| + } |
| + pubArg := base64.StdEncoding.EncodeToString(pub) |
| + carlos := func(want bool, args ...string) string { |
| + t.Helper() |
| + cmd := exec.CommandContext(ctx, "/opt/carlos/carlos", args...) |
| + cmd.Env = environment |
| + output, e := cmd.CombinedOutput() |
| + if (e == nil) != want { |
| + t.Fatal(args, string(output), e) |
| + } |
| + return string(output) |
| + } |
| + absent := func(path string) { |
| + t.Helper() |
| + if _, e := os.Lstat(path); !os.IsNotExist(e) { |
| + t.Fatal("present", path, e) |
| + } |
| + } |
| + write("/opt/carlos/carlos", activationCarlosImage, 0755) |
| + write("/opt/carlos/carlos.prev", activationCarlosImage, 0755) |
| + write(d+"/release.key", []byte(base64.StdEncoding.EncodeToString(key.Seed())+"\n"), 0600) |
| + |
| + // The console release, as carlos ship and carlos promote leave it with |
| + // pointer digests on: a signed pointer naming the build and its manifest |
| + // digest. |
| + check(release.WriteDigestMarker(ctx, store, signer, "bab", "root-runner")) |
| + check(release.Ship(ctx, store, "bab", release.Manifest{App: "console", Version: "abc1234", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-arm64": {}}}, map[string][]byte{"linux-arm64": activationConsoleImage})) |
| + check(signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234")) |
| + |
| + // The release identity, through the publish-identity phase over the |
| + // production S3 store (create-only PUT) against the loopback bucket. The |
| + // router hash comes from a signed checksum list; the runner's sums key is |
| + // this fixture's, because the vendor key cannot sign a fixture. The real |
| + // binary's own refusal of that list is checked below. |
| + s3, err := release.NewS3StoreIn(ctx, bucket, "eu-west-1") |
| + check(err) |
| + sumsPub, sumsKey, _ := ed25519.GenerateKey(nil) |
| + identityRunner := claimactivation.Runner{Directory: hostactors.Directory{Store: s3, PublicKey: pub, Bucket: bucket}, Signer: signer, SumsKey: base64.StdEncoding.EncodeToString(sumsPub)} |
| + sums := hostfloor.Digest(activationCarlosImage) + " carlos-linux-arm64\n" + hostfloor.Digest([]byte("amd64 deb")) + " carlos_1.0.0_amd64.deb\n" + hostfloor.Digest([]byte("arm64 deb")) + " carlos_1.0.0_arm64.deb\n" |
| + o, err = directory.Read(ctx) |
| check(err) |
| - identity, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable")) |
| + identityPlan := claimactivation.Plan{Phase: "publish-identity", Bucket: bucket, ExpectedRevision: o.Roster.Revision, Identity: &claimactivation.IdentityInput{ReleaseTag: "v1.0.0", MovesProtocol: 1, Sums: sums, |
| + SumsSignature: base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(sums))), |
| + Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}} |
| + preview, err := identityRunner.Run(ctx, identityPlan, true) |
| check(err) |
| - check(release.PublishSystemIdentity(ctx, store, pub, identity)) |
| + if _, _, e := store.Get(ctx, "control/system/router/releases/v1.0.0.json"); !errors.Is(e, os.ErrNotExist) || preview.Output != nil { |
| + t.Fatal("identity dry run published", e) |
| + } |
| + published, err := identityRunner.Run(ctx, identityPlan, false) |
| + check(err) |
| + identityOut, err := reserveActivationOutput(d+"/identity.json", false) |
| + check(err) |
| + check(identityOut.finish(published.Output)) |
| + identityOut.close() |
| + planRaw, _ := json.Marshal(identityPlan) |
| + write(d+"/identity-plan.json", planRaw, 0600) |
| + carlos(false, "system", "claims", "run", "--plan", d+"/identity-plan.json", "--signing-key", d+"/release.key", "--public-key", pubArg, "--aws-region", "eu-west-1", "--out", d+"/identity-vendor.json") |
| + absent(d + "/identity-vendor.json") |
| + |
| + // The armed policy, through the real binary's sign-policy phase, to --out. |
| floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"} |
| floorRaw, _ := json.Marshal(floor) |
| - auth, err := release.SignRootEnvelope(signer, hostfloor.Authorization{Schema: 1, Purpose: "moves-protocol", DeploymentBucket: bucket, Key: hostfloor.Key, SHA256: hostfloor.Digest(floorRaw)}) |
| + o, err = directory.Read(ctx) |
| + check(err) |
| + planRaw, _ = json.Marshal(claimactivation.Plan{Phase: "sign-policy", Bucket: bucket, ExpectedRevision: o.Roster.Revision, Floor: &floor}) |
| + write(d+"/sign-policy.json", planRaw, 0600) |
| + sign := []string{"system", "claims", "run", "--plan", d + "/sign-policy.json", "--signing-key", d + "/release.key", "--public-key", pubArg, "--aws-region", "eu-west-1", "--out", d + "/policy.json"} |
| + before := activationStoreSnapshot(t, store) |
| + carlos(true, append(append([]string{}, sign...), "--dry-run")...) |
| + absent(d + "/policy.json") |
| + t.Log("sign-policy", carlos(true, sign...)) |
| + carlos(false, sign...) // --out is never replaced |
| + if !reflect.DeepEqual(before, activationStoreSnapshot(t, store)) { |
| + t.Fatal("sign-policy wrote to the bucket") |
| + } |
| + policyRaw, err := os.ReadFile(d + "/policy.json") |
| check(err) |
| - policy := hostfloor.Policy{Schema: 1, DeploymentBucket: bucket, Mode: "armed", Floor: floorRaw, Authorization: auth} |
| - policyRaw, _ := json.Marshal(policy) |
| + var policy hostfloor.Policy |
| + check(json.Unmarshal(policyRaw, &policy)) |
| + if st, e := os.Stat(d + "/policy.json"); e != nil || st.Mode().Perm() != 0600 || !bytes.Equal(policy.Floor, floorRaw) || policy.Mode != "armed" { |
| + t.Fatal("policy file", st, e) |
| + } |
| + |
| + // The protected console copies, from a carlos-owned versions tree as on |
| + // the dev console box, through the real binary's install-console. |
| + source := d + "/versions/abc1234/console" |
| + write(source, activationConsoleImage, 0755) |
| + write(d+"/versions/other/console", append([]byte("not the release "), activationConsoleImage[:64]...), 0755) |
| + for _, p := range []string{d + "/versions", d + "/versions/abc1234", source} { |
| + check(os.Chown(p, uid, gid)) |
| + } |
| + install := func(src string) []string { |
| + return []string{"system", "claims", "install-console", "--identity", d + "/identity.json", "--source", src, "--bucket", bucket, "--aws-region", "eu-west-1", "--public-key", pubArg} |
| + } |
| + carlos(false, install(d+"/versions/other/console")...) |
| + carlos(false, install("/opt/carlos/carlos")...) |
| + absent("/opt/carlos/system/console") |
| + carlos(true, append(install(source), "--dry-run")...) |
| + absent("/opt/carlos/system/console") |
| + t.Log("install-console", carlos(true, install(source)...)) |
| + for _, p := range []string{"/opt/carlos/system/console/console", "/opt/carlos/system/console/console.prev"} { |
| + body, e := os.ReadFile(p) |
| + st, se := os.Stat(p) |
| + if e != nil || se != nil || !bytes.Equal(body, activationConsoleImage) || st.Mode().Perm() != 0755 || st.Sys().(*syscall.Stat_t).Uid != 0 || st.Sys().(*syscall.Stat_t).Gid != 0 { |
| + t.Fatal("protected console", p, e, se) |
| + } |
| + } |
| + if out := carlos(true, install(source)...); !strings.Contains(out, `"already_installed": true`) { |
| + t.Fatal("rerun was not a no-op", out) |
| + } |
| local := hostfloor.Enrollment{Box: "sophie", Enrollment: c.Compute.Enrollment, InstanceID: instance} |
| localRaw, _ := json.Marshal(local) |
| - write("/opt/carlos/carlos", activationCarlosImage, 0755) |
| - write("/opt/carlos/carlos.prev", activationCarlosImage, 0755) |
| - write("/opt/carlos/console", activationConsoleImage, 0755) |
| - write("/opt/carlos/console.prev", activationConsoleImage, 0755) |
| - write(d+"/policy.json", policyRaw, 0600) |
| - write(d+"/identity.json", identity, 0600) |
| write(d+"/enrollment.json", localRaw, 0600) |
| check(os.MkdirAll(d+"/data", 0755)) |
| check(os.Chown(d+"/data", uid, gid)) |
| check(os.Chmod(d, 0755)) |
| - environment := []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "CARLOS_DEPLOYMENT_BUCKET=" + bucket, "AWS_REGION=eu-west-1", "AWS_DEFAULT_REGION=eu-west-1", "AWS_ACCESS_KEY_ID=fixture", "AWS_SECRET_ACCESS_KEY=fixture", "AWS_EC2_METADATA_DISABLED=true", "AWS_CONFIG_FILE=/nonexistent", "AWS_SHARED_CREDENTIALS_FILE=/nonexistent", "CARLOS_S3_ENDPOINT=" + endpoint, "CARLOS_RELEASE_PUBKEY=" + base64.StdEncoding.EncodeToString(pub), "CARLOS_RELEASE_KEY=" + base64.StdEncoding.EncodeToString(key.Seed()), "CARLOS_DATA_DIR=" + d + "/data"} |
| write("/etc/carlos/host.env", []byte(strings.Join(environment[1:], "\n")+"\n"), 0600) |
| - cmd := exec.CommandContext(ctx, "/opt/carlos/carlos", "system", "guard-arm", "--policy", d+"/policy.json", "--identity", d+"/identity.json", "--enrollment", d+"/enrollment.json", "--console", "/opt/carlos/console") |
| - cmd.Env = environment |
| - if output, err := cmd.CombinedOutput(); err != nil { |
| - t.Fatal("guard arm", string(output), err) |
| - } |
| + carlos(true, "system", "guard-arm", "--policy", d+"/policy.json", "--identity", d+"/identity.json", "--enrollment", d+"/enrollment.json", "--console", "/opt/carlos/system/console/console") |
| dbPath := d + "/data/console.db" |
| st, err := console.Open(dbPath) |
| check(err) |
| @@ -187,7 +320,7 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| check(err) |
| addr := ln.Addr().String() |
| check(ln.Close()) |
| - unit := "[Service]\nType=notify\nUser=carlos\nGroup=carlos\nEnvironmentFile=/etc/carlos/host.env\nEnvironment=CONSOLE_ADDR=" + addr + " CONSOLE_ORIGIN=http://" + addr + " CONSOLE_KEYMAIL=http://127.0.0.1:1 CONSOLE_DB=" + dbPath + "\nExecStart=/opt/carlos/console serve\nRestart=no\nTimeoutStartSec=30s\nNoNewPrivileges=yes\nProtectSystem=strict\nReadWritePaths=" + d + "/data\nPrivateTmp=yes\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n" |
| + unit := "[Service]\nType=notify\nUser=carlos\nGroup=carlos\nEnvironmentFile=/etc/carlos/host.env\nEnvironment=CONSOLE_ADDR=" + addr + " CONSOLE_ORIGIN=http://" + addr + " CONSOLE_KEYMAIL=http://127.0.0.1:1 CONSOLE_DB=" + dbPath + "\nExecStart=/opt/carlos/system/console/console serve\nRestart=no\nTimeoutStartSec=30s\nNoNewPrivileges=yes\nProtectSystem=strict\nReadWritePaths=" + d + "/data\nPrivateTmp=yes\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n" |
| write("/etc/systemd/system/console.service", []byte(unit), 0644) |
| config := func(enabled bool) { |
| t.Helper() |
| @@ -228,6 +361,26 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| return hostactors.Report{} |
| } |
| first := report(false, "") |
| + // The console runs under the guard drop-in as the service user, from |
| + // the protected copy's bytes. |
| + pidOut, err := exec.Command("systemctl", "show", "-p", "MainPID", "--value", "console.service").Output() |
| + check(err) |
| + pid := strings.TrimSpace(string(pidOut)) |
| + statusRaw, err := os.ReadFile("/proc/" + pid + "/status") |
| + check(err) |
| + if !strings.Contains(string(statusRaw), fmt.Sprintf("\nUid:\t%d\t%d\t", uid, uid)) { |
| + t.Fatal("console is not the service user", string(statusRaw)) |
| + } |
| + exe, err := os.ReadFile("/proc/" + pid + "/exe") |
| + check(err) |
| + if hostfloor.Digest(exe) != hostfloor.Digest(activationConsoleImage) { |
| + t.Fatal("console process image differs from the protected copy") |
| + } |
| + dropIn, err := os.ReadFile("/etc/systemd/system/console.service.d/90-host-floor.conf") |
| + check(err) |
| + if string(dropIn) != hostfloor.ConsoleGuardDropIn("/opt/carlos/system/console/console") { |
| + t.Fatal("guard drop-in", string(dropIn)) |
| + } |
| // Box reports are fixture telemetry. The console process entry is produced |
| // by the real root observer below, never copied from the console report. |
| put := func(k string, v any) { |
| @@ -264,6 +417,13 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| } |
| publish() |
| phase(claimactivation.Plan{Phase: "publish-floor", Floor: &floor}) |
| + // The floor publish-floor wrote is the armed policy's, byte for byte. |
| + for k, want := range map[string][]byte{hostfloor.Key: policy.Floor, hostfloor.AuthKey: policy.Authorization} { |
| + got, _, e := store.Get(ctx, k) |
| + if e != nil || !bytes.Equal(got, want) { |
| + t.Fatal("published floor differs from the armed policy", k, e) |
| + } |
| + } |
| phase(claimactivation.Plan{Phase: "home", HomeRegion: "ie"}) |
| binding := hostactors.CensusKeyBinding{Bucket: bucket, Box: "leo", Enrollment: next.Boxes["leo"].Enrollment} |
| proof, err := installActivationEvidenceKey(d+"/leo-evidence.key", binding, false) |
diff --git a/internal/host/guardarm.go b/internal/host/guardarm.go| index 6a37a8919..98e4c7693 100644 |
| --- a/internal/host/guardarm.go |
| +++ b/internal/host/guardarm.go |
| @@ -2,6 +2,7 @@ package host |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| "flag" |
| @@ -49,14 +50,6 @@ func RunSystemGuardArm(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| - var p hostfloor.Policy |
| - if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| - log.Fatal(hostfloor.ErrFloor) |
| - } |
| - f, err := p.Verify(d.PublicKey, d.Bucket) |
| - if err != nil { |
| - log.Fatal(err) |
| - } |
| identity, err := os.ReadFile(*identityPath) |
| if err != nil { |
| log.Fatal(err) |
| @@ -66,7 +59,8 @@ func RunSystemGuardArm(args []string) { |
| log.Fatal(err) |
| } |
| platform := runtime.GOOS + "-" + runtime.GOARCH |
| - if err = hostfloor.CheckImage(d.PublicKey, f, identity, version.Build, platform, hash); err != nil { |
| + p, f, err := guardArmInputs(raw, identity, d.PublicKey, d.Bucket, version.Build, platform, hash) |
| + if err != nil { |
| log.Fatal(err) |
| } |
| if err = guardArmIdle(d.Dir, systemRootDir(release.OptDir())); err != nil { |
| @@ -206,6 +200,26 @@ func RunSystemGuardArm(args []string) { |
| } |
| } |
| +// guardArmInputs is guard-arm's parse of its --policy and --identity files, |
| +// byte for byte as read: an armed policy for this deployment, and an identity |
| +// that names the running build (build, platform and the executing image's |
| +// hash) at or above the policy's floor. The claims phases sign-policy and |
| +// publish-identity write exactly these files (--out). |
| +func guardArmInputs(policy, identity []byte, pub ed25519.PublicKey, bucket, build, platform, hash string) (hostfloor.Policy, hostfloor.Verified, error) { |
| + var p hostfloor.Policy |
| + if json.Unmarshal(policy, &p) != nil || p.Mode != "armed" { |
| + return hostfloor.Policy{}, hostfloor.Verified{}, hostfloor.ErrFloor |
| + } |
| + f, err := p.Verify(pub, bucket) |
| + if err != nil { |
| + return hostfloor.Policy{}, hostfloor.Verified{}, err |
| + } |
| + if err = hostfloor.CheckImage(pub, f, identity, build, platform, hash); err != nil { |
| + return hostfloor.Policy{}, hostfloor.Verified{}, err |
| + } |
| + return p, f, nil |
| +} |
| + |
| // guardArmIdle refuses to arm while root's update state is on the box: an |
| // open journal, or any update-begun.json* in the guard directory. Main's |
| // region marker, its temporary file or a stale copy beside an armed policy |
diff --git a/internal/hostfloor/armedpolicy_test.go b/internal/hostfloor/armedpolicy_test.go| new file mode 100644 |
| index 000000000..a901876a8 |
| --- /dev/null |
| +++ b/internal/hostfloor/armedpolicy_test.go |
| @@ -0,0 +1,98 @@ |
| +package hostfloor |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/json" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The armed policy signed offline (claims run sign-policy) carries exactly |
| +// the floor bytes and authorization that Publish later writes for the same |
| +// floor. A box armed with it reports that floor's digest, and guard-exec |
| +// compares its local policy with the published pair, so any difference |
| +// would refuse every guarded launch once the floor is published. |
| +func TestArmedPolicyIsThePublishedAuthorization(t *testing.T) { |
| + pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| + sg := release.RootSigner(key) |
| + floor := Floor{Min: 1, MinRelease: "v0.28.2"} |
| + p, err := ArmedPolicy(sg, "bucket", floor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if p.Schema != 1 || p.DeploymentBucket != "bucket" || p.Mode != "armed" { |
| + t.Fatalf("policy header %+v", p) |
| + } |
| + verified, err := p.Verify(pub, "bucket") |
| + if err != nil || verified.Floor != floor { |
| + t.Fatal("armed policy does not verify", verified, err) |
| + } |
| + if _, err = p.Verify(pub, "other"); err == nil { |
| + t.Fatal("armed policy verified for another deployment") |
| + } |
| + // The policy survives the file round trip guard-arm makes. |
| + wire, err := json.Marshal(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var read Policy |
| + if err = json.Unmarshal(wire, &read); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = read.Verify(pub, "bucket"); err != nil || !bytes.Equal(read.Floor, p.Floor) || !bytes.Equal(read.Authorization, p.Authorization) { |
| + t.Fatal("policy file round trip changed the signed bytes", err) |
| + } |
| + |
| + s := release.NewDirStore(t.TempDir()) |
| + if err = Publish(t.Context(), s, sg, "bucket", floor); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := s.Get(t.Context(), Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + auth, _, err := s.Get(t.Context(), AuthKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(raw, p.Floor) || !bytes.Equal(auth, p.Authorization) { |
| + t.Fatalf("published pair differs from the armed policy:\nfloor %s\npolicy %s\nauth %s\npolicy %s", raw, p.Floor, auth, p.Authorization) |
| + } |
| + published, err := Read(t.Context(), s, pub, "bucket") |
| + if err != nil || published.Digest != verified.Digest { |
| + t.Fatal("published digest differs", published, verified, err) |
| + } |
| + // Signing again is the same bytes: ed25519 is deterministic and the |
| + // floor marshals one way. |
| + again, err := ArmedPolicy(sg, "bucket", floor) |
| + if err != nil || !bytes.Equal(again.Floor, p.Floor) || !bytes.Equal(again.Authorization, p.Authorization) { |
| + t.Fatal("second signature differs", err) |
| + } |
| +} |
| + |
| +func TestArmedPolicyRefusesAnInvalidFloor(t *testing.T) { |
| + _, key, _ := ed25519.GenerateKey(rand.Reader) |
| + sg := release.RootSigner(key) |
| + for name, tc := range map[string]struct { |
| + bucket string |
| + floor Floor |
| + }{ |
| + "no bucket": {"", Floor{Min: 1, MinRelease: "v1.0.0"}}, |
| + "protocol zero": {"bucket", Floor{Min: 0, MinRelease: "v1.0.0"}}, |
| + "no release": {"bucket", Floor{Min: 1}}, |
| + "not a tag": {"bucket", Floor{Min: 1, MinRelease: "1.0.0"}}, |
| + "build, not tag": {"bucket", Floor{Min: 1, MinRelease: "26065378"}}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := ArmedPolicy(sg, tc.bucket, tc.floor); err == nil { |
| + t.Fatal("signed an invalid floor") |
| + } |
| + if _, _, err := Authorize(sg, tc.bucket, tc.floor); err == nil { |
| + t.Fatal("authorized an invalid floor") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostfloor/floor.go b/internal/hostfloor/floor.go| index 2d9d99bb7..551cf5b46 100644 |
| --- a/internal/hostfloor/floor.go |
| +++ b/internal/hostfloor/floor.go |
| @@ -80,16 +80,47 @@ func Read(ctx context.Context, s release.Store, pub ed25519.PublicKey, bucket st |
| } |
| return Verify(pub, bucket, raw, auth) |
| } |
| -func Publish(ctx context.Context, s release.Store, sg release.Signer, bucket string, f Floor) error { |
| - raw, err := json.Marshal(f) |
| + |
| +// Authorize returns the exact floor bytes and the root-signed moves-protocol |
| +// authorization over them, verified before they are returned. It is the one |
| +// signer of that pair: Publish writes what it returns, and ArmedPolicy puts |
| +// the same bytes in the policy a box arms with before publication. ed25519 |
| +// is deterministic and a Floor marshals one way, so the same key, bucket and |
| +// floor always give the same bytes, and a box armed offline holds exactly |
| +// the pair that is published later. |
| +func Authorize(sg release.Signer, bucket string, f Floor) (raw, auth []byte, err error) { |
| + raw, err = json.Marshal(f) |
| if err != nil { |
| - return err |
| + return nil, nil, err |
| } |
| - auth, err := release.SignRootEnvelope(sg, Authorization{1, "moves-protocol", bucket, Key, Digest(raw)}) |
| + auth, err = release.SignRootEnvelope(sg, Authorization{1, "moves-protocol", bucket, Key, Digest(raw)}) |
| if err != nil { |
| - return err |
| + return nil, nil, err |
| } |
| if _, err = Verify(sg.RootPublicKey(), bucket, raw, auth); err != nil { |
| + return nil, nil, err |
| + } |
| + return raw, auth, nil |
| +} |
| + |
| +// ArmedPolicy is the policy guard-arm takes: the floor and its |
| +// authorization from Authorize, signed on the trusted host and written |
| +// nowhere. Publishing the floor is a separate, later step. |
| +func ArmedPolicy(sg release.Signer, bucket string, f Floor) (Policy, error) { |
| + raw, auth, err := Authorize(sg, bucket, f) |
| + if err != nil { |
| + return Policy{}, err |
| + } |
| + p := Policy{Schema: 1, DeploymentBucket: bucket, Mode: "armed", Floor: raw, Authorization: auth} |
| + if _, err = p.Verify(sg.RootPublicKey(), bucket); err != nil { |
| + return Policy{}, err |
| + } |
| + return p, nil |
| +} |
| + |
| +func Publish(ctx context.Context, s release.Store, sg release.Signer, bucket string, f Floor) error { |
| + raw, auth, err := Authorize(sg, bucket, f) |
| + if err != nil { |
| return err |
| } |
| if err = release.CreateExact(ctx, s, AuthKey, auth); err != nil { |
diff --git a/internal/provision/claim_step6_artifacts_census_test.go b/internal/provision/claim_step6_artifacts_census_test.go| new file mode 100644 |
| index 000000000..7fe7532e5 |
| --- /dev/null |
| +++ b/internal/provision/claim_step6_artifacts_census_test.go |
| @@ -0,0 +1,18 @@ |
| +package provision |
| + |
| +// plan14ClaimStep6ArtifactsWriteCensusInventory is the record-write |
| +// inventory with claims run's putAttempts.Put (fix round 1, F4), ruled |
| +// nonrecord as census-ruling-claim-step6-artifacts-fix1 (platform-1e, |
| +// 2026-10-18; docs/superpowers/notes/census-proposal-claim-step6-artifacts.md). |
| +// |
| +// putAttempts wraps the store that claims run hands sign-policy and |
| +// publish-identity, only to record that a write was attempted, so a failed |
| +// phase keeps its reserved --out file once the bucket may have changed. It |
| +// adds no key, etag or body: every Put it passes on comes from the runner's |
| +// checkedStore, whose allow-list admits only the phase's identity key. |
| +// Hence nonrecord. |
| +func plan14ClaimStep6ArtifactsWriteCensusInventory() []censusEntry { |
| + return append(plan14BoxPublishRecordWriteCensusInventory(), censusEntry{ |
| + "internal/host/activation_artifacts_linux.go", "putAttempts.Put", "nonrecord", "def:Put,sel:Put", "0c8bbda8e6f648b10ab1ec26bc3155c9c1755e11f4e7133e4dedd32f06c8b848", |
| + }) |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 397cfddf4..889ac9320 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -835,6 +835,29 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-box-publish-record.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay claim-step6-artifacts (claim trial step 6: sign-policy, |
| + // publish-identity, install-console and guard-arm's parse), loaded last. |
| + // Ruled by the controller, accepted as proposed |
| + // (docs/superpowers/notes/census-proposal-claim-step6-artifacts.md). It |
| + // adds no accepted-legacy row, so the seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-step6-artifacts.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Census overlay claim-step6-artifacts-fix1 (fix round 1: F1 bounded ELF |
| + // pre-check, F3 sums bound to the tag, F4 --out reserved before the |
| + // phase), loaded last, under census-ruling-claim-step6-artifacts-fix1 |
| + // (platform-1e, accepted as proposed). It adds no accepted-legacy row, so |
| + // the seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Census overlay claim-step6-artifacts-fix2 (fix round 2: F1 name caps, |
| + // F5 a reserved --out file is never removed), loaded last, under |
| + // census-ruling-claim-step6-artifacts-fix2 (platform-1e, accepted as |
| + // proposed). It adds no accepted-legacy row, so the seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix2.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index f66dd1740..6a7232b82 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) { |
| if len(got) < 100 { |
| t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root) |
| } |
| - inventory := plan14BoxPublishRecordWriteCensusInventory() |
| + inventory := plan14ClaimStep6ArtifactsWriteCensusInventory() |
| problems := append(censusDiff(inventory, got), censusPolicy(inventory)...) |
| if len(problems) > 0 { |
| t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n ")) |
diff --git a/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv b/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv| new file mode 100644 |
| index 000000000..82424d188 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv |
| @@ -0,0 +1,12 @@ |
| +# Census overlay claim-step6-artifacts-fix1 (fix round 1 after Astra's review: controller rulings F1, F3 and F4; F2 accepted as a trial gap), loaded LAST, after claim-step6-artifacts. RULED by platform-1e 2026-10-18 as census-ruling-claim-step6-artifacts-fix1: every row ACCEPTED as proposed (2 changed, 3 new, 1 removed), with the 9B8 entry putAttempts.Put as nonrecord; the seal stands; docs/superpowers/notes/census-proposal-claim-step6-artifacts.md, section "Fix round 1". |
| +# F1 internal/release ArtifactBuild now runs release.CheckELFBounds (new, elfbounds.go: fixed-offset header reads only, no read site) before buildinfo.Read. F3 claimactivation sumsNameRelease (no read site) binds the signed sums to the tag through its two debs. F4 internal/host: claims run reserves --out before the phase runs (reserveActivationOutputAt), writes and reads it back through the held descriptor (activationOutput.finish), and removes it only while the entry is still its own file (activationOutput.ours); writeActivationOutputAt is gone (@remove: its checks moved into reserveActivationOutputAt and activationOutput.finish). |
| +# The seal stands: no accepted-legacy row. No box agent, update, recovery or edge path reaches any changed unit. |
| +@coverage internal/claimactivation ee00b6e03fe3a0148a2b1e2835f21852f9a3498cec00f34bcbb3895b0519f45e |
| +@coverage internal/host 8105036aeaefbe18f9236cf798d39cb90c8db6a910e86b6312ce1e2feeec0858 |
| +@coverage internal/release 158c6cc42d90632619f4c8c41b47238c3cbdc2170f58c3a8fb7c7a12d2032783 |
| +@remove internal/host/activation_artifacts_linux.go writeActivationOutputAt |
| +internal/release/program.go ArtifactBuild authority G,J4 rev3=census-ruling-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0 on the trusted signing host (publish-identity);basis=release.CheckELFBounds first: ELF64 little-endian EM_AARCH64, at most 256 sections and 64 segments, every table and range inside the file, no overlap, no SHF_COMPRESSED; then build info of bytes already hashed against a signed manifest; one plain -X version.Build flag or nothing; reads the recorded flag, not the linked value (F2 trial gap); no box path calls it file:Read@96 e89aeb9befc84a21f8f43d4c13e03f7246f00bf464813aa326083c9e6a3b1984 |
| +internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=census-ruling-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0;basis=as census-ruling-claim-step6-artifacts; run reserves --out for sign-policy and publish-identity by exclusive create before the phase can write, and runs the phase on a store that records write attempts file:Read@1602 0d740c974987081d35a3ce3b8cc71d2584ad28f591d47b65c105766598421eef |
| +internal/host/activation_artifacts_linux.go reserveActivationOutputAt authority G,J4 rev3=census-ruling-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0 on the trusted signing host;basis=checked root-owned parent; refuses any existing entry without following it; exclusive 0600 create held open, owner checked, directory synced; dry-run only checks the path is free file:Fstat@659,file:Fstatat@236 0c47fca349ad833e49f479a448437a66377a18ee3f4952fee17946261abc2235 |
| +internal/host/activation_artifacts_linux.go activationOutput.finish authority G,J4 rev3=census-ruling-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0 on the trusted signing host;basis=writes the phase's exact bytes through the held descriptor, syncs, reads them back through it, requires mode 0600, one link and the entry still its own file, syncs the directory file:Fstat@402,file:ReadAt@293 24d118853d45c9db4e8223db6f6dae810714415bdaf90c9442a7e50f96eebef9 |
| +internal/host/activation_artifacts_linux.go activationOutput.ours authority G,J4 rev3=census-ruling-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0 on the trusted signing host;basis=compares the directory entry, not followed, with the held file's device and inode; abandon removes only a match file:Fstatat@69 a3056d2b0d2b2090c723aec8beb7633ed0a3418a078c9721e52e0c37730173da |
diff --git a/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix2.tsv b/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix2.tsv| new file mode 100644 |
| index 000000000..62f59b513 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix2.tsv |
| @@ -0,0 +1,7 @@ |
| +# Census overlay claim-step6-artifacts-fix2 (fix round 2 after Astra's re-review: controller rulings F1 and F5), loaded LAST, after claim-step6-artifacts-fix1. RULED by platform-1e 2026-10-18 as census-ruling-claim-step6-artifacts-fix2: every row ACCEPTED as proposed (reserveOutput new, reserveActivationOutputAt removed, internal/host and internal/release envelopes re-pinned); the 9B8 inventory and the seal are unchanged; docs/superpowers/notes/census-proposal-claim-step6-artifacts.md, section "Fix round 2". |
| +# F1 internal/release CheckELFBounds (no read site) also caps the section-name table at 64 KiB, every name offset inside it and every name at 256 bytes up to its NUL, before ArtifactBuild calls buildinfo.Read (ArtifactBuild's digest is unchanged). F5 internal/host: a reserved --out file is never removed; activationOutput.abandon is gone, and every failure keeps the file and asks for a new --out path (outputKept, no read site). reserveActivationOutputAt is now a wrapper with no read site; its reads moved unchanged into reserveOutput, which also names the full path in its errors. |
| +# The seal stands: no accepted-legacy row. No box agent, update, recovery or edge path reaches any changed unit. The 9B8 inventory is unchanged. |
| +@coverage internal/host fd2371702b42080690d2b8c0442695bec53bcb34d8842e487007d7825d5fbf7b |
| +@coverage internal/release f4b2953a76bf7cb0b50abfe6a7022181e127f1018710b41159349c6a8dd0f5bf |
| +@remove internal/host/activation_artifacts_linux.go reserveActivationOutputAt |
| +internal/host/activation_artifacts_linux.go reserveOutput authority G,J4 rev3=census-ruling-claim-step6-artifacts-fix2;identity=explicit operator CLI uid 0 on the trusted signing host;basis=checked root-owned parent; refuses any existing entry without following it; exclusive 0600 create held open, owner checked, directory synced; never removes what it created: a failure after the create keeps the file and the error asks for a new --out path; dry-run only checks the path is free file:Fstat@653,file:Fstatat@230 45021760106ca7a1a2216e160a770e407e91b1f42c67fc47bb2076283ba034f5 |
diff --git a/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts.tsv b/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts.tsv| new file mode 100644 |
| index 000000000..21796fe81 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts.tsv |
| @@ -0,0 +1,22 @@ |
| +# Census overlay claim-step6-artifacts (claim trial step 6 artefacts, approved by Paul 2026-10-18; owner platform-1e as the host claim's owner), loaded LAST, after box-publish-record. RULED by platform-1e 2026-10-18 as census-ruling-claim-step6-artifacts: every row ACCEPTED as proposed, both censuses; the seal stays fc584e11 and no 9B8 entry is needed; the proposal and the executing identity of each unit are docs/superpowers/notes/census-proposal-claim-step6-artifacts.md. |
| +# R1 sign-policy (internal/claimactivation artifacts.go Runner.signPolicy): signs the armed hostfloor.Policy with hostfloor.ArmedPolicy, the signer hostfloor.Publish now shares (Authorize); writes nothing to the bucket; reads the floor pair only to refuse a different or half-published floor. |
| +# R2 publish-identity (Runner.publishIdentity, identityRelease, consoleArtifact, readConsole): router sha256 from the vendor-signed SHA256SUMS.txt (publicrelease.VerifySums, no network); console sha256 from the console channel's root-verified pointer digest and its manifest (C2), the artefact read back, hashed and its version.Build stamp (release.ArtifactBuild) required to equal the tag; signed by release.SignSystemIdentity and published create-only through the existing release.PublishSystemIdentity and the runner's checkedStore (Put unchanged). Its file goes to claims run --out (writeActivationOutputAt, C4). |
| +# R3 install-console (internal/host activation_artifacts_linux.go): root box installer copying a verified console into /opt/carlos/system/console/console and .prev (C3); it does not touch console.service. |
| +# guard-arm (RunSystemGuardArm): its policy and identity parse moved, unchanged, into guardArmInputs so tests feed it the phases' files; the read sites are the same files in a new order. |
| +# No box agent, update, recovery or edge path reaches any new unit: each is reached only from carlos system claims run, install-console or guard-arm, run by hand. Ruling 6: no effect on a claims-off or no-census box. |
| +@coverage internal/claimactivation 1a5ad17d5eb220f8603f8bfb7c034299af9de808e1152b339865c55bd5ff12ac |
| +@coverage internal/host ce32b61817fba3f5c709c9e6e7e4951679f840a4b274ab97599b4d7359deff53 |
| +@coverage internal/hostfloor 84f6f8b3de790df8f8db3fda540f6b86eb757a8ea322c6d5b5e5aaaef94ccaa0 |
| +@coverage internal/publicrelease 77b1f61a7bf9c89468993db1a638b91b58ff1419ea3883ea62ce9749df378ad7 |
| +@coverage internal/release 68357c92cbd5d37ac7b14c8880d690da016be211e1c03a7246b963bf2f93170b |
| +internal/claimactivation/runner.go Runner.Run authority F,G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit operator CLI uid 0 on the trusted signing host;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put; sign-policy has no writable key and publish-identity only its tag's identity key file:Read@310,file:Read@6223,file:Read@6838 f692a20a4e705b763bfb47501885d0afc4d35a8c76aeb502ef01f4a67250c14b |
| +internal/claimactivation/artifacts.go Runner.signPolicy authority F,G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit operator CLI uid 0 on the trusted signing host;basis=reads the published floor pair through the runner's checked store only to refuse a different, half-published or roster-unbound floor; signs with hostfloor.ArmedPolicy; writes nothing file:Read@833 3b9d80cdb40d07febb76aa58db883b7140918f251a782caeca46bf186c837f88 |
| +internal/claimactivation/artifacts.go readConsole authority F,G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit operator CLI uid 0 on the trusted signing host;basis=reads the console artefact the root-verified pointer and digest-checked manifest name, bounded to 512 MiB; the caller requires its sha256 and build stamp before any use file:ReadAll@202 73f7833251f87e9996bf9baf5a9bd4103b4232fb78844a72800d4ee0dfbd9f83 |
| +internal/release/program.go ArtifactBuild authority G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit operator CLI uid 0 on the trusted signing host (publish-identity);basis=parses build info of bytes already hashed against a signed manifest; returns a stamp only for exactly one plain -X version.Build flag; no box path calls it file:Read@50 059d3c75e9b753e9f186a110d20a6f05182f6dc28bbe2950e78b1e55c48fee3a |
| +internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit operator CLI uid 0;basis=explicit command validates per-command arguments, protected input files and signed roster revisions or matching signing key before invoking phased writes, reporting or installers; dry-run propagates without writes; install-control requires offline migration preflight; run takes --out only for sign-policy and publish-identity and checks it before signing; install-console verifies the staged identity against the published one file:Read@1602 f39b0f0c3803392de7634edf278ae354e7bf611f69c0d8c7e06cf589eac70a18 |
| +internal/host/activation_artifacts_linux.go writeActivationOutputAt authority G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit operator CLI uid 0 on the trusted signing host;basis=checked root-owned parent; refuses any existing entry without following it; creates exclusively 0600, fsyncs file and directory, reads back owner, mode and exact bytes; dry-run writes nothing file:Fstat@1051,file:Fstatat@220,file:ReadAll@1126 90f83716bcedfde1c2df95da5507517d378cdf072f3bf27acbc42e035b7efe15 |
| +internal/host/activation_artifacts_linux.go consoleDir authority G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit box installer uid 0 (claims install-console);basis=walks from / by no-follow descriptors; every component root-owned, not group or other writable and other-searchable; creates missing components 0755 and fsyncs the parent; the protected directory must be exactly 0755 root:root file:Fstat@374,file:Open@196,file:Openat@1083,file:Openat@770 da62b08ade87fe3b24f9fb7dc3c7e32f4cbdcc8cc8797ca2162a264447f9c721 |
| +internal/host/activation_artifacts_linux.go consoleSlotHolds authority G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit box installer uid 0 (claims install-console);basis=a slot is absent, or a root:root 0755 regular file with one link hashing to the identity's console sha256; anything else refuses file:Fstatat@96 fc9eb31225e1fa57def05ebc3b704730590e5ebd419673c73d5a44a198d49464 |
| +internal/host/activation_artifacts_linux.go installProtectedConsole authority G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit box installer uid 0 (claims install-console);basis=verifies the source bytes before creating anything; refuses foreign slots; writes .prev then current through private staging and rename, 0755 root; reads both back through hostfloor.OpenProtected and rehashes; .prev equals current (C3, install-channel A2-2); dry-run writes nothing file:Fstat@1459 40e0d5fa1add46e9cb1f9f142ad6e662bd29705a806f82d5d3cb7d70fd628d01 |
| +internal/host/activation_artifacts_linux.go readConsoleSource authority G,J4 rev3=census-ruling-claim-step6-artifacts;identity=explicit box installer uid 0 (claims install-console);basis=opens the source with no symlink followed on its path, regular file with one link, at most 512 MiB, and refuses it unless it hashes to the identity's console sha256 file:OpenFile@72,file:ReadAll@183 8db59f5b0d0d8af36c3e06de68f4cc694e005feb96a84566603976c356a334ee |
| +internal/host/guardarm.go RunSystemGuardArm authority F,J4 rev3=census-ruling-claim-step6-artifacts;identity=carlos system guard-arm, offline CLI uid 0;basis=root guard-arm checks retained census floor before installing guard; no census retains main; policy and identity parse moved unchanged into guardArmInputs, same files read file:OpenFile@565,file:ReadFile@1572,file:ReadFile@5613,file:ReadFile@865,file:ReadFile@945,file:Stat@3264 e753096e19334946dab8e3bd4f06abe17187c8a62a775e4e43befa8daa168374 |
diff --git a/internal/publicrelease/verifysums.go b/internal/publicrelease/verifysums.go| new file mode 100644 |
| index 000000000..0b84cb0c5 |
| --- /dev/null |
| +++ b/internal/publicrelease/verifysums.go |
| @@ -0,0 +1,47 @@ |
| +package publicrelease |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "errors" |
| + "regexp" |
| + "strings" |
| +) |
| + |
| +// ErrSums is every way a checksum list can fail VerifySums. |
| +var ErrSums = errors.New("publicrelease: signed checksum list refused") |
| + |
| +var sumLine = regexp.MustCompile(`^([0-9a-f]{64}) (\S+)$`) |
| + |
| +// VerifySums checks a release's SHA256SUMS.txt against its .sig with pubKey |
| +// (base64 ed25519, over the raw sums bytes, as Feed.Sums checks it) and |
| +// returns asset name to sha256. Unlike Feed.Sums it is strict, because its |
| +// caller signs what it returns into deployment authority: every line must be |
| +// sha256sum's text form, "<64 lowercase hex> <name>", each name once, and a |
| +// list with no lines is refused. It reads nothing from the network. |
| +func VerifySums(pubKey string, sums []byte, sig string) (map[string]string, error) { |
| + pub, err := base64.StdEncoding.DecodeString(pubKey) |
| + if err != nil || len(pub) != ed25519.PublicKeySize { |
| + return nil, ErrSums |
| + } |
| + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sig)) |
| + if err != nil || len(raw) != ed25519.SignatureSize || !ed25519.Verify(ed25519.PublicKey(pub), sums, raw) { |
| + return nil, ErrSums |
| + } |
| + text := string(sums) |
| + if !strings.HasSuffix(text, "\n") { |
| + return nil, ErrSums |
| + } |
| + out := map[string]string{} |
| + for _, line := range strings.Split(strings.TrimSuffix(text, "\n"), "\n") { |
| + m := sumLine.FindStringSubmatch(line) |
| + if m == nil { |
| + return nil, ErrSums |
| + } |
| + if _, dup := out[m[2]]; dup { |
| + return nil, ErrSums |
| + } |
| + out[m[2]] = m[1] |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/publicrelease/verifysums_test.go b/internal/publicrelease/verifysums_test.go| new file mode 100644 |
| index 000000000..fd3fc6901 |
| --- /dev/null |
| +++ b/internal/publicrelease/verifysums_test.go |
| @@ -0,0 +1,42 @@ |
| +package publicrelease |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestVerifySumsIsStrict(t *testing.T) { |
| + pub, priv, _ := ed25519.GenerateKey(nil) |
| + key := base64.StdEncoding.EncodeToString(pub) |
| + sign := func(s string) string { return base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(s))) + "\n" } |
| + a, b := strings.Repeat("a", 64), strings.Repeat("b", 64) |
| + good := a + " carlos-linux-arm64\n" + b + " carlos-linux-amd64\n" |
| + got, err := VerifySums(key, []byte(good), sign(good)) |
| + if err != nil || len(got) != 2 || got["carlos-linux-arm64"] != a || got["carlos-linux-amd64"] != b { |
| + t.Fatal(got, err) |
| + } |
| + _, other, _ := ed25519.GenerateKey(nil) |
| + for name, tc := range map[string]struct{ key, sums, sig string }{ |
| + "other signer": {key, good, base64.StdEncoding.EncodeToString(ed25519.Sign(other, []byte(good)))}, |
| + "changed after": {key, good + b + " extra\n", sign(good)}, |
| + "bad key": {"not-a-key", good, sign(good)}, |
| + "no signature": {key, good, ""}, |
| + "duplicate name": {key, a + " x\n" + b + " x\n", sign(a + " x\n" + b + " x\n")}, |
| + "upper hex": {key, strings.ToUpper(a) + " x\n", sign(strings.ToUpper(a) + " x\n")}, |
| + "binary marker": {key, a + " *x\n", sign(a + " *x\n")}, |
| + "one space": {key, a + " x\n", sign(a + " x\n")}, |
| + "blank line": {key, a + " x\n\n", sign(a + " x\n\n")}, |
| + "no newline": {key, a + " x", sign(a + " x")}, |
| + "empty": {key, "", sign("")}, |
| + "short hash": {key, a[:63] + " x\n", sign(a[:63] + " x\n")}, |
| + "name with space": {key, a + " x y\n", sign(a + " x y\n")}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := VerifySums(tc.key, []byte(tc.sums), tc.sig); err == nil { |
| + t.Fatal("accepted") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/release/artifactbuild_test.go b/internal/release/artifactbuild_test.go| new file mode 100644 |
| index 000000000..192478d10 |
| --- /dev/null |
| +++ b/internal/release/artifactbuild_test.go |
| @@ -0,0 +1,200 @@ |
| +package release |
| + |
| +import ( |
| + "encoding/binary" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// buildStampedProgram builds, for goarch, a real program in a module named |
| +// github.com/carlosframework/platform whose internal/version package holds a |
| +// Build string variable, the shape carlos and cmd/console have, with |
| +// ldflags as given. |
| +func buildStampedProgram(t *testing.T, goarch, ldflags string) []byte { |
| + t.Helper() |
| + dir := t.TempDir() |
| + for name, body := range map[string]string{ |
| + "go.mod": "module github.com/carlosframework/platform\n\ngo 1.21\n", |
| + "internal/version/version.go": "package version\n\nvar Build = \"dev\"\n", |
| + "main.go": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/carlosframework/platform/internal/version\"\n)\n\nfunc main() { fmt.Println(version.Build) }\n", |
| + } { |
| + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + out := filepath.Join(dir, "bin") |
| + args := []string{"build", "-o", out} |
| + if ldflags != "" { |
| + args = append(args, "-ldflags", ldflags) |
| + } |
| + cmd := exec.Command("go", append(args, ".")...) |
| + cmd.Dir = dir |
| + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH="+goarch, "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false", "GOWORK=off", "GOPROXY=off") |
| + if output, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatal(string(output), err) |
| + } |
| + b, err := os.ReadFile(out) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +// ArtifactBuild reads the -X version.Build flag a real linux-arm64 build |
| +// records, for a program that has the version.Build variable it stamps. It |
| +// reads the recorded flag; it does not establish the linked value |
| +// (controller ruling F2: owed before production cohorts, trusted build |
| +// attestation tied to the artefact digest). |
| +func TestArtifactBuildReadsTheRecordedStampFlag(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("builds Go binaries") |
| + } |
| + for ldflags, want := range map[string]string{ |
| + "-s -w -X " + BuildStampFlag + "=v0.28.2": "v0.28.2", |
| + "-s -w -X " + BuildStampFlag + "=26065378": "26065378", |
| + // Unstripped: Go compresses its DWARF sections (SHF_COMPRESSED), which |
| + // the bounded pre-check refuses. Release and console builds use -s -w. |
| + "-X " + BuildStampFlag + "=v0.28.2": "", |
| + "": "", |
| + "-s -w": "", |
| + "-s -w -X " + BuildStampFlag + "=v1 -X " + BuildStampFlag + "=v2": "", |
| + "-s -w -X=" + BuildStampFlag + "=v0.28.2": "", |
| + "-s -w -X github.com/carlosframework/platform/internal/version.BuildX=v0.28.2": "", |
| + "-X github.com/carlosframework/platform/internal/other.Build=v0.28.2 -s -w": "", |
| + } { |
| + if got := ArtifactBuild(buildStampedProgram(t, "arm64", ldflags)); got != want { |
| + t.Errorf("ldflags %q: stamp %q, want %q", ldflags, got, want) |
| + } |
| + } |
| + // linux-amd64 is outside the bounded pre-check, so it has no stamp. |
| + if got := ArtifactBuild(buildStampedProgram(t, "amd64", "-s -w -X "+BuildStampFlag+"=v0.28.2")); got != "" { |
| + t.Fatal("stamp read from a non-arm64 binary", got) |
| + } |
| + if ArtifactBuild([]byte("not a binary")) != "" { |
| + t.Fatal("stamp from non-binary") |
| + } |
| +} |
| + |
| +// elfMutate returns a copy of a real linux-arm64 build with change applied |
| +// to it, given its section and program header offsets. |
| +func elfMutate(b []byte, change func(b []byte, shoff, phoff uint64)) []byte { |
| + c := append([]byte(nil), b...) |
| + change(c, binary.LittleEndian.Uint64(c[40:]), binary.LittleEndian.Uint64(c[32:])) |
| + return c |
| +} |
| + |
| +// elfShstrtabCompressed sets SHF_COMPRESSED on the section-name table of a |
| +// real linux-arm64 build: the shape whose decompression Go's ELF reader |
| +// performs during parsing, unbounded by the file size. |
| +func elfShstrtabCompressed(b []byte) []byte { |
| + return elfMutate(b, func(c []byte, shoff, _ uint64) { |
| + idx := uint64(binary.LittleEndian.Uint16(c[62:])) |
| + h := c[shoff+idx*64:] |
| + binary.LittleEndian.PutUint64(h[8:], binary.LittleEndian.Uint64(h[8:])|0x800) |
| + }) |
| +} |
| + |
| +// elfOversizedShnum claims 65000 section headers in a real build. |
| +func elfOversizedShnum(b []byte) []byte { |
| + return elfMutate(b, func(c []byte, _, _ uint64) { binary.LittleEndian.PutUint16(c[60:], 65000) }) |
| +} |
| + |
| +// elfRepeatedName appends a name table holding one name of size bytes |
| +// to a real build, points its section-name table at it and every section's |
| +// name at that one name: the shape debug/elf would copy once per section |
| +// (Astra's re-review of F1). |
| +func elfRepeatedName(b []byte, size int) []byte { |
| + c := append(append([]byte(nil), b...), make([]byte, size+1)...) |
| + for i := len(b); i < len(b)+size; i++ { |
| + c[i] = 'a' |
| + } |
| + le := binary.LittleEndian |
| + shoff, shnum, idx := le.Uint64(c[40:]), uint64(le.Uint16(c[60:])), uint64(le.Uint16(c[62:])) |
| + h := c[shoff+idx*64:] |
| + le.PutUint64(h[24:], uint64(len(b))) |
| + le.PutUint64(h[32:], uint64(size+1)) |
| + for i := uint64(0); i < shnum; i++ { |
| + le.PutUint32(c[shoff+i*64:], 0) |
| + } |
| + return c |
| +} |
| + |
| +func TestCheckELFBoundsRefusesUnboundedMetadata(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("builds Go binaries") |
| + } |
| + real := buildStampedProgram(t, "arm64", "-s -w -X "+BuildStampFlag+"=v0.28.2") |
| + if err := CheckELFBounds(real); err != nil { |
| + t.Fatal("a real stripped linux-arm64 build refused", err) |
| + } |
| + // A 256-byte name, repeated, is inside the caps: 256 x 256 bytes at most. |
| + if err := CheckELFBounds(elfRepeatedName(real, 256)); err != nil { |
| + t.Fatal("a name at the cap refused", err) |
| + } |
| + le := binary.LittleEndian |
| + for name, crafted := range map[string][]byte{ |
| + "compressed section-name table": elfShstrtabCompressed(real), |
| + "oversized shnum": elfOversizedShnum(real), |
| + "compressed data section": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| + h := c[shoff+3*64:] |
| + le.PutUint64(h[8:], le.Uint64(h[8:])|0x800) |
| + }), |
| + "extended numbering": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[60:], 0) }), |
| + "shstrndx extended": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[62:], 0xffff) }), |
| + "shstrndx past table": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[62:], le.Uint16(c[60:])) }), |
| + "oversized phnum": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[56:], 65000) }), |
| + "section table outside": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint64(c[40:], uint64(len(c))) }), |
| + "section table wraps": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint64(c[40:], ^uint64(0)-10) }), |
| + "tables overlap": elfMutate(real, func(c []byte, _, phoff uint64) { le.PutUint64(c[40:], phoff) }), |
| + "table over the header": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint64(c[32:], 0) }), |
| + "wrong header entry size": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[58:], 40) }), |
| + "section past the end": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| + h := c[shoff+1*64:] |
| + le.PutUint32(h[4:], 1) |
| + le.PutUint64(h[32:], uint64(len(c))+1) |
| + }), |
| + "segment past the end": elfMutate(real, func(c []byte, _, phoff uint64) { le.PutUint64(c[phoff+32:], uint64(len(c))+1) }), |
| + "overlapping sections": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| + a, b := c[shoff+1*64:], c[shoff+2*64:] |
| + le.PutUint32(a[4:], 1) |
| + le.PutUint32(b[4:], 1) |
| + le.PutUint64(b[24:], le.Uint64(a[24:])) |
| + if le.Uint64(a[32:]) == 0 || le.Uint64(b[32:]) == 0 { |
| + le.PutUint64(a[32:], 16) |
| + le.PutUint64(b[32:], 16) |
| + } |
| + }), |
| + "repeated 32 MiB name": elfRepeatedName(real, 32<<20), |
| + "name table over 64 KiB": elfRepeatedName(real, 64<<10), |
| + "repeated 257-byte name": elfRepeatedName(real, 257), |
| + "name table not a strtab": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| + le.PutUint32(c[shoff+uint64(le.Uint16(c[62:]))*64+4:], 1) |
| + }), |
| + "empty name table": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| + le.PutUint64(c[shoff+uint64(le.Uint16(c[62:]))*64+32:], 0) |
| + }), |
| + "name offset outside the table": elfMutate(real, func(c []byte, shoff, _ uint64) { |
| + size := le.Uint64(c[shoff+uint64(le.Uint16(c[62:]))*64+32:]) |
| + le.PutUint32(c[shoff+64:], uint32(size)) |
| + }), |
| + "x86-64": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[18:], 62) }), |
| + "32-bit class": elfMutate(real, func(c []byte, _, _ uint64) { c[4] = 1 }), |
| + "big endian": elfMutate(real, func(c []byte, _, _ uint64) { c[5] = 2 }), |
| + "not ELF": append([]byte("MZ"), real[2:]...), |
| + "truncated": real[:63], |
| + "empty": nil, |
| + } { |
| + if err := CheckELFBounds(crafted); !errors.Is(err, ErrELFBounds) { |
| + t.Errorf("%s: %v", name, err) |
| + } |
| + if ArtifactBuild(crafted) != "" { |
| + t.Errorf("%s: stamp read", name) |
| + } |
| + } |
| +} |
diff --git a/internal/release/elfbounds.go b/internal/release/elfbounds.go| new file mode 100644 |
| index 000000000..d27c20dea |
| --- /dev/null |
| +++ b/internal/release/elfbounds.go |
| @@ -0,0 +1,129 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "encoding/binary" |
| + "errors" |
| + "sort" |
| +) |
| + |
| +// ErrELFBounds is every way CheckELFBounds refuses an artefact. |
| +var ErrELFBounds = errors.New("release: artefact is not a bounded linux-arm64 ELF") |
| + |
| +// ELF limits for CheckELFBounds. A Go linux-arm64 binary has a few dozen |
| +// sections and about ten program headers; these leave ample room and bound |
| +// the metadata any later parser can be made to walk. |
| +const ( |
| + elfMaxSections = 256 |
| + elfMaxPrograms = 64 |
| + elfHeaderSize = 64 |
| + elfPhentSize = 56 |
| + elfShentSize = 64 |
| + elfMachAArch64 = 183 |
| + elfStrTab = 3 // SHT_STRTAB |
| + elfNoBits = 8 // SHT_NOBITS: no file contents |
| + elfMaxNameTab = 64 << 10 |
| + elfMaxName = 256 |
| + elfCompressed = 0x800 // SHF_COMPRESSED |
| + elfXIndex = 0xffff |
| +) |
| + |
| +// CheckELFBounds is the bounded pre-check run before debug/buildinfo parses |
| +// an artefact in a root process (controller ruling F1). It reads only the |
| +// fixed-size headers, by offset, with every count and offset checked |
| +// against len(b), and accepts only: |
| +// - ELF64, little-endian, version 1, EM_AARCH64; |
| +// - a program header table of at most 64 entries of 56 bytes, and a |
| +// section header table of 1 to 256 entries of 64 bytes, each inside the |
| +// file, not overlapping each other or the ELF header, with no extended |
| +// numbering (e_shnum 0, e_shstrndx 0xffff); |
| +// - every segment's file range inside the file; |
| +// - every section's file range inside the file, no section flagged |
| +// SHF_COMPRESSED (whose decompression is unbounded by the file size), |
| +// and no two sections with contents overlapping; |
| +// - a section-name table (e_shstrndx) of type SHT_STRTAB, 1 byte to |
| +// 64 KiB, every section's name offset inside it, and every name at |
| +// most 256 bytes up to its NUL. |
| +// |
| +// debug/elf copies each section's name out of the name table, so names |
| +// that all point at one huge string would multiply it by the section count |
| +// (Astra's re-review, controller ruling F1). With the caps, the names it |
| +// copies are at most 256 x 256 bytes, no section is decompressed, and every |
| +// range lies inside the bounded file. No further parser hardening is in |
| +// scope for this branch. |
| +func CheckELFBounds(b []byte) error { |
| + n := uint64(len(b)) |
| + if n < elfHeaderSize || string(b[:4]) != "\x7fELF" || b[4] != 2 || b[5] != 1 || b[6] != 1 { |
| + return ErrELFBounds |
| + } |
| + le := binary.LittleEndian |
| + if le.Uint16(b[18:]) != elfMachAArch64 || le.Uint32(b[20:]) != 1 || le.Uint16(b[52:]) != elfHeaderSize { |
| + return ErrELFBounds |
| + } |
| + phoff, shoff := le.Uint64(b[32:]), le.Uint64(b[40:]) |
| + phentsize, phnum := uint64(le.Uint16(b[54:])), uint64(le.Uint16(b[56:])) |
| + shentsize, shnum := uint64(le.Uint16(b[58:])), uint64(le.Uint16(b[60:])) |
| + shstrndx := uint64(le.Uint16(b[62:])) |
| + if phnum > elfMaxPrograms || phnum > 0 && phentsize != elfPhentSize || shnum == 0 || shnum > elfMaxSections || shentsize != elfShentSize || shstrndx == elfXIndex || shstrndx >= shnum { |
| + return ErrELFBounds |
| + } |
| + within := func(off, size uint64) bool { return off <= n && size <= n-off } |
| + phEnd, shEnd := phoff+phnum*elfPhentSize, shoff+shnum*elfShentSize |
| + if phnum > 0 && (!within(phoff, phnum*elfPhentSize) || phoff < elfHeaderSize) { |
| + return ErrELFBounds |
| + } |
| + if !within(shoff, shnum*elfShentSize) || shoff < elfHeaderSize || phnum > 0 && shoff < phEnd && phoff < shEnd { |
| + return ErrELFBounds |
| + } |
| + for i := uint64(0); i < phnum; i++ { |
| + h := b[phoff+i*elfPhentSize:] |
| + if !within(le.Uint64(h[8:]), le.Uint64(h[32:])) { // p_offset, p_filesz |
| + return ErrELFBounds |
| + } |
| + } |
| + type span struct{ off, end uint64 } |
| + var spans []span |
| + for i := uint64(0); i < shnum; i++ { |
| + h := b[shoff+i*elfShentSize:] |
| + typ, flags, off, size := le.Uint32(h[4:]), le.Uint64(h[8:]), le.Uint64(h[24:]), le.Uint64(h[32:]) |
| + if flags&elfCompressed != 0 { |
| + return ErrELFBounds |
| + } |
| + if typ == elfNoBits || size == 0 { |
| + continue |
| + } |
| + if !within(off, size) { |
| + return ErrELFBounds |
| + } |
| + spans = append(spans, span{off, off + size}) |
| + } |
| + names := b[shoff+shstrndx*elfShentSize:] |
| + if le.Uint32(names[4:]) != elfStrTab { |
| + return ErrELFBounds |
| + } |
| + nameOff, nameSize := le.Uint64(names[24:]), le.Uint64(names[32:]) |
| + if nameSize == 0 || nameSize > elfMaxNameTab || !within(nameOff, nameSize) { |
| + return ErrELFBounds |
| + } |
| + table := b[nameOff : nameOff+nameSize] |
| + for i := uint64(0); i < shnum; i++ { |
| + at := uint64(le.Uint32(b[shoff+i*elfShentSize:])) |
| + if at >= nameSize { |
| + return ErrELFBounds |
| + } |
| + name := table[at:] |
| + if len(name) > elfMaxName+1 { |
| + name = name[:elfMaxName+1] |
| + } |
| + if bytes.IndexByte(name, 0) < 0 { |
| + return ErrELFBounds |
| + } |
| + } |
| + sort.Slice(spans, func(i, j int) bool { return spans[i].off < spans[j].off }) |
| + for i := 1; i < len(spans); i++ { |
| + if spans[i].off < spans[i-1].end { |
| + return ErrELFBounds |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/release/program.go b/internal/release/program.go| index da2c1280a..3a0abc570 100644 |
| --- a/internal/release/program.go |
| +++ b/internal/release/program.go |
| @@ -32,6 +32,54 @@ func ArtifactProgram(b []byte) string { |
| return bi.Path |
| } |
| +// BuildStampFlag is the linker flag that stamps a carlos build's version |
| +// (internal/version.Build). Its value is a release build's tag. |
| +const BuildStampFlag = "github.com/carlosframework/platform/internal/version.Build" |
| + |
| +// ArtifactBuild is the version.Build stamp a linux-arm64 Go binary's build |
| +// info records: the value of the one "-X <BuildStampFlag>=<v>" in its |
| +// recorded -ldflags. It is "" when the bytes fail CheckELFBounds, have no |
| +// build info, or record the flag absent, more than once or in any other |
| +// form, so a caller that needs a stamp refuses all of those alike. |
| +// |
| +// It reads the RECORDED flag, not the linked value of version.Build: a |
| +// release binary is stripped (-s -w), so there is no symbol table to read |
| +// the variable from, and a build's recorded flags could be edited without |
| +// changing its linked value. Its caller (claims publish-identity) relies on |
| +// the release key's authentication of the artefact: the signed pointer, |
| +// manifest digest and sha256 only the operator can produce. Owed before |
| +// production cohorts: trusted build attestation tied to the artefact digest |
| +// (controller ruling F2, accepted as a trial gap). |
| +func ArtifactBuild(b []byte) string { |
| + if CheckELFBounds(b) != nil { |
| + return "" |
| + } |
| + bi, err := buildinfo.Read(bytes.NewReader(b)) |
| + if err != nil { |
| + return "" |
| + } |
| + ldflags := "" |
| + for _, s := range bi.Settings { |
| + if s.Key == "-ldflags" { |
| + ldflags = s.Value |
| + } |
| + } |
| + fields := strings.Fields(ldflags) |
| + stamp, seen := "", 0 |
| + for i, f := range fields { |
| + if strings.Contains(f, BuildStampFlag) { |
| + seen++ |
| + if i > 0 && fields[i-1] == "-X" && strings.HasPrefix(f, BuildStampFlag+"=") { |
| + stamp = strings.TrimPrefix(f, BuildStampFlag+"=") |
| + } |
| + } |
| + } |
| + if seen != 1 { |
| + return "" |
| + } |
| + return stamp |
| +} |
| + |
| // FileProgram extracts the Go main package path from a file at path, |
| // returning "" if the file is not a Go binary or buildinfo is unreadable. |
| func FileProgram(path string) string { |
diff --git a/internal/release/systemidentity.go b/internal/release/systemidentity.go| index c65a52cbd..21fd9ded0 100644 |
| --- a/internal/release/systemidentity.go |
| +++ b/internal/release/systemidentity.go |
| @@ -49,6 +49,26 @@ func IdentityKey(tag string) (string, error) { |
| return "control/system/router/releases/" + tag + ".json", nil |
| } |
| +// SignSystemIdentity signs one release's identity on the trusted host: the |
| +// router component on the stable channel, no history, no audit and a zero |
| +// spread, so the record says only what the release is. The channel is |
| +// required by the pointer format and read by nothing that checks an |
| +// identity. rel.ReleasedAt is kept as given (zero from the claims phase), so |
| +// signing the same release again gives an equal Current and |
| +// PublishSystemIdentity treats it as a no-op. The result verifies as an |
| +// identity before it is returned. |
| +func SignSystemIdentity(sg Signer, rel SystemRelease) ([]byte, error) { |
| + p := &SystemPointer{Component: SystemComponentRouter, Channel: "stable", Current: rel, Spread: "0s"} |
| + wire, err := signSystemPointer(sg, p) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if _, err = VerifySystemIdentity(sg.verifyPub(), wire); err != nil { |
| + return nil, err |
| + } |
| + return wire, nil |
| +} |
| + |
| // PublishSystemIdentity accepts a release-workflow signed pointer, never an |
| // upload request's assertion of its own tag. |
| func PublishSystemIdentity(ctx context.Context, s Store, pub ed25519.PublicKey, wire []byte) error { |
diff --git a/internal/release/systemidentity_sign_test.go b/internal/release/systemidentity_sign_test.go| new file mode 100644 |
| index 000000000..ad7082f50 |
| --- /dev/null |
| +++ b/internal/release/systemidentity_sign_test.go |
| @@ -0,0 +1,102 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "errors" |
| + "reflect" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func identityRelease(tag, router, console string) SystemRelease { |
| + return SystemRelease{Version: tag, Source: "release:" + tag, ReleaseTag: tag, MovesProtocol: 1, |
| + Artifacts: map[string]SystemArtifact{"linux-arm64": {Key: SystemArtifactKey(tag, "linux-arm64"), SHA256: router}}, |
| + ConsoleArtifacts: map[string]SystemArtifact{"linux-arm64": {Key: "bootstrap/console/" + tag + "/carlos-linux-arm64", SHA256: console}}} |
| +} |
| + |
| +// SignSystemIdentity is the trusted host's signer for a release identity. |
| +// Two signatures of one release are different bytes (promoted_at moves) but |
| +// the same Current, so PublishSystemIdentity takes the second as a no-op and |
| +// refuses a different release under the same tag. |
| +func TestSignSystemIdentityPublishesCreateOnly(t *testing.T) { |
| + pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| + sg := RootSigner(key) |
| + rel := identityRelease("v0.28.2", hexOf('a'), hexOf('b')) |
| + first, err := SignSystemIdentity(sg, rel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p, err := VerifySystemIdentity(pub, first) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if p.Component != SystemComponentRouter || p.Channel != "stable" || p.Spread != "0s" || len(p.History) != 0 || len(p.Audit) != 0 || !reflect.DeepEqual(p.Current, rel) { |
| + t.Fatalf("identity %+v", p) |
| + } |
| + s := NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + if err = PublishSystemIdentity(ctx, s, pub, first); err != nil { |
| + t.Fatal(err) |
| + } |
| + time.Sleep(1100 * time.Millisecond) // promoted_at has one-second resolution |
| + second, err := SignSystemIdentity(sg, rel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(second) == string(first) { |
| + t.Fatal("expected a fresh signature") |
| + } |
| + if err = PublishSystemIdentity(ctx, s, pub, second); err != nil { |
| + t.Fatal("same release refused", err) |
| + } |
| + stored, _, err := s.Get(ctx, "control/system/router/releases/v0.28.2.json") |
| + if err != nil || string(stored) != string(first) { |
| + t.Fatal("create-only identity was replaced", err) |
| + } |
| + other, err := SignSystemIdentity(sg, identityRelease("v0.28.2", hexOf('a'), hexOf('c'))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = PublishSystemIdentity(ctx, s, pub, other); !errors.Is(err, ErrCASConflict) { |
| + t.Fatal("different release under the same tag accepted", err) |
| + } |
| +} |
| + |
| +func TestSignSystemIdentityRefusesWhatGuardArmCannotUse(t *testing.T) { |
| + _, key, _ := ed25519.GenerateKey(rand.Reader) |
| + sg := RootSigner(key) |
| + for name, mutate := range map[string]func(*SystemRelease){ |
| + "no tag": func(r *SystemRelease) { r.ReleaseTag = "" }, |
| + "build as tag": func(r *SystemRelease) { r.ReleaseTag = "26065378" }, |
| + "no protocol": func(r *SystemRelease) { r.MovesProtocol = 0 }, |
| + "bad router sha": func(r *SystemRelease) { |
| + r.Artifacts["linux-arm64"] = SystemArtifact{Key: SystemArtifactKey(r.Version, "linux-arm64"), SHA256: "abc"} |
| + }, |
| + "bad console key": func(r *SystemRelease) { |
| + r.ConsoleArtifacts["linux-arm64"] = SystemArtifact{Key: "elsewhere", SHA256: hexOf('b')} |
| + }, |
| + "no artifacts": func(r *SystemRelease) { r.Artifacts = nil }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + rel := identityRelease("v0.28.2", hexOf('a'), hexOf('b')) |
| + mutate(&rel) |
| + if _, err := SignSystemIdentity(sg, rel); err == nil { |
| + t.Fatal("signed an identity guard-arm cannot use") |
| + } |
| + }) |
| + } |
| + scoped := sg |
| + scoped.scope = &Grant{} |
| + if _, err := SignSystemIdentity(scoped, identityRelease("v0.28.2", hexOf('a'), hexOf('b'))); err == nil { |
| + t.Fatal("scoped key signed an identity") |
| + } |
| +} |
| + |
| +func hexOf(c byte) string { |
| + b := make([]byte, 64) |
| + for i := range b { |
| + b[i] = c |
| + } |
| + return string(b) |
| +} |