carlos / platform Public

claim-step6-artifacts: sign the armed policy, publish the release identity, install the protected console

Step 6 of the dev cohort-1 claim trial runs carlos system guard-arm, and two
of its inputs had no producer on main, nor did the protected console copy.

- claims run sign-policy: signs the armed hostfloor.Policy offline and
  writes nothing to the bucket. hostfloor.Authorize is now the one signer of
  the floor pair, shared with hostfloor.Publish, so publish-floor later writes
  byte-identical bytes (pinned in hostfloor, the runner and the root witness).
  Refuses a floor the deployment is not bound to.
- claims run publish-identity: no typed hashes (controller ruling C2). The
  router sha256 comes from the vendor-signed SHA256SUMS.txt
  (publicrelease.VerifySums, key pinned to cliUpdatePubKey). The console
  sha256 comes from the console channel's root-verified pointer digest and
  manifest for an explicit build, with the artefact read back, hashed and its
  version.Build stamp required to equal the tag (release.ArtifactBuild): the
  console's own floor report needs that. The identity is signed with
  release.SignSystemIdentity, published create-only through
  release.PublishSystemIdentity and read back.
- claims run --out: both phases write their file byte for byte, 0600,
  exclusive create (C4). guard-arm's parse moved into guardArmInputs, and a
  test round-trips the files into it.
- claims install-console: root box installer. Copies the console the
  published identity names into /opt/carlos/system/console/console and .prev
  (equal, C3, install-channel A2-2), 0755 root:root, checked ancestry, no
  symlinks, staged rename, readback. Does not touch console.service.
- Root-runner witness: identity, policy and console copies now come from
  these phases and the installer. C5 records and asserts the embedded image
  build command, both images stamped v1.0.0.

