moves: the HMAC launch report and T3's comparison; activator: LaunchEnvironment (§7.3)
ReportKeyFromSecrets rejects malformed base64 and any key that is not 32 bytes. LaunchEnvironment composes the pre-spawn environment and reports names the instance's own .env shadows; instanceEnv is unchanged (now over instanceEnvParts).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4 files changed,
+439
−5
internal/activator/backend_exec.go+60 −5internal/activator/backend_exec_test.go+55 −0internal/moves/envreport.go+173 −0internal/moves/envreport_test.go+151 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 60fbaa181..d1f8008b7 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -1162,6 +1162,18 @@ func (b *ExecBackend) nestedGate(rt registry.Route) error { |
| // instance's CARLOS_ADMIN_TOKEN into <host>.env first, so "the same |
| // environment" includes the same secret.) |
| func (b *ExecBackend) instanceEnv(rt registry.Route) (map[string]string, error) { |
| + cfgEnv, ownEnv, err := b.instanceEnvParts(rt) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return mergeEnv(cfgEnv, ownEnv), nil |
| +} |
| + |
| +// instanceEnvParts is instanceEnv's two reads, before they are merged: |
| +// the sync-owned config layer and the instance's own file. Extracted so |
| +// LaunchEnvironment can see which names the own file shadows; instanceEnv |
| +// is exactly mergeEnv over these two, as it was. |
| +func (b *ExecBackend) instanceEnvParts(rt registry.Route) (cfgEnv, ownEnv map[string]string, err error) { |
| paths := b.pathsFor(rt) |
| // A nested host's files live under a tenant-writable directory (design |
| // §3.2): read them without following a symlink. A flat host's files sit |
| @@ -1172,15 +1184,58 @@ func (b *ExecBackend) instanceEnv(rt registry.Route) (map[string]string, error) |
| return readEnvFileBeneath(filepath.Dir(path), filepath.Base(path)) |
| } |
| } |
| - cfgEnv, err := read(paths.configEnv) |
| + cfgEnv, err = read(paths.configEnv) |
| if err != nil && !os.IsNotExist(err) { |
| - return nil, fmt.Errorf("read config env file: %w", err) |
| + return nil, nil, fmt.Errorf("read config env file: %w", err) |
| } |
| - ownEnv, err := read(paths.envFile) |
| + ownEnv, err = read(paths.envFile) |
| if err != nil && !os.IsNotExist(err) { |
| - return nil, fmt.Errorf("read env file: %w", err) |
| + return nil, nil, fmt.Errorf("read env file: %w", err) |
| } |
| - return mergeEnv(cfgEnv, ownEnv), nil |
| + return cfgEnv, ownEnv, nil |
| +} |
| + |
| +// LaunchEnvironment is the composed environment the next Start of rt |
| +// would spawn with: childEnv over agentOwnedVars over instanceEnv, the |
| +// exact composition Start uses (agentOwnedVars(rt, fileEnv, haveControl)), |
| +// decoded back to a map. agentOwnedVars REMOVES tenant-provided |
| +// CARLOS_CONTROL_SOCKET, CARLOS_PURGE and CARLOS_REGION when the agent |
| +// does not provide them, so a report over mergeEnv alone would misstate |
| +// the environment. haveControl is true: startChild calls |
| +// edge.ListenControl for EVERY exec child, and b.Control is the nested |
| +// root-control client, which says nothing about the flat channel, so |
| +// before the spawn the honest composition is "the agent will bind it"; a |
| +// bind that fails at spawn is logged there and the probe's spawn-time |
| +// check compares the actual outcome. CARLOS_PURGE follows b.Cache != nil |
| +// exactly as agentOwnedVars does. |
| +// |
| +// shadowed is the sorted names present in both the rendered configuration |
| +// file and the instance's own <host>.env: the override mergeEnv applies |
| +// (moves spec §7.3 T3 reports it), collected here because this is the only |
| +// layer that sees both files. The move's launch report (§7.3, codex R2-8) |
| +// is computed over exactly this env, so a target compares what would run, |
| +// not a reconstruction of it. The values are raw: the caller HMACs them |
| +// and must never log or return them. |
| +func (b *ExecBackend) LaunchEnvironment(rt registry.Route) (map[string]string, []string, error) { |
| + cfgEnv, ownEnv, err := b.instanceEnvParts(rt) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + var shadowed []string |
| + for k := range ownEnv { |
| + if _, rendered := cfgEnv[k]; rendered { |
| + shadowed = append(shadowed, k) |
| + } |
| + } |
| + sort.Strings(shadowed) |
| + fileEnv := mergeEnv(cfgEnv, ownEnv) |
| + out := map[string]string{} |
| + for _, kv := range childEnv(b.agentOwnedVars(rt, fileEnv, true)) { |
| + if i := strings.IndexByte(kv, '='); i > 0 { |
| + out[kv[:i]] = kv[i+1:] |
| + } |
| + } |
| + return out, shadowed, nil |
| } |
| // childEnv is the child's complete environment: the agent's own, minus |
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go| index 8b4c41ceb..a3602a38c 100644 |
| --- a/internal/activator/backend_exec_test.go |
| +++ b/internal/activator/backend_exec_test.go |
| @@ -13,6 +13,7 @@ import ( |
| _ "modernc.org/sqlite" |
| "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -315,3 +316,57 @@ func writeMinimalSQLite(dbPath string) error { |
| } |
| return db.Close() |
| } |
| + |
| +func TestLaunchEnvironmentComposesTheSpawnEnvironment(t *testing.T) { |
| + b := &ExecBackend{DataDir: t.TempDir(), Region: "ie"} // Cache nil: no purge channel on this box |
| + rt := registry.Route{Host: "a.go.example.com", Socket: filepath.Join(b.DataDir, "a.sock")} |
| + if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("MINE=1\nCARLOS_ADMIN_TOKEN=t\nCARLOS_PURGE=header\nCARLOS_REGION=fake\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_SECRETS_KEY", "never") |
| + t.Setenv("UNRELATED", "yes") |
| + // The rendered configuration file (the path instanceEnv reads first) |
| + // renders MINE=0; the instance's own .env overrides it, so MINE is |
| + // reported as shadowed (spec §7.3 T3). |
| + if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte("MINE=0\nFROM_CONFIG=c\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + env, shadowed, err := b.LaunchEnvironment(rt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(shadowed) != 1 || shadowed[0] != "MINE" || env["FROM_CONFIG"] != "c" { |
| + t.Fatalf("shadowed %v (env values withheld: the agent's own environment is in it)", shadowed) |
| + } |
| + if env["MINE"] != "1" || env["UNRELATED"] != "yes" || env["CARLOS_SECRETS_KEY"] != "" || env["CARLOS_ADMIN_TOKEN"] != "t" { |
| + t.Fatalf("launch env: MINE=%q SECRETS_KEY set=%v UNRELATED=%q", env["MINE"], env["CARLOS_SECRETS_KEY"] != "", env["UNRELATED"]) |
| + } |
| + // The agent-owned layer, exactly as agentOwnedVars composes it: the |
| + // tenant's region and purge values are overridden or removed. |
| + if env["CARLOS_REGION"] != "ie" || env["CARLOS_CONTROL_SOCKET"] != rt.Socket+".control" { |
| + t.Fatalf("agent-owned vars: region %q control %q", env["CARLOS_REGION"], env["CARLOS_CONTROL_SOCKET"]) |
| + } |
| + if _, ok := env["CARLOS_PURGE"]; ok { |
| + t.Fatal("CARLOS_PURGE survived on a box without a cache") |
| + } |
| + b.Cache = edge.NewCache(16, 1<<20) |
| + if env, _, _ = b.LaunchEnvironment(rt); env["CARLOS_PURGE"] != "header" { |
| + t.Fatalf("CARLOS_PURGE on a box with a cache: %q", env["CARLOS_PURGE"]) |
| + } |
| + // A box with no region strips the tenant's value rather than keeping it. |
| + b.Region = "" |
| + if env, _, _ = b.LaunchEnvironment(rt); env["CARLOS_REGION"] != "" { |
| + t.Fatalf("CARLOS_REGION survived on a box with no region: %q", env["CARLOS_REGION"]) |
| + } |
| +} |
| + |
| +// With neither file present the composition is the agent-owned layer over |
| +// the agent's own environment, no error and nothing shadowed. |
| +func TestLaunchEnvironmentNoFiles(t *testing.T) { |
| + b := &ExecBackend{DataDir: t.TempDir(), Region: "ie"} |
| + rt := registry.Route{Host: "b.go.example.com", Socket: filepath.Join(b.DataDir, "b.sock")} |
| + env, shadowed, err := b.LaunchEnvironment(rt) |
| + if err != nil || len(shadowed) != 0 || env["CARLOS_REGION"] != "ie" { |
| + t.Fatalf("region %q shadowed %v err %v", env["CARLOS_REGION"], shadowed, err) |
| + } |
| +} |
diff --git a/internal/moves/envreport.go b/internal/moves/envreport.go| new file mode 100644 |
| index 000000000..6837fb41c |
| --- /dev/null |
| +++ b/internal/moves/envreport.go |
| @@ -0,0 +1,173 @@ |
| +package moves |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "sort" |
| + "strings" |
| + |
| + "golang.org/x/crypto/hkdf" |
| +) |
| + |
| +const RegionVar = "CARLOS_REGION" |
| + |
| +// secretsKeyLen is the length of CARLOS_SECRETS_KEY's raw bytes: a P-256 |
| +// private scalar (internal/appconfig GenerateKey). |
| +const secretsKeyLen = 32 |
| + |
| +var ( |
| + // LayoutVars name a channel the flat layout always provides: present |
| + // on both sides of a flat move, values not compared (they are paths |
| + // on each box). |
| + LayoutVars = map[string]bool{"CARLOS_CONTROL_SOCKET": true} |
| + // AdvisoryVars follow a box fact (CARLOS_PURGE is set iff the box has |
| + // a response cache, ExecBackend.agentOwnedVars): a presence difference |
| + // is reported as a note, never a failure (round-4 finding 10). The |
| + // spec's T3 table puts CARLOS_PURGE in the layout class; the plan |
| + // deliberately makes it advisory, and the spec ledger records that. |
| + AdvisoryVars = map[string]bool{"CARLOS_PURGE": true} |
| + // BoxVars are the per-process variables of a box's own shell or |
| + // systemd unit, never compared. systemd may inject others |
| + // (SYSTEMD_EXEC_PID, MEMORY_PRESSURE_WATCH); the rehearsal checks the |
| + // real box. |
| + BoxVars = map[string]bool{"INVOCATION_ID": true, "JOURNAL_STREAM": true, "HOSTNAME": true, "PWD": true, "OLDPWD": true, "SHLVL": true, "_": true} |
| +) |
| + |
| +// ReportKeyFromSecrets derives the report's HMAC key from |
| +// CARLOS_SECRETS_KEY (HKDF-SHA256), so the report reveals names and |
| +// equality only and only to boxes holding the deployment's key (§7.3). |
| +// The input must be the base64 of exactly 32 raw bytes; anything else is |
| +// an error, and the error never carries the input. |
| +func ReportKeyFromSecrets(secretsKeyB64 string) ([]byte, error) { |
| + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(secretsKeyB64)) |
| + if err != nil { |
| + return nil, fmt.Errorf("moves: secrets key: %w", err) |
| + } |
| + if len(raw) != secretsKeyLen { |
| + return nil, errors.New("moves: secrets key: not a 32-byte key") |
| + } |
| + key := make([]byte, 32) |
| + if _, err := io.ReadFull(hkdf.New(sha256.New, raw, nil, []byte("carlos-move-env-report-v1")), key); err != nil { |
| + return nil, err |
| + } |
| + return key, nil |
| +} |
| + |
| +type EnvReport struct { |
| + V int `json:"v"` |
| + Vars map[string]string `json:"vars"` |
| + Digest string `json:"digest"` |
| +} |
| + |
| +// ReportEnv HMACs every value (bound to its name); Digest identifies the |
| +// whole report. Raw values never appear in the result. |
| +func ReportEnv(key []byte, env map[string]string) EnvReport { |
| + r := EnvReport{V: 1, Vars: make(map[string]string, len(env))} |
| + names := make([]string, 0, len(env)) |
| + for k, v := range env { |
| + mac := hmac.New(sha256.New, key) |
| + mac.Write([]byte(k)) |
| + mac.Write([]byte{0}) |
| + mac.Write([]byte(v)) |
| + r.Vars[k] = hex.EncodeToString(mac.Sum(nil)) |
| + names = append(names, k) |
| + } |
| + sort.Strings(names) |
| + h := sha256.New() |
| + for _, k := range names { |
| + fmt.Fprintf(h, "%s=%s\n", k, r.Vars[k]) |
| + } |
| + r.Digest = "sha256:" + hex.EncodeToString(h.Sum(nil)) |
| + return r |
| +} |
| + |
| +// Class is T3's variable class. |
| +func Class(name string) string { |
| + switch { |
| + case name == RegionVar: |
| + return "region" |
| + case LayoutVars[name]: |
| + return "layout" |
| + case AdvisoryVars[name]: |
| + return "advisory" |
| + case BoxVars[name]: |
| + return "box" |
| + default: |
| + return "equal" |
| + } |
| +} |
| + |
| +type EnvDiff struct { |
| + Name, Kind string |
| +} |
| + |
| +// Note reports a diff T3 records without failing. |
| +func (d EnvDiff) Note() bool { return d.Kind == "advisory-differs" } |
| + |
| +// CompareEnv is T3: region must differ, layout variables must be present |
| +// iff the flat layout provides them (value not compared), advisory |
| +// variables report a presence difference as a note (EnvDiff.Note), box |
| +// variables are not compared, everything else must be equal by HMAC. T3 |
| +// fails on any diff that is not a note. |
| +func CompareEnv(source, target EnvReport, flatLayout bool) []EnvDiff { |
| + var diffs []EnvDiff |
| + seen := map[string]bool{} |
| + for name, s := range source.Vars { |
| + seen[name] = true |
| + tv, ok := target.Vars[name] |
| + switch Class(name) { |
| + case "region": |
| + if !ok { |
| + diffs = append(diffs, EnvDiff{name, "region-missing"}) // the target must set its own region |
| + } else if tv == s { |
| + diffs = append(diffs, EnvDiff{name, "region-same"}) |
| + } |
| + case "layout": |
| + if flatLayout && !ok { |
| + diffs = append(diffs, EnvDiff{name, "layout-missing"}) |
| + } |
| + if !flatLayout && ok { |
| + diffs = append(diffs, EnvDiff{name, "layout-present"}) |
| + } |
| + case "advisory": |
| + if !ok { |
| + diffs = append(diffs, EnvDiff{name, "advisory-differs"}) |
| + } |
| + case "box": |
| + default: |
| + if !ok { |
| + diffs = append(diffs, EnvDiff{name, "missing"}) |
| + } else if tv != s { |
| + diffs = append(diffs, EnvDiff{name, "differs"}) |
| + } |
| + } |
| + } |
| + for name := range target.Vars { |
| + if seen[name] { |
| + continue |
| + } |
| + switch Class(name) { |
| + case "equal": |
| + diffs = append(diffs, EnvDiff{name, "extra"}) |
| + case "advisory": |
| + diffs = append(diffs, EnvDiff{name, "advisory-differs"}) |
| + } |
| + } |
| + // layout names absent from BOTH reports are still checked: a flat |
| + // landing must provide every layout variable the flat layout provides |
| + for name := range LayoutVars { |
| + if _, inSrc := source.Vars[name]; inSrc { |
| + continue // handled above |
| + } |
| + if _, inTgt := target.Vars[name]; flatLayout && !inTgt { |
| + diffs = append(diffs, EnvDiff{name, "layout-missing"}) |
| + } |
| + } |
| + sort.Slice(diffs, func(i, j int) bool { return diffs[i].Name < diffs[j].Name }) |
| + return diffs |
| +} |
diff --git a/internal/moves/envreport_test.go b/internal/moves/envreport_test.go| new file mode 100644 |
| index 000000000..bf6ffd09a |
| --- /dev/null |
| +++ b/internal/moves/envreport_test.go |
| @@ -0,0 +1,151 @@ |
| +package moves |
| + |
| +import ( |
| + "encoding/base64" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestEnvReportRevealsNamesAndEqualityOnly(t *testing.T) { |
| + key := []byte("0123456789abcdef0123456789abcdef") |
| + src := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "ie", "CARLOS_CONTROL_SOCKET": "/run/x", "INVOCATION_ID": "1", "SECRET": "s3"}) |
| + if src.Vars["SECRET"] == "s3" || len(src.Vars["SECRET"]) != 64 { |
| + t.Fatal("a value leaked or the mac is not hex sha256") |
| + } |
| + same := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "uk", "CARLOS_CONTROL_SOCKET": "/run/y", "INVOCATION_ID": "2", "SECRET": "s3"}) |
| + if diffs := CompareEnv(src, same, true); len(diffs) != 0 { |
| + t.Fatalf("a matching target reported %v", diffs) |
| + } |
| + // Region must differ; layout vars are present iff flat provides them; |
| + // box vars are never compared; everything else equal. |
| + sameRegion := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "ie", "CARLOS_CONTROL_SOCKET": "/run/y", "SECRET": "s3"}) |
| + diffs := CompareEnv(src, sameRegion, true) |
| + if len(diffs) != 1 || diffs[0].Name != "CARLOS_REGION" || diffs[0].Kind != "region-same" { |
| + t.Fatalf("same region: %v", diffs) |
| + } |
| + noRegion := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_CONTROL_SOCKET": "/run/y", "SECRET": "s3"}) |
| + if diffs := CompareEnv(src, noRegion, true); len(diffs) != 1 || diffs[0].Kind != "region-missing" { |
| + t.Fatalf("missing region: %v", diffs) |
| + } |
| + // the layout variable absent from both: still required of a flat landing |
| + srcNoLayout := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "ie"}) |
| + if diffs := CompareEnv(srcNoLayout, ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "uk"}), true); len(diffs) != 1 || diffs[0].Name != "CARLOS_CONTROL_SOCKET" || diffs[0].Kind != "layout-missing" { |
| + t.Fatalf("layout name absent from both: %v", diffs) |
| + } |
| + // CARLOS_PURGE is advisory (set iff the box has a cache): a presence |
| + // difference is a note, never a failure; main omits the variable on a |
| + // box without a cache (backend_exec.go agentOwnedVars), so neither |
| + // example above carries it and they compare clean. |
| + purge := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:a", "CARLOS_REGION": "uk", "CARLOS_CONTROL_SOCKET": "/run/y", "SECRET": "s3", "CARLOS_PURGE": "header"}) |
| + if diffs := CompareEnv(src, purge, true); len(diffs) != 1 || diffs[0].Name != "CARLOS_PURGE" || diffs[0].Kind != "advisory-differs" || !diffs[0].Note() { |
| + t.Fatalf("purge presence: %v", diffs) |
| + } |
| + drift := ReportEnv(key, map[string]string{"DATABASE_URL": "sqlite:b", "CARLOS_REGION": "uk", "EXTRA": "x"}) |
| + kinds := map[string]string{} |
| + for _, d := range CompareEnv(src, drift, false) { |
| + kinds[d.Name] = d.Kind |
| + } |
| + if kinds["DATABASE_URL"] != "differs" || kinds["SECRET"] != "missing" || kinds["EXTRA"] != "extra" || kinds["CARLOS_CONTROL_SOCKET"] != "" { |
| + t.Fatalf("drift: %v", kinds) |
| + } |
| + if ReportEnv([]byte("another-key-another-key-another!"), map[string]string{"SECRET": "s3"}).Vars["SECRET"] == src.Vars["SECRET"] { |
| + t.Fatal("the mac does not depend on the key") |
| + } |
| + if src.Digest == "" || src.Digest == same.Digest { |
| + t.Fatal("digest") |
| + } |
| +} |
| + |
| +func TestEnvReportClassesAndNotes(t *testing.T) { |
| + for name, want := range map[string]string{ |
| + "CARLOS_REGION": "region", "CARLOS_CONTROL_SOCKET": "layout", "CARLOS_PURGE": "advisory", |
| + "INVOCATION_ID": "box", "JOURNAL_STREAM": "box", "HOSTNAME": "box", "PWD": "box", "OLDPWD": "box", "SHLVL": "box", "_": "box", |
| + "DATABASE_URL": "equal", "CARLOS_ADMIN_TOKEN": "equal", |
| + } { |
| + if got := Class(name); got != want { |
| + t.Errorf("Class(%q) = %q, want %q", name, got, want) |
| + } |
| + } |
| + if len(LayoutVars) != 1 || !LayoutVars["CARLOS_CONTROL_SOCKET"] || len(AdvisoryVars) != 1 || !AdvisoryVars["CARLOS_PURGE"] { |
| + t.Fatalf("layout %v advisory %v", LayoutVars, AdvisoryVars) |
| + } |
| + for _, k := range []string{"missing", "extra", "differs", "region-missing", "region-same", "layout-missing", "layout-present"} { |
| + if (EnvDiff{"X", k}).Note() { |
| + t.Errorf("%s is a note", k) |
| + } |
| + } |
| + if !(EnvDiff{"X", "advisory-differs"}).Note() { |
| + t.Error("advisory-differs is not a note") |
| + } |
| +} |
| + |
| +func TestCompareEnvNestedLayout(t *testing.T) { |
| + key := []byte("0123456789abcdef0123456789abcdef") |
| + src := ReportEnv(key, map[string]string{"A": "1", "CARLOS_REGION": "ie"}) |
| + tgt := ReportEnv(key, map[string]string{"A": "1", "CARLOS_REGION": "uk", "CARLOS_CONTROL_SOCKET": "/run/y"}) |
| + diffs := CompareEnv(src, tgt, false) |
| + // a layout name only the target carries is not "extra": the not-flat |
| + // rule (layout-present) fires only for a name in the source report, so |
| + // the target-only case is pinned here as the current behaviour. |
| + if len(diffs) != 0 { |
| + t.Fatalf("target-only layout var, non-flat: %v", diffs) |
| + } |
| + src2 := ReportEnv(key, map[string]string{"A": "1", "CARLOS_REGION": "ie", "CARLOS_CONTROL_SOCKET": "/run/x"}) |
| + if diffs := CompareEnv(src2, tgt, false); len(diffs) != 1 || diffs[0].Kind != "layout-present" { |
| + t.Fatalf("layout-present: %v", diffs) |
| + } |
| +} |
| + |
| +func TestReportEnvIsDeterministicAndDigestCoversNamesAndValues(t *testing.T) { |
| + key := []byte("0123456789abcdef0123456789abcdef") |
| + a := ReportEnv(key, map[string]string{"A": "1", "B": "2"}) |
| + b := ReportEnv(key, map[string]string{"B": "2", "A": "1"}) |
| + if a.Digest != b.Digest || a.V != 1 || !strings.HasPrefix(a.Digest, "sha256:") { |
| + t.Fatalf("%v %v", a, b) |
| + } |
| + // the mac binds the name: the same value under another name differs |
| + c := ReportEnv(key, map[string]string{"A": "2", "B": "1"}) |
| + if c.Digest == a.Digest || c.Vars["A"] == a.Vars["B"] { |
| + t.Fatal("the mac does not bind the name") |
| + } |
| + // name/value boundary: "AB"+"" is not "A"+"B" |
| + if ReportEnv(key, map[string]string{"AB": ""}).Vars["AB"] == ReportEnv(key, map[string]string{"A": "B"}).Vars["A"] { |
| + t.Fatal("name and value run together") |
| + } |
| +} |
| + |
| +func TestReportKeyFromSecrets(t *testing.T) { |
| + raw := make([]byte, 32) |
| + for i := range raw { |
| + raw[i] = byte(i + 1) |
| + } |
| + good := base64.StdEncoding.EncodeToString(raw) |
| + k1, err := ReportKeyFromSecrets(good) |
| + if err != nil || len(k1) != 32 { |
| + t.Fatalf("%v %d", err, len(k1)) |
| + } |
| + k2, _ := ReportKeyFromSecrets(good) |
| + if string(k1) != string(k2) { |
| + t.Fatal("not deterministic") |
| + } |
| + if string(k1) == string(raw) { |
| + t.Fatal("the report key is the raw secret") |
| + } |
| + raw[0] ^= 1 |
| + k3, err := ReportKeyFromSecrets(base64.StdEncoding.EncodeToString(raw)) |
| + if err != nil || string(k3) == string(k1) { |
| + t.Fatalf("key does not depend on the secret: %v", err) |
| + } |
| + for name, in := range map[string]string{ |
| + "malformed base64": "not base64!!", |
| + "empty": "", |
| + "short": base64.StdEncoding.EncodeToString(raw[:31]), |
| + "long": base64.StdEncoding.EncodeToString(append(append([]byte{}, raw...), 0)), |
| + } { |
| + if k, err := ReportKeyFromSecrets(in); err == nil || k != nil { |
| + t.Errorf("%s: key %v err %v", name, k, err) |
| + } else if strings.Contains(err.Error(), in) && in != "" { |
| + t.Errorf("%s: the error echoes the input: %v", name, err) |
| + } |
| + } |
| +} |