box publish-record: keep any record enrollment's verifier accepts
Astra's re-review of 3165a3bf: exact evidence matching refused a valid, older AWS-signed record after a lifecycle event (pendingTime, instanceType change while the instance stays the same). Enrollment accepts that record, and since it is immutable a re-run could never fix it. A record already under the box's key is now judged by enrollment's own rule: boxid.Verifier.Enroll, with the account, AWS region and instance id (TofuInstanceID) taken from this box's current IMDS identity and boxid.EmbeddedCerts. That is a strict v1 envelope naming this box, no unknown fields or trailing data, and AWS's RSA-SHA256 signature under the certificate for the document's region; no freshness check. A record it accepts is kept byte for byte and nothing is written; anything it refuses is refused, named with the existing reasons where they fit (unreadable, version, box, instance) and new ones for account, region and a signature that does not verify. The in-place line and cli.md say only that valid evidence is in place: enrollment still makes its roster and operator checks. An IMDS identity with no account, region or instance now stops before any write. Tests sign documents with a test key and certificate made the way boxid's own tests make them: two signed documents for the same account, region and instance with different pendingTime and instanceType are kept with nothing written; a different account, region, instance, box, a bad, missing or altered signature and the envelope cases are each refused. Census: internal/host envelope re-pinned; EmbeddedCerts (accepted-legacy) and the internal/boxid envelope are unchanged, and the import graph gains no package (internal/host already imported internal/boxid). The 9B8 digest for boxRecordStore.Put is unchanged. Seal fc584e11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+412
−176
docs/site/cli.md+1 −1docs/superpowers/notes/census-proposal-box-publish-record.md+44 −19internal/host/boxpublish.go+102 −46internal/host/boxpublish_test.go+262 −107internal/provision/testdata/plan14-root-reads-box-publish-record.tsv+3 −3
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.013s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 325.040s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.499s
ok github.com/carlosframework/platform/cmd/moveprobe 93.651s
ok github.com/carlosframework/platform/cmd/zddprobe 0.731s
ok github.com/carlosframework/platform/internal/activator 252.181s
ok github.com/carlosframework/platform/internal/agentsock 0.019s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.052s
ok github.com/carlosframework/platform/internal/appconfig 2.763s
ok github.com/carlosframework/platform/internal/apperr 0.158s
ok github.com/carlosframework/platform/internal/applog 0.204s
ok github.com/carlosframework/platform/internal/awsinv 0.026s
ok github.com/carlosframework/platform/internal/boxchoice 0.006s
ok github.com/carlosframework/platform/internal/boxid 0.680s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.009s
ok github.com/carlosframework/platform/internal/caps 0.026s
ok github.com/carlosframework/platform/internal/certwarm 0.005s
ok github.com/carlosframework/platform/internal/cgroup 0.745s
ok github.com/carlosframework/platform/internal/changelog 0.458s
ok github.com/carlosframework/platform/internal/claimactivation 0.042s
ok github.com/carlosframework/platform/internal/clamd 0.060s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 408.790s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.796s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbverify 0.011s
ok github.com/carlosframework/platform/internal/dnsrec 0.008s
ok github.com/carlosframework/platform/internal/docsite 0.007s
ok github.com/carlosframework/platform/internal/domains 2.542s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.007s
ok github.com/carlosframework/platform/internal/edge 45.132s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.009s
ok github.com/carlosframework/platform/internal/fleetapi 0.148s
ok github.com/carlosframework/platform/internal/gate 1.704s
ok github.com/carlosframework/platform/internal/genselect 0.045s
ok github.com/carlosframework/platform/internal/geo 0.026s
ok github.com/carlosframework/platform/internal/geoip 0.041s
ok github.com/carlosframework/platform/internal/historyreadback 0.225s
ok github.com/carlosframework/platform/internal/host 181.336s
ok github.com/carlosframework/platform/internal/hostactors 6.356s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.013s
ok github.com/carlosframework/platform/internal/hostbox 2.988s
ok github.com/carlosframework/platform/internal/hostclaim 0.596s
ok github.com/carlosframework/platform/internal/hostenv 0.013s
ok github.com/carlosframework/platform/internal/hostevidence 0.013s
ok github.com/carlosframework/platform/internal/hostexec 5.103s
ok github.com/carlosframework/platform/internal/hostfence 0.033s
ok github.com/carlosframework/platform/internal/hostfiles 10.484s
ok github.com/carlosframework/platform/internal/hostfloor 0.014s
ok github.com/carlosframework/platform/internal/hostguard 1.032s
ok github.com/carlosframework/platform/internal/hostops 1.685s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.013s
ok github.com/carlosframework/platform/internal/hostquarantine 0.014s
ok github.com/carlosframework/platform/internal/hostrelocate 0.479s
ok github.com/carlosframework/platform/internal/hostresolve 0.158s
ok github.com/carlosframework/platform/internal/hostwrite 0.013s
ok github.com/carlosframework/platform/internal/infraview 0.015s
ok github.com/carlosframework/platform/internal/instancectl 5.255s
ok github.com/carlosframework/platform/internal/instanceexec 2.768s
ok github.com/carlosframework/platform/internal/instancefs 0.018s
ok github.com/carlosframework/platform/internal/instancekey 0.014s
ok github.com/carlosframework/platform/internal/instanceupdates 0.027s
ok github.com/carlosframework/platform/internal/layout 1.617s
ok github.com/carlosframework/platform/internal/lbhealth 0.002s
ok github.com/carlosframework/platform/internal/lease 9.829s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.015s
ok github.com/carlosframework/platform/internal/mail 0.204s
ok github.com/carlosframework/platform/internal/maintenance 0.080s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.594s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.084s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.337s
ok github.com/carlosframework/platform/internal/moves/archive 0.846s
ok github.com/carlosframework/platform/internal/objstore 0.214s
ok github.com/carlosframework/platform/internal/ops 0.185s
ok github.com/carlosframework/platform/internal/park 0.219s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 38.639s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.510s
ok github.com/carlosframework/platform/internal/relay 7.224s
ok github.com/carlosframework/platform/internal/release 22.667s
ok github.com/carlosframework/platform/internal/rollout 3.828s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.010s
ok github.com/carlosframework/platform/internal/rootparticipation 0.058s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.573s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.542s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.389s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.935s
ok github.com/carlosframework/platform/internal/status 0.075s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.280s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.621s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49348770 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.025s
=== RUN TestT4StorageProbeMinio
2026/10/18 01:05:37 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF7A335346D46A, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.023s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md| index c2b3f688c..81aff3dd5 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2272,7 +2272,7 @@ carlos system box publish-record publishes the box's identity record to the depl |
| systemd-run --wait --pipe -p EnvironmentFile=/etc/carlos/host.env -p User=carlos /opt/carlos/carlos system box publish-record |
| ``` |
| -It refuses to run as root, on a fleet box, and on a box with no S3 deployment bucket. It only ever creates the record, and only an S3 bucket can refuse to overwrite one. If a record is already there with exactly the evidence this box would publish, it says so and writes nothing. If the record there differs in any other way, for example it is for another instance, it refuses, says what differs, and changes nothing. |
| +It refuses to run as root, on a fleet box, and on a box with no S3 deployment bucket. It only ever creates the record, and only an S3 bucket can refuse to overwrite one. If a record is already there, it checks it the way enrollment does: AWS's signature, the account, the region, the instance and the box name. A valid record is kept as it is, even an older one written before the instance was stopped and started, and nothing is written. Any other record is refused, with what is wrong, and left alone. A valid record means only that the evidence is in place. Enrollment still makes its own checks before it accepts the box. |
| #### Boxes a region's group launches |
diff --git a/docs/superpowers/notes/census-proposal-box-publish-record.md b/docs/superpowers/notes/census-proposal-box-publish-record.md| index c9f5e072c..beac2e573 100644 |
| --- a/docs/superpowers/notes/census-proposal-box-publish-record.md |
| +++ b/docs/superpowers/notes/census-proposal-box-publish-record.md |
| @@ -1,8 +1,10 @@ |
| # Census: carlos system box publish-record |
| Status: RULED as census-ruling-box-publish-record (platform-1e, R1-R4), on |
| -ea68e8a6. This note records the ruling and the fix round that applied it. |
| -platform-1e rules as the host claim's owner. |
| +ea68e8a6. This note records the ruling and the two fix rounds after it: |
| +3165a3bf applied the ruling, and the second round replaced exact evidence |
| +matching with enrollment's own verifier. platform-1e rules as the host |
| +claim's owner. |
| Branch `box-publish-record`, cut from main at b1118925 (region HA 2b). |
| @@ -14,7 +16,7 @@ Branch `box-publish-record`, cut from main at b1118925 (region HA 2b). |
| - **R2.** The one supported way to run it: |
| `systemd-run --wait --pipe -p EnvironmentFile=/etc/carlos/host.env -p User=carlos /opt/carlos/carlos system box publish-record`. |
| - **R3.** No region HA gate. Run after the loop has published, the verb is a |
| - no-op: it finds exactly the same evidence and writes nothing. |
| + no-op: it keeps the loop's record and writes nothing. |
| - **R4.** `boxRecordStore.Put` is `nonrecord`, on condition that |
| `boxRecordStore` is the only store the verb hands `publishBoxRecord`. The |
| store-guard test asserts zero S3 requests for a delete, a list, an |
| @@ -29,7 +31,7 @@ trial needs records at `control/boxes/<box>.json` for leo-climbing |
| dev. |
| - `internal/host/boxpublish.go` (new): `RunSystemBoxPublishRecord`, |
| - `boxPublishRun`, `sameEvidence`, `boxRecordStore` and the verb's lines. |
| + `boxPublishRun`, `heldEvidence`, `boxRecordStore` and the verb's lines. |
| - `system_roles.go`: `runSystemBoxVerb` hands `box publish-record` to the |
| host verb before `roleArgs`. |
| - `system.go`: `systemUsage` gains the synopsis and description lines. |
| @@ -49,18 +51,30 @@ The verb's checks, in order: |
| `release.S3Store`. |
| 6. The record under the key is read through `boxRecordStore`, then this |
| box's record is read from IMDS once. |
| -7. If a record is there, `sameEvidence` decides. The envelope must be |
| - version 1, this box, no other fields and nothing after it. The identity |
| - document and signature must be byte for byte this box's. Only `written_at` |
| - may differ. Equal is a no-op success. Anything else is refused, the |
| - mismatch is named, and nothing is written. |
| +7. If a record is there, enrollment's own verifier decides |
| + (`heldEvidence`). It calls `boxid.Verifier.Enroll` with the account from |
| + this box's current IMDS identity and `boxid.EmbeddedCerts`. The `Expect` |
| + holds the box label, the current AWS region, and the current instance id |
| + as `TofuInstanceID`. Enroll checks: |
| + - a strict version 1 envelope naming this box, with no other fields and |
| + nothing after it; |
| + - AWS's RSA-SHA256 signature under the certificate for the document's |
| + region; |
| + - the account, the region and the instance. |
| + |
| + There is no freshness check. An older record whose `pendingTime` or |
| + `instanceType` has since changed, or whose `written_at` is old, is kept. |
| + Its bytes are never touched, because the record cannot be replaced. |
| + Anything Enroll refuses is refused, with the reason named, and nothing is |
| + written. Keeping a record means only that valid evidence is in place. The |
| + roster and operator checks are still enrollment's. |
| 8. If nothing is there, `publishBoxRecord` runs through `boxRecordStore` with |
| the record already read. Its outcome is reported by what reached the |
| bucket. A failure before any create says nothing was written. A failure |
| after a create reached the bucket says the write may have happened. A |
| create answered 412 whose stored bytes are exactly this run's (a retried |
| create) is reported as published. A record that arrived after step 6 is |
| - held to `sameEvidence` too. |
| + held to the same verifier. |
| ## Root-read census (TestPlan14RootReadCensus) |
| @@ -73,7 +87,9 @@ is added or changed. The only discrepancies were the two source envelopes. |
| | Unit | Disposition | Reason | |
| |---|---|---| |
| | `@coverage .` | re-pinned to `d123dce7…5402` | `system.go` (usage lines) and `system_roles.go` (dispatch) changed. Neither adds a read. | |
| -| `@coverage internal/host` | re-pinned to `ef673c8c…60ac` | `boxpublish.go` is new. Its units have no read site of their own, and root is refused before any read (R1). | |
| +| `@coverage internal/host` | re-pinned to `b1fb4b84…7ab3` | `boxpublish.go` is new. Its units have no read site of their own, and root is refused before any read (R1). | |
| +| `internal/boxid/certs.go:EmbeddedCerts` | unchanged, accepted-legacy (J4, root-input) | The verb now calls it for the certificates enrollment verifies with. Its reads are the binary's own embedded files, its digest is unchanged, and root never reaches it through the verb (R1). | |
| +| `internal/boxid` envelope | unchanged (`0c6243af…3794`) | `Verifier.Enroll` has no read site. internal/host already imported internal/boxid (`claimproduction.go` calls `EmbeddedCerts`), so the verb's import graph gains no package. Only `crypto/x509` is added, from the standard library. | |
| | legacy seal | unchanged (`fc584e11…1b5d`) | The overlay adds no `@legacy` row. | |
| ## Record-write census (TestPlan9B8RecordWriteCensus) |
| @@ -105,19 +121,28 @@ without `If-None-Match: *` and any DELETE there. |
| - Fresh box: the record is written at the right key with the right fields, |
| by one create-only PUT. |
| -- After the loop has published (R3): already in place, no PUT, bytes |
| - unchanged. |
| -- Any other evidence is refused, named, with no PUT and bytes unchanged: |
| +- After the loop has published (R3): kept, no PUT, bytes unchanged. |
| +- An older record, correctly signed for the same account, region and |
| + instance but with a different `pendingTime` and `instanceType`: kept as |
| + it is, no PUT, bytes unchanged. |
| +- Anything enrollment's verifier refuses is refused, named, with no PUT and |
| + bytes unchanged. The signing certificates are generated the way boxid's |
| + own tests generate them. The cases: |
| + - a different account; |
| + - a different region (one that has a certificate); |
| - another instance; |
| - - the same instance with another document; |
| - - a different or missing signature; |
| - - the wrong box; |
| + - a bad signature (another key); |
| + - a missing signature; |
| + - a document altered after signing; |
| + - a different box; |
| - version 2; |
| - an unknown field; |
| - trailing data; |
| - - not JSON. |
| + - not JSON; |
| + - a document that is not JSON. |
| - A record arriving between the verb's read and `publishBoxRecord`'s is held |
| - to the same evidence. |
| + to the same verifier: a valid older one is kept, a forged one refused. |
| +- The embedded production certificates cover eu-west-1. |
| - A different record created just before the PUT: the bucket's 412 stops it |
| and nothing is overwritten. |
| - A create committed but answered 500: the SDK retries, gets 412, and the |
diff --git a/internal/host/boxpublish.go b/internal/host/boxpublish.go| index 6ab302ce9..962ab8dcb 100644 |
| --- a/internal/host/boxpublish.go |
| +++ b/internal/host/boxpublish.go |
| @@ -3,6 +3,8 @@ package host |
| import ( |
| "bytes" |
| "context" |
| + "crypto/x509" |
| + "encoding/base64" |
| "encoding/json" |
| "errors" |
| "flag" |
| @@ -10,6 +12,7 @@ import ( |
| "io" |
| "net/http" |
| "os" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/boxid" |
| @@ -34,14 +37,20 @@ import ( |
| // bucket: only S3's If-None-Match makes the write create-only, and a |
| // deployment directory's check-then-rename does not. |
| // |
| -// It reads the record already under the box's key first. One that is |
| -// there counts as this box's only if it carries exactly the evidence the |
| -// box would publish now (sameEvidence): only written_at may differ. |
| -// Anything else is refused and left alone. With nothing there, it calls |
| -// publishBoxRecord through boxRecordStore, which can read and create this |
| -// box's one record key and nothing else (R4), so it can never delete or |
| -// overwrite: the dev bucket's box_records policy |
| -// (BoxRecordsAreCreateOnly, BoxRecordsAreNeverDeleted) denies both anyway. |
| +// It reads the record already under the box's key first, and keeps one |
| +// that enrollment's own verifier accepts as evidence for this box |
| +// (heldEvidence: boxid.Verifier.Enroll, with the account, AWS region and |
| +// instance id taken from this box's current IMDS identity). Its bytes are |
| +// never touched, however old: an instance's identity document changes |
| +// with its lifecycle (pendingTime, instanceType) while the instance stays |
| +// the same, and the record cannot be replaced. Anything the verifier |
| +// refuses is refused and left alone. Keeping a record says only that |
| +// valid evidence is in place; enrollment still makes its roster and |
| +// operator checks. With nothing there, it calls publishBoxRecord through |
| +// boxRecordStore, which can read and create this box's one record key and |
| +// nothing else (R4), so it can never delete or overwrite: the dev bucket's |
| +// box_records policy (BoxRecordsAreCreateOnly, BoxRecordsAreNeverDeleted) |
| +// denies both anyway. |
| const boxPublishRecordCommand = "systemd-run --wait --pipe -p EnvironmentFile=/etc/carlos/host.env -p User=carlos /opt/carlos/carlos system box publish-record" |
| @@ -102,7 +111,7 @@ func boxRecordPublishedLine(box, instance string) string { |
| } |
| func boxRecordInPlaceLine(box, instance string) string { |
| - return fmt.Sprintf("the identity record for %s (instance %s) is already in place at %s. nothing was written", box, instance, boxid.Key(box)) |
| + return fmt.Sprintf("the record at %s is valid AWS-signed evidence for %s (instance %s), so nothing was written. enrollment still makes its own checks before it accepts the box", boxid.Key(box), box, instance) |
| } |
| const ( |
| @@ -112,6 +121,11 @@ const ( |
| boxRecordNoNameRefusal = "this box's name could not be read, so nothing was written" |
| ) |
| +// errOwnIdentityIncomplete is the IMDS identity document naming no account, |
| +// region or instance: there is nothing to check a stored record against, |
| +// and nothing worth publishing. |
| +var errOwnIdentityIncomplete = errors.New("the identity document AWS served this box names no account, region or instance") |
| + |
| func boxRecordBadName(box string) error { |
| return fmt.Errorf("this box's name %q is not a valid box reference, so nothing was written", box) |
| } |
| @@ -125,7 +139,7 @@ func boxRecordRefusal(err error) error { |
| } |
| func boxRecordMismatch(box, what string) error { |
| - return fmt.Errorf("refused: the record already under %s does not match what this box would publish: %s. nothing was changed", box, what) |
| + return fmt.Errorf("refused: the record already under %s is not valid evidence for this box: %s. nothing was changed", box, what) |
| } |
| // boxRecordRetryable is a failure before any write reached the bucket. |
| @@ -140,49 +154,84 @@ func boxRecordUncertain(box string, err error) error { |
| return fmt.Errorf("the identity record for %s may or may not have been written: %v. it is safe to run again, which only ever creates the record and says what is there", box, err) |
| } |
| -// sameEvidence says how raw, the record already under the box's key, |
| -// differs from mine, the record this box would publish now, or "" when it |
| -// carries the same evidence. The envelope must be what enrollment reads |
| -// (version 1, this box, no other fields, nothing after it), and the |
| -// identity document and signature must be byte for byte this box's. Only |
| -// written_at may differ. |
| -func sameEvidence(raw []byte, mine boxid.Record) string { |
| +// awsIdentity is the part of an identity document enrollment checks. |
| +type awsIdentity struct { |
| + Account string `json:"accountId"` |
| + Region string `json:"region"` |
| + Instance string `json:"instanceId"` |
| +} |
| + |
| +// identityOf reads rec's identity document. It does not check the |
| +// signature. |
| +func identityOf(rec boxid.Record) (awsIdentity, bool) { |
| + doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument) |
| + if err != nil { |
| + return awsIdentity{}, false |
| + } |
| + var id awsIdentity |
| + if json.Unmarshal(doc, &id) != nil { |
| + return awsIdentity{}, false |
| + } |
| + return id, true |
| +} |
| + |
| +// heldEvidence says why raw, the record already under the box's key, is |
| +// not evidence for this box, or "" when it is. The verdict is |
| +// boxid.Verifier.Enroll's, with the account, AWS region and instance id |
| +// (TofuInstanceID) taken from own, this box's current IMDS identity, and |
| +// certs as enrollment's: a strict v1 envelope naming this box, no other |
| +// fields and nothing after it, and AWS's signature under the certificate |
| +// for the document's region. There is no freshness check: the document's |
| +// mutable fields (pendingTime, instanceType) and written_at may differ |
| +// from today's. The rest only names the refusal, in Enroll's own order. |
| +func heldEvidence(raw []byte, box string, own awsIdentity, certs map[string]*x509.Certificate) string { |
| + v := boxid.Verifier{Account: own.Account, Certs: certs} |
| + _, err := v.Enroll(raw, boxid.Expect{Box: box, AWSRegion: own.Region, TofuInstanceID: own.Instance}) |
| + if err == nil { |
| + return "" |
| + } |
| + const unreadable = "it cannot be read as an identity record" |
| dec := json.NewDecoder(bytes.NewReader(raw)) |
| dec.DisallowUnknownFields() |
| var held boxid.Record |
| - if err := dec.Decode(&held); err != nil { |
| - return "it cannot be read as an identity record" |
| + if dec.Decode(&held) != nil { |
| + return unreadable |
| } |
| - if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { |
| - return "it cannot be read as an identity record" |
| + if _, terr := dec.Token(); !errors.Is(terr, io.EOF) { |
| + return unreadable |
| } |
| switch { |
| case held.V != 1: |
| return fmt.Sprintf("its version is %d, not 1", held.V) |
| - case held.Box != mine.Box: |
| + case held.Box != box: |
| return fmt.Sprintf("it is for box %q", held.Box) |
| - case held.IdentityDocument != mine.IdentityDocument: |
| - theirs, terr := recordInstanceID(held) |
| - own, oerr := recordInstanceID(mine) |
| - if terr == nil && oerr == nil && theirs != own { |
| - return fmt.Sprintf("it is signed for instance %s, and this box is %s", theirs, own) |
| - } |
| - return "its identity document differs from the one AWS serves this box" |
| - case held.IdentitySignature != mine.IdentitySignature: |
| - return "its identity signature differs from the one AWS serves this box" |
| } |
| - return "" |
| + id, ok := identityOf(held) |
| + switch { |
| + case !ok: |
| + return unreadable |
| + case id.Instance != own.Instance: |
| + return fmt.Sprintf("it is signed for instance %s, and this box is %s", id.Instance, own.Instance) |
| + case id.Account != own.Account: |
| + return fmt.Sprintf("it is signed for account %s, and this box is in account %s", id.Account, own.Account) |
| + case id.Region != own.Region: |
| + return fmt.Sprintf("it is signed in region %s, and this box is in %s", id.Region, own.Region) |
| + } |
| + reason := strings.TrimPrefix(err.Error(), boxid.ErrUnverified.Error()+": ") |
| + return fmt.Sprintf("it does not verify as AWS-signed evidence: %s", reason) |
| } |
| // boxPublishRun is one run of the verb, with its seams: the environment, |
| // the effective uid (R1), the machine id (a host read, made only after the |
| -// uid check), the deployment store and the IMDS read. |
| +// uid check), the deployment store, the IMDS read and the AWS signing |
| +// certificates enrollment verifies with. |
| type boxPublishRun struct { |
| getenv func(string) string |
| geteuid func() int |
| machineID func() string |
| open func(context.Context, func(string) string) (release.Store, error) |
| read func(box string) (boxid.Record, error) |
| + certs func() (map[string]*x509.Certificate, error) |
| } |
| // publish is the verb. It returns the line to print, or the refusal. |
| @@ -220,15 +269,24 @@ func (r boxPublishRun) publish(ctx context.Context) (string, error) { |
| if err != nil { |
| return "", boxRecordRetryable(box, err) |
| } |
| - instance, ierr := recordInstanceID(mine) |
| - if ierr != nil { |
| - instance = "unknown" |
| + own, ok := identityOf(mine) |
| + if !ok || own.Account == "" || own.Region == "" || own.Instance == "" { |
| + return "", boxRecordRetryable(box, errOwnIdentityIncomplete) |
| } |
| - if gerr == nil { |
| - if what := sameEvidence(existing, mine); what != "" { |
| + // keep decides a record that is already there: kept as it is when the |
| + // verifier accepts it, refused otherwise. |
| + keep := func(raw []byte) (string, error) { |
| + certs, err := r.certs() |
| + if err != nil { |
| + return "", boxRecordRetryable(box, err) |
| + } |
| + if what := heldEvidence(raw, box, own, certs); what != "" { |
| return "", boxRecordMismatch(box, what) |
| } |
| - return boxRecordInPlaceLine(box, instance), nil |
| + return boxRecordInPlaceLine(box, own.Instance), nil |
| + } |
| + if gerr == nil { |
| + return keep(existing) |
| } |
| done, err := publishBoxRecord(ctx, store, box, func() (boxid.Record, error) { return mine, nil }) |
| @@ -243,19 +301,16 @@ func (r boxPublishRun) publish(ctx context.Context) (string, error) { |
| // Either this create landed, or a retry of it was answered 412 |
| // and boxid.Publish found exactly these bytes, written_at |
| // included: this run's record either way. |
| - return boxRecordPublishedLine(box, instance), nil |
| + return boxRecordPublishedLine(box, own.Instance), nil |
| } |
| // No create was attempted: a record arrived after the read above and |
| // publishBoxRecord took it as this instance's. It is held to the same |
| - // evidence as one that was there from the start. |
| + // verifier as one that was there from the start. |
| raw, _, err := store.Get(ctx, store.key) |
| if err != nil { |
| return "", boxRecordRetryable(box, err) |
| } |
| - if what := sameEvidence(raw, mine); what != "" { |
| - return "", boxRecordMismatch(box, what) |
| - } |
| - return boxRecordInPlaceLine(box, instance), nil |
| + return keep(raw) |
| } |
| // RunSystemBoxPublishRecord is `carlos system box publish-record`. |
| @@ -269,7 +324,8 @@ func RunSystemBoxPublishRecord(args []string) { |
| } |
| client := &http.Client{Timeout: 2 * time.Second} |
| run := boxPublishRun{getenv: os.Getenv, geteuid: os.Geteuid, machineID: readMachineID, open: openDeployStoreEnv, |
| - read: func(box string) (boxid.Record, error) { return readIdentityRecord(client, imdsBase, box, time.Now()) }} |
| + read: func(box string) (boxid.Record, error) { return readIdentityRecord(client, imdsBase, box, time.Now()) }, |
| + certs: boxid.EmbeddedCerts} |
| line, err := run.publish(context.Background()) |
| if err != nil { |
| fmt.Fprintln(os.Stderr, err) |
diff --git a/internal/host/boxpublish_test.go b/internal/host/boxpublish_test.go| index e61442657..ec6c80402 100644 |
| --- a/internal/host/boxpublish_test.go |
| +++ b/internal/host/boxpublish_test.go |
| @@ -3,9 +3,16 @@ package host |
| import ( |
| "bytes" |
| "context" |
| + "crypto" |
| + "crypto/rand" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| "encoding/base64" |
| "encoding/json" |
| "errors" |
| + "math/big" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| @@ -25,16 +32,73 @@ import ( |
| // real S3Store (openDeployStoreEnv over CARLOS_DEPLOYMENT_BUCKET and |
| // CARLOS_S3_ENDPOINT) against leasetest's S3 server, which enforces |
| // If-None-Match: * itself, behind the dev bucket's box_records policy; |
| -// and the real IMDS reader over fakeIMDS (boxrecord_test.go). |
| +// the real IMDS reader over fakeIMDS (boxrecord_test.go); and enrollment's |
| +// own verifier over documents a test AWS signs. |
| const ( |
| publishMachineID = "0123456789abcdef0123456789abcdef" |
| publishBucket = "deploy" |
| - publishDoc = "{\n \"accountId\" : \"111122223333\",\n \"region\" : \"eu-west-1\",\n \"instanceId\" : \"i-0abc\"\n}\n" |
| publishAgentUID = 999 |
| + publishAccount = "111122223333" |
| + publishRegion = "eu-west-1" |
| + publishInstance = "i-0abc" |
| ) |
| -var publishSig = base64.StdEncoding.EncodeToString([]byte(strings.Repeat("s", 256))) |
| +// testAWS stands in for AWS's instance-identity signer the way boxid's |
| +// own tests do (boxid/internal/signtest, which this package cannot |
| +// import): one RSA key, a self-signed certificate for it, and PKCS #1 |
| +// v1.5 SHA-256 signatures over identity documents. |
| +type testAWS struct { |
| + key *rsa.PrivateKey |
| + cert *x509.Certificate |
| +} |
| + |
| +func newTestAWS(t testing.TB) *testAWS { |
| + t.Helper() |
| + key, err := rsa.GenerateKey(rand.Reader, 2048) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"}, |
| + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cert, err := x509.ParseCertificate(der) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &testAWS{key: key, cert: cert} |
| +} |
| + |
| +// identityDoc is an identity document as IMDS serves one, with the two |
| +// fields that change over an instance's life. |
| +func identityDoc(account, region, instance, pendingTime, instanceType string) string { |
| + return "{\n \"accountId\" : \"" + account + "\",\n \"instanceId\" : \"" + instance + "\",\n \"instanceType\" : \"" + instanceType + |
| + "\",\n \"pendingTime\" : \"" + pendingTime + "\",\n \"region\" : \"" + region + "\"\n}\n" |
| +} |
| + |
| +func (a *testAWS) sign(t testing.TB, doc string) string { |
| + t.Helper() |
| + sum := sha256.Sum256([]byte(doc)) |
| + sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, sum[:]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return base64.StdEncoding.EncodeToString(sig) |
| +} |
| + |
| +// certs certifies eu-west-1, this box's region, and eu-west-2, so a record |
| +// signed in another supported region verifies and is still refused. |
| +func (a *testAWS) certs() (map[string]*x509.Certificate, error) { |
| + return map[string]*x509.Certificate{"eu-west-1": a.cert, "eu-west-2": a.cert}, nil |
| +} |
| + |
| +func (a *testAWS) record(t testing.TB, box, doc, writtenAt string) boxid.Record { |
| + return boxid.Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString([]byte(doc)), |
| + IdentitySignature: a.sign(t, doc), WrittenAt: writtenAt} |
| +} |
| // s3Request is one request the fake S3 saw. |
| type s3Request struct{ Method, Key, IfNoneMatch, IfMatch string } |
| @@ -43,7 +107,8 @@ type s3Request struct{ Method, Key, IfNoneMatch, IfMatch string } |
| // server, behind the dev bucket's deny-only box_records policy. Under |
| // control/boxes/ a PUT without If-None-Match: * is AccessDenied |
| // (BoxRecordsAreCreateOnly) and so is any DELETE |
| -// (BoxRecordsAreNeverDeleted). Every request is recorded. |
| +// (BoxRecordsAreNeverDeleted). Every request is recorded. aws and doc |
| +// are the box's AWS and the identity document IMDS serves it now. |
| // |
| // Hooks, each used once: afterGet runs after the first GET has been |
| // answered (a writer arriving between the verb's read and |
| @@ -56,6 +121,8 @@ type boxRecordsBucket struct { |
| model *leasetest.VersionedBucket |
| mu sync.Mutex |
| seen []s3Request |
| + aws *testAWS |
| + doc string |
| afterGet func() |
| beforePut func() |
| @@ -114,10 +181,11 @@ func s3Error(w http.ResponseWriter, status int, code string) { |
| } |
| // publishFixture points the process's AWS and CARLOS_* environment at a |
| -// fresh fake bucket, as host.env would on a box. |
| +// fresh fake bucket, as host.env would on a box, and makes the box's AWS. |
| func publishFixture(t *testing.T) *boxRecordsBucket { |
| t.Helper() |
| - b := &boxRecordsBucket{model: leasetest.NewVersionedBucket()} |
| + b := &boxRecordsBucket{model: leasetest.NewVersionedBucket(), aws: newTestAWS(t), |
| + doc: identityDoc(publishAccount, publishRegion, publishInstance, "2026-10-06T08:00:00Z", "t4g.small")} |
| b.model.AddBucket(publishBucket) |
| server := httptest.NewServer(b.handler()) |
| t.Cleanup(server.Close) |
| @@ -134,30 +202,33 @@ func publishFixture(t *testing.T) *boxRecordsBucket { |
| return b |
| } |
| -func publishIMDSRead(written time.Time) func(string) (boxid.Record, error) { |
| - client := imdsClient(&fakeIMDS{doc: publishDoc, sig: publishSig}) |
| +// imdsRead is the real IMDS reader over fakeIMDS serving b's current |
| +// document and AWS's signature over it. |
| +func (b *boxRecordsBucket) imdsRead(t *testing.T, written time.Time) func(string) (boxid.Record, error) { |
| + client := imdsClient(&fakeIMDS{doc: b.doc, sig: b.aws.sign(t, b.doc)}) |
| return func(box string) (boxid.Record, error) { |
| return readIdentityRecord(client, "http://imds.test", box, written) |
| } |
| } |
| // publishRun is the verb's production wiring with the test's seams: the |
| -// agent user's uid, the fixed machine id and the fake IMDS. |
| -func publishRun() boxPublishRun { |
| +// agent user's uid, the fixed machine id, the fake IMDS and the test |
| +// AWS's certificates. |
| +func (b *boxRecordsBucket) publishRun(t *testing.T) boxPublishRun { |
| return boxPublishRun{getenv: os.Getenv, geteuid: func() int { return publishAgentUID }, |
| - machineID: func() string { return publishMachineID }, open: openDeployStoreEnv, read: publishIMDSRead(recordNow)} |
| + machineID: func() string { return publishMachineID }, open: openDeployStoreEnv, read: b.imdsRead(t, recordNow), |
| + certs: b.aws.certs} |
| } |
| -func runPublish(t *testing.T) (string, error) { |
| +func (b *boxRecordsBucket) run(t *testing.T) (string, error) { |
| t.Helper() |
| - return publishRun().publish(context.Background()) |
| + return b.publishRun(t).publish(context.Background()) |
| } |
| -// thisBoxRecord is the record this box would publish, written at writtenAt. |
| -func thisBoxRecord(t *testing.T, writtenAt string) boxid.Record { |
| - t.Helper() |
| - return boxid.Record{V: 1, Box: status.Label(publishMachineID), IdentityDocument: base64.StdEncoding.EncodeToString([]byte(publishDoc)), |
| - IdentitySignature: publishSig, WrittenAt: writtenAt} |
| +// thisBoxRecord is the record this box would publish now, written at |
| +// writtenAt. |
| +func (b *boxRecordsBucket) thisBoxRecord(t *testing.T, writtenAt string) boxid.Record { |
| + return b.aws.record(t, status.Label(publishMachineID), b.doc, writtenAt) |
| } |
| // assertCreateOnly: no DELETE, no request off this box's key, and every |
| @@ -184,10 +255,15 @@ func assertCreateOnly(t *testing.T, reqs []s3Request) (puts int) { |
| return puts |
| } |
| +func inPlaceLine(box string) string { |
| + return "the record at control/boxes/" + box + ".json is valid AWS-signed evidence for " + box + |
| + " (instance i-0abc), so nothing was written. enrollment still makes its own checks before it accepts the box" |
| +} |
| + |
| func TestPublishRecordWritesAFreshBoxsRecord(t *testing.T) { |
| b := publishFixture(t) |
| box := status.Label(publishMachineID) |
| - line, err := runPublish(t) |
| + line, err := b.run(t) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -202,15 +278,15 @@ func TestPublishRecordWritesAFreshBoxsRecord(t *testing.T) { |
| if err := json.Unmarshal(raw, &rec); err != nil { |
| t.Fatal(err) |
| } |
| - want := map[string]any{"v": float64(1), "box": box, "identity_document": base64.StdEncoding.EncodeToString([]byte(publishDoc)), |
| - "identity_signature": publishSig, "written_at": "2026-10-06T09:00:00Z"} |
| - if len(rec) != len(want) { |
| - t.Fatalf("record has fields %v, want exactly %v", rec, want) |
| + want := b.thisBoxRecord(t, "2026-10-06T09:00:00Z") |
| + if len(rec) != 5 || rec["v"] != float64(1) || rec["box"] != box || rec["identity_document"] != want.IdentityDocument || |
| + rec["written_at"] != "2026-10-06T09:00:00Z" { |
| + t.Fatalf("record = %v", rec) |
| } |
| - for k, v := range want { |
| - if rec[k] != v { |
| - t.Errorf("record %s = %v, want %v", k, rec[k], v) |
| - } |
| + // The record the verb wrote is evidence enrollment accepts. |
| + certs, _ := b.aws.certs() |
| + if _, err := (boxid.Verifier{Account: publishAccount, Certs: certs}).Enroll(raw, boxid.Expect{Box: box, AWSRegion: publishRegion}); err != nil { |
| + t.Fatalf("the published record does not enroll: %v", err) |
| } |
| if puts := assertCreateOnly(t, b.requests()); puts != 1 { |
| t.Fatalf("%d PUTs, want one", puts) |
| @@ -219,7 +295,7 @@ func TestPublishRecordWritesAFreshBoxsRecord(t *testing.T) { |
| // R3: there is no region HA gate. Run after the loop has published (its |
| // publishBoxRecord, store, label and IMDS read, an hour earlier), the |
| -// verb finds exactly its own evidence and changes nothing. |
| +// verb keeps that record and changes nothing. |
| func TestPublishRecordAfterTheLoopIsANoOp(t *testing.T) { |
| b := publishFixture(t) |
| ctx := context.Background() |
| @@ -228,17 +304,17 @@ func TestPublishRecordAfterTheLoopIsANoOp(t *testing.T) { |
| t.Fatal(err) |
| } |
| box := metricsBoxLabel(os.Getenv, publishMachineID) |
| - loopRead := publishIMDSRead(recordNow.Add(-time.Hour)) |
| + loopRead := b.imdsRead(t, recordNow.Add(-time.Hour)) |
| if done, err := publishBoxRecord(ctx, store, box, func() (boxid.Record, error) { return loopRead(box) }); !done || err != nil { |
| t.Fatalf("the loop's publish: done=%v err=%v", done, err) |
| } |
| before, _ := b.model.Current(publishBucket, boxid.Key(box)) |
| seenBefore := len(b.requests()) |
| - line, err := runPublish(t) |
| + line, err := b.run(t) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if want := "the identity record for " + box + " (instance i-0abc) is already in place at control/boxes/" + box + ".json. nothing was written"; line != want { |
| + if want := inPlaceLine(box); line != want { |
| t.Fatalf("line = %q\nwant %q", line, want) |
| } |
| if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) { |
| @@ -249,42 +325,88 @@ func TestPublishRecordAfterTheLoopIsANoOp(t *testing.T) { |
| } |
| } |
| -// Astra P2 (1): a record already under the key counts as this box's only |
| -// with exactly its evidence. Anything else is refused, named, and left |
| +// Astra, round 2: an older record AWS signed for this same instance, in |
| +// this account and region, is evidence enrollment accepts, although its |
| +// document's mutable fields have changed since (a stop and start, a |
| +// resize). It is kept byte for byte, never replaced, and nothing is |
| +// written. |
| +func TestPublishRecordKeepsAnOlderValidRecord(t *testing.T) { |
| + b := publishFixture(t) |
| + box := status.Label(publishMachineID) |
| + older := identityDoc(publishAccount, publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.micro") |
| + if older == b.doc { |
| + t.Fatal("the two documents are the same") |
| + } |
| + before := mustJSON(t, b.aws.record(t, box, older, "2026-09-01T00:05:00Z")) |
| + b.model.Put(publishBucket, boxid.Key(box), before) |
| + line, err := b.run(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := inPlaceLine(box); line != want { |
| + t.Fatalf("line = %q\nwant %q", line, want) |
| + } |
| + if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) { |
| + t.Fatalf("the older record was replaced:\n%s\n%s", before, after) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()); puts != 0 { |
| + t.Fatalf("%d PUTs, want none", puts) |
| + } |
| +} |
| + |
| +// Anything enrollment's verifier refuses is refused, named, and left |
| // alone. |
| -func TestPublishRecordRefusesAnyOtherEvidence(t *testing.T) { |
| +func TestPublishRecordRefusesWhatEnrollmentWouldRefuse(t *testing.T) { |
| box := status.Label(publishMachineID) |
| - otherDoc := base64.StdEncoding.EncodeToString([]byte(strings.Replace(publishDoc, "\n}", ",\n \"pendingTime\" : \"2026-10-07T00:00:00Z\"\n}", 1))) |
| - zzzDoc := base64.StdEncoding.EncodeToString([]byte(strings.Replace(publishDoc, "i-0abc", "i-0zzz", 1))) |
| - prefix := "refused: the record already under " + box + " does not match what this box would publish: " |
| + prefix := "refused: the record already under " + box + " is not valid evidence for this box: " |
| + type held struct { |
| + box, doc string |
| + forger bool // signed by another key |
| + edit func(*boxid.Record) |
| + raw func([]byte) []byte |
| + } |
| cases := map[string]struct { |
| - edit func(*boxid.Record) |
| - raw func([]byte) []byte |
| + h held |
| want string |
| }{ |
| - "another instance": {edit: func(r *boxid.Record) { r.IdentityDocument = zzzDoc }, want: "it is signed for instance i-0zzz, and this box is i-0abc"}, |
| - "same instance, other doc": {edit: func(r *boxid.Record) { r.IdentityDocument = otherDoc }, want: "its identity document differs from the one AWS serves this box"}, |
| - "different signature": {edit: func(r *boxid.Record) { r.IdentitySignature = "c2ln" }, want: "its identity signature differs from the one AWS serves this box"}, |
| - "missing signature": {edit: func(r *boxid.Record) { r.IdentitySignature = "" }, want: "its identity signature differs from the one AWS serves this box"}, |
| - "wrong box": {edit: func(r *boxid.Record) { r.Box = "bert-sawing" }, want: `it is for box "bert-sawing"`}, |
| - "version 2": {edit: func(r *boxid.Record) { r.V = 2 }, want: "its version is 2, not 1"}, |
| - "unknown field": {raw: func(b []byte) []byte { return append(bytes.TrimSuffix(b, []byte("}")), []byte(`,"extra":1}`)...) }, want: "it cannot be read as an identity record"}, |
| - "trailing data": {raw: func(b []byte) []byte { return append(b, []byte(`{}`)...) }, want: "it cannot be read as an identity record"}, |
| - "not json": {raw: func([]byte) []byte { return []byte("not json") }, want: "it cannot be read as an identity record"}, |
| + "different account": {held{doc: identityDoc("999999999999", publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.small")}, "it is signed for account 999999999999, and this box is in account 111122223333"}, |
| + "different region": {held{doc: identityDoc(publishAccount, "eu-west-2", publishInstance, "2026-09-01T00:00:00Z", "t4g.small")}, "it is signed in region eu-west-2, and this box is in eu-west-1"}, |
| + "another instance": {held{doc: identityDoc(publishAccount, publishRegion, "i-0zzz", "2026-09-01T00:00:00Z", "t4g.small")}, "it is signed for instance i-0zzz, and this box is i-0abc"}, |
| + "bad signature": {held{forger: true}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"}, |
| + "missing signature": {held{edit: func(r *boxid.Record) { r.IdentitySignature = "" }}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"}, |
| + "altered document": {held{edit: func(r *boxid.Record) { |
| + r.IdentityDocument = base64.StdEncoding.EncodeToString([]byte(strings.Replace(identityDocFor(r), "t4g.small", "m7g.large", 1))) |
| + }}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"}, |
| + "different box": {held{box: "bert-sawing"}, `it is for box "bert-sawing"`}, |
| + "version 2": {held{edit: func(r *boxid.Record) { r.V = 2 }}, "its version is 2, not 1"}, |
| + "unknown field": {held{raw: func(b []byte) []byte { return append(bytes.TrimSuffix(b, []byte("}")), []byte(`,"extra":1}`)...) }}, "it cannot be read as an identity record"}, |
| + "trailing data": {held{raw: func(b []byte) []byte { return append(b, []byte(`{}`)...) }}, "it cannot be read as an identity record"}, |
| + "not json": {held{raw: func([]byte) []byte { return []byte("not json") }}, "it cannot be read as an identity record"}, |
| + "document not json": {held{edit: func(r *boxid.Record) { r.IdentityDocument = base64.StdEncoding.EncodeToString([]byte("nope")) }}, "it cannot be read as an identity record"}, |
| } |
| for name, tc := range cases { |
| t.Run(name, func(t *testing.T) { |
| b := publishFixture(t) |
| - held := thisBoxRecord(t, "2026-10-01T00:00:00Z") |
| - if tc.edit != nil { |
| - tc.edit(&held) |
| + signer, doc, hbox := b.aws, b.doc, box |
| + if tc.h.forger { |
| + signer = newTestAWS(t) |
| + } |
| + if tc.h.doc != "" { |
| + doc = tc.h.doc |
| + } |
| + if tc.h.box != "" { |
| + hbox = tc.h.box |
| + } |
| + rec := signer.record(t, hbox, doc, "2026-09-01T00:05:00Z") |
| + if tc.h.edit != nil { |
| + tc.h.edit(&rec) |
| } |
| - before := mustJSON(t, held) |
| - if tc.raw != nil { |
| - before = tc.raw(before) |
| + before := mustJSON(t, rec) |
| + if tc.h.raw != nil { |
| + before = tc.h.raw(before) |
| } |
| b.model.Put(publishBucket, boxid.Key(box), before) |
| - line, err := runPublish(t) |
| + line, err := b.run(t) |
| if want := prefix + tc.want + ". nothing was changed"; err == nil || err.Error() != want { |
| t.Fatalf("line=%q err=%v\nwant %q", line, err, want) |
| } |
| @@ -298,32 +420,38 @@ func TestPublishRecordRefusesAnyOtherEvidence(t *testing.T) { |
| } |
| } |
| +// identityDocFor is r's decoded identity document. |
| +func identityDocFor(r *boxid.Record) string { |
| + doc, _ := base64.StdEncoding.DecodeString(r.IdentityDocument) |
| + return string(doc) |
| +} |
| + |
| // A record that arrives after the verb's read and before |
| -// publishBoxRecord's is held to the same evidence: publishBoxRecord alone |
| +// publishBoxRecord's is held to the same verifier: publishBoxRecord alone |
| // would take any record naming this instance. |
| -func TestPublishRecordHoldsALateRecordToTheSameEvidence(t *testing.T) { |
| +func TestPublishRecordHoldsALateRecordToTheSameVerifier(t *testing.T) { |
| box := status.Label(publishMachineID) |
| - same := thisBoxRecord(t, "2026-10-01T00:00:00Z") |
| - forged := same |
| - forged.IdentitySignature = "c2ln" |
| for name, tc := range map[string]struct { |
| - held boxid.Record |
| - want string |
| - fail bool |
| + forged bool |
| + want string |
| }{ |
| - "same evidence": {same, "the identity record for " + box + " (instance i-0abc) is already in place at control/boxes/" + box + ".json. nothing was written", false}, |
| - "same instance, other signature": {forged, "refused: the record already under " + box + " does not match what this box would publish: its identity signature differs from the one AWS serves this box. nothing was changed", true}, |
| + "valid, older": {false, inPlaceLine(box)}, |
| + "same instance, forged signature": {true, "refused: the record already under " + box + " is not valid evidence for this box: it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error. nothing was changed"}, |
| } { |
| t.Run(name, func(t *testing.T) { |
| b := publishFixture(t) |
| - late := mustJSON(t, tc.held) |
| + signer := b.aws |
| + if tc.forged { |
| + signer = newTestAWS(t) |
| + } |
| + late := mustJSON(t, signer.record(t, box, identityDoc(publishAccount, publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.micro"), "2026-09-01T00:05:00Z")) |
| b.afterGet = func() { b.model.Put(publishBucket, boxid.Key(box), late) } |
| - line, err := runPublish(t) |
| + line, err := b.run(t) |
| got := line |
| if err != nil { |
| got = err.Error() |
| } |
| - if got != tc.want || (err != nil) != tc.fail { |
| + if got != tc.want || (err != nil) != tc.forged { |
| t.Fatalf("line=%q err=%v\nwant %q", line, err, tc.want) |
| } |
| if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(late) { |
| @@ -342,12 +470,10 @@ func TestPublishRecordHoldsALateRecordToTheSameEvidence(t *testing.T) { |
| func TestPublishRecordLosesARaceWithoutOverwriting(t *testing.T) { |
| b := publishFixture(t) |
| box := status.Label(publishMachineID) |
| - held := thisBoxRecord(t, "2026-10-01T00:00:00Z") |
| - held.IdentityDocument = base64.StdEncoding.EncodeToString([]byte(strings.Replace(publishDoc, "i-0abc", "i-0zzz", 1))) |
| - racer := mustJSON(t, held) |
| + racer := mustJSON(t, b.aws.record(t, box, identityDoc(publishAccount, publishRegion, "i-0zzz", "2026-10-06T08:00:00Z", "t4g.small"), "2026-10-01T00:00:00Z")) |
| b.beforePut = func() { b.model.Put(publishBucket, boxid.Key(box), racer) } |
| want := "refused: another record already holds " + box + ": boxid: " + box + " already has a different record: store: compare-and-swap conflict. nothing was changed" |
| - if line, err := runPublish(t); err == nil || err.Error() != want { |
| + if line, err := b.run(t); err == nil || err.Error() != want { |
| t.Fatalf("line=%q err=%v\nwant %q", line, err, want) |
| } |
| if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(racer) { |
| @@ -358,22 +484,24 @@ func TestPublishRecordLosesARaceWithoutOverwriting(t *testing.T) { |
| } |
| } |
| -// Astra P3: S3 commits the create but its answer is lost (500). The SDK |
| -// retries, the retry is answered 412, and boxid.Publish finds exactly |
| -// this run's bytes. That is this run's record: published, not already in |
| -// place, although the store never saw a successful Put. |
| +// S3 commits the create but its answer is lost (500). The SDK retries, |
| +// the retry is answered 412, and boxid.Publish finds exactly this run's |
| +// bytes. That is this run's record: published, although the store never |
| +// saw a successful Put. |
| func TestPublishRecordReportsARetriedCreateAsPublished(t *testing.T) { |
| b := publishFixture(t) |
| box := status.Label(publishMachineID) |
| b.commitThenFail = []int{http.StatusInternalServerError} |
| - line, err := runPublish(t) |
| + line, err := b.run(t) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if want := "published the identity record for " + box + " (instance i-0abc) to control/boxes/" + box + ".json"; line != want { |
| t.Fatalf("line = %q\nwant %q", line, want) |
| } |
| - if got, _ := b.model.Current(publishBucket, boxid.Key(box)); string(got) != string(mustJSON(t, thisBoxRecord(t, "2026-10-06T09:00:00Z"))) { |
| + got, _ := b.model.Current(publishBucket, boxid.Key(box)) |
| + var rec boxid.Record |
| + if json.Unmarshal(got, &rec) != nil || rec.WrittenAt != "2026-10-06T09:00:00Z" || rec.IdentityDocument != base64.StdEncoding.EncodeToString([]byte(b.doc)) { |
| t.Fatalf("stored %s", got) |
| } |
| if puts := assertCreateOnly(t, b.requests()); puts != 2 { |
| @@ -381,13 +509,13 @@ func TestPublishRecordReportsARetriedCreateAsPublished(t *testing.T) { |
| } |
| } |
| -// Astra P3: once a create has reached the bucket, a failure does not |
| -// claim nothing was written. Here S3 committed it and answered 400. |
| +// Once a create has reached the bucket, a failure does not claim nothing |
| +// was written. Here S3 committed it and answered 400. |
| func TestPublishRecordSaysAnUncertainWriteMayHaveHappened(t *testing.T) { |
| b := publishFixture(t) |
| box := status.Label(publishMachineID) |
| b.commitThenFail = []int{http.StatusBadRequest} |
| - _, err := runPublish(t) |
| + _, err := b.run(t) |
| if err == nil || !strings.HasPrefix(err.Error(), "the identity record for "+box+" may or may not have been written: ") || |
| !strings.HasSuffix(err.Error(), ". it is safe to run again, which only ever creates the record and says what is there") { |
| t.Fatalf("err = %v", err) |
| @@ -395,30 +523,47 @@ func TestPublishRecordSaysAnUncertainWriteMayHaveHappened(t *testing.T) { |
| if _, ok := b.model.Current(publishBucket, boxid.Key(box)); !ok { |
| t.Fatal("the fake did not commit the create") |
| } |
| - // The second run finds this box's evidence and says so. |
| - if line, err := runPublish(t); err != nil || !strings.Contains(line, "is already in place") { |
| + // The second run finds valid evidence and says so. |
| + if line, err := b.run(t); err != nil || line != inPlaceLine(box) { |
| t.Fatalf("second run: line=%q err=%v", line, err) |
| } |
| } |
| -// A failure before any create reached the bucket says nothing was written. |
| -func TestPublishRecordReportsAReadFailureAsRetryable(t *testing.T) { |
| - b := publishFixture(t) |
| +// Failures before any create reached the bucket say nothing was written. |
| +func TestPublishRecordReportsFailuresBeforeAnyWriteAsRetryable(t *testing.T) { |
| box := status.Label(publishMachineID) |
| - run := publishRun() |
| - run.read = func(string) (boxid.Record, error) { return boxid.Record{}, errors.New("IMDS down") } |
| - _, err := run.publish(context.Background()) |
| - want := "the identity record for " + box + " was not published: IMDS down. nothing was written, so it is safe to run again" |
| - if err == nil || err.Error() != want { |
| - t.Fatalf("err = %v\nwant %q", err, want) |
| - } |
| - if puts := assertCreateOnly(t, b.requests()); puts != 0 { |
| - t.Fatalf("%d PUTs, want none", puts) |
| + for name, tc := range map[string]struct { |
| + imdsDown bool |
| + doc string // the document IMDS serves, when not the usual one |
| + reason string |
| + }{ |
| + "IMDS down": {imdsDown: true, reason: "IMDS down"}, |
| + "own identity incomplete": {doc: "{\"accountId\" : \"111122223333\", \"region\" : \"eu-west-1\"}\n", reason: "the identity document AWS served this box names no account, region or instance"}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + b := publishFixture(t) |
| + if tc.doc != "" { |
| + b.doc = tc.doc |
| + } |
| + run := b.publishRun(t) |
| + if tc.imdsDown { |
| + run.read = func(string) (boxid.Record, error) { return boxid.Record{}, errors.New("IMDS down") } |
| + } |
| + _, err := run.publish(context.Background()) |
| + want := "the identity record for " + box + " was not published: " + tc.reason + ". nothing was written, so it is safe to run again" |
| + if err == nil || err.Error() != want { |
| + t.Fatalf("err = %v\nwant %q", err, want) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()); puts != 0 { |
| + t.Fatalf("%d PUTs, want none", puts) |
| + } |
| + }) |
| } |
| } |
| // refusedEarly runs the verb with every later seam tripwired: it must |
| -// refuse before the machine id, the store or IMDS is touched. |
| +// refuse before the machine id, the store, IMDS or the certificates are |
| +// touched. |
| func refusedEarly(t *testing.T, b *boxRecordsBucket, run boxPublishRun, want string) { |
| t.Helper() |
| touched := []string{} |
| @@ -428,6 +573,7 @@ func refusedEarly(t *testing.T, b *boxRecordsBucket, run boxPublishRun, want str |
| return nil, errors.New("opened") |
| } |
| run.read = func(string) (boxid.Record, error) { touched = append(touched, "IMDS"); return boxid.Record{}, nil } |
| + run.certs = func() (map[string]*x509.Certificate, error) { touched = append(touched, "certs"); return nil, nil } |
| _, err := run.publish(context.Background()) |
| if err == nil || err.Error() != want { |
| t.Fatalf("err = %v\nwant %q", err, want) |
| @@ -440,7 +586,7 @@ func refusedEarly(t *testing.T, b *boxRecordsBucket, run boxPublishRun, want str |
| // Census ruling R1: root is refused before any store, IMDS or host read. |
| func TestPublishRecordRefusesRoot(t *testing.T) { |
| b := publishFixture(t) |
| - run := publishRun() |
| + run := b.publishRun(t) |
| run.geteuid = func() int { return 0 } |
| refusedEarly(t, b, run, "carlos system box publish-record runs as the agent user, not root. run it with systemd-run -p User=carlos.") |
| } |
| @@ -449,24 +595,24 @@ func TestPublishRecordRefusesAFleetBox(t *testing.T) { |
| b := publishFixture(t) |
| t.Setenv("CARLOS_FLEET", "acme") |
| t.Setenv("CARLOS_FLEET_BOX", "kass-1") |
| - refusedEarly(t, b, publishRun(), boxRecordFleetRefusal) |
| + refusedEarly(t, b, b.publishRun(t), boxRecordFleetRefusal) |
| } |
| -// Astra P2 (2): a deployment directory cannot refuse an overwrite |
| -// (DirStore.Put checks, then renames), so only an S3 bucket will do. |
| +// A deployment directory cannot refuse an overwrite (DirStore.Put checks, |
| +// then renames), so only an S3 bucket will do. |
| func TestPublishRecordRequiresAnS3Bucket(t *testing.T) { |
| b := publishFixture(t) |
| dir := t.TempDir() |
| t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) |
| - refusedEarly(t, b, publishRun(), boxRecordNoBucket) |
| + refusedEarly(t, b, b.publishRun(t), boxRecordNoBucket) |
| if entries, _ := os.ReadDir(dir); len(entries) != 0 { |
| t.Fatalf("the deployment directory was written: %v", entries) |
| } |
| // Belt and braces: a store that is not S3 is refused even with a |
| // bucket named. |
| t.Setenv("CARLOS_DEPLOYMENT_BUCKET", publishBucket) |
| - run := publishRun() |
| + run := b.publishRun(t) |
| run.open = func(context.Context, func(string) string) (release.Store, error) { |
| return release.NewDirStore(dir), nil |
| } |
| @@ -478,17 +624,17 @@ func TestPublishRecordRequiresAnS3Bucket(t *testing.T) { |
| } |
| } |
| -// Astra coverage: a box its region's group launched publishes under its |
| -// group reference, the label boxRecordLoop takes (metricsBoxLabel). |
| +// A box its region's group launched publishes under its group reference, |
| +// the label boxRecordLoop takes (metricsBoxLabel). |
| func TestPublishRecordUsesTheGroupReference(t *testing.T) { |
| b := publishFixture(t) |
| t.Setenv("CARLOS_BOX_REFERENCE", "group") |
| - withGroupIdentity(t, "111122223333", "i-0abc", nil) |
| - ref := hostactors.GroupBoxReference("111122223333", "i-0abc") |
| + withGroupIdentity(t, publishAccount, publishInstance, nil) |
| + ref := hostactors.GroupBoxReference(publishAccount, publishInstance) |
| if loop := metricsBoxLabel(os.Getenv, publishMachineID); loop != ref { |
| t.Fatalf("the loop's label is %q, want the group reference %q", loop, ref) |
| } |
| - line, err := runPublish(t) |
| + line, err := b.run(t) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -505,6 +651,15 @@ func TestPublishRecordUsesTheGroupReference(t *testing.T) { |
| } |
| } |
| +// The verb's certificates are enrollment's: boxid.EmbeddedCerts covers |
| +// the dev region, so a real box's record can verify. |
| +func TestPublishRecordProductionCertsCoverTheDevRegion(t *testing.T) { |
| + certs, err := boxid.EmbeddedCerts() |
| + if err != nil || certs[publishRegion] == nil { |
| + t.Fatalf("embedded certs: %v (eu-west-1 present: %v)", err, certs[publishRegion] != nil) |
| + } |
| +} |
| + |
| // Census ruling R4: the store publish-record hands publishBoxRecord sends |
| // the bucket nothing for a delete, a list, an overwrite or any other key. |
| func TestBoxRecordStoreOnlyReadsAndCreatesItsOwnKey(t *testing.T) { |
diff --git a/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv b/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv| index af5c6d515..1b23d566f 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv |
| @@ -1,7 +1,7 @@ |
| # Plan 14 census overlay box-publish-record (carlos system box publish-record, approved by Paul; owner platform-1e as the host claim's owner), loaded last, after region-ha-2b. RULED by platform-1e as census-ruling-box-publish-record (R1-R4); the ruling and its reasons are docs/superpowers/notes/census-proposal-box-publish-record.md. |
| -# root unreachable: verb refuses euid 0 (R1). boxPublishRun.publish's first check is the effective uid, before any store, IMDS or host read (the machine id included), so root never reaches publishBoxRecord through this verb. The one supported way to run it is systemd-run --wait --pipe -p EnvironmentFile=/etc/carlos/host.env -p User=carlos /opt/carlos/carlos system box publish-record (R2). It has no region HA gate, and run after the loop has published it finds exactly that evidence and writes nothing (R3). boxRecordStore is the only store it hands publishBoxRecord, and it sends the bucket nothing for a delete, a list, an overwrite or any other key (R4). |
| -# internal/host boxpublish.go: one publishBoxRecord attempt, without boxRecordLoop's CARLOS_REGION_HA gate. It refuses a fleet box (shouldSkipPublisherElection) and any box without an S3 deployment bucket (CARLOS_DEPLOYMENT_BUCKET unset, or a store that is not release.S3Store: a deployment directory's check-then-rename cannot refuse an overwrite). It reads the record already under control/boxes/<box>.json through boxRecordStore and accepts it only with exactly this box's identity document and signature (sameEvidence; only written_at may differ); with nothing there it calls publishBoxRecord through boxRecordStore, which admits only Get and create-only Put (If-None-Match: *) on that key. Its reads are existing units, unchanged: metricsBoxLabel and readMachineID for the label, openDeployStoreEnv for the store, readIdentityRecord and imdsGetRaw (accepted-legacy) for IMDS, publishBoxRecord and boxid.Load for the record. The scan finds no read site in the new units, so no root-read unit is added or changed. boxrecord.go is not edited, and boxRecordLoop's gate is pinned by TestBoxRecordLoopGateIsUnchanged. |
| +# root unreachable: verb refuses euid 0 (R1). boxPublishRun.publish's first check is the effective uid, before any store, IMDS or host read (the machine id included), so root never reaches publishBoxRecord through this verb. The one supported way to run it is systemd-run --wait --pipe -p EnvironmentFile=/etc/carlos/host.env -p User=carlos /opt/carlos/carlos system box publish-record (R2). It has no region HA gate, and run after the loop has published it keeps that record and writes nothing (R3). boxRecordStore is the only store it hands publishBoxRecord, and it sends the bucket nothing for a delete, a list, an overwrite or any other key (R4). |
| +# internal/host boxpublish.go: one publishBoxRecord attempt, without boxRecordLoop's CARLOS_REGION_HA gate. It refuses a fleet box (shouldSkipPublisherElection) and any box without an S3 deployment bucket (CARLOS_DEPLOYMENT_BUCKET unset, or a store that is not release.S3Store: a deployment directory's check-then-rename cannot refuse an overwrite). It reads the record already under control/boxes/<box>.json through boxRecordStore and keeps it, bytes untouched, only when enrollment's own verifier accepts it (heldEvidence: boxid.Verifier.Enroll with the account, AWS region and instance id, as TofuInstanceID, from this box's current IMDS identity, and boxid.EmbeddedCerts, the certificates enrollment uses; no freshness check, so an older document whose pendingTime or instanceType has since changed is kept); with nothing there it calls publishBoxRecord through boxRecordStore, which admits only Get and create-only Put (If-None-Match: *) on that key. Its reads are existing units, unchanged: metricsBoxLabel and readMachineID for the label, openDeployStoreEnv for the store, readIdentityRecord and imdsGetRaw (accepted-legacy) for IMDS, publishBoxRecord and boxid.Load for the record, and boxid.EmbeddedCerts (accepted-legacy, J4, root-input; its reads are the binary's embedded files, and internal/host already imported it through claimproduction.go, so the import graph gains no package) for the certificates. Verifier.Enroll has no read site. The scan finds no read site in the new units, so no root-read unit is added or changed. boxrecord.go is not edited, and boxRecordLoop's gate is pinned by TestBoxRecordLoopGateIsUnchanged. |
| # Root package (system_roles.go, system.go): runSystemBoxVerb hands `box publish-record` to host.RunSystemBoxPublishRecord before roleArgs; systemUsage gains its synopsis and description lines. No root read is added. |
| # Envelope-only: the internal/host and root package envelopes are re-pinned; the legacy seal stands at fc584e11. The record-write census (9B8) gains internal/host/boxpublish.go:boxRecordStore.Put, nonrecord under R4 (box_publish_record_census_test.go). |
| @coverage . d123dce752a02f9db9b87f591d8efab5c37148caff628f596968b2df3b875402 |
| -@coverage internal/host ef673c8ceb936a4164829bf41c65b77c6247eed92b28f1888305313a6bd560ac |
| +@coverage internal/host b1fb4b8431cb790bd17f8d8016e5d9b34fa93527e1fc0c464130401780137ab3 |