-
Notifications
You must be signed in to change notification settings - Fork 0
feat(daemon): remote transport with mTLS caller authentication #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8a177a3
5398451
2106fba
633c559
9e8af23
7872c46
e7fbb63
d44c5ed
06202b6
d25529f
e606145
d22bcb2
36d29dd
e715d57
7d4fd0e
28866bd
22ee43b
df6d429
ad8d53d
219e724
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -153,7 +153,9 @@ resolves the path fresh on every connection and picks up the new socket. | |||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **The socket group is the entire access-control list.** There is no token and no TLS — | ||||||||||||||||||||||||||||
| a Unix socket is a kernel object with no wire to intercept, and mTLS would add a CA, | ||||||||||||||||||||||||||||
| issuance and rotation for no real gain here. Two different groups do two different jobs | ||||||||||||||||||||||||||||
| issuance and rotation for no real gain here. That calculus only holds while caller and | ||||||||||||||||||||||||||||
| daemon share a host; see [Remote transport](#remote-transport) below for the network | ||||||||||||||||||||||||||||
| case, where a CA is unavoidable and buys something real. Two different groups do two different jobs | ||||||||||||||||||||||||||||
| here, and they are easy to conflate: `socket_group` in the daemon's own config file | ||||||||||||||||||||||||||||
| (`deploy/openbloxd.example.yaml`) names the group that may reach the socket — the | ||||||||||||||||||||||||||||
| daemon itself creates the socket `0660` and chowns it to that group in `Listen` | ||||||||||||||||||||||||||||
|
|
@@ -184,6 +186,109 @@ audit trail or profile-to-identity binding off of. Under user-namespace remappin | |||||||||||||||||||||||||||
| that's the *remapped* uid — the one the kernel sees on the socket, not the uid the | ||||||||||||||||||||||||||||
| process believes it's running as inside its container. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ## Remote transport | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| openbloxd serves a Unix socket by default, and that remains the recommended | ||||||||||||||||||||||||||||
| arrangement wherever the caller and the daemon share a host. The optional | ||||||||||||||||||||||||||||
| `listen` block (`internal/daemon/config.go`) adds a network listener so the | ||||||||||||||||||||||||||||
| daemon can run on a machine of its own — because gVisor contains escape, not | ||||||||||||||||||||||||||||
| contention, and sandboxes otherwise compete for CPU, memory bandwidth and disk | ||||||||||||||||||||||||||||
| IO with whatever runs beside them. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| A network listener requires mutual TLS. There is no unauthenticated network | ||||||||||||||||||||||||||||
| mode and there is no way to configure one: every field of `listen.tls` is | ||||||||||||||||||||||||||||
| required, and `Load` refuses to start the daemon if any is missing. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### What authenticates a caller | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Two gates, both during the TLS handshake in `ListenTLS` | ||||||||||||||||||||||||||||
| (`internal/daemon/listener_tls.go`): | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 1. The client certificate must chain to `listen.tls.client_ca_file`. | ||||||||||||||||||||||||||||
| 2. Its Common Name must appear in `listen.tls.allowed_client_cns`. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| The second is not redundant. With verification alone **the CA is the entire | ||||||||||||||||||||||||||||
| access control list** — any certificate it ever signs is accepted. Use a CA | ||||||||||||||||||||||||||||
| that signs nothing else, and treat the allowlist as the thing that makes a | ||||||||||||||||||||||||||||
| mis-issuance survivable. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### What this does not protect against | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **mTLS authenticates the process holding the key, not its intent.** A caller | ||||||||||||||||||||||||||||
| that has been compromised is a *valid* caller: it holds the certificate. | ||||||||||||||||||||||||||||
| Authentication contributes nothing to that case. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| That case is the one openbloxd exists for, and the credential is not what | ||||||||||||||||||||||||||||
| answers it. The guarantee is the same one the rest of this page describes: a | ||||||||||||||||||||||||||||
| compromised caller gains sandboxes bounded by a profile, never the host, and | ||||||||||||||||||||||||||||
| that bound is enforced daemon-side and unreachable from a request — see | ||||||||||||||||||||||||||||
| [Profiles are the whole policy surface](#deploying-the-policy-broker-openbloxd) | ||||||||||||||||||||||||||||
| above. Nothing about arriving over the network relaxes that. `openbloxd` | ||||||||||||||||||||||||||||
| serves both listeners from the one handler | ||||||||||||||||||||||||||||
| (`cmd/openbloxd/main.go`), and `internal/daemon/policy_test.go` asserts every | ||||||||||||||||||||||||||||
| hostile request body rejected over both transports rather than leaving that | ||||||||||||||||||||||||||||
| as a convention. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **A private network is a real mitigation and a poor sole control.** Running | ||||||||||||||||||||||||||||
| the daemon on a VPN or a private subnet meaningfully reduces exposure and is | ||||||||||||||||||||||||||||
| recommended. It is not a substitute for the credential: it authenticates a | ||||||||||||||||||||||||||||
| route rather than a peer, and it fails open the moment anything else on that | ||||||||||||||||||||||||||||
| network is compromised. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **Confidentiality in transit is TLS's alone.** Exec output, file reads and | ||||||||||||||||||||||||||||
| dialled streams all cross the network now, with no application-layer | ||||||||||||||||||||||||||||
| encryption beneath. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### Revocation | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| There is none beyond configuration. Go checks neither CRL nor OCSP by default, | ||||||||||||||||||||||||||||
| and openbloxd runs neither. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **To revoke a caller: remove its Common Name from `allowed_client_cns` and | ||||||||||||||||||||||||||||
| restart the daemon.** `RuntimeDirectoryPreserve=yes` in the shipped unit | ||||||||||||||||||||||||||||
| (`deploy/openbloxd.service`) is what makes that restart transparent to | ||||||||||||||||||||||||||||
| clients mounting the socket directory, as described above. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| This is a limitation, not a design feature. It is workable for a small, | ||||||||||||||||||||||||||||
| enumerated set of callers and would not be workable at a scale where | ||||||||||||||||||||||||||||
| certificates are issued automatically — anything issuing certificates | ||||||||||||||||||||||||||||
| automatically should revoke them automatically too. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### Issuing the certificates | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| openbloxd is not a certificate authority and does not want to be. A minimal | ||||||||||||||||||||||||||||
| private CA, sufficient for one daemon and one caller (bash/zsh — `<(...)` | ||||||||||||||||||||||||||||
| process substitution is not POSIX `sh`): | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||||
| # A CA that signs nothing else. | ||||||||||||||||||||||||||||
| openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 3650 \ | ||||||||||||||||||||||||||||
| -keyout ca.key -out ca.crt -subj "/CN=openbloxd-ca" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # The daemon's certificate. The SAN must match the address callers dial. | ||||||||||||||||||||||||||||
| openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ | ||||||||||||||||||||||||||||
| -keyout server.key -out server.csr -subj "/CN=openbloxd" | ||||||||||||||||||||||||||||
| openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ | ||||||||||||||||||||||||||||
| -days 825 -out server.crt \ | ||||||||||||||||||||||||||||
| -extfile <(printf "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth") | ||||||||||||||||||||||||||||
|
Comment on lines
+268
to
+273
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Make the server SAN a placeholder, not This section covers a daemon on a machine of its own. The recipe pins the SAN to 📝 Suggested wording-# The daemon's certificate. The SAN must match the address callers dial.
+# The daemon's certificate. The SAN must match the address callers dial:
+# use DNS:<daemon-hostname>, or IP:<daemon-address> when callers dial by IP.
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout server.key -out server.csr -subj "/CN=openbloxd"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 825 -out server.crt \
- -extfile <(printf "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth")
+ -extfile <(printf "subjectAltName=DNS:openbloxd.internal.example\nextendedKeyUsage=serverAuth")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # One caller. The CN is what goes in allowed_client_cns. | ||||||||||||||||||||||||||||
| openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ | ||||||||||||||||||||||||||||
| -keyout client.key -out client.csr -subj "/CN=sandbox-caller" | ||||||||||||||||||||||||||||
| openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ | ||||||||||||||||||||||||||||
| -days 825 -out client.crt \ | ||||||||||||||||||||||||||||
| -extfile <(printf "extendedKeyUsage=clientAuth") | ||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Keep `ca.key` off both machines once the certificates are issued. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| `server.crt`/`server.key` and `ca.crt` stay on the daemon's host, as | ||||||||||||||||||||||||||||
| `cert_file`, `key_file` and `client_ca_file`; the CN `sandbox-caller` is what | ||||||||||||||||||||||||||||
| goes in `allowed_client_cns`. `client.crt`/`client.key` are the only pair | ||||||||||||||||||||||||||||
| that leaves the daemon's host at all — they travel to the caller, which | ||||||||||||||||||||||||||||
| configures its own TLS client with them and with `ca.crt` to verify the | ||||||||||||||||||||||||||||
| server. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ## Reporting a vulnerability | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| See [SECURITY.md](https://github.com/blox-eng/openblox/blob/main/SECURITY.md). Please do | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package daemon | ||
|
|
||
| import ( | ||
| "context" | ||
| "log/slog" | ||
| "net/http" | ||
| ) | ||
|
|
||
| // Transport names the way a request arrived. | ||
| const ( | ||
| TransportUnix = "unix" | ||
| TransportTLS = "tls" | ||
| ) | ||
|
|
||
| // Caller is who made a request. | ||
| // | ||
| // Nothing consumes this yet. It exists anyway because a transport that | ||
| // discards the caller's identity has to be reopened to add per-caller quotas | ||
| // or an audit trail, and the place to record identity is where it is still | ||
| // available. | ||
| // | ||
| // Transport is carried explicitly rather than inferred from an empty Name: a | ||
| // log line for a security boundary should say whether a request arrived | ||
| // locally or over a network, not leave it to be deduced. | ||
| type Caller struct { | ||
| Transport string | ||
| Name string | ||
| } | ||
|
|
||
| type callerKey struct{} | ||
|
|
||
| // WithCaller records the caller on the request context, over every transport. | ||
| // | ||
| // Name is empty for a Unix caller because SO_PEERCRED is unimplemented; that | ||
| // is the local transport's identity seam and is unrelated to this one. | ||
| func WithCaller(next http.Handler) http.Handler { | ||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| c := Caller{Transport: TransportUnix} | ||
| if r.TLS != nil { | ||
| c.Transport = TransportTLS | ||
| if len(r.TLS.PeerCertificates) > 0 { | ||
| c.Name = r.TLS.PeerCertificates[0].Subject.CommonName | ||
| } | ||
| // Logged only for network callers. The Unix socket is the | ||
| // high-volume local path and its behaviour is deliberately | ||
| // unchanged; a remote request is the one worth an audit line. | ||
| slog.Info("openbloxd request", | ||
| slog.String("caller", c.Name), | ||
| slog.String("method", r.Method), | ||
| slog.String("path", r.URL.Path)) | ||
|
|
||
| } | ||
| next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), callerKey{}, c))) | ||
| }) | ||
| } | ||
|
|
||
| // CallerFrom returns the caller recorded by WithCaller. | ||
| func CallerFrom(ctx context.Context) (Caller, bool) { | ||
| c, ok := ctx.Value(callerKey{}).(Caller) | ||
| return c, ok | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| package daemon | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/tls" | ||
| "crypto/x509" | ||
| "crypto/x509/pkix" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestWithCallerRecordsUnixCaller(t *testing.T) { | ||
| var got Caller | ||
| var ok bool | ||
| h := WithCaller(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { | ||
| got, ok = CallerFrom(r.Context()) | ||
| })) | ||
| h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/profiles", nil)) | ||
|
|
||
| if !ok { | ||
| t.Fatal("no caller on the context") | ||
| } | ||
| if got.Transport != TransportUnix { | ||
| t.Errorf("Transport = %q, want %q", got.Transport, TransportUnix) | ||
| } | ||
| // SO_PEERCRED is unimplemented, so a local caller has no name yet. This | ||
| // asserts the current honest answer rather than a placeholder. | ||
| if got.Name != "" { | ||
| t.Errorf("Name = %q, want empty for a unix caller", got.Name) | ||
| } | ||
| } | ||
|
|
||
| func TestWithCallerRecordsCertificateCommonName(t *testing.T) { | ||
| var got Caller | ||
| h := WithCaller(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { | ||
| got, _ = CallerFrom(r.Context()) | ||
| })) | ||
|
|
||
| req := httptest.NewRequest(http.MethodGet, "/profiles", nil) | ||
| req.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{ | ||
| {Subject: pkix.Name{CommonName: "sandbox-caller"}}, | ||
| }} | ||
| h.ServeHTTP(httptest.NewRecorder(), req) | ||
|
|
||
| if got.Transport != TransportTLS { | ||
| t.Errorf("Transport = %q, want %q", got.Transport, TransportTLS) | ||
| } | ||
| if got.Name != "sandbox-caller" { | ||
| t.Errorf("Name = %q, want sandbox-caller", got.Name) | ||
| } | ||
| } | ||
|
|
||
| func TestCallerFromReportsAbsence(t *testing.T) { | ||
| if _, ok := CallerFrom(context.Background()); ok { | ||
| t.Fatal("CallerFrom reported a caller on a bare context") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Close the server when one
Servefails.servenow starts oneServeper listener. If one fails on its own, the function returns through the firstselectcase without callinghttpSrv.ShutdownorhttpSrv.Close. The other listeners stay open, and the Unix listener never runs its unlink-on-close. The socket file then survives the process exit, so a down daemon answersECONNREFUSEDinstead ofENOENT— the exact property lines 103-107 protect on theListenTLSfailure path.🛠️ Close the server on the failure path
🤖 Prompt for AI Agents