From 8c248e3d7fe9f3706f66548bd8f64706557aee60 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 22:25:57 +0200 Subject: [PATCH] A secret can reach a container's environment, and sit inside a config file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found by writing the first real module's manifest rather than by reasoning about one. Both are fields on existing shapes, so the vocabulary is still nine. **env-file on a container.** A declaration reaches a node over the broker and `env` is plain text in it, so a password there is a password the broker sees — the transitive trust refused everywhere else. A sealed file arrives unreadable, the host writes it, the runtime reads it. It is also simply how third-party software takes credentials: nothing shipping in a container will read a path the mesh invented, and every one of them reads its environment. **secrets in a file's content.** A program wanting its token inside a JSON document cannot be handed a file that is entirely a token, and the mesh cannot compose the document because it discarded the value. So the module supplies the document with `${secret:name}` in it, the mesh delivers the value sealed, and the host is the only thing that ever holds both. Substitution is textual and the host learns no formats. Deliberate: a mechanism that understood JSON would be asked to understand YAML next, and then INI, which is how the arrangement this replaces became something nobody could hold in their head. The module knows its own format because it wrote the rest of the file. The sharp edge is stated rather than left to be discovered — a value containing a quote is not escaped for whatever surrounds it. Refused in both directions, because both are somebody being wrong about where a credential is: a placeholder with nothing to fill it would write `${secret:x}` into a config file, and a secret the content never uses means somebody believes a credential is in a file where it is not. A file that carries one is 0600 unless the module said otherwise. --- internal/apply/apply.go | 24 +++++++ internal/apply/vocabulary_test.go | 86 ++++++++++++++++++++++++ internal/declaration/declaration.go | 79 ++++++++++++++++++++-- internal/declaration/declaration_test.go | 23 +++++++ 4 files changed, 207 insertions(+), 5 deletions(-) diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 2613725..c610a2a 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -369,6 +369,27 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outc } content = string(opened) } + // Sealed values into the holes the content left for them. **The host is the only thing that + // ever holds both** — the mesh discarded the value, and the module wrote the document without + // it (novox/hq ADR 0024). + if len(r.Secrets) > 0 { + if unseal == nil { + return out, fmt.Errorf( + "%s needs %d sealed value(s) and this node has no sealing key", + r.Path, len(r.Secrets)) + } + for _, name := range r.SecretsUsed() { + opened, err := unseal(r.Secrets[name]) + if err != nil { + return out, fmt.Errorf("cannot open the secret %q for %s: %w", name, r.Path, err) + } + content = strings.ReplaceAll(content, "${secret:"+name+"}", string(opened)) + } + // A file carrying a credential is not world-readable, whatever else it also carries. + if r.Mode == "" { + fallback = 0o600 + } + } out.wrote = digestOf(content) mode, err := modeOf(r.Mode, fallback) if err != nil { @@ -887,6 +908,9 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner) ( } args := []string{"run", "--detach", "--name", r.Name, "--restart", "unless-stopped"} + for _, file := range r.EnvFile { + args = append(args, "--env-file", file) + } if r.Network != "" { args = append(args, "--network", r.Network) } diff --git a/internal/apply/vocabulary_test.go b/internal/apply/vocabulary_test.go index aacf596..1670f49 100644 --- a/internal/apply/vocabulary_test.go +++ b/internal/apply/vocabulary_test.go @@ -13,6 +13,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" @@ -317,3 +318,88 @@ func TestANetworkAlreadyThereIsNotRebuilt(t *testing.T) { "name and not the thing, so it does not tear one down and rebuild it", created) } } + +// A secret inside a configuration file, substituted on the machine. +// +// **The one place a credential and a configuration meet.** A program wanting its token inside a +// JSON document cannot be handed a file that is entirely a token, and the mesh cannot compose the +// document because it discarded the value. So the module supplies the document with a hole, the +// mesh delivers the value sealed, and the host is the only thing that ever holds both. +func TestASealedValueIsPutIntoTheFileThatNamesIt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + d := declare(t, `{"id":"settings","type":"file","path":"`+path+`",`+ + `"content":"{\"tracking\":\"on\",\"token\":\"${secret:atlassian}\"}",`+ + `"secrets":{"atlassian":"SEALED"}}`) + + open := func(blob string) ([]byte, error) { + if blob != "SEALED" { + return nil, fmt.Errorf("asked to open %q", blob) + } + return []byte("the-real-token"), nil + } + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, nil, nil, open); err != nil { + t.Fatal(err) + } + + written, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(written), `"token":"the-real-token"`) { + t.Fatalf("the secret was not put in: %s", written) + } + if strings.Contains(string(written), "secret:") { + t.Fatalf("a placeholder survived into the file: %s", written) + } + // The rest of the document is untouched — this is substitution, not replacement. + if !strings.Contains(string(written), `"tracking":"on"`) { + t.Fatalf("the content around the secret was lost: %s", written) + } + // And it carries a credential, so it is not world-readable. + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("a file holding a credential is %v", info.Mode().Perm()) + } +} + +// Defends the reason env-file exists: a credential may not travel in `env`. +// +// A declaration reaches a node over the broker and `env` is plain text in it, so a password there +// is a password the broker sees. A sealed file arrives unreadable, the host writes it, and the +// runtime reads it. +func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) { + var ran []string + run := func(_ context.Context, name string, args ...string) (string, error) { + ran = append(ran, name+" "+strings.Join(args, " ")) + if len(args) > 0 && args[0] == "inspect" { + return "", fmt.Errorf("no such container") + } + return "", nil + } + d := declare(t, `{"id":"app","type":"container","name":"umami",`+ + `"image":"umami@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+ + `"env-file":["/var/lib/umami/database.env","/var/lib/umami/app.env"]}`) + + _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, run, nil, nil) + + var started string + for _, line := range ran { + if strings.Contains(line, "run ") { + started = line + } + } + for _, want := range []string{ + "--env-file /var/lib/umami/database.env", + "--env-file /var/lib/umami/app.env", + } { + if !strings.Contains(started, want) { + t.Errorf("the container was started without %q:\n%s", want, started) + } + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 0d4041e..98355f1 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -12,6 +12,8 @@ import ( "fmt" "io" "reflect" + "regexp" + "slices" "sort" "strings" ) @@ -118,6 +120,23 @@ type File struct { // by looking rather than by knowing which field won. Sealed string `json:"sealed,omitempty"` + // Secrets are sealed values put into Content where it says `${secret:name}`. + // + // **The one place a secret and a configuration meet, and it happens on the machine.** A + // program that wants its token inside a JSON document cannot be given a file that is entirely + // a token, and the mesh cannot compose the document itself — it discarded the value + // (novox/hq ADR 0024). So the module supplies the document with a hole in it, the mesh + // delivers the value sealed, and the host is the only thing that ever sees both. + // + // **Substitution is textual and the host learns no formats.** That is deliberate: a mechanism + // that understood JSON would be asked to understand YAML next, and then INI, which is how the + // arrangement this replaces became something nobody could hold in their head. The module knows + // its own format, because it wrote the rest of the file. + // + // The sharp edge, stated rather than discovered: a value containing a quote or a backslash + // will not be escaped for whatever syntax surrounds it. + Secrets map[string]string `json:"secrets,omitempty"` + // Bytes is content that is not text, base64-encoded — a wallpaper, a font, an icon. // // A third way of saying what is in a file, and the three are exclusive. It would have been @@ -137,6 +156,22 @@ func (f *File) Identity() string { return f.ID } func (f *File) Kind() Type { return TypeFile } func (f *File) Target() string { return f.Path } +// placeholder is what Content says where a sealed value belongs: ${secret:name}. +var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`) + +// SecretsUsed are the names Content asks for, in the order they first appear. +func (f *File) SecretsUsed() []string { + var used []string + seen := map[string]bool{} + for _, m := range placeholder.FindAllStringSubmatch(f.Content, -1) { + if !seen[m[1]] { + seen[m[1]] = true + used = append(used, m[1]) + } + } + return used +} + func (f *File) validate(where string, _ bool) []string { var problems []string if f.Path == "" { @@ -157,6 +192,29 @@ func (f *File) validate(where string, _ bool) []string { strings.Join(said, " and ")+ " — otherwise nobody can tell by looking which one landed on the machine") } + // A file whose content names a secret must be given exactly the secrets it names. + // + // **Both directions, and both are refusals rather than warnings.** A placeholder with nothing + // to fill it would write `${secret:x}` into a configuration file, which the program reads as + // a value and fails on somewhere unrelated. A secret nobody uses means whoever wrote this + // believes a credential is in a file where it is not. + if len(f.Secrets) > 0 && f.Content == "" { + problems = append(problems, where+ + ": secrets were given and there is no content to put them in") + } + used := f.SecretsUsed() + for _, name := range used { + if f.Secrets[name] == "" { + problems = append(problems, fmt.Sprintf( + "%s: the content asks for the secret %q and none was given", where, name)) + } + } + for name := range f.Secrets { + if !slices.Contains(used, name) { + problems = append(problems, fmt.Sprintf( + "%s: the secret %q was given and the content never asks for it", where, name)) + } + } return append(problems, checkMode(where, f.Mode)...) } @@ -364,11 +422,22 @@ type Container struct { Type Type `json:"type"` Name string `json:"name"` // Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not. - Image string `json:"image"` - Env map[string]string `json:"env,omitempty"` - Ports []string `json:"ports,omitempty"` - Volumes []string `json:"volumes,omitempty"` - Args []string `json:"args,omitempty"` + Image string `json:"image"` + Env map[string]string `json:"env,omitempty"` + // EnvFile names files the runtime reads environment from, in order. + // + // **Because a secret may not travel in Env.** A declaration reaches a node over the broker, + // and `env` is plain text in it — so a password there is a password the broker sees, which is + // the transitive trust refused everywhere else (novox/hq ADR 0004). A sealed file reaches the + // machine unreadable, the host writes it, and the runtime reads it: the mesh never holds it + // and neither does anything between them. + // + // It is also simply how third-party software takes credentials. Nothing that ships in a + // container will read a path the mesh invented; every one of them reads its environment. + EnvFile []string `json:"env-file,omitempty"` + Ports []string `json:"ports,omitempty"` + Volumes []string `json:"volumes,omitempty"` + Args []string `json:"args,omitempty"` // Names this container can reach, as `name:address`. // // **Because a container does not inherit the machine's names.** It gets its own `/etc/hosts` diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index a09df0f..f738002 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -359,3 +359,26 @@ func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) { } } } + +// A placeholder with nothing to fill it is refused before anything is written. +// +// The alternative is a configuration file containing the literal `${secret:x}`, which the program +// reads as a value and fails on somewhere with no connection to this. +func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) { + refusal := refusalFor(t, `{"declaration":1,"resources":[ + {"id":"c","type":"file","path":"/tmp/x","content":"token=${secret:absent}"} + ]}`) + if len(refusal.Problems) == 0 { + t.Fatal("a file naming a secret nobody gave it was accepted") + } +} + +// And a secret nobody asked for, because somebody believes it is in a file where it is not. +func TestASecretTheContentNeverUsesIsRefused(t *testing.T) { + refusal := refusalFor(t, `{"declaration":1,"resources":[ + {"id":"c","type":"file","path":"/tmp/x","content":"nothing here","secrets":{"spare":"S"}} + ]}`) + if len(refusal.Problems) == 0 { + t.Fatal("a secret the content never mentions was accepted") + } +}