Skip to content

Commit c691cbe

Browse files
author
gluck
committed
gateway: authenticate at the door, replace attribution, and a NixOS module
Step 4, plus the two things spain needs before it can host this. CREDENTIALS FROM A FILE (auth.FileValue, api_key_file). figaro's chain was env -> plaintext api_key -> AGE-encrypted api_key -> OAuth, and the last two need hush, which needs a keyring, which needs a human session. A server has none. That left only two ways to give a hosted figaro a provider key on NixOS and BOTH PUBLISH IT: a plaintext key in config.toml is rendered into the world-readable nix store, and Environment= in a unit lands there too. So: a path. The secret lives in sops, sops-nix drops it at /run/secrets 0400 owned by the service user, and the nix store holds only the path. Tried first in the chain -- on a server it is the only source that works; on a workstation it is absent and everything falls through unchanged. Absent falls through silently; present-but-unreadable is a loud error, because the usual cause is a mode or owner mismatch and falling through would surface it as "no credential available" three layers from the mistake. AUTHENTICATORS. `upstream` reads Remote-User/Remote-Groups and verifies NOTHING -- it cannot, they are plain headers -- so its safety is entirely that the proxy is the only route, which the refusal table enforces. A missing Remote-User is a refusal, not an anonymous fallback: its absence means we are not behind the proxy we think we are. `doorkey` is a constant- time bearer compare and is never accepted from a query string, which would land the secret in access logs, Referer and history. Refusals are an HTTP 401 rather than a socket that opens and closes, because the second is indistinguishable from a network fault and clients retry it forever. THE ENVELOPE REWRITE, which is the real security fix. authz.AriaHeader trusts the calling aria's id ON ASSERTION -- honest for a unix socket whose model is 0600, since a local process could claim any id anyway. Through a gateway that argument dies: what nothing stops a LOCAL process claiming, nothing stops a REMOTE one claiming. A remote caller could name itself another aria, satisfy self-targeted policy rules, and appear in a transcript as a figaro the reader is instructed to trust. Every frame now has its attribution stripped and replaced with what the gateway authenticated. This is NOT a second copy of the API. Params are decoded to map[string]json.RawMessage, the two attribution keys are replaced, and every other field -- including ones this gateway has never heard of -- is written back byte-exact. Only key order changes, and key order carries no meaning. Tested: a forged id does not survive, a forged label does not survive, stripping happens even when there is nothing to stamp back, and an unknown nested field round-trips unaltered. THE NIXOS MODULE (nix/module.nix, flake nixosModules.default). Dedicated user rather than DynamicUser, because a new uid per activation cannot own a long-lived figwal store. KillMode=mixed so agent grandchildren die with the unit instead of being reparented to pid 1 still holding the store. MemoryMax and CPUQuota because spain is the house router and a runaway agent must not starve dnsmasq. BindsTo so a dead angelus takes the door down rather than 502ing at an authenticated admin. FIGARO_NO_SELF_SPAWN, since the CLI's detached grandchild would escape Type=exec. Sandboxing is honest rather than decorative: ProtectSystem, ProtectHome, PrivateTmp yes; NoNewPrivileges and SystemCallFilter NO, listed as comments with the reason, because this workload's purpose is running arbitrary commands and a filter tight enough to matter would break it. Platform coupling is a SEPARATE module (nix/site.nix, nixosModules.site): a mkIf guard cannot keep a host without kelliher-web from evaluating its options, since the module system asserts an option path before consulting the condition. Verified by instantiating both and reading the units back. Two guards tested: a literal key in providerKeyFiles is refused by the type, and an empty requiredGroups is refused by an assertion. Full suite green.
1 parent d401458 commit c691cbe

12 files changed

Lines changed: 949 additions & 6 deletions

File tree

‎flake.nix‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@
111111
default = figaro;
112112
});
113113