carlos system update and every flagship path are untouched; nothing here
runs unless an operator runs it (ruling 6). Census overlay
plan14-root-reads-claim-step6-artifacts.tsv is PROPOSED
(docs/superpowers/notes/census-proposal-claim-step6-artifacts.md).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f028b372b5ae20943ba1b6d33cef5369fb95b035 parent 3b69c2b
24 files changed, +2243 −38
  • claims_sums_key_test.go +16 −0
  • docs/site/cli.md +4 −0
  • docs/superpowers/notes/census-proposal-claim-step6-artifacts.md +61 −0
  • internal/claimactivation/artifacts.go +204 −0
  • internal/claimactivation/artifacts_test.go +463 −0
  • internal/claimactivation/runner.go +70 −0
  • internal/claimactivation/runner_test.go +17 −0
  • internal/host/activation_artifacts_linux.go +352 −0
  • internal/host/activation_artifacts_linux_test.go +195 −0
  • internal/host/activation_artifacts_rootrunner_linux_test.go +141 −0
  • internal/host/activation_command.go +3 −1
  • internal/host/activation_command_linux.go +51 −3
  • internal/host/activation_console_rootrunner_linux_test.go +175 −20
  • internal/host/guardarm.go +23 −9
  • internal/hostfloor/armedpolicy_test.go +98 −0
  • internal/hostfloor/floor.go +36 −5
  • internal/provision/plan14_root_reads_test.go +8 −0
  • internal/provision/testdata/plan14-root-reads-claim-step6-artifacts.tsv +22 −0
  • internal/publicrelease/verifysums.go +47 −0
  • internal/publicrelease/verifysums_test.go +42 −0
  • internal/release/artifactbuild_test.go +57 −0
  • internal/release/program.go +36 −0
  • internal/release/systemidentity.go +20 −0
  • internal/release/systemidentity_sign_test.go +102 −0
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..1f1a7ea37 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. 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..820d4a128
--- /dev/null
+++ b/docs/superpowers/notes/census-proposal-claim-step6-artifacts.md
@@ -0,0 +1,61 @@
+# Census proposal: claim trial step 6 artefacts
+
+Status: PROPOSED (`rev3=PROPOSED-claim-step6-artifacts`), for the controller's ruling. 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.
diff --git a/internal/claimactivation/artifacts.go b/internal/claimactivation/artifacts.go
new file mode 100644
index 000000000..3a7134c31
--- /dev/null
+++ b/internal/claimactivation/artifacts.go
@@ -0,0 +1,204 @@
+package claimactivation
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "reflect"
+
+ "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
+ }
+ 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.
+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_test.go b/internal/claimactivation/artifacts_test.go
new file mode 100644
index 000000000..6fce31c8c
--- /dev/null
+++ b/internal/claimactivation/artifacts_test.go
@@ -0,0 +1,463 @@
+package claimactivation
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "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.
+func sums(key ed25519.PrivateKey, files map[string][]byte) (string, string) {
+ var b strings.Builder
+ for _, name := range []string{"carlos-darwin-arm64", "carlos-linux-amd64", "carlos-linux-arm64", "carlos_0.28.2_arm64.deb"} {
+ if body, ok := files[name]; ok {
+ b.WriteString(sha(body) + " " + name + "\n")
+ }
+ }
+ return b.String(), base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b.String()))) + "\n"
+}
+
+func (w *artifactsWorld) identityPlan(t *testing.T, version string) Plan {
+ text, sig := sums(w.sumsKey, map[string][]byte{"carlos-linux-arm64": w.router, "carlos-linux-amd64": []byte("amd64"), "carlos_0.28.2_arm64.deb": []byte("deb")})
+ 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, map[string][]byte{"carlos-linux-arm64": 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) {
+ p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, map[string][]byte{"carlos-linux-amd64": w.router})
+ }},
+ "console is a release file": {mutate: func(w *artifactsWorld, p *Plan) {
+ p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, map[string][]byte{"carlos-linux-arm64": w.router, "carlos-darwin-arm64": w.console})
+ }},
+ "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..8d832a731
--- /dev/null
+++ b/internal/host/activation_artifacts_linux.go
@@ -0,0 +1,352 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/safefile"
+ "golang.org/x/sys/unix"
+)
+
+// writeActivationOutput writes a claims phase's file (the armed policy or the
+// published identity) to path: absolute and clean, in a root-owned directory
+// no group or other user can write, created exclusively with mode 0600, the
+// exact bytes and nothing else. An existing path is refused, never replaced.
+// The dry run checks the same and writes nothing.
+func writeActivationOutput(path string, raw []byte, dry bool) error {
+ if !filepath.IsAbs(path) || filepath.Clean(path) != path || !dry && len(raw) == 0 {
+ return errActivation
+ }
+ dir, err := activationDir(filepath.Dir(path), false)
+ if err != nil {
+ return err
+ }
+ defer dir.Close()
+ return writeActivationOutputAt(dir, filepath.Base(path), raw, dry)
+}
+
+// writeActivationOutputAt is writeActivationOutput under an already checked
+// directory.
+func writeActivationOutputAt(dir *os.File, name string, raw []byte, dry bool) error {
+ if name != filepath.Base(name) || name == "." || name == ".." || name == "/" {
+ return errActivation
+ }
+ var st unix.Stat_t
+ err := unix.Fstatat(int(dir.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW)
+ if err == nil {
+ return errActivation
+ }
+ if err != unix.ENOENT {
+ return err
+ }
+ if dry {
+ return nil
+ }
+ f, err := safefile.OpenFileAt(int(dir.Fd()), name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ _, err = f.Write(raw)
+ if err == nil {
+ err = f.Chmod(0o600)
+ }
+ if err == nil {
+ err = f.Sync()
+ }
+ err = errors.Join(err, f.Close())
+ if err == nil {
+ err = unix.Fsync(int(dir.Fd()))
+ }
+ if err != nil {
+ _ = unix.Unlinkat(int(dir.Fd()), name, 0)
+ return err
+ }
+ // Read back through the directory: a regular file with one link, owned
+ // by this process, mode 0600, holding exactly raw.
+ back, err := safefile.OpenFileAt(int(dir.Fd()), name, os.O_RDONLY, 0)
+ if err != nil {
+ return err
+ }
+ defer back.Close()
+ if err = unix.Fstat(int(back.Fd()), &st); err != nil {
+ return err
+ }
+ got, err := io.ReadAll(io.LimitReader(back, int64(len(raw))+1))
+ if err != nil {
+ return err
+ }
+ if st.Uid != uint32(os.Geteuid()) || st.Mode&0o7777 != 0o600 || !bytes.Equal(got, raw) {
+ return errActivation
+ }
+ return nil
+}
+
+// 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..80f94d928
--- /dev/null
+++ b/internal/host/activation_artifacts_linux_test.go
@@ -0,0 +1,195 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "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[:]) }
+
+// 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()
+ ctx := t.Context()
+ store := release.NewDirStore(t.TempDir())
+ pub, key, _ := ed25519.GenerateKey(rand.Reader)
+ sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader)
+ 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" }}
+ run := func(p claimactivation.Plan) claimactivation.Result {
+ t.Helper()
+ p.Bucket = "bucket"
+ if o, err := r.Directory.Read(ctx); err == nil {
+ p.ExpectedRevision = o.Roster.Revision
+ }
+ result, err := r.Run(ctx, p, false)
+ if err != nil {
+ t.Fatal(p.Phase, err)
+ }
+ return result
+ }
+ run(claimactivation.Plan{Phase: "initialize"})
+ run(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, 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 := r.Signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234"); err != nil {
+ t.Fatal(err)
+ }
+ router = []byte("carlos v0.28.2")
+ sums := artifactSHA(router) + " carlos-linux-arm64\n"
+ sig := base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(sums)))
+ floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"}
+ policy = run(claimactivation.Plan{Phase: "sign-policy", Floor: &floor}).Output
+ identity = run(claimactivation.Plan{Phase: "publish-identity", Identity: &claimactivation.IdentityInput{ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: sums, SumsSignature: sig,
+ Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}}).Output
+ return policy, identity, pub, router
+}
+
+// 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)
+ dirPath := t.TempDir()
+ dir, err := os.Open(dirPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer dir.Close()
+ for name, raw := range map[string][]byte{"policy.json": policy, "identity.json": identity} {
+ if err = writeActivationOutputAt(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 = writeActivationOutputAt(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 = writeActivationOutputAt(dir, name, raw, false); err == nil {
+ t.Fatal("replaced an existing", name)
+ }
+ if err = writeActivationOutputAt(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 = writeActivationOutputAt(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)
+ }
+ }
+}
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..9dcbd9fd8 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,34 @@ 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
+ }
+ if produces {
+ if err = writeActivationOutput(*outPath, nil, true); err != nil {
+ return err
+ }
+ }
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)
+ result, err := activationRunner(store, pub, plan.Bucket, key).Run(ctx, plan, *dry)
if err != nil {
return err
}
- return encode(result)
+ if produces && !*dry {
+ if err = writeActivationOutput(*outPath, result.Output, false); err != nil {
+ return err
+ }
+ }
+ 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 +266,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 +295,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..3b6f2a403 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,39 @@ 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 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 "-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 "-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", "-X github.com/carlosframework/platform/internal/version.Build=v1.0.0"},
+ {activationConsoleImage, "github.com/carlosframework/platform/cmd/console", "-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 +79,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 +156,129 @@ 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"
+ o, err = directory.Read(ctx)
+ check(err)
+ 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)
- identity, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable"))
+ 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)
- check(release.PublishSystemIdentity(ctx, store, pub, identity))
+ check(writeActivationOutput(d+"/identity.json", published.Output, false))
+ 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 +315,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 +356,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 +412,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/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 397cfddf4..a95b2a34c 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -835,6 +835,14 @@ 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.
+ // PROPOSED for the controller's ruling
+ // (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)
+ }
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
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..6e87a03c0
--- /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. PROPOSED: every row marked rev3=PROPOSED-claim-step6-artifacts waits for the controller's ruling; 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=PROPOSED-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=PROPOSED-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=PROPOSED-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=PROPOSED-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=PROPOSED-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=PROPOSED-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=PROPOSED-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=PROPOSED-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=PROPOSED-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=PROPOSED-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=PROPOSED-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..8bdc2d18c
--- /dev/null
+++ b/internal/release/artifactbuild_test.go
@@ -0,0 +1,57 @@
+package release
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+// ArtifactBuild reads the version stamp from a real Go binary's recorded
+// -ldflags, for any architecture, without running it.
+func TestArtifactBuildReadsTheVersionStamp(t *testing.T) {
+ if testing.Short() {
+ t.Skip("builds Go binaries")
+ }
+ dir := t.TempDir()
+ src := filepath.Join(dir, "main.go")
+ if err := os.WriteFile(src, []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ build := func(ldflags string) []byte {
+ t.Helper()
+ out := filepath.Join(dir, "bin")
+ args := []string{"build", "-o", out}
+ if ldflags != "" {
+ args = append(args, "-ldflags", ldflags)
+ }
+ cmd := exec.Command("go", append(args, src)...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false", "GO111MODULE=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
+ }
+ for ldflags, want := range map[string]string{
+ "-s -w -X " + BuildStampFlag + "=v0.28.2": "v0.28.2",
+ "-X " + BuildStampFlag + "=26065378": "26065378",
+ "": "",
+ "-s -w": "",
+ "-X " + BuildStampFlag + "=v1 -X " + BuildStampFlag + "=v2": "",
+ "-X=" + BuildStampFlag + "=v0.28.2": "",
+ "-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(build(ldflags)); got != want {
+ t.Errorf("ldflags %q: stamp %q, want %q", ldflags, got, want)
+ }
+ }
+ if ArtifactBuild([]byte("not a binary")) != "" {
+ t.Fatal("stamp from non-binary")
+ }
+}
diff --git a/internal/release/program.go b/internal/release/program.go
index da2c1280a..251f23f3f 100644
--- a/internal/release/program.go
+++ b/internal/release/program.go
@@ -32,6 +32,42 @@ 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 Go binary was linked with: the
+// value of the one "-X <BuildStampFlag>=<v>" in the -ldflags its build info
+// records. It is "" when the bytes have no build info, the flag is absent,
+// given more than once, or written in any other form, so a caller that
+// needs a stamp refuses all of those alike.
+func ArtifactBuild(b []byte) string {
+ 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)
+}