114+
# The NixOS module, so a host can run figaro as a service. It lives
115+
# here rather than in a separate repo because the knowledge it encodes
116+
# -- that the angelus must not self-spawn, that agents are grandchildren
117+
# needing KillMode=mixed, that the store's ownership must survive a
118+
# redeploy -- is figaro's own, and would rot anywhere else.
119+
# The DAEMON: units, user, resource caps. Works on any NixOS host.
120+
nixosModules.default = import ./nix/module.nix;
121+
nixosModules.figaro = self.nixosModules.default;
122+
# The PLATFORM COUPLING: registers the site with kelliher-web. Kept
123+
# apart so a host without that platform never evaluates its options.
124+
nixosModules.site = import ./nix/site.nix;
125+
114126
devShells = forAllSystems ({ pkgs }: let
115127
figaroPkg = self.packages.${pkgs.system}.default;
116128

‎internal/auth/filevalue_test.go‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package auth
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func write(t *testing.T, name, body string, mode os.FileMode) string {
11+
t.Helper()
12+
p := filepath.Join(t.TempDir(), name)
13+
if err := os.WriteFile(p, []byte(body), mode); err != nil {
14+
t.Fatal(err)
15+
}
16+
return p
17+
}
18+
19+
// The trailing newline is the whole reason this trims. Every mechanism that
20+
// writes a credential file -- a heredoc, an editor, echo, sops -- leaves one,
21+
// and a bearer with \n on the end fails authentication in a way that reads
22+
// exactly like a wrong key.
23+
func TestFileValueTrimsWhitespace(t *testing.T) {
24+
for _, body := range []string{"sk-abc123", "sk-abc123\n", " sk-abc123 \n\n", "sk-abc123\r\n"} {
25+
f := &FileValue{Path: write(t, "k", body, 0o600)}
26+
got, ok, err := f.TryResolve()
27+
if err != nil || !ok {
28+
t.Fatalf("%q: ok=%v err=%v", body, ok, err)
29+
}
30+
if got != "sk-abc123" {
31+
t.Fatalf("%q resolved to %q", body, got)
32+
}
33+
}
34+
}
35+
36+
// ABSENT IS NOT AN ERROR. This strategy sits in an Aggregate beside env vars
37+
// and hush; a config that names no file must fall through so a workstation
38+
// keeps using the credentials a person owns.
39+
func TestFileValueAbsentFallsThrough(t *testing.T) {
40+
for _, p := range []string{"", filepath.Join(t.TempDir(), "nope")} {
41+
f := &FileValue{Path: p}
42+
_, ok, err := f.TryResolve()
43+
if ok {
44+
t.Fatalf("%q reported a credential", p)
45+
}
46+
if err != nil {
47+
t.Fatalf("%q errored instead of falling through: %v", p, err)
48+
}
49+
}
50+
}
51+
52+
// PRESENT BUT UNREADABLE IS AN ERROR, and must not fall through. The usual
53+
// cause is a credential mode or owner that does not match the service user,
54+
// and falling through would surface it as "no credential available" three
55+
// layers from the mistake.
56+
func TestFileValueUnreadableIsLoud(t *testing.T) {
57+
if os.Geteuid() == 0 {
58+
t.Skip("root reads everything")
59+
}
60+
p := write(t, "k", "sk-abc", 0o000)
61+
f := &FileValue{Path: p}
62+
_, ok, err := f.TryResolve()
63+
if ok {
64+
t.Fatal("an unreadable file yielded a credential")
65+
}
66+
if err == nil {
67+
t.Fatal("an unreadable credential file fell through silently")
68+
}
69+
if !strings.Contains(err.Error(), p) {
70+
t.Fatalf("error does not name the path: %v", err)
71+
}
72+
}
73+
74+
// An empty file is a provisioning failure, not an empty credential.
75+
func TestFileValueEmptyIsAnError(t *testing.T) {
76+
f := &FileValue{Path: write(t, "k", "\n \n", 0o600)}
77+
_, ok, err := f.TryResolve()
78+
if ok || err == nil {
79+
t.Fatalf("an empty credential file was accepted: ok=%v err=%v", ok, err)
80+
}
81+
}
82+
83+
// The file is the source of truth; this process does not get to decide it is
84+
// wrong. Rotation is the operator's job, so Invalidate must not delete it.
85+
func TestFileValueInvalidateLeavesTheFile(t *testing.T) {
86+
p := write(t, "k", "sk-abc", 0o600)
87+
f := &FileValue{Path: p}
88+
if err := f.Invalidate("sk-abc"); err != nil {
89+
t.Fatalf("Invalidate errored: %v", err)
90+
}
91+
if _, err := os.Stat(p); err != nil {
92+
t.Fatalf("Invalidate touched the credential file: %v", err)
93+
}
94+
}

‎internal/auth/strategy.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"os"
7+
"strings"
78
"sync/atomic"
89

910
hush "github.com/jack-work/hush/client"
@@ -168,3 +169,53 @@ func (o *OAuth) Invalidate(token string) error {
168169
type EndpointCarrier interface {
169170
Endpoint() string
170171
}
172+
173+
// FileValue reads a token from a file on disk.
174+
//
175+
// IT EXISTS FOR MACHINES. hush is a human-plane agent: it unlocks a person's
176+
// credentials from a person's keyring and deliberately dies with a session. A
177+
// server has no session and no keyring, so putting a hush passphrase in a
178+
// secret store the machine can decrypt unattended keeps all of hush's
179+
// ceremony and none of its property -- it is sops with extra steps.
180+
//
181+
// The line the platform already draws is the right one: secrets belonging to
182+
// a MACHINE live in the machine's secret store; secrets belonging to a PERSON
183+
// live in hush. A hosted figaro's provider keys belong to the machine, and
184+
// this is how they arrive -- systemd LoadCredential drops a file into
185+
// $CREDENTIALS_DIRECTORY and hands us the path.
186+
type FileValue struct {
187+
// Path to a file whose contents are the token. Surrounding whitespace is
188+
// trimmed, because every mechanism that writes one of these -- a heredoc,
189+
// an editor, `echo` -- leaves a trailing newline, and a bearer with \n on
190+
// the end fails authentication in a way that reads like a wrong key.
191+
Path string
192+
}
193+
194+
func (f *FileValue) TryResolve() (string, bool, error) {
195+
if f.Path == "" {
196+
return "", false, nil
197+
}
198+
b, err := os.ReadFile(f.Path)
199+
if err != nil {
200+
if os.IsNotExist(err) {
201+
// Absent is "not my turn", not a failure: this strategy sits in
202+
// an Aggregate beside env vars and hush, and a config that names
203+
// no file must fall through rather than fail the chain.
204+
return "", false, nil
205+
}
206+
// Present but unreadable IS a failure, and a loud one. The usual
207+
// cause is a credential mode or owner that does not match the
208+
// service user, and silently falling through would surface it as
209+
// "no credential available" three layers away from the mistake.
210+
return "", false, fmt.Errorf("read credential file %s: %w", f.Path, err)
211+
}
212+
v := strings.TrimSpace(string(b))
213+
if v == "" {
214+
return "", false, fmt.Errorf("credential file %s is empty", f.Path)
215+
}
216+
return v, true, nil
217+
}
218+
219+
// Invalidate is a no-op: the file is the source of truth and this process
220+
// does not get to decide it is wrong. Rotating it is the operator's job.
221+
func (*FileValue) Invalidate(string) error { return nil }

‎internal/cli/provider.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package cli
44
import (
55
"encoding/json"
66
"fmt"
7+
"os"
78
"strings"
89
"text/template"
910

@@ -58,6 +59,11 @@ func buildResolver(loaded *config.Loaded, providerName string) (auth.TokenResolv
5859
}
5960

6061
strategies := environmentStrategies(reg)
62+
// The FILE comes before the stored key and before hush: it is what a
63+
// SERVER has. A machine has no keyring for hush to unlock, so where this
64+
// is set it is the only source that can work; where it is not set it
65+
// costs one stat and the chain falls through to a person's credentials.
66+
strategies = append(strategies, &auth.FileValue{Path: apiKeyFile(loaded, providerName)})
6167
strategies = append(strategies,
6268
&auth.ConfigValue{Get: func() string {
6369
var pa config.ProviderAuth
@@ -81,6 +87,18 @@ func buildResolver(loaded *config.Loaded, providerName string) (auth.TokenResolv
8187
return &auth.Aggregate{Strategies: strategies}, nil
8288
}
8389

90+
// apiKeyFile resolves the credential path for a provider: the per-provider
91+
// config file first, then a conventional environment variable so a unit can
92+
// point at $CREDENTIALS_DIRECTORY without a config file at all.
93+
func apiKeyFile(loaded *config.Loaded, providerName string) string {
94+
var pa config.ProviderAuth
95+
_ = loaded.LoadProviderAuth(providerName, &pa)
96+
if pa.APIKeyFile != "" {
97+
return pa.APIKeyFile
98+
}
99+
return os.Getenv("FIGARO_" + strings.ToUpper(providerName) + "_API_KEY_FILE")
100+
}
101+
84102
func environmentStrategies(reg *providerPkg.Registration) []auth.CredentialStrategy {
85103
if reg == nil {
86104
return nil

‎internal/config/config.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,17 @@ type ProviderAuth struct {
739739
// encrypted; plain string otherwise.
740740
APIKey string `toml:"api_key"`
741741

742+
// APIKeyFile names a file whose contents are the credential. It is the
743+
// MACHINE's way in: systemd LoadCredential drops a file into
744+
// $CREDENTIALS_DIRECTORY from sops and hands over the path, and nothing
745+
// secret ever enters the nix store or this config file.
746+
//
747+
// It is tried BEFORE the stored api_key and before hush. A server has no
748+
// keyring for hush to talk to, so on a host the file is the only source
749+
// that works; on a workstation it is simply absent and the chain falls
750+
// through to the credentials a person owns.
751+
APIKeyFile string `toml:"api_key_file"`
752+
742753
// OAuth tokens (AGE-encrypted when present).
743754
AccessToken string `toml:"access_token"`
744755
RefreshToken string `toml:"refresh_token"`

0 commit comments

Comments
 (0)