a73x

9f7b4f69

feat(ssh): bring-your-own per-tenant SSH user CA

a73x   2026-07-25 15:28

Commit message
feat(ssh): bring-your-own per-tenant SSH user CA

Tenants upload their own SSH user CA set and eitri holds no user signing
key — user certs come only from tenant CAs. A tenant_user_cas table backs
the trusted set, the gate trusts the DB-backed CAs and stamps the tenant
from the signing CA, /ssh-ca serves the host CA, and the sync service
pushes the per-tenant CA set to guests. Upload is a precondition of VM
create.

cmd/eitri-mcp/main.go
Old New
@@ -1,11 +1,17 @@
1 // Command eitri-mcp is an MCP server exposing eitri VM tools to Claude: 1 // Command eitri-mcp is an MCP server exposing eitri VM tools to Claude:
2 // create/list/info/exec/write_file/read_file/destroy. It is a client of the 2 // create/list/info/exec/write_file/read_file/destroy. It is a client of the
3 // eitri API plus SSH; it embeds no control-plane code. Wiring only — logic 3 // eitri API plus SSH; it embeds no control-plane code. It holds its OWN
4 // lives in internal/mcpserver. 4 // persistent user CA, uploads that CA's public key to its tenant once at
5 // startup, and self-signs short-lived user certs locally (BYO model — the
6 // server no longer mints user certs). Wiring only — logic lives in
7 // internal/mcpserver.
5 package main 8 package main
6 9
7 import ( 10 import (
8 "context" 11 "context"
12 "crypto/ed25519"
13 "crypto/rand"
14 "encoding/pem"
9 "flag" 15 "flag"
10 "fmt" 16 "fmt"
11 "os" 17 "os"
@@ -15,6 +21,7 @@ import (
15 "github.com/a73x/eitri/internal/mcpserver" 21 "github.com/a73x/eitri/internal/mcpserver"
16 "github.com/a73x/eitri/internal/server/api/client" 22 "github.com/a73x/eitri/internal/server/api/client"
17 "github.com/modelcontextprotocol/go-sdk/mcp" 23 "github.com/modelcontextprotocol/go-sdk/mcp"
24 "golang.org/x/crypto/ssh"
18 ) 25 )
19 26
20 func main() { 27 func main() {
@@ -36,16 +43,24 @@ func run(cfgPath string) error {
36 if err != nil { 43 if err != nil {
37 return err 44 return err
38 } 45 }
46 // This client owns its own persistent user CA (BYO model): it self-signs
47 // short-lived user certs locally rather than asking the server to mint them.
48 userCA, err := loadOrCreateCA(cfg.CAKeyPath)
49 if err != nil {
50 return fmt.Errorf("load mcp user CA: %w", err)
51 }
39 // The Runner reaches VMs by name through the eitri SSH-CA jump gate: it 52 // The Runner reaches VMs by name through the eitri SSH-CA jump gate: it
40 // authenticates with short-lived CA-signed user certs (minted on demand) and 53 // authenticates with short-lived user certs it self-signs on demand with its
41 // verifies both hops' host certs against the eitri CA. GateAuth is backed by 54 // own user CA, and verifies both hops' host certs against the eitri host CA.
42 // the same API client. 55 // The user CA's public key is uploaded to the tenant once (Register, below)
56 // so VMs trust those certs. GateAuth is backed by the same API client.
43 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.AdminToken} 57 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.AdminToken}
58 gateAuth := mcpserver.NewGateAuth(api, userCA, cfg.Tenant, nil)
44 tools := &mcpserver.Tools{ 59 tools := &mcpserver.Tools{
45 API: mcpserver.API{Client: api}, 60 API: mcpserver.API{Client: api},
46 Runner: mcpserver.NewRunner(mcpserver.RunnerConfig{ 61 Runner: mcpserver.NewRunner(mcpserver.RunnerConfig{
47 Gate: cfg.Gate, 62 Gate: cfg.Gate,
48 Auth: mcpserver.NewGateAuth(api, nil), 63 Auth: gateAuth,
49 VMUser: cfg.VMUser, 64 VMUser: cfg.VMUser,
50 }), 65 }),
51 Gate: cfg.Gate, 66 Gate: cfg.Gate,
@@ -63,9 +78,60 @@ func run(cfgPath string) error {
63 78
64 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 79 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
65 defer stop() 80 defer stop()
81
82 // Upload our user CA to the tenant before serving, so vm_create's precondition
83 // (a registered user CA) is satisfied and VMs trust the certs we sign.
84 if err := gateAuth.Register(ctx); err != nil {
85 return fmt.Errorf("register mcp user CA: %w", err)
86 }
66 return server.Run(ctx, &mcp.StdioTransport{}) 87 return server.Run(ctx, &mcp.StdioTransport{})
67 } 88 }
68 89
90 // loadOrCreateCA returns a stable ssh.Signer for the key at path. If the file
91 // is absent it generates a fresh ed25519 key, writes it 0600 with O_EXCL (so a
92 // concurrent creator can't clobber it and a symlink can't be followed), and
93 // returns its signer; if present it parses and returns the existing key. This
94 // is deliberately local to the MCP (a pure API client) and does NOT import the
95 // server's sshca package. Never logs or returns key material in errors.
96 func loadOrCreateCA(path string) (ssh.Signer, error) {
97 pemBytes, err := os.ReadFile(path)
98 if err == nil {
99 signer, perr := ssh.ParsePrivateKey(pemBytes)
100 if perr != nil {
101 return nil, fmt.Errorf("parse ssh key %q: %w", path, perr)
102 }
103 return signer, nil
104 }
105 if !os.IsNotExist(err) {
106 return nil, fmt.Errorf("read ssh key %q: %w", path, err)
107 }
108
109 _, priv, err := ed25519.GenerateKey(rand.Reader)
110 if err != nil {
111 return nil, fmt.Errorf("generate ssh key: %w", err)
112 }
113 block, err := ssh.MarshalPrivateKey(priv, "")
114 if err != nil {
115 return nil, fmt.Errorf("marshal ssh key: %w", err)
116 }
117 signer, err := ssh.NewSignerFromSigner(priv)
118 if err != nil {
119 return nil, fmt.Errorf("new signer: %w", err)
120 }
121 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
122 if err != nil {
123 return nil, fmt.Errorf("create ssh key %q: %w", path, err)
124 }
125 if _, werr := f.Write(pem.EncodeToMemory(block)); werr != nil {
126 f.Close()
127 return nil, fmt.Errorf("write ssh key %q: %w", path, werr)
128 }
129 if cerr := f.Close(); cerr != nil {
130 return nil, fmt.Errorf("close ssh key %q: %w", path, cerr)
131 }
132 return signer, nil
133 }
134
69 // register adapts a Tools method to the SDK. This is the ONLY place that 135 // register adapts a Tools method to the SDK. This is the ONLY place that
70 // touches SDK generics; if the SDK's handler signature changes, change it here. 136 // touches SDK generics; if the SDK's handler signature changes, change it here.
71 // 137 //
cmd/eitri-server/main.go
Old New
@@ -193,8 +193,6 @@ func main() {
193 sshGate.wireAPI(a) 193 sshGate.wireAPI(a)
194 194
195 svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) 195 svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge)
196 // Advertise the user-CA public key in desired-VM snapshots (no-op when off).
197 sshGate.wireSync(svc)
198 // Console broker: the API bridges browser WebSockets to agent console 196 // Console broker: the API bridges browser WebSockets to agent console
199 // streams over the live sync connections the service tracks. 197 // streams over the live sync connections the service tracks.
200 a.SetConsoleDialer(svc) 198 a.SetConsoleDialer(svc)
cmd/eitri-server/sshgate.go
Old New
@@ -19,16 +19,14 @@ import (
19 // A nil *sshGateSetup means the gate is OFF: every method is a no-op on a nil 19 // A nil *sshGateSetup means the gate is OFF: every method is a no-op on a nil
20 // receiver, so main holds one value instead of repeating `!= nil` guards. 20 // receiver, so main holds one value instead of repeating `!= nil` guards.
21 type sshGateSetup struct { 21 type sshGateSetup struct {
22 ca *sshca.CA 22 ca *sshca.CA
23 certTTL time.Duration 23 listen string // cfg.SSHListen
24 listen string // cfg.SSHListen 24 domain string // cfg.SSHGateDomain
25 domain string // cfg.SSHGateDomain
26 } 25 }
27 26
28 // setupSSHGate wires the SSH jump gate (§B): OFF unless ssh_listen is set 27 // setupSSHGate wires the SSH jump gate (§B): OFF unless ssh_listen is set
29 // (returns nil). When enabled, load or create the persistent user CA + gate 28 // (returns nil). When enabled, load or create the persistent user CA + gate
30 // host key (0600, never logged) and resolve the cert TTL now so a misconfig 29 // host key (0600, never logged).
31 // fails fast at startup.
32 func setupSSHGate(cfg config) *sshGateSetup { 30 func setupSSHGate(cfg config) *sshGateSetup {
33 if cfg.SSHListen == "" { 31 if cfg.SSHListen == "" {
34 return nil 32 return nil
@@ -37,47 +35,36 @@ func setupSSHGate(cfg config) *sshGateSetup {
37 slog.Error("ssh_ca_key and ssh_host_key are required when ssh_listen is set") 35 slog.Error("ssh_ca_key and ssh_host_key are required when ssh_listen is set")
38 os.Exit(1) 36 os.Exit(1)
39 } 37 }
40 sshCertTTL := parseDurationCfg("ssh_cert_ttl", cfg.SSHCertTTL, 10*time.Minute,
41 func(d time.Duration) bool { return d > 0 }, "> 0")
42 sshGate, err := sshca.New(cfg.SSHCAKey, cfg.SSHHostKey) 38 sshGate, err := sshca.New(cfg.SSHCAKey, cfg.SSHHostKey)
43 if err != nil { 39 if err != nil {
44 slog.Error("ssh ca", "err", err) 40 slog.Error("ssh ca", "err", err)
45 os.Exit(1) 41 os.Exit(1)
46 } 42 }
47 // Log the CA identity operators pin in known_hosts / inject into VMs. 43 // Log the HOST CA identity operators pin via @cert-authority for gate + VM
48 // Only the *public* key is ever logged (private material never is). 44 // host verification. Only the *public* key is ever logged (private material
45 // never is). eitri holds no user CA — those are BYO per-tenant.
49 slog.Info("ssh jump gate configured", "listen", cfg.SSHListen, 46 slog.Info("ssh jump gate configured", "listen", cfg.SSHListen,
50 "cert_ttl", sshCertTTL, 47 "host_ca", string(sshGate.HostCAAuthorizedKey()))
51 "user_ca", string(sshGate.UserCAAuthorizedKey()))
52 // The gate listener itself is started later (startListener), once 48 // The gate listener itself is started later (startListener), once
53 // syncsvc.Service (the tunnel dialer) exists. 49 // syncsvc.Service (the tunnel dialer) exists.
54 return &sshGateSetup{ca: sshGate, certTTL: sshCertTTL, listen: cfg.SSHListen, domain: cfg.SSHGateDomain} 50 return &sshGateSetup{ca: sshGate, listen: cfg.SSHListen, domain: cfg.SSHGateDomain}
55 } 51 }
56 52
57 // wireAPI installs the SSH cert minter: when the jump gate is enabled, the API 53 // wireAPI installs the per-VM host-cert minter and publishes the HOST CA: when
58 // mints short-lived user certs signed by the persistent user CA 54 // the jump gate is enabled, eitri signs a persistent host key + cert at each VM
59 // (POST /api/v1/ssh-certs). Left nil when the gate is off, so the endpoint 404s. 55 // create and serves the host CA pubkey via GET /api/v1/ssh-ca. eitri never
56 // mints user certs — user CAs are BYO per-tenant (uploaded, never held here).
57 // Left unwired when the gate is off, so the ssh-ca endpoint 404s.
60 func (g *sshGateSetup) wireAPI(a *api.API) { 58 func (g *sshGateSetup) wireAPI(a *api.API) {
61 if g == nil { 59 if g == nil {
62 return 60 return
63 } 61 }
64 a.SetCertMinter(api.NewMinter(g.ca.UserCA(), g.certTTL))
65 // Per-VM host certs: sign a persistent host key + cert at each VM create, 62 // Per-VM host certs: sign a persistent host key + cert at each VM create,
66 // so VMs present verifiable host keys (clients accept via @cert-authority). 63 // so VMs present verifiable host keys (clients accept via @cert-authority).
67 a.SetHostCertMinter(api.NewHostMinter(g.ca.UserCA())) 64 a.SetHostCertMinter(api.NewHostMinter(g.ca.HostCA()))
68 // Publish the CA public key so clients can pin `@cert-authority` for host 65 // Publish the HOST CA public key so clients can pin `@cert-authority` for
69 // verification of both the gate and every VM. 66 // host verification of both the gate and every VM.
70 a.SetSSHCAAuthorizedKey(string(g.ca.UserCAAuthorizedKey())) 67 a.SetSSHCAAuthorizedKey(string(g.ca.HostCAAuthorizedKey()))
71 }
72
73 // wireSync: when the jump gate is enabled, advertise the user-CA public key in
74 // every desired-VM snapshot so guests inject it as an sshd TrustedUserCAKeys
75 // drop-in and trust CA-signed certs. Off (nil gate) => no injection.
76 func (g *sshGateSetup) wireSync(svc *syncsvc.Service) {
77 if g == nil {
78 return
79 }
80 svc.SetSSHUserCAKey(string(g.ca.UserCAAuthorizedKey()))
81 } 68 }
82 69
83 // startListener starts the SSH jump gate listener: when enabled, front 70 // startListener starts the SSH jump gate listener: when enabled, front
@@ -121,7 +108,7 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service) {
121 // Sign a long-lived HOST cert for the gate's own host key and present THAT 108 // Sign a long-lived HOST cert for the gate's own host key and present THAT
122 // (via a cert signer) instead of the bare key, so a client verifying with 109 // (via a cert signer) instead of the bare key, so a client verifying with
123 // `@cert-authority` accepts the gate on first connect — no TOFU window. 110 // `@cert-authority` accepts the gate on first connect — no TOFU window.
124 gateCert, err := sshca.SignHostCert(g.ca.UserCA(), g.ca.HostKey().PublicKey(), 111 gateCert, err := sshca.SignHostCert(g.ca.HostCA(), g.ca.HostKey().PublicKey(),
125 []string{gateDomain}, "eitri-gate", time.Now(), sshca.HostCertTTL) 112 []string{gateDomain}, "eitri-gate", time.Now(), sshca.HostCertTTL)
126 if err != nil { 113 if err != nil {
127 slog.Error("sign gate host cert", "err", err) 114 slog.Error("sign gate host cert", "err", err)
@@ -145,7 +132,19 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service) {
145 } 132 }
146 return revoked 133 return revoked
147 } 134 }
148 gate := sshgate.New(gateHostSigner, g.ca.UserCA().PublicKey(), store.DefaultTenant, resolve, authorize, svc.OpenTCP, isRevoked) 135 // The gate trusts the DB-registered set of tenant user CAs and stamps each
136 // connection with the tenant that registered the signing CA. Look up by the
137 // SAME canonical authorized_keys line the store persists (ca_pubkey), so the
138 // bytes agree. A lookup error fails closed (rejects the cert).
139 userCALookup := func(pub ssh.PublicKey) (string, bool) {
140 tenant, ok, err := st.TenantForUserCA(sshca.AuthorizedKeyLine(pub))
141 if err != nil {
142 slog.Error("tenant user-ca lookup failed; rejecting", "err", err)
143 return "", false
144 }
145 return tenant, ok
146 }
147 gate := sshgate.New(gateHostSigner, userCALookup, resolve, authorize, svc.OpenTCP, isRevoked)
149 ln, err := net.Listen("tcp", g.listen) 148 ln, err := net.Listen("tcp", g.listen)
150 if err != nil { 149 if err != nil {
151 slog.Error("ssh gate listen", "err", err) 150 slog.Error("ssh gate listen", "err", err)
docs/credential-revocation.md
Old New
@@ -42,9 +42,28 @@ mechanism.
42 42
43 ## SSH user certificates 43 ## SSH user certificates
44 44
45 Guest SSH access uses short-lived certificates minted by the server's SSH CA 45 Guest SSH access uses short-lived certificates self-signed with a tenant's own
46 (`POST /api/v1/ssh-certs`). A short TTL (`ssh_cert_ttl`, default 10m, set 46 user CA (see [ssh-access.md](ssh-access.md)) — eitri holds no user signing key.
47 server-side) is the first line of defense: a leaked user cert expires on its 47 The short validity you sign with (`hack/eitri-ssh` uses 30 minutes) is the
48 own within minutes. Before it does, a specific cert can be revoked at the gate 48 first line of defense: a leaked cert expires on its own.
49 by serial (`POST /api/v1/ssh-certs/revoke`, idempotent; the gate rejects 49
50 revoked serials at auth) — pass either the raw serial or the full cert line. 50 Before it does, a specific cert can be revoked at the gate by serial
51 (admin-authed, idempotent):
52
53 ```
54 POST /api/v1/ssh-certs/revoke {"serial": N} or {"certificate": "<cert line>"}
55 GET /api/v1/ssh-certs/revoked
56 ```
57
58 The by-line form extracts the serial from a pasted cert. The gate rejects
59 revoked serials at auth, and the revocation is recorded in the audit log
60 (`ssh-cert.revoke`).
61
62 A compromised **tenant user CA** is the bigger event: the tenant's CA must be
63 replaced and its VMs re-seeded to drop trust in the old one — fast CA-level
64 revocation is an open follow-up.
65
66 ## Related
67
68 - [ssh-access.md](ssh-access.md) — how user and host certs work
69 - [cert-rotation.md](cert-rotation.md) — rotating the server's QUIC identity
docs/openapi.json
Old New
@@ -278,64 +278,76 @@
278 ], 278 ],
279 "type": "object" 279 "type": "object"
280 }, 280 },
281 "SSHCertRequest": { 281 "StateSnapshot": {
282 "properties": { 282 "properties": {
283 "principals": { 283 "hosts": {
284 "items": { 284 "items": {
285 "type": "string" 285 "$ref": "#/components/schemas/Host"
286 }, 286 },
287 "type": "array" 287 "type": "array"
288 }, 288 },
289 "public_key": { 289 "vms": {
290 "items": {
291 "$ref": "#/components/schemas/VM"
292 },
293 "type": "array"
294 }
295 },
296 "required": [
297 "hosts",
298 "vms"
299 ],
300 "type": "object"
301 },
302 "StreamTicketResponse": {
303 "properties": {
304 "ticket": {
290 "type": "string" 305 "type": "string"
291 } 306 }
292 }, 307 },
308 "required": [
309 "ticket"
310 ],
293 "type": "object" 311 "type": "object"
294 }, 312 },
295 "SSHCertResponse": { 313 "UserCA": {
296 "properties": { 314 "properties": {
297 "certificate": { 315 "fingerprint": {
316 "type": "string"
317 },
318 "label": {
298 "type": "string" 319 "type": "string"
299 }, 320 },
300 "tenant": { 321 "pubkey": {
301 "type": "string" 322 "type": "string"
302 } 323 }
303 }, 324 },
304 "required": [ 325 "required": [
305 "certificate", 326 "fingerprint",
306 "tenant" 327 "label",
328 "pubkey"
307 ], 329 ],
308 "type": "object" 330 "type": "object"
309 }, 331 },
310 "StateSnapshot": { 332 "UserCARequest": {
311 "properties": { 333 "properties": {
312 "hosts": { 334 "label": {
313 "items": { 335 "type": "string"
314 "$ref": "#/components/schemas/Host"
315 },
316 "type": "array"
317 }, 336 },
318 "vms": { 337 "public_key": {
319 "items": { 338 "type": "string"
320 "$ref": "#/components/schemas/VM"
321 },
322 "type": "array"
323 } 339 }
324 }, 340 },
325 "required": [
326 "hosts",
327 "vms"
328 ],
329 "type": "object" 341 "type": "object"
330 }, 342 },
331 "StreamTicketResponse": { 343 "UserCAUploadResponse": {
332 "properties": { 344 "properties": {
333 "ticket": { 345 "fingerprint": {
334 "type": "string" 346 "type": "string"
335 } 347 }
336 }, 348 },
337 "required": [ 349 "required": [
338 "ticket" 350 "fingerprint"
339 ], 351 ],
340 "type": "object" 352 "type": "object"
341 }, 353 },
@@ -715,27 +727,55 @@
715 "description": "error (plain text)" 727 "description": "error (plain text)"
716 } 728 }
717 }, 729 },
718 "summary": "The eitri SSH CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off." 730 "summary": "The eitri SSH host CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off."
719 } 731 }
720 }, 732 },
721 "/api/v1/ssh-certs": { 733 "/api/v1/ssh-certs/revoke": {
722 "post": { 734 "post": {
723 "requestBody": { 735 "requestBody": {
724 "content": { 736 "content": {
725 "application/json": { 737 "application/json": {
726 "schema": { 738 "schema": {
727 "$ref": "#/components/schemas/SSHCertRequest" 739 "$ref": "#/components/schemas/RevokeSSHCertRequest"
728 } 740 }
729 } 741 }
730 }, 742 },
731 "required": true 743 "required": true
732 }, 744 },
733 "responses": { 745 "responses": {
746 "204": {
747 "description": "success"
748 },
749 "default": {
750 "content": {
751 "text/plain": {
752 "schema": {
753 "type": "string"
754 }
755 }
756 },
757 "description": "error (plain text)"
758 }
759 },
760 "security": [
761 {
762 "adminToken": []
763 }
764 ],
765 "summary": "Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent."
766 }
767 },
768 "/api/v1/ssh-certs/revoked": {
769 "get": {
770 "responses": {
734 "200": { 771 "200": {
735 "content": { 772 "content": {
736 "application/json": { 773 "application/json": {
737 "schema": { 774 "schema": {
738 "$ref": "#/components/schemas/SSHCertResponse" 775 "items": {
776 "$ref": "#/components/schemas/RevokedCert"
777 },
778 "type": "array"
739 } 779 }
740 } 780 }
741 }, 781 },
@@ -757,16 +797,58 @@
757 "adminToken": [] 797 "adminToken": []
758 } 798 }
759 ], 799 ],
760 "summary": "Mint a short-lived SSH user certificate for the caller's public key. 404 when the jump gate is off (no CA wired)." 800 "summary": "List revoked SSH user certificate serials (with reason and time), newest first."
761 } 801 }
762 }, 802 },
763 "/api/v1/ssh-certs/revoke": { 803 "/api/v1/stream-tickets": {
764 "post": { 804 "post": {
805 "responses": {
806 "201": {
807 "content": {
808 "application/json": {
809 "schema": {
810 "$ref": "#/components/schemas/StreamTicketResponse"
811 }
812 }
813 },
814 "description": "success"
815 },
816 "default": {
817 "content": {
818 "text/plain": {
819 "schema": {
820 "type": "string"
821 }
822 }
823 },
824 "description": "error (plain text)"
825 }
826 },
827 "security": [
828 {
829 "adminToken": []
830 }
831 ],
832 "summary": "Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL."
833 }
834 },
835 "/api/v1/tenants/{tenant}/user-cas": {
836 "delete": {
837 "parameters": [
838 {
839 "in": "path",
840 "name": "tenant",
841 "required": true,
842 "schema": {
843 "type": "string"
844 }
845 }
846 ],
765 "requestBody": { 847 "requestBody": {
766 "content": { 848 "content": {
767 "application/json": { 849 "application/json": {
768 "schema": { 850 "schema": {
769 "$ref": "#/components/schemas/RevokeSSHCertRequest" 851 "$ref": "#/components/schemas/UserCARequest"
770 } 852 }
771 } 853 }
772 }, 854 },
@@ -792,18 +874,26 @@
792 "adminToken": [] 874 "adminToken": []
793 } 875 }
794 ], 876 ],
795 "summary": "Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent." 877 "summary": "Remove a registered SSH user CA by its public_key line."
796 } 878 },
797 },
798 "/api/v1/ssh-certs/revoked": {
799 "get": { 879 "get": {
880 "parameters": [
881 {
882 "in": "path",
883 "name": "tenant",
884 "required": true,
885 "schema": {
886 "type": "string"
887 }
888 }
889 ],
800 "responses": { 890 "responses": {
801 "200": { 891 "200": {
802 "content": { 892 "content": {
803 "application/json": { 893 "application/json": {
804 "schema": { 894 "schema": {
805 "items": { 895 "items": {
806 "$ref": "#/components/schemas/RevokedCert" 896 "$ref": "#/components/schemas/UserCA"
807 }, 897 },
808 "type": "array" 898 "type": "array"
809 } 899 }
@@ -827,17 +917,35 @@
827 "adminToken": [] 917 "adminToken": []
828 } 918 }
829 ], 919 ],
830 "summary": "List revoked SSH user certificate serials (with reason and time), newest first." 920 "summary": "List the tenant's registered SSH user CAs (pubkey, label, fingerprint)."
831 } 921 },
832 },
833 "/api/v1/stream-tickets": {
834 "post": { 922 "post": {
923 "parameters": [
924 {
925 "in": "path",
926 "name": "tenant",
927 "required": true,
928 "schema": {
929 "type": "string"
930 }
931 }
932 ],
933 "requestBody": {
934 "content": {
935 "application/json": {
936 "schema": {
937 "$ref": "#/components/schemas/UserCARequest"
938 }
939 }
940 },
941 "required": true
942 },
835 "responses": { 943 "responses": {
836 "201": { 944 "201": {
837 "content": { 945 "content": {
838 "application/json": { 946 "application/json": {
839 "schema": { 947 "schema": {
840 "$ref": "#/components/schemas/StreamTicketResponse" 948 "$ref": "#/components/schemas/UserCAUploadResponse"
841 } 949 }
842 } 950 }
843 }, 951 },
@@ -859,7 +967,7 @@
859 "adminToken": [] 967 "adminToken": []
860 } 968 }
861 ], 969 ],
862 "summary": "Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL." 970 "summary": "Register a BYO SSH user CA public key for the tenant; eitri never holds a user signing key."
863 } 971 }
864 }, 972 },
865 "/api/v1/vms": { 973 "/api/v1/vms": {
@@ -934,7 +1042,7 @@
934 "adminToken": [] 1042 "adminToken": []
935 } 1043 }
936 ], 1044 ],
937 "summary": "Create a VM on a host. Omitted fields get one-click defaults." 1045 "summary": "Create a VM on a host. Omitted fields get one-click defaults; the tenant must have a registered SSH user CA first."
938 } 1046 }
939 }, 1047 },
940 "/api/v1/vms/{id}": { 1048 "/api/v1/vms/{id}": {
docs/ssh-access.md
Old New
@@ -2,22 +2,43 @@
2 2
3 eitri runs an SSH **jump gate**: a bastion that accepts an `ssh -J` hop and 3 eitri runs an SSH **jump gate**: a bastion that accepts an `ssh -J` hop and
4 forwards you to a VM's SSHd. You authenticate to the gate with a **short-lived 4 forwards you to a VM's SSHd. You authenticate to the gate with a **short-lived
5 SSH user certificate** minted by the eitri server and signed by eitri's user CA. 5 SSH user certificate that you sign yourself**, with your tenant's own user CA —
6 Every VM built from the eitri seed already trusts that CA, so no per-VM key 6 eitri never holds a user signing key. Every VM in your tenant trusts your
7 management is needed. The cert carries the principal `ubuntu`, which is the login 7 tenant's user CAs (seeded at VM create), so no per-VM key management is needed.
8 user on the VM. 8 The cert carries the principal `ubuntu`, which is the login user on the VM.
9 9
10 Verification runs **both ways**. Just as the VM trusts your user cert, you 10 Verification runs **both ways**. Just as the VM trusts your user cert, you
11 verify what you connect to: the gate and every VM present a **host certificate** 11 verify what you connect to: the gate and every VM present a **host certificate**
12 signed by the same eitri CA. You pin the CA once (`@cert-authority`) and both 12 signed by eitri's host CA. You pin that CA once (`@cert-authority`) and both
13 hops are then verified by certificate — no blind trust-on-first-use, and no 13 hops are then verified by certificate — no blind trust-on-first-use, and no
14 host-key-changed warnings when VM names or IPs are recycled. 14 host-key-changed warnings when VM names or IPs are recycled.
15 15
16 ## One-liner 16 Two CAs, two directions: **your tenant's user CA** (private key on your machine)
17 signs what you present; **eitri's host CA** (private key on the server) signs
18 what the gate and VMs present.
19
20 ## Bring your own CA (once per tenant)
21
22 Generate a user CA and register its **public** key with your tenant
23 (admin-authed, `POST /api/v1/tenants/<tenant>/user-cas`):
17 24
18 ```sh 25 ```sh
26 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA"
27
19 export EITRI_URL=https://eitri.example.com 28 export EITRI_URL=https://eitri.example.com
20 export EITRI_TOKEN=<admin-bearer-token> 29 export EITRI_TOKEN=<admin-bearer-token>
30 hack/eitri-ca upload default ~/.ssh/eitri_user_ca.pub
31 ```
32
33 The CA's private key never leaves your machine; the server stores only the
34 public key. Upload the CA **before creating VMs** — a VM trusts the tenant user
35 CAs present at its creation. The gate authorizes each connection against the
36 tenant the signing CA was uploaded to.
37
38 ## One-liner
39
40 ```sh
41 export EITRI_URL=https://eitri.example.com
21 export EITRI_GATE=eitri.example.com:2222 # the gate's ssh_listen address 42 export EITRI_GATE=eitri.example.com:2222 # the gate's ssh_listen address
22 43
23 hack/eitri-ssh <vm-name> # opens a shell on the VM 44 hack/eitri-ssh <vm-name> # opens a shell on the VM
@@ -26,21 +47,24 @@ hack/eitri-ssh <vm-name> uptime # runs a command and exits
26 47
27 You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect 48 You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect
28 name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `hack/eitri-ssh` 49 name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `hack/eitri-ssh`
29 builds it automatically from the tenant the mint response returns. 50 builds it from `EITRI_TENANT`.
30 51
31 Environment variables: 52 Environment variables:
32 53
33 | Var | Meaning | 54 | Var | Meaning |
34 | ------------- | --------------------------------------------------------- | 55 | ------------- | --------------------------------------------------------- |
35 | `EITRI_URL` | Base URL of the eitri server | 56 | `EITRI_URL` | Base URL of the eitri server |
36 | `EITRI_TOKEN` | Admin bearer token used to mint the cert | 57 | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`) |
37 | `EITRI_GATE` | Jump gate address for `ssh -J` (host:port, `ssh_listen`) | 58 | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) |
59 | `EITRI_TENANT`| Tenant your CA was uploaded to (default `default`) |
38 | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) | 60 | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) |
39 | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) | 61 | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) |
40 62
41 The helper generates `~/.ssh/id_ed25519` if it is missing, mints a cert, writes 63 No token: the helper needs no API credential — your signing CA *is* the
42 it to `<key>-cert.pub`, fetches the eitri CA and pins it as `@cert-authority *` 64 credential. It generates `~/.ssh/id_ed25519` if missing, self-signs a 30-minute
43 in a dedicated known_hosts file, and execs `ssh`. 65 cert to `<key>-cert.pub` (which OpenSSH auto-offers), fetches the eitri host CA
66 and pins it as `@cert-authority *` in a dedicated known_hosts file, and execs
67 `ssh` with both hops verified.
44 68
45 > The host `EITRI_GATE` points at **must match** the gate's host-cert principal, 69 > The host `EITRI_GATE` points at **must match** the gate's host-cert principal,
46 > i.e. the server's `ssh_gate_domain` (which defaults to the host part of 70 > i.e. the server's `ssh_gate_domain` (which defaults to the host part of
@@ -50,22 +74,17 @@ in a dedicated known_hosts file, and execs `ssh`.
50 74
51 The helper is a thin wrapper over three steps you can run by hand: 75 The helper is a thin wrapper over three steps you can run by hand:
52 76
53 1. **Mint a cert** for your public key (admin-authed): 77 1. **Self-sign a cert** for your public key with your tenant CA — no server
78 involved:
54 79
55 ```sh 80 ```sh
56 curl -sS \ 81 ssh-keygen -s ~/.ssh/eitri_user_ca -I "$(whoami)@$(hostname)" \
57 -H "Authorization: Bearer $EITRI_TOKEN" \ 82 -n ubuntu -V +30m ~/.ssh/id_ed25519.pub
58 -H 'Content-Type: application/json' \
59 -d "{\"public_key\":\"$(cat ~/.ssh/id_ed25519.pub)\"}" \
60 "$EITRI_URL/api/v1/ssh-certs" | jq -r .certificate > ~/.ssh/id_ed25519-cert.pub
61 ``` 83 ```
62 84
63 The same response also carries a `.tenant` field — the prefix of the 85 2. **Place the cert beside the key.** `ssh-keygen -s` writes
64 `<tenant>.<vm-name>` connect name you dial in step 3. 86 `id_ed25519-cert.pub` next to the key, and OpenSSH auto-offers a cert named
65 87 `<key>-cert.pub` — nothing further needed, no `ssh-add`.
66 2. **Place the cert beside the key.** OpenSSH auto-offers a cert named
67 `<key>-cert.pub` next to `<key>`, so the write above is all that's needed —
68 no `ssh-add` required.
69 88
70 3. **Hop through the gate** to `ubuntu@<tenant>.<vm-name>`: 89 3. **Hop through the gate** to `ubuntu@<tenant>.<vm-name>`:
71 90
@@ -74,25 +93,26 @@ The helper is a thin wrapper over three steps you can run by hand:
74 ``` 93 ```
75 94
76 The inner user must be `ubuntu` (the cert principal). The outer gate hop 95 The inner user must be `ubuntu` (the cert principal). The outer gate hop
77 accepts any username. The host part is the `<tenant>.<vm-name>` connect name 96 accepts any username. The gate derives your tenant from the CA that signed
78 (the tenant is the `.tenant` from the mint response); the gate resolves names 97 your cert, resolves names within that tenant, and rejects a bare or
79 within your tenant and rejects a bare or foreign-prefixed name. 98 foreign-prefixed name.
80 99
81 ## Certs are short-lived 100 ## Certs are short-lived
82 101
83 Minted certs have a short TTL. When one expires, ssh will simply be rejected — 102 Self-signed certs should carry a short validity (`-V +30m` above). When one
84 re-run `hack/eitri-ssh` (or the mint step) to refresh. Nothing to revoke. 103 expires, ssh is simply rejected — re-run `hack/eitri-ssh` (or the signing step)
104 to refresh. A specific cert can also be revoked at the gate by serial before it
105 expires; see [credential-revocation.md](credential-revocation.md).
85 106
86 ## Host verification (via the CA) 107 ## Host verification (via the CA)
87 108
88 The gate and every VM present a **host certificate** signed by the eitri CA 109 The gate and every VM present a **host certificate** signed by eitri's host CA.
89 (the same CA that signs your user certs — it doubles as the host CA). You verify 110 You verify them by pinning that CA once as a `@cert-authority` entry, rather
90 them by pinning the CA once as a `@cert-authority` entry, rather than 111 than trust-on-first-use.
91 trust-on-first-use.
92 112
93 Fetch the CA (public material, no token needed) and pin it in a **dedicated** 113 Fetch the host CA (public material, no token needed) and pin it in a
94 known_hosts file — never your main `~/.ssh/known_hosts`, where a `*` wildcard 114 **dedicated** known_hosts file — never your main `~/.ssh/known_hosts`, where a
95 CA would be trusted for *every* host you ssh to: 115 `*` wildcard CA would be trusted for *every* host you ssh to:
96 116
97 ```sh 117 ```sh
98 curl -sS "$EITRI_URL/api/v1/ssh-ca" | jq -r .ca \ 118 curl -sS "$EITRI_URL/api/v1/ssh-ca" | jq -r .ca \
@@ -120,3 +140,9 @@ inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by
120 CA, recycling a VM name or IP never produces a host-key-changed warning — the 140 CA, recycling a VM name or IP never produces a host-key-changed warning — the
121 new VM simply presents a fresh CA-signed cert for that name. `hack/eitri-ssh` 141 new VM simply presents a fresh CA-signed cert for that name. `hack/eitri-ssh`
122 does all of this for you. 142 does all of this for you.
143
144 ## Related
145
146 - [credential-revocation.md](credential-revocation.md) — revoking a leaked user
147 cert or host credential
148 - [cert-rotation.md](cert-rotation.md) — rotating the server's QUIC identity
hack/eitri-ca
Old New
@@ -0,0 +1,14 @@
1 #!/usr/bin/env bash
2 # eitri-ca — register a BYO user-CA public key with a tenant.
3 # eitri-ca upload [<tenant>] <ca-public-key-file>
4 # Env: EITRI_URL, EITRI_TOKEN (admin). Tenant defaults to "default".
5 set -eu
6 [ "${1:-}" = upload ] || { echo "usage: eitri-ca upload [<tenant>] <ca.pub>" >&2; exit 2; }
7 shift
8 if [ "$#" -eq 2 ]; then TENANT=$1; PUBFILE=$2; else TENANT=default; PUBFILE=$1; fi
9 : "${EITRI_URL:?}" "${EITRI_TOKEN:?}"
10 PUB=$(cat "$PUBFILE")
11 curl -fsS -X POST -H "Authorization: Bearer $EITRI_TOKEN" -H 'Content-Type: application/json' \
12 -d "{\"public_key\":$(printf '%s' "$PUB" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))')}" \
13 "$EITRI_URL/api/v1/tenants/$TENANT/user-cas"
14 echo
hack/eitri-ssh
Old New
@@ -1,7 +1,9 @@
1 #!/usr/bin/env bash 1 #!/usr/bin/env bash
2 # 2 #
3 # eitri-ssh — mint a short-lived SSH user cert from the eitri server and SSH into 3 # eitri-ssh — self-sign a short-lived SSH user cert with YOUR OWN tenant user CA
4 # a VM through the eitri jump gate in one shot. 4 # and SSH into a VM through the eitri jump gate in one shot. The server never
5 # mints user certs (and never sees your CA's private key) — it only holds the
6 # CA's public key, uploaded once via `eitri-ca upload`.
5 # 7 #
6 # Usage: 8 # Usage:
7 # eitri-ssh <vm-name> [extra ssh args/command...] 9 # eitri-ssh <vm-name> [extra ssh args/command...]
@@ -9,32 +11,34 @@
9 # 11 #
10 # Environment: 12 # Environment:
11 # EITRI_URL Base URL of the eitri server (e.g. https://eitri.example.com) 13 # EITRI_URL Base URL of the eitri server (e.g. https://eitri.example.com)
12 # EITRI_TOKEN Admin bearer token used to mint the cert
13 # EITRI_GATE Jump gate address for `ssh -J` (e.g. eitri.example.com:2222) 14 # EITRI_GATE Jump gate address for `ssh -J` (e.g. eitri.example.com:2222)
15 # EITRI_CA Path to your tenant user-CA PRIVATE key (default ~/.ssh/eitri_user_ca)
16 # EITRI_TENANT Tenant your user CA was uploaded to (default "default")
14 # EITRI_KEY Optional path to the SSH private key (default ~/.ssh/id_ed25519) 17 # EITRI_KEY Optional path to the SSH private key (default ~/.ssh/id_ed25519)
15 # EITRI_KNOWN_HOSTS Optional eitri-managed known_hosts file 18 # EITRI_KNOWN_HOSTS Optional eitri-managed known_hosts file
16 # (default ~/.ssh/eitri_known_hosts) 19 # (default ~/.ssh/eitri_known_hosts)
17 # 20 #
18 # The minted cert is written beside the key as "<key>-cert.pub", which OpenSSH 21 # The self-signed cert is written beside the key as "<key>-cert.pub", which
19 # auto-offers. The inner login user is always "ubuntu" (the cert principal); the 22 # OpenSSH auto-offers. The inner login user is always "ubuntu" (the cert
20 # outer gate hop accepts any username. Certs are short-lived — just re-run to 23 # principal); the outer gate hop accepts any username. Certs are short-lived —
21 # refresh. 24 # just re-run to refresh.
22 # 25 #
23 # VMs are dialed by their gate connect name <tenant>.<vm-name> (which is also the 26 # VMs are dialed by their gate connect name <tenant>.<vm-name> (which is also
24 # VM's host-cert principal); eitri-ssh builds it automatically from the tenant 27 # the VM's host-cert principal); eitri-ssh builds it from EITRI_TENANT, so you
25 # the mint response returns, so you pass just the bare <vm-name>. 28 # pass just the bare <vm-name>.
26 # 29 #
27 # Host verification is by CERTIFICATE, not TOFU: the helper fetches eitri's CA 30 # Host verification is by CERTIFICATE, not TOFU: the helper fetches eitri's HOST
28 # public key and pins it as a `@cert-authority *` entry in a DEDICATED 31 # CA public key (from the same `/api/v1/ssh-ca` endpoint, now serving the host
29 # known_hosts file (never your main ~/.ssh/known_hosts — a wildcard cert 32 # CA) and pins it as a `@cert-authority *` entry in a DEDICATED known_hosts file
30 # authority there would trust eitri's CA for every host you ssh to). Both the 33 # (never your main ~/.ssh/known_hosts — a wildcard cert authority there would
31 # gate hop and the VM hop are then verified against that CA with 34 # trust eitri's CA for every host you ssh to). Both the gate hop and the VM hop
32 # StrictHostKeyChecking=yes. See docs/ssh-access.md. 35 # are then verified against that CA with StrictHostKeyChecking=yes. See
36 # docs/ssh-access.md.
33 37
34 set -eu 38 set -eu
35 39
36 usage() { 40 usage() {
37 sed -n '3,20p' "$0" | sed 's/^# \{0,1\}//' 41 sed -n '3,19p' "$0" | sed 's/^# \{0,1\}//'
38 exit "${1:-0}" 42 exit "${1:-0}"
39 } 43 }
40 44
@@ -46,20 +50,19 @@ VM=$1
46 shift 50 shift
47 51
48 : "${EITRI_URL:?set EITRI_URL to the eitri server base URL}" 52 : "${EITRI_URL:?set EITRI_URL to the eitri server base URL}"
49 : "${EITRI_TOKEN:?set EITRI_TOKEN to an admin bearer token}"
50 : "${EITRI_GATE:?set EITRI_GATE to the jump gate host:port}" 53 : "${EITRI_GATE:?set EITRI_GATE to the jump gate host:port}"
51 KEY=${EITRI_KEY:-$HOME/.ssh/id_ed25519} 54 KEY=${EITRI_KEY:-$HOME/.ssh/id_ed25519}
52 # A DEDICATED known_hosts for the `@cert-authority *` pin — deliberately NOT the 55 # A DEDICATED known_hosts for the `@cert-authority *` pin — deliberately NOT the
53 # user's main known_hosts, where a wildcard CA would apply to every ssh target. 56 # user's main known_hosts, where a wildcard CA would apply to every ssh target.
54 KNOWN_HOSTS=${EITRI_KNOWN_HOSTS:-$HOME/.ssh/eitri_known_hosts} 57 KNOWN_HOSTS=${EITRI_KNOWN_HOSTS:-$HOME/.ssh/eitri_known_hosts}
58 : "${EITRI_CA:=$HOME/.ssh/eitri_user_ca}" # member's user-CA PRIVATE key
59 : "${EITRI_TENANT:=default}"
55 60
56 # json_field <name>: extract a top-level JSON string field from stdin. Uses jq 61 # json_field <name>: extract a top-level JSON string field from stdin. Uses jq
57 # when available, else a sed fallback. The capture is NON-GREEDY (`[^"]*`, not 62 # when available, else a sed fallback. The capture is NON-GREEDY (`[^"]*`, not
58 # `.*`) so a multi-field body doesn't swallow through to the last quote — the 63 # `.*`) so a multi-field body doesn't swallow through to the last quote.
59 # cause of a corrupted cert on jq-less machines now that the mint response 64 # `[^"]*` is safe because the server's JSON string values contain no raw double
60 # carries both `certificate` and `tenant`. `[^"]*` is safe because the server's 65 # quotes; `\n` escapes are unescaped so a multi-line value survives.
61 # JSON string values contain no raw double quotes; `\n` escapes are unescaped so
62 # the multi-line cert survives (a no-op for single-token fields like tenant).
63 json_field() { 66 json_field() {
64 if command -v jq >/dev/null 2>&1; then 67 if command -v jq >/dev/null 2>&1; then
65 jq -r ".$1" 68 jq -r ".$1"
@@ -74,46 +77,24 @@ if [ ! -f "$KEY" ]; then
74 ssh-keygen -t ed25519 -N '' -f "$KEY" >/dev/null 77 ssh-keygen -t ed25519 -N '' -f "$KEY" >/dev/null
75 fi 78 fi
76 79
77 # 2. Mint a cert for our public key. Capture body + HTTP status separately so a 80 # 2. Self-sign a short-lived cert for our public key with OUR OWN user CA — no
78 # non-200 prints the server's error and fails loudly. 81 # server mint involved. The server never sees (or holds) a user private key;
79 PUB=$(cat "$KEY.pub") 82 # it only ever saw the CA's PUBLIC key at `eitri-ca upload` time.
80 resp=$(curl -sS -w '\n%{http_code}' \ 83 [ -f "$EITRI_CA" ] || { echo "eitri-ssh: no user CA at $EITRI_CA (generate one and 'eitri-ca upload')" >&2; exit 1; }
81 -H "Authorization: Bearer $EITRI_TOKEN" \ 84 ssh-keygen -s "$EITRI_CA" -I "$(whoami)@$(hostname)" -n ubuntu -V +30m "$KEY.pub" >/dev/null
82 -H 'Content-Type: application/json' \ 85
83 -d "{\"public_key\":\"$PUB\"}" \ 86 # 3. Build the gate connect name <tenant>.<vm>. The gate resolves names WITHIN
84 "$EITRI_URL/api/v1/ssh-certs") 87 # a tenant and each VM's host-cert principal is the namespaced name, so the
85 code=${resp##*$'\n'} 88 # connect name must carry the tenant prefix. The tenant is no longer told
86 body=${resp%$'\n'*} 89 # to us by a mint response — it's just EITRI_TENANT, since our CA already
87 90 # only signs for the one tenant it was uploaded to.
88 if [ "$code" != "200" ]; then 91 TARGET="$EITRI_TENANT.$VM"
89 echo "eitri-ssh: mint failed (HTTP $code): $body" >&2 92
90 exit 1 93 # 4. Fetch the eitri HOST CA public key and pin it as a `@cert-authority *`
91 fi 94 # entry so BOTH hops are verified by certificate (no TOFU). The CA endpoint
92 95 # is public (no token). We overwrite the dedicated known_hosts each run so
93 # 3. Extract .certificate (jq preferred; non-greedy sed fallback via json_field). 96 # it always reflects the current CA — this file holds nothing but the eitri
94 cert=$(printf '%s' "$body" | json_field certificate) 97 # pin.
95 if [ -z "$cert" ] || [ "$cert" = "null" ]; then
96 echo "eitri-ssh: could not extract certificate from response: $body" >&2
97 exit 1
98 fi
99
100 # 3b. Extract the tenant and build the gate connect name <tenant>.<vm>. The
101 # gate resolves names WITHIN a tenant and each VM's host-cert principal is
102 # the namespaced name, so the connect name must carry the tenant prefix.
103 tenant=$(printf '%s' "$body" | json_field tenant)
104 if [ -z "$tenant" ] || [ "$tenant" = "null" ]; then
105 echo "eitri-ssh: server did not return a tenant (pre-tenancy server?); cannot build connect name" >&2
106 exit 1
107 fi
108 TARGET="$tenant.$VM"
109
110 # 4. Write it beside the key so ssh auto-offers it.
111 printf '%s\n' "$cert" >"$KEY-cert.pub"
112
113 # 5. Fetch the eitri CA public key and pin it as a `@cert-authority *` entry so
114 # BOTH hops are verified by certificate (no TOFU). The CA endpoint is public
115 # (no token). We overwrite the dedicated known_hosts each run so it always
116 # reflects the current CA — this file holds nothing but the eitri pin.
117 ca_resp=$(curl -sS -w '\n%{http_code}' "$EITRI_URL/api/v1/ssh-ca") 98 ca_resp=$(curl -sS -w '\n%{http_code}' "$EITRI_URL/api/v1/ssh-ca")
118 ca_code=${ca_resp##*$'\n'} 99 ca_code=${ca_resp##*$'\n'}
119 ca_body=${ca_resp%$'\n'*} 100 ca_body=${ca_resp%$'\n'*}
@@ -130,7 +111,7 @@ mkdir -p "$(dirname "$KNOWN_HOSTS")"
130 # Trim any trailing newline the CA line carries, then write the single pin. 111 # Trim any trailing newline the CA line carries, then write the single pin.
131 printf '@cert-authority * %s\n' "$(printf '%s' "$ca" | tr -d '\r\n')" >"$KNOWN_HOSTS" 112 printf '@cert-authority * %s\n' "$(printf '%s' "$ca" | tr -d '\r\n')" >"$KNOWN_HOSTS"
132 113
133 # 6. Hop through the gate to ubuntu@<vm>. Pass through any extra args/command. 114 # 5. Hop through the gate to ubuntu@<vm>. Pass through any extra args/command.
134 # 115 #
135 # We do NOT use `ssh -J`: command-line `-o` options (host-key checking, 116 # We do NOT use `ssh -J`: command-line `-o` options (host-key checking,
136 # known_hosts, key) reach ONLY the final hop, so on a machine with no tty the 117 # known_hosts, key) reach ONLY the final hop, so on a machine with no tty the
@@ -141,8 +122,8 @@ printf '@cert-authority * %s\n' "$(printf '%s' "$ca" | tr -d '\r\n')" >"$KNOWN_H
141 # 122 #
142 # The gate host cert's principal must match $GATE_HOST (eitri's 123 # The gate host cert's principal must match $GATE_HOST (eitri's
143 # ssh_gate_domain); each VM's host cert principal is its <tenant>.<vm-name> 124 # ssh_gate_domain); each VM's host cert principal is its <tenant>.<vm-name>
144 # connect name, which eitri-ssh builds automatically from the mint response. 125 # connect name, which eitri-ssh builds from EITRI_TENANT. A mismatch is a
145 # A mismatch is a hard failure, not a prompt — that is the point. 126 # hard failure, not a prompt — that is the point.
146 GATE_HOST=${EITRI_GATE%%:*} 127 GATE_HOST=${EITRI_GATE%%:*}
147 GATE_PORT=${EITRI_GATE##*:} 128 GATE_PORT=${EITRI_GATE##*:}
148 [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 129 [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22
internal/agent/reconcile/reconcile.go
Old New
@@ -25,6 +25,7 @@ import (
25 "encoding/json" 25 "encoding/json"
26 "errors" 26 "errors"
27 "fmt" 27 "fmt"
28 "strings"
28 "time" 29 "time"
29 30
30 "github.com/a73x/eitri/internal/agent/seed" 31 "github.com/a73x/eitri/internal/agent/seed"
@@ -443,7 +444,7 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rep *pb.ActualStat
443 InstanceID: d.VmId, 444 InstanceID: d.VmId,
444 SSHAuthorizedKey: d.SshAuthorizedKey, 445 SSHAuthorizedKey: d.SshAuthorizedKey,
445 UserData: d.CloudInit, 446 UserData: d.CloudInit,
446 SSHUserCAAuthorizedKey: d.SshUserCaAuthorizedKey, 447 SSHUserCAAuthorizedKey: joinCALines(d.GetSshUserCaAuthorizedKeys()),
447 SSHHostKeyPEM: d.SshHostKeyPem, 448 SSHHostKeyPEM: d.SshHostKeyPem,
448 SSHHostCert: d.SshHostCert, 449 SSHHostCert: d.SshHostCert,
449 }); err != nil { 450 }); err != nil {
@@ -632,6 +633,17 @@ func addReport(rep *pb.ActualStateReport, vmID, ip, power, phase, lastError stri
632 }) 633 })
633 } 634 }
634 635
636 // joinCALines renders the tenant user-CA set into the multi-line content of the
637 // guest's TrustedUserCAKeys file — one canonical CA per line, each newline-
638 // terminated. Empty set ⇒ "" ⇒ the seed writes no drop-in (gate off / no CA).
639 func joinCALines(lines []string) string {
640 var b strings.Builder
641 for _, l := range lines {
642 b.WriteString(strings.TrimRight(l, "\r\n") + "\n")
643 }
644 return b.String()
645 }
646
635 // specFromDesired maps a pb.VMDesired to state.VMSpec. 647 // specFromDesired maps a pb.VMDesired to state.VMSpec.
636 func specFromDesired(d *pb.VMDesired) state.VMSpec { 648 func specFromDesired(d *pb.VMDesired) state.VMSpec {
637 return state.VMSpec{ 649 return state.VMSpec{
internal/agent/seed/seed.go
Old New
@@ -18,8 +18,9 @@ type Params struct {
18 SSHAuthorizedKey string 18 SSHAuthorizedKey string
19 UserData string // verbatim if set; default generated otherwise 19 UserData string // verbatim if set; default generated otherwise
20 InstanceID string // used as cloud-init instance-id; falls back to Hostname when empty 20 InstanceID string // used as cloud-init instance-id; falls back to Hostname when empty
21 // SSHUserCAAuthorizedKey, when non-empty, is the eitri user-CA public key in 21 // SSHUserCAAuthorizedKey, when non-empty, is the tenant's user-CA set in
22 // authorized_keys form (from sshca.CA.UserCAAuthorizedKey). Its presence 22 // authorized_keys form — one canonical CA per line (from the agent joining the
23 // VMDesired.ssh_user_ca_authorized_keys set). Its presence
23 // makes the seed inject an sshd drop-in (TrustedUserCAKeys) so the guest 24 // makes the seed inject an sshd drop-in (TrustedUserCAKeys) so the guest
24 // trusts CA-signed user certs minted by the jump gate. Empty = no injection. 25 // trusts CA-signed user certs minted by the jump gate. Empty = no injection.
25 // Exempt from the newline check: it is embedded as a YAML block scalar (like 26 // Exempt from the newline check: it is embedded as a YAML block scalar (like
internal/mcpserver/api_test.go
Old New
@@ -6,6 +6,7 @@ import (
6 "encoding/json" 6 "encoding/json"
7 "net/http" 7 "net/http"
8 "net/http/httptest" 8 "net/http/httptest"
9 "strings"
9 "testing" 10 "testing"
10 11
11 "github.com/stretchr/testify/assert" 12 "github.com/stretchr/testify/assert"
@@ -123,64 +124,29 @@ func TestFetchSSHCANotEnabled(t *testing.T) {
123 assert.Contains(t, err.Error(), "not enabled", "error should clearly explain the gate is off") 124 assert.Contains(t, err.Error(), "not enabled", "error should clearly explain the gate is off")
124 } 125 }
125 126
126 func TestMintUserCert(t *testing.T) { 127 func TestUploadUserCA(t *testing.T) {
127 caPub, caPriv, err := ed25519.GenerateKey(rand.Reader) 128 caLine := string(ssh.MarshalAuthorizedKey(genTestKey(t)))
128 require.NoError(t, err) 129 caLine = strings.TrimSpace(caLine)
129 _ = caPub
130 caSigner, err := ssh.NewSignerFromKey(caPriv)
131 require.NoError(t, err)
132
133 userPub := genTestKey(t)
134 130
131 var gotBody map[string]string
135 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { 132 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
136 assert.Equal(t, "POST", r.Method) 133 assert.Equal(t, "POST", r.Method)
137 assert.Equal(t, "/api/v1/ssh-certs", r.URL.Path) 134 assert.Equal(t, "/api/v1/tenants/default/user-cas", r.URL.Path)
138 assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization")) 135 assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
139 var req map[string]any 136 require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
140 require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) 137 w.WriteHeader(http.StatusNoContent)
141 assert.Equal(t, string(ssh.MarshalAuthorizedKey(userPub)), req["public_key"].(string)+"\n")
142
143 cert := &ssh.Certificate{
144 Key: userPub,
145 Serial: 1,
146 CertType: ssh.UserCert,
147 KeyId: "ubuntu",
148 ValidPrincipals: []string{"ubuntu"},
149 ValidAfter: 0,
150 ValidBefore: ssh.CertTimeInfinity,
151 }
152 require.NoError(t, cert.SignCert(rand.Reader, caSigner))
153 json.NewEncoder(w).Encode(map[string]string{
154 "certificate": string(ssh.MarshalAuthorizedKey(cert)),
155 "tenant": "default",
156 })
157 }) 138 })
158 139
159 got, tenant, err := c.MintUserCert(t.Context(), userPub) 140 err := c.UploadUserCA(t.Context(), "default", caLine)
160 require.NoError(t, err) 141 require.NoError(t, err)
161 require.NotNil(t, got) 142 assert.Equal(t, caLine, gotBody["public_key"], "must POST the CA public key")
162 assert.Equal(t, uint64(1), got.Serial)
163 assert.Equal(t, []string{"ubuntu"}, got.ValidPrincipals)
164 assert.Equal(t, userPub.Marshal(), got.Key.Marshal())
165 assert.Equal(t, "default", tenant, "mint must return the response tenant")
166 } 143 }
167 144
168 func TestMintUserCertErrorDoesNotLeakToken(t *testing.T) { 145 func TestUploadUserCAErrorDoesNotLeakToken(t *testing.T) {
169 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { 146 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
170 http.Error(w, "internal error", http.StatusInternalServerError) 147 http.Error(w, "internal error", http.StatusInternalServerError)
171 }) 148 })
172 _, _, err := c.MintUserCert(t.Context(), genTestKey(t)) 149 err := c.UploadUserCA(t.Context(), "default", "ssh-ed25519 AAAA")
173 require.Error(t, err) 150 require.Error(t, err)
174 assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors") 151 assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors")
175 } 152 }
176
177 func TestMintUserCertRejectsNonCertResponse(t *testing.T) {
178 notACert := genTestKey(t)
179 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
180 json.NewEncoder(w).Encode(map[string]string{
181 "certificate": string(ssh.MarshalAuthorizedKey(notACert)),
182 })
183 })
184 _, _, err := c.MintUserCert(t.Context(), genTestKey(t))
185 require.Error(t, err)
186 }
internal/mcpserver/config.go
Old New
@@ -18,6 +18,8 @@ type Config struct {
18 AdminTokenFile string `json:"admin_token_file"` // file holding the bearer token 18 AdminTokenFile string `json:"admin_token_file"` // file holding the bearer token
19 Gate string `json:"gate"` // SSH-CA jump gate address "<gate-domain>:<port>" 19 Gate string `json:"gate"` // SSH-CA jump gate address "<gate-domain>:<port>"
20 VMUser string `json:"vm_user"` // guest user (default "ubuntu") 20 VMUser string `json:"vm_user"` // guest user (default "ubuntu")
21 CAKeyPath string `json:"ca_key_path"` // this client's own user CA (load-or-create; default next to config)
22 Tenant string `json:"tenant"` // this client's tenant (default "default")
21 23
22 AdminToken string `json:"-"` // loaded from AdminTokenFile; never serialized 24 AdminToken string `json:"-"` // loaded from AdminTokenFile; never serialized
23 } 25 }
@@ -44,6 +46,14 @@ func LoadConfig(path string) (*Config, error) {
44 if cfg.VMUser == "" { 46 if cfg.VMUser == "" {
45 cfg.VMUser = "ubuntu" 47 cfg.VMUser = "ubuntu"
46 } 48 }
49 if cfg.Tenant == "" {
50 cfg.Tenant = "default"
51 }
52 if cfg.CAKeyPath == "" {
53 cfg.CAKeyPath = filepath.Join(filepath.Dir(path), "user_ca")
54 } else {
55 cfg.CAKeyPath = expandTilde(cfg.CAKeyPath)
56 }
47 if cfg.ServerURL == "" { 57 if cfg.ServerURL == "" {
48 return nil, fmt.Errorf("config %s: server_url is required", path) 58 return nil, fmt.Errorf("config %s: server_url is required", path)
49 } 59 }
internal/mcpserver/gateauth.go
Old New
@@ -5,8 +5,9 @@ import (
5 "context" 5 "context"
6 "crypto/ed25519" 6 "crypto/ed25519"
7 "crypto/rand" 7 "crypto/rand"
8 "errors" 8 "encoding/binary"
9 "fmt" 9 "fmt"
10 "strings"
10 "sync" 11 "sync"
11 "time" 12 "time"
12 13
@@ -14,45 +15,67 @@ import (
14 ) 15 )
15 16
16 // CertAuthority is the subset of the eitri API client GateAuth needs: enough 17 // CertAuthority is the subset of the eitri API client GateAuth needs: enough
17 // to fetch the SSH CA's public key and mint short-lived user certificates. 18 // to fetch the (host) SSH CA's public key and register this client's own user
18 // It's declared here (rather than depending on the shared API client 19 // CA with its tenant. It's declared here (rather than depending on the
19 // directly) so tests can fake it in-memory without spinning up an httptest 20 // shared API client directly) so tests can fake it in-memory without
20 // server. 21 // spinning up an httptest server.
21 type CertAuthority interface { 22 type CertAuthority interface {
22 FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) 23 FetchSSHCA(ctx context.Context) (ssh.PublicKey, error)
23 MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, string, error) 24 UploadUserCA(ctx context.Context, tenant, caLine string) error
24 } 25 }
25 26
26 // GateAuth is a concurrency-safe credential cache for authenticating to the 27 // GateAuth is a concurrency-safe credential cache for authenticating to the
27 // eitri SSH-CA jump gate and the VMs behind it. It holds an ephemeral 28 // eitri SSH-CA jump gate and the VMs behind it. It holds an ephemeral
28 // (never-persisted) ed25519 keypair generated once at first use, mints a 29 // (never-persisted) ed25519 keypair generated once at first use and LOCALLY
29 // short-lived user certificate for it on demand (refreshing shortly before 30 // self-signs a short-lived user certificate for it on demand (refreshing
30 // expiry), and verifies host certificates against the eitri CA. 31 // shortly before expiry) using this client's own persistent user CA. It
32 // verifies host certificates against the eitri host CA. The user CA's public
33 // key must be registered with the tenant (see Register) so VMs trust the
34 // certs this client signs.
31 type GateAuth struct { 35 type GateAuth struct {
32 api CertAuthority 36 api CertAuthority
33 now func() time.Time 37 userCA ssh.Signer // this client's persistent user CA; signs user certs locally
38 tenant string // this client's tenant; connect names are <tenant>.<vm>
39 now func() time.Time
34 40
35 mu sync.Mutex 41 mu sync.Mutex
36 ephemeral ssh.Signer // ephemeral SSH keypair; generated lazily, once 42 ephemeral ssh.Signer // ephemeral SSH keypair; generated lazily, once
37 ca ssh.PublicKey // eitri SSH CA; fetched lazily, once 43 ca ssh.PublicKey // eitri host CA; fetched lazily, once
38 cert *ssh.Certificate 44 cert *ssh.Certificate
39 certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert 45 certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert
40 tenant string // minting principal's tenant; set alongside cert 46 registered bool // true once the user CA has been uploaded (once-guard)
41 } 47 }
42 48
43 // NewGateAuth constructs a GateAuth backed by api. If now is nil, time.Now 49 // NewGateAuth constructs a GateAuth backed by api, self-signing user certs with
44 // is used. The ephemeral keypair and CA key are NOT fetched here; both are 50 // userCA and dialing VMs under tenant. If now is nil, time.Now is used. The
45 // established lazily on first use so construction cannot fail. 51 // ephemeral keypair and host CA key are NOT fetched here; both are established
46 func NewGateAuth(api CertAuthority, now func() time.Time) *GateAuth { 52 // lazily on first use so construction cannot fail.
53 func NewGateAuth(api CertAuthority, userCA ssh.Signer, tenant string, now func() time.Time) *GateAuth {
47 if now == nil { 54 if now == nil {
48 now = time.Now 55 now = time.Now
49 } 56 }
50 return &GateAuth{api: api, now: now} 57 return &GateAuth{api: api, userCA: userCA, tenant: tenant, now: now}
58 }
59
60 // Register uploads this client's user-CA public key to its tenant so VMs trust
61 // certs it signs. Idempotent; safe to call at startup before creating VMs.
62 func (g *GateAuth) Register(ctx context.Context) error {
63 g.mu.Lock()
64 defer g.mu.Unlock()
65 if g.registered {
66 return nil
67 }
68 line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(g.userCA.PublicKey())))
69 if err := g.api.UploadUserCA(ctx, g.tenant, line); err != nil {
70 return fmt.Errorf("registering user CA: %w", err)
71 }
72 g.registered = true
73 return nil
51 } 74 }
52 75
53 // Signer returns an ssh.Signer backed by a cached, cert-signed identity, 76 // Signer returns an ssh.Signer backed by a cached, cert-signed identity,
54 // minting (or re-minting, if the cached cert is missing or expires within a 77 // self-signing (or re-signing, if the cached cert is missing or expires within
55 // minute) as needed. 78 // a minute) as needed.
56 func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) { 79 func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) {
57 g.mu.Lock() 80 g.mu.Lock()
58 defer g.mu.Unlock() 81 defer g.mu.Unlock()
@@ -66,38 +89,57 @@ func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) {
66 } 89 }
67 90
68 if g.needsMintLocked() { 91 if g.needsMintLocked() {
69 // g.mu is deliberately held across this network call: it single-flights 92 cert, err := g.signCertLocked()
70 // minting so concurrent Signer callers reuse one in-flight request
71 // rather than stampeding the CA with duplicate mints. Do not "fix" this
72 // into a per-call unlock — that reintroduces a thundering herd.
73 cert, tenant, err := g.api.MintUserCert(ctx, g.ephemeral.PublicKey())
74 if err != nil { 93 if err != nil {
75 return nil, fmt.Errorf("minting user certificate: %w", err) 94 return nil, fmt.Errorf("signing user certificate: %w", err)
76 } 95 }
77 certSigner, err := ssh.NewCertSigner(cert, g.ephemeral) 96 certSigner, err := ssh.NewCertSigner(cert, g.ephemeral)
78 if err != nil { 97 if err != nil {
79 return nil, fmt.Errorf("wrapping minted certificate: %w", err) 98 return nil, fmt.Errorf("wrapping signed certificate: %w", err)
80 } 99 }
81 g.cert = cert 100 g.cert = cert
82 g.certSigner = certSigner 101 g.certSigner = certSigner
83 g.tenant = tenant
84 } 102 }
85 103
86 return g.certSigner, nil 104 return g.certSigner, nil
87 } 105 }
88 106
107 // signCertLocked self-signs a short-lived user certificate for g.ephemeral's
108 // public key using g.userCA. Callers must hold g.mu. Mirrors the (removed)
109 // server-side minter's cert shape.
110 func (g *GateAuth) signCertLocked() (*ssh.Certificate, error) {
111 var serial uint64
112 if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil {
113 return nil, err
114 }
115 now := g.now()
116 cert := &ssh.Certificate{
117 Key: g.ephemeral.PublicKey(),
118 Serial: serial,
119 CertType: ssh.UserCert,
120 KeyId: "ubuntu",
121 ValidPrincipals: []string{"ubuntu"},
122 ValidAfter: uint64(now.Add(-time.Minute).Unix()), // small skew backdate
123 ValidBefore: uint64(now.Add(30 * time.Minute).Unix()),
124 Permissions: ssh.Permissions{Extensions: map[string]string{
125 "permit-pty": "", "permit-port-forwarding": "", "permit-user-rc": "", "permit-agent-forwarding": "",
126 }},
127 }
128 if err := cert.SignCert(rand.Reader, g.userCA); err != nil {
129 return nil, err
130 }
131 return cert, nil
132 }
133
89 // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>", 134 // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>",
90 // the form the gate resolves and the VM's host-cert principal matches. Minting 135 // the form the gate resolves and the VM's host-cert principal matches. g.tenant
91 // is ensured first (the tenant rides the mint response). 136 // is non-empty by construction (the caller validates it).
92 func (g *GateAuth) ConnectName(ctx context.Context, vmName string) (string, error) { 137 func (g *GateAuth) ConnectName(ctx context.Context, vmName string) (string, error) {
93 if _, err := g.Signer(ctx); err != nil { 138 if _, err := g.Signer(ctx); err != nil {
94 return "", err 139 return "", err
95 } 140 }
96 g.mu.Lock() 141 g.mu.Lock()
97 defer g.mu.Unlock() 142 defer g.mu.Unlock()
98 if g.tenant == "" {
99 return "", errors.New("eitri server did not return a tenant at cert mint (pre-tenancy server?)")
100 }
101 return g.tenant + "." + vmName, nil 143 return g.tenant + "." + vmName, nil
102 } 144 }
103 145
internal/mcpserver/gateauth_test.go
Old New
@@ -16,26 +16,24 @@ import (
16 ) 16 )
17 17
18 // fakeCertAuthority is an in-memory CertAuthority backed by a real ed25519 18 // fakeCertAuthority is an in-memory CertAuthority backed by a real ed25519
19 // CA signer, so tests exercise real cert signing/verification without an 19 // host-CA signer, so tests exercise real host-cert verification without an
20 // httptest server. 20 // httptest server. User certs are now self-signed by GateAuth locally, so this
21 // fake only serves the host CA (FetchSSHCA) and records user-CA uploads.
21 type fakeCertAuthority struct { 22 type fakeCertAuthority struct {
22 caSigner ssh.Signer 23 caSigner ssh.Signer // host CA served by FetchSSHCA
23 24
24 mu sync.Mutex 25 mu sync.Mutex
25 fetchCACalls int 26 fetchCACalls int
26 mintCertCalls int 27 uploadCalls int
27 nextValidBefore uint64 // configurable expiry for the next minted cert 28 lastTenant string
28 fetchErr error // if set, FetchSSHCA returns it 29 lastUploadLine string
29 mintErr error // if set, MintUserCert returns it 30 fetchErr error // if set, FetchSSHCA returns it
31 uploadErr error // if set, UploadUserCA returns it
30 } 32 }
31 33
32 func newFakeCertAuthority(t *testing.T) *fakeCertAuthority { 34 func newFakeCertAuthority(t *testing.T) *fakeCertAuthority {
33 t.Helper() 35 t.Helper()
34 _, priv, err := ed25519.GenerateKey(rand.Reader) 36 return &fakeCertAuthority{caSigner: newTestSigner(t)}
35 require.NoError(t, err)
36 signer, err := ssh.NewSignerFromSigner(priv)
37 require.NoError(t, err)
38 return &fakeCertAuthority{caSigner: signer, nextValidBefore: ssh.CertTimeInfinity}
39 } 37 }
40 38
41 func (f *fakeCertAuthority) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) { 39 func (f *fakeCertAuthority) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) {
@@ -48,34 +46,13 @@ func (f *fakeCertAuthority) FetchSSHCA(ctx context.Context) (ssh.PublicKey, erro
48 return f.caSigner.PublicKey(), nil 46 return f.caSigner.PublicKey(), nil
49 } 47 }
50 48
51 func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, string, error) { 49 func (f *fakeCertAuthority) UploadUserCA(ctx context.Context, tenant, caLine string) error {
52 f.mu.Lock()
53 validBefore := f.nextValidBefore
54 f.mintCertCalls++
55 mintErr := f.mintErr
56 f.mu.Unlock()
57
58 if mintErr != nil {
59 return nil, "", mintErr
60 }
61
62 cert := &ssh.Certificate{
63 Key: pub,
64 CertType: ssh.UserCert,
65 ValidPrincipals: []string{"ubuntu"},
66 ValidAfter: 0,
67 ValidBefore: validBefore,
68 }
69 if err := cert.SignCert(rand.Reader, f.caSigner); err != nil {
70 return nil, "", err
71 }
72 return cert, "default", nil
73 }
74
75 func (f *fakeCertAuthority) setNextValidBefore(v uint64) {
76 f.mu.Lock() 50 f.mu.Lock()
77 defer f.mu.Unlock() 51 defer f.mu.Unlock()
78 f.nextValidBefore = v 52 f.uploadCalls++
53 f.lastTenant = tenant
54 f.lastUploadLine = caLine
55 return f.uploadErr
79 } 56 }
80 57
81 func (f *fakeCertAuthority) setFetchErr(err error) { 58 func (f *fakeCertAuthority) setFetchErr(err error) {
@@ -84,16 +61,34 @@ func (f *fakeCertAuthority) setFetchErr(err error) {
84 f.fetchErr = err 61 f.fetchErr = err
85 } 62 }
86 63
87 func (f *fakeCertAuthority) setMintErr(err error) { 64 func (f *fakeCertAuthority) counts() (fetchCA, upload int) {
88 f.mu.Lock() 65 f.mu.Lock()
89 defer f.mu.Unlock() 66 defer f.mu.Unlock()
90 f.mintErr = err 67 return f.fetchCACalls, f.uploadCalls
91 } 68 }
92 69
93 func (f *fakeCertAuthority) counts() (fetchCA, mintCert int) { 70 // newTestSigner generates a fresh ed25519 ssh.Signer for use as a test CA.
94 f.mu.Lock() 71 func newTestSigner(t *testing.T) ssh.Signer {
95 defer f.mu.Unlock() 72 t.Helper()
96 return f.fetchCACalls, f.mintCertCalls 73 _, priv, err := ed25519.GenerateKey(rand.Reader)
74 require.NoError(t, err)
75 signer, err := ssh.NewSignerFromSigner(priv)
76 require.NoError(t, err)
77 return signer
78 }
79
80 // newTestGateAuth builds a GateAuth wired to fake, self-signing with userCA
81 // under tenant "default" and driven by clock (nil = time.Now).
82 func newTestGateAuth(fake *fakeCertAuthority, userCA ssh.Signer, clock func() time.Time) *GateAuth {
83 return NewGateAuth(fake, userCA, "default", clock)
84 }
85
86 // certOf returns the *ssh.Certificate a cert-signer's public key carries.
87 func certOf(t *testing.T, s ssh.Signer) *ssh.Certificate {
88 t.Helper()
89 cert, ok := s.PublicKey().(*ssh.Certificate)
90 require.True(t, ok, "signer public key is not a certificate")
91 return cert
97 } 92 }
98 93
99 // hostCert builds and signs a host certificate for a fresh ephemeral host 94 // hostCert builds and signs a host certificate for a fresh ephemeral host
@@ -114,56 +109,95 @@ func hostCert(t *testing.T, ca ssh.Signer) *ssh.Certificate {
114 return cert 109 return cert
115 } 110 }
116 111
117 func TestGateAuthSignerMintsOnceAndReuses(t *testing.T) { 112 func TestGateAuthSignerSelfSignsOnceAndReuses(t *testing.T) {
118 fake := newFakeCertAuthority(t) 113 fake := newFakeCertAuthority(t)
119 fake.setNextValidBefore(ssh.CertTimeInfinity) 114 userCA := newTestSigner(t)
120 ga := NewGateAuth(fake, nil) 115 ga := newTestGateAuth(fake, userCA, nil)
121 116
122 s1, err := ga.Signer(t.Context()) 117 s1, err := ga.Signer(t.Context())
123 require.NoError(t, err) 118 require.NoError(t, err)
124 s2, err := ga.Signer(t.Context()) 119 s2, err := ga.Signer(t.Context())
125 require.NoError(t, err) 120 require.NoError(t, err)
126 121
127 _, mintCalls := fake.counts()
128 assert.Equal(t, 1, mintCalls, "expected exactly one mint across two Signer calls")
129 assert.Same(t, s1, s2, "expected the same cached signer to be returned") 122 assert.Same(t, s1, s2, "expected the same cached signer to be returned")
130 assert.Regexp(t, `-cert-v01@openssh\.com$`, s1.PublicKey().Type()) 123 assert.Regexp(t, `-cert-v01@openssh\.com$`, s1.PublicKey().Type())
124
125 // The cert is self-signed by our own user CA, for principal "ubuntu".
126 cert := certOf(t, s1)
127 assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals)
128 assert.Equal(t, ssh.UserCert, int(cert.CertType))
129 assert.Equal(t, userCA.PublicKey().Marshal(), cert.SignatureKey.Marshal(),
130 "cert must be signed by our own user CA")
131 } 131 }
132 132
133 func TestGateAuthSignerRefreshesNearExpiry(t *testing.T) { 133 func TestGateAuthSignerRefreshesNearExpiry(t *testing.T) {
134 fake := newFakeCertAuthority(t) 134 fake := newFakeCertAuthority(t)
135 userCA := newTestSigner(t)
135 current := time.Unix(1_700_000_000, 0) 136 current := time.Unix(1_700_000_000, 0)
136 clock := func() time.Time { return current } 137 clock := func() time.Time { return current }
137 ga := NewGateAuth(fake, clock) 138 ga := newTestGateAuth(fake, userCA, clock)
138 139
139 // First cert expires in 30s from "now" — within the 1-minute refresh 140 // First sign: cert is valid until now+30m.
140 // window on the very next call. 141 s1, err := ga.Signer(t.Context())
141 fake.setNextValidBefore(uint64(current.Add(30 * time.Second).Unix()))
142 _, err := ga.Signer(t.Context())
143 require.NoError(t, err) 142 require.NoError(t, err)
143 serial1 := certOf(t, s1).Serial
144 144
145 _, mintCalls := fake.counts() 145 // Advance the clock to within a minute of expiry: this must re-sign.
146 require.Equal(t, 1, mintCalls) 146 current = current.Add(30 * time.Minute)
147 s2, err := ga.Signer(t.Context())
148 require.NoError(t, err)
149 serial2 := certOf(t, s2).Serial
150 assert.NotEqual(t, serial1, serial2, "expected re-sign when cached cert expires within a minute")
147 151
148 // Second call, same "now": remaining validity (30s) < 1 minute, so this 152 // Same "now" again: the fresh cert has ~30m left, so this must NOT re-sign.
149 // must re-mint. 153 s3, err := ga.Signer(t.Context())
150 fake.setNextValidBefore(uint64(current.Add(2 * time.Hour).Unix()))
151 _, err = ga.Signer(t.Context())
152 require.NoError(t, err) 154 require.NoError(t, err)
153 _, mintCalls = fake.counts() 155 assert.Same(t, s2, s3, "expected no re-sign when cached cert has >1min remaining")
154 assert.Equal(t, 2, mintCalls, "expected re-mint when cached cert expires within a minute") 156 }
155 157
156 // Third call, same "now": remaining validity is now 2h, well beyond a 158 func TestGateAuthRegisterUploadsOnce(t *testing.T) {
157 // minute, so this must NOT re-mint. 159 fake := newFakeCertAuthority(t)
158 _, err = ga.Signer(t.Context()) 160 userCA := newTestSigner(t)
161 ga := newTestGateAuth(fake, userCA, nil)
162
163 require.NoError(t, ga.Register(t.Context()))
164 require.NoError(t, ga.Register(t.Context()))
165
166 _, uploads := fake.counts()
167 assert.Equal(t, 1, uploads, "Register must be idempotent (upload once)")
168 assert.Equal(t, "default", fake.lastTenant)
169 assert.Equal(t, string(ssh.MarshalAuthorizedKey(userCA.PublicKey())), fake.lastUploadLine+"\n",
170 "uploaded line must be our user CA public key")
171 }
172
173 func TestGateAuthRegisterSurfacesUploadError(t *testing.T) {
174 fake := newFakeCertAuthority(t)
175 fake.uploadErr = errors.New("upload boom")
176 ga := newTestGateAuth(fake, newTestSigner(t), nil)
177
178 err := ga.Register(t.Context())
179 require.Error(t, err)
180 assert.Contains(t, err.Error(), "registering user CA")
181
182 // A failed upload must not flip the once-guard: a retry re-attempts.
183 fake.uploadErr = nil
184 require.NoError(t, ga.Register(t.Context()))
185 _, uploads := fake.counts()
186 assert.Equal(t, 2, uploads, "failed upload must be retried, not swallowed")
187 }
188
189 func TestGateAuthConnectName(t *testing.T) {
190 fake := newFakeCertAuthority(t)
191 ga := newTestGateAuth(fake, newTestSigner(t), nil)
192
193 name, err := ga.ConnectName(t.Context(), "web-1")
159 require.NoError(t, err) 194 require.NoError(t, err)
160 _, mintCalls = fake.counts() 195 assert.Equal(t, "default.web-1", name)
161 assert.Equal(t, 2, mintCalls, "expected no re-mint when cached cert has >1min remaining")
162 } 196 }
163 197
164 func TestGateAuthHostKeyCallbackAcceptsCASignedHostCert(t *testing.T) { 198 func TestGateAuthHostKeyCallbackAcceptsCASignedHostCert(t *testing.T) {
165 fake := newFakeCertAuthority(t) 199 fake := newFakeCertAuthority(t)
166 ga := NewGateAuth(fake, nil) 200 ga := newTestGateAuth(fake, newTestSigner(t), nil)
167 201
168 cert := hostCert(t, fake.caSigner) 202 cert := hostCert(t, fake.caSigner)
169 err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert) 203 err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert)
@@ -172,21 +206,17 @@ func TestGateAuthHostKeyCallbackAcceptsCASignedHostCert(t *testing.T) {
172 206
173 func TestGateAuthHostKeyCallbackRejectsForeignCAHostCert(t *testing.T) { 207 func TestGateAuthHostKeyCallbackRejectsForeignCAHostCert(t *testing.T) {
174 fake := newFakeCertAuthority(t) 208 fake := newFakeCertAuthority(t)
175 ga := NewGateAuth(fake, nil) 209 ga := newTestGateAuth(fake, newTestSigner(t), nil)
176
177 _, foreignPriv, err := ed25519.GenerateKey(rand.Reader)
178 require.NoError(t, err)
179 foreignCA, err := ssh.NewSignerFromSigner(foreignPriv)
180 require.NoError(t, err)
181 210
211 foreignCA := newTestSigner(t)
182 cert := hostCert(t, foreignCA) 212 cert := hostCert(t, foreignCA)
183 err = ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert) 213 err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert)
184 assert.Error(t, err) 214 assert.Error(t, err)
185 } 215 }
186 216
187 func TestGateAuthHostKeyCallbackRejectsBareHostKey(t *testing.T) { 217 func TestGateAuthHostKeyCallbackRejectsBareHostKey(t *testing.T) {
188 fake := newFakeCertAuthority(t) 218 fake := newFakeCertAuthority(t)
189 ga := NewGateAuth(fake, nil) 219 ga := newTestGateAuth(fake, newTestSigner(t), nil)
190 220
191 pub, _, err := ed25519.GenerateKey(rand.Reader) 221 pub, _, err := ed25519.GenerateKey(rand.Reader)
192 require.NoError(t, err) 222 require.NoError(t, err)
@@ -199,8 +229,7 @@ func TestGateAuthHostKeyCallbackRejectsBareHostKey(t *testing.T) {
199 229
200 func TestGateAuthFetchesCAOnlyOnce(t *testing.T) { 230 func TestGateAuthFetchesCAOnlyOnce(t *testing.T) {
201 fake := newFakeCertAuthority(t) 231 fake := newFakeCertAuthority(t)
202 fake.setNextValidBefore(ssh.CertTimeInfinity) 232 ga := newTestGateAuth(fake, newTestSigner(t), nil)
203 ga := NewGateAuth(fake, nil)
204 233
205 cb := ga.HostKeyCallback() 234 cb := ga.HostKeyCallback()
206 cert := hostCert(t, fake.caSigner) 235 cert := hostCert(t, fake.caSigner)
@@ -219,7 +248,7 @@ func TestGateAuthFetchesCAOnlyOnce(t *testing.T) {
219 func TestGateAuthHostKeyCallbackRejectsWhenCAFetchFails(t *testing.T) { 248 func TestGateAuthHostKeyCallbackRejectsWhenCAFetchFails(t *testing.T) {
220 fake := newFakeCertAuthority(t) 249 fake := newFakeCertAuthority(t)
221 fake.setFetchErr(errors.New("ssh-ca gate is not enabled")) 250 fake.setFetchErr(errors.New("ssh-ca gate is not enabled"))
222 ga := NewGateAuth(fake, nil) 251 ga := newTestGateAuth(fake, newTestSigner(t), nil)
223 252
224 // A perfectly valid, CA-signed host cert must STILL be rejected when we 253 // A perfectly valid, CA-signed host cert must STILL be rejected when we
225 // cannot fetch the CA to verify against it — failing closed, never open. 254 // cannot fetch the CA to verify against it — failing closed, never open.
@@ -227,26 +256,3 @@ func TestGateAuthHostKeyCallbackRejectsWhenCAFetchFails(t *testing.T) {
227 err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert) 256 err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert)
228 assert.Error(t, err, "host must be rejected when the CA cannot be fetched") 257 assert.Error(t, err, "host must be rejected when the CA cannot be fetched")
229 } 258 }
230
231 func TestGateAuthSignerMintErrorDoesNotPoisonCache(t *testing.T) {
232 fake := newFakeCertAuthority(t)
233 fake.setNextValidBefore(ssh.CertTimeInfinity)
234 fake.setMintErr(errors.New("mint boom"))
235 ga := NewGateAuth(fake, nil)
236
237 // First call: mint fails, error is surfaced, nothing is cached.
238 _, err := ga.Signer(t.Context())
239 require.Error(t, err)
240
241 // Recovery: with minting working again, a subsequent call must mint fresh
242 // and succeed — proving the failed attempt did not poison the cache with a
243 // broken cert/signer.
244 fake.setMintErr(nil)
245 s, err := ga.Signer(t.Context())
246 require.NoError(t, err)
247 require.NotNil(t, s)
248 assert.Regexp(t, `-cert-v01@openssh\.com$`, s.PublicKey().Type())
249
250 _, mintCalls := fake.counts()
251 assert.Equal(t, 2, mintCalls, "expected the failed mint to retry, not serve a poisoned cache")
252 }
internal/mcpserver/sshrun_test.go
Old New
@@ -222,7 +222,7 @@ func (f fakeGateCreds) ConnectName(_ context.Context, vmName string) (string, er
222 222
223 func TestExecThroughGate(t *testing.T) { 223 func TestExecThroughGate(t *testing.T) {
224 fake := newFakeCertAuthority(t) 224 fake := newFakeCertAuthority(t)
225 ga := NewGateAuth(fake, nil) 225 ga := NewGateAuth(fake, fake.caSigner, "default", nil)
226 ca := fake.caSigner 226 ca := fake.caSigner
227 227
228 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) 228 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0)
@@ -239,7 +239,7 @@ func TestExecThroughGate(t *testing.T) {
239 239
240 func TestExecVMForeignCAHostCertRejected(t *testing.T) { 240 func TestExecVMForeignCAHostCertRejected(t *testing.T) {
241 fake := newFakeCertAuthority(t) 241 fake := newFakeCertAuthority(t)
242 ga := NewGateAuth(fake, nil) 242 ga := NewGateAuth(fake, fake.caSigner, "default", nil)
243 ca := fake.caSigner 243 ca := fake.caSigner
244 244
245 // The VM presents a host cert signed by a DIFFERENT CA. Its user-auth policy 245 // The VM presents a host cert signed by a DIFFERENT CA. Its user-auth policy
@@ -257,7 +257,7 @@ func TestExecVMForeignCAHostCertRejected(t *testing.T) {
257 257
258 func TestExecGateRejectsNonCAUserKey(t *testing.T) { 258 func TestExecGateRejectsNonCAUserKey(t *testing.T) {
259 fake := newFakeCertAuthority(t) 259 fake := newFakeCertAuthority(t)
260 ga := NewGateAuth(fake, nil) 260 ga := NewGateAuth(fake, fake.caSigner, "default", nil)
261 ca := fake.caSigner 261 ca := fake.caSigner
262 262
263 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) 263 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0)
internal/pb/sync.pb.go
Old New
@@ -632,24 +632,24 @@ func (x *ActualStateReport) GetLastSeenEpoch() uint64 {
632 } 632 }
633 633
634 type VMDesired struct { 634 type VMDesired struct {
635 state protoimpl.MessageState `protogen:"open.v1"` 635 state protoimpl.MessageState `protogen:"open.v1"`
636 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` 636 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"`
637 Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` 637 Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
638 ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"` 638 ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"`
639 ImageSha256 string `protobuf:"bytes,4,opt,name=image_sha256,json=imageSha256,proto3" json:"image_sha256,omitempty"` 639 ImageSha256 string `protobuf:"bytes,4,opt,name=image_sha256,json=imageSha256,proto3" json:"image_sha256,omitempty"`
640 CloudInit string `protobuf:"bytes,5,opt,name=cloud_init,json=cloudInit,proto3" json:"cloud_init,omitempty"` // user-data YAML, may be empty 640 CloudInit string `protobuf:"bytes,5,opt,name=cloud_init,json=cloudInit,proto3" json:"cloud_init,omitempty"` // user-data YAML, may be empty
641 Vcpus int64 `protobuf:"varint,6,opt,name=vcpus,proto3" json:"vcpus,omitempty"` 641 Vcpus int64 `protobuf:"varint,6,opt,name=vcpus,proto3" json:"vcpus,omitempty"`
642 MemMb int64 `protobuf:"varint,7,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"` 642 MemMb int64 `protobuf:"varint,7,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"`
643 DiskGb int64 `protobuf:"varint,8,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"` 643 DiskGb int64 `protobuf:"varint,8,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"`
644 Persistent bool `protobuf:"varint,9,opt,name=persistent,proto3" json:"persistent,omitempty"` 644 Persistent bool `protobuf:"varint,9,opt,name=persistent,proto3" json:"persistent,omitempty"`
645 PowerState string `protobuf:"bytes,10,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped" 645 PowerState string `protobuf:"bytes,10,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped"
646 Tombstoned bool `protobuf:"varint,11,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // present-but-tombstoned (drives quarantine + destroyed[]) 646 Tombstoned bool `protobuf:"varint,11,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // present-but-tombstoned (drives quarantine + destroyed[])
647 SshAuthorizedKey string `protobuf:"bytes,12,opt,name=ssh_authorized_key,json=sshAuthorizedKey,proto3" json:"ssh_authorized_key,omitempty"` 647 SshAuthorizedKey string `protobuf:"bytes,12,opt,name=ssh_authorized_key,json=sshAuthorizedKey,proto3" json:"ssh_authorized_key,omitempty"`
648 SshUserCaAuthorizedKey string `protobuf:"bytes,15,opt,name=ssh_user_ca_authorized_key,json=sshUserCaAuthorizedKey,proto3" json:"ssh_user_ca_authorized_key,omitempty"` // eitri user-CA public key (authorized_keys form); seed injects it as an sshd TrustedUserCAKeys drop-in. Empty when the jump gate is off. 648 SshHostKeyPem string `protobuf:"bytes,16,opt,name=ssh_host_key_pem,json=sshHostKeyPem,proto3" json:"ssh_host_key_pem,omitempty"` // the VM's persistent ed25519 host private key (OpenSSH PEM); seed installs it as /etc/ssh/ssh_host_ed25519_key. WRITE-ONLY key material. Empty when the jump gate is off.
649 SshHostKeyPem string `protobuf:"bytes,16,opt,name=ssh_host_key_pem,json=sshHostKeyPem,proto3" json:"ssh_host_key_pem,omitempty"` // the VM's persistent ed25519 host private key (OpenSSH PEM); seed installs it as /etc/ssh/ssh_host_ed25519_key. WRITE-ONLY key material. Empty when the jump gate is off. 649 SshHostCert string `protobuf:"bytes,17,opt,name=ssh_host_cert,json=sshHostCert,proto3" json:"ssh_host_cert,omitempty"` // the VM's CA-signed host cert (authorized_keys form); seed installs it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty when the jump gate is off.
650 SshHostCert string `protobuf:"bytes,17,opt,name=ssh_host_cert,json=sshHostCert,proto3" json:"ssh_host_cert,omitempty"` // the VM's CA-signed host cert (authorized_keys form); seed installs it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty when the jump gate is off. 650 SshUserCaAuthorizedKeys []string `protobuf:"bytes,18,rep,name=ssh_user_ca_authorized_keys,json=sshUserCaAuthorizedKeys,proto3" json:"ssh_user_ca_authorized_keys,omitempty"` // the VM's tenant user-CA set (canonical authorized_keys lines); seed writes them all into TrustedUserCAKeys. Empty when the gate is off / tenant has none.
651 unknownFields protoimpl.UnknownFields 651 unknownFields protoimpl.UnknownFields
652 sizeCache protoimpl.SizeCache 652 sizeCache protoimpl.SizeCache
653 } 653 }
654 654
655 func (x *VMDesired) Reset() { 655 func (x *VMDesired) Reset() {
@@ -766,13 +766,6 @@ func (x *VMDesired) GetSshAuthorizedKey() string {
766 return "" 766 return ""
767 } 767 }
768 768
769 func (x *VMDesired) GetSshUserCaAuthorizedKey() string {
770 if x != nil {
771 return x.SshUserCaAuthorizedKey
772 }
773 return ""
774 }
775
776 func (x *VMDesired) GetSshHostKeyPem() string { 769 func (x *VMDesired) GetSshHostKeyPem() string {
777 if x != nil { 770 if x != nil {
778 return x.SshHostKeyPem 771 return x.SshHostKeyPem
@@ -787,6 +780,13 @@ func (x *VMDesired) GetSshHostCert() string {
787 return "" 780 return ""
788 } 781 }
789 782
783 func (x *VMDesired) GetSshUserCaAuthorizedKeys() []string {
784 if x != nil {
785 return x.SshUserCaAuthorizedKeys
786 }
787 return nil
788 }
789
790 type DesiredStateSnapshot struct { 790 type DesiredStateSnapshot struct {
791 state protoimpl.MessageState `protogen:"open.v1"` 791 state protoimpl.MessageState `protogen:"open.v1"`
792 Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen 792 Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen
@@ -1104,7 +1104,7 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1104 "\vquarantined\x18\x03 \x03(\v2\x17.eitri.v1.QuarantinedVMR\vquarantined\x12.\n" + 1104 "\vquarantined\x18\x03 \x03(\v2\x17.eitri.v1.QuarantinedVMR\vquarantined\x12.\n" +
1105 "\bcapacity\x18\x04 \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12'\n" + 1105 "\bcapacity\x18\x04 \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12'\n" +
1106 "\x0ffence_violation\x18\x05 \x01(\bR\x0efenceViolation\x12&\n" + 1106 "\x0ffence_violation\x18\x05 \x01(\bR\x0efenceViolation\x12&\n" +
1107 "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\"\xfd\x03\n" + 1107 "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\"\x85\x04\n" +
1108 "\tVMDesired\x12\x13\n" + 1108 "\tVMDesired\x12\x13\n" +
1109 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + 1109 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" +
1110 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + 1110 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
@@ -1124,10 +1124,10 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1124 "\n" + 1124 "\n" +
1125 "tombstoned\x18\v \x01(\bR\n" + 1125 "tombstoned\x18\v \x01(\bR\n" +
1126 "tombstoned\x12,\n" + 1126 "tombstoned\x12,\n" +
1127 "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\x12:\n" + 1127 "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\x12'\n" +
1128 "\x1assh_user_ca_authorized_key\x18\x0f \x01(\tR\x16sshUserCaAuthorizedKey\x12'\n" +
1129 "\x10ssh_host_key_pem\x18\x10 \x01(\tR\rsshHostKeyPem\x12\"\n" + 1128 "\x10ssh_host_key_pem\x18\x10 \x01(\tR\rsshHostKeyPem\x12\"\n" +
1130 "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCertJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0f\"S\n" + 1129 "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" +
1130 "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeysJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10\"S\n" +
1131 "\x14DesiredStateSnapshot\x12\x14\n" + 1131 "\x14DesiredStateSnapshot\x12\x14\n" +
1132 "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" + 1132 "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" +
1133 "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vms\"\"\n" + 1133 "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vms\"\"\n" +
internal/server/api/api.go
Old New
@@ -58,7 +58,6 @@ type API struct {
58 tickets *ticketStore // one-time SSE stream tickets 58 tickets *ticketStore // one-time SSE stream tickets
59 snap *snapshotHub // central SSE snapshot: one marshal fanned to all clients 59 snap *snapshotHub // central SSE snapshot: one marshal fanned to all clients
60 console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer) 60 console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer)
61 certs CertMinter // nil until main wires the SSH user CA (SetCertMinter); nil ⇒ gate off
62 hostCerts HostCertMinter // nil until main wires the SSH host CA (SetHostCertMinter); nil ⇒ gate off 61 hostCerts HostCertMinter // nil until main wires the SSH host CA (SetHostCertMinter); nil ⇒ gate off
63 sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off 62 sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off
64 } 63 }
@@ -587,6 +586,16 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
587 return 586 return
588 } 587 }
589 588
589 // BYO CA precondition: a VM with no trusted user CA baked at create is
590 // unreachable. Require the tenant to have registered ≥1 user CA first.
591 if has, err := a.st.TenantHasUserCA(host.Tenant); err != nil {
592 http.Error(w, "internal error", http.StatusInternalServerError)
593 return
594 } else if !has {
595 http.Error(w, "tenant has no registered SSH user CA; upload one via POST /api/v1/tenants/"+host.Tenant+"/user-cas before creating VMs", http.StatusBadRequest)
596 return
597 }
598
590 // Install the SSH key into user-supplied cloud-init. When only one of the 599 // Install the SSH key into user-supplied cloud-init. When only one of the
591 // two is set the seed builder handles it (verbatim user-data, or the 600 // two is set the seed builder handles it (verbatim user-data, or the
592 // generated default template); it's the BOTH case that used to silently 601 // generated default template); it's the BOTH case that used to silently
internal/server/api/api_test.go
Old New
@@ -196,6 +196,11 @@ func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registr
196 AdvertiseQUIC: "127.0.0.1:8443", 196 AdvertiseQUIC: "127.0.0.1:8443",
197 ServerCertSHA256: strings.Repeat("c", 64), 197 ServerCertSHA256: strings.Repeat("c", 64),
198 }, st, reg, h) 198 }, st, reg, h)
199 // BYO-CA precondition: VM create now requires the tenant to have ≥1
200 // registered SSH user CA. Seed the default tenant with a throwaway CA line
201 // so existing VM-create tests exercise the create path, not the precondition.
202 require.NoError(t, st.AddTenantUserCA(store.DefaultTenant,
203 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTSEEDCA eitri-test-seed", "tenant", "test-seed", "test"))
199 ts := httptest.NewServer(a.Handler()) 204 ts := httptest.NewServer(a.Handler())
200 t.Cleanup(ts.Close) 205 t.Cleanup(ts.Close)
201 t.Cleanup(a.Close) // stop the snapshot hub goroutine 206 t.Cleanup(a.Close) // stop the snapshot hub goroutine
internal/server/api/client/client.go
Old New
@@ -35,11 +35,13 @@ type (
35 35
36 // Client calls the eitri API at BaseURL, authenticating with Token (sent as a 36 // Client calls the eitri API at BaseURL, authenticating with Token (sent as a
37 // Bearer header when non-empty). The zero value plus a BaseURL is a working 37 // Bearer header when non-empty). The zero value plus a BaseURL is a working
38 // client; a nil HTTP falls back to a 30s-timeout http.Client. 38 // client; a nil HTTP falls back to a 30s-timeout http.Client. UserCALabel, if
39 // set, labels user-CA uploads.
39 type Client struct { 40 type Client struct {
40 BaseURL string 41 BaseURL string
41 Token string 42 Token string
42 HTTP *http.Client 43 UserCALabel string
44 HTTP *http.Client
43 } 45 }
44 46
45 // Error is the typed failure for any non-2xx API response, carrying the 47 // Error is the typed failure for any non-2xx API response, carrying the
@@ -133,7 +135,7 @@ func (c *Client) DeleteVM(ctx context.Context, id string) error {
133 return c.do(ctx, http.MethodDelete, "/api/v1/vms/"+url.PathEscape(id), nil, nil) 135 return c.do(ctx, http.MethodDelete, "/api/v1/vms/"+url.PathEscape(id), nil, nil)
134 } 136 }
135 137
136 // FetchSSHCALine retrieves the eitri SSH CA public key as the VERBATIM 138 // FetchSSHCALine retrieves the eitri host-CA public key as the VERBATIM
137 // authorized_keys line the server serves — trailing comment and all — after 139 // authorized_keys line the server serves — trailing comment and all — after
138 // parse-validating it (never hand back a line ssh can't read). A 404 means 140 // parse-validating it (never hand back a line ssh can't read). A 404 means
139 // the SSH-CA jump gate isn't enabled; that case is surfaced as a clear, 141 // the SSH-CA jump gate isn't enabled; that case is surfaced as a clear,
@@ -153,7 +155,7 @@ func (c *Client) FetchSSHCALine(ctx context.Context) (string, error) {
153 return out.CA, nil 155 return out.CA, nil
154 } 156 }
155 157
156 // FetchSSHCA is FetchSSHCALine, parsed: the CA as an ssh.PublicKey, for 158 // FetchSSHCA is FetchSSHCALine, parsed: the host CA as an ssh.PublicKey, for
157 // callers that verify host certs. 159 // callers that verify host certs.
158 func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) { 160 func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) {
159 line, err := c.FetchSSHCALine(ctx) 161 line, err := c.FetchSSHCALine(ctx)
@@ -167,22 +169,10 @@ func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) {
167 return pub, nil 169 return pub, nil
168 } 170 }
169 171
170 // MintUserCert asks the eitri server to mint a short-lived SSH user 172 // UploadUserCA registers caLine (a BYO user-CA authorized_keys line) with
171 // certificate for pub, signed by the server's CA. It returns the cert and the 173 // tenant, labeled with c.UserCALabel, so the tenant's VMs trust certs that CA
172 // minting principal's tenant: clients dial VMs as <tenant>.<name>. 174 // signs. Idempotent server-side.
173 func (c *Client) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, string, error) { 175 func (c *Client) UploadUserCA(ctx context.Context, tenant, caLine string) error {
174 req := types.SSHCertRequest{PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))} 176 req := types.UserCARequest{PublicKey: caLine, Label: c.UserCALabel}
175 var out types.SSHCertResponse 177 return c.do(ctx, http.MethodPost, "/api/v1/tenants/"+url.PathEscape(tenant)+"/user-cas", req, nil)
176 if err := c.do(ctx, http.MethodPost, "/api/v1/ssh-certs", req, &out); err != nil {
177 return nil, "", err
178 }
179 parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.Certificate))
180 if err != nil {
181 return nil, "", fmt.Errorf("client: parsing minted certificate: %w", err)
182 }
183 cert, ok := parsed.(*ssh.Certificate)
184 if !ok {
185 return nil, "", errors.New("client: server response is not an SSH certificate")
186 }
187 return cert, out.Tenant, nil
188 } 178 }
internal/server/api/client/client_test.go
Old New
@@ -289,3 +289,49 @@ func TestFetchSSHCAGarbage(t *testing.T) {
289 }) 289 })
290 } 290 }
291 } 291 }
292
293 func TestUploadUserCA(t *testing.T) {
294 for _, tc := range []struct {
295 name string
296 label string
297 }{
298 {"with label", "laptop"},
299 {"empty label", ""},
300 } {
301 t.Run(tc.name, func(t *testing.T) {
302 var cap capture
303 srv := serve(t, &cap, http.StatusCreated, `{"fingerprint":"SHA256:abc"}`)
304 c := &client.Client{BaseURL: srv.URL, Token: "tok", UserCALabel: tc.label}
305
306 if err := c.UploadUserCA(context.Background(), "default", testCALine); err != nil {
307 t.Fatalf("UploadUserCA: %v", err)
308 }
309 if cap.method != http.MethodPost || cap.path != "/api/v1/tenants/default/user-cas" {
310 t.Errorf("request = %s %s, want POST /api/v1/tenants/default/user-cas", cap.method, cap.path)
311 }
312 var req types.UserCARequest
313 if err := json.Unmarshal(cap.body, &req); err != nil {
314 t.Fatalf("request body did not decode as UserCARequest: %v", err)
315 }
316 if req.PublicKey != testCALine {
317 t.Errorf("public_key = %q, want the CA line", req.PublicKey)
318 }
319 if req.Label != tc.label {
320 t.Errorf("label = %q, want %q", req.Label, tc.label)
321 }
322 })
323 }
324 }
325
326 func TestUploadUserCAEscapesTenant(t *testing.T) {
327 var cap capture
328 srv := serve(t, &cap, http.StatusCreated, `{}`)
329 c := &client.Client{BaseURL: srv.URL}
330
331 if err := c.UploadUserCA(context.Background(), "a/b", testCALine); err != nil {
332 t.Fatalf("UploadUserCA: %v", err)
333 }
334 if want := "/api/v1/tenants/a%2Fb/user-cas"; cap.path != want {
335 t.Errorf("path = %q, want %q", cap.path, want)
336 }
337 }
internal/server/api/decommission_api_test.go
Old New
@@ -31,6 +31,11 @@ func apiServer(t *testing.T) (*httptest.Server, *API, *store.Store) {
31 AdvertiseQUIC: "127.0.0.1:8443", 31 AdvertiseQUIC: "127.0.0.1:8443",
32 ServerCertSHA256: strings.Repeat("c", 64), 32 ServerCertSHA256: strings.Repeat("c", 64),
33 }, st, registry.New(time.Now), hub.New()) 33 }, st, registry.New(time.Now), hub.New())
34 // BYO-CA precondition: VM create requires the tenant to have ≥1 registered
35 // SSH user CA. Seed the default tenant so VM-create tests reach the create
36 // path rather than the precondition (mirrors newServer in api_test.go).
37 require.NoError(t, st.AddTenantUserCA(store.DefaultTenant,
38 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTSEEDCA eitri-test-seed", "tenant", "test-seed", "test"))
34 ts := httptest.NewServer(a.Handler()) 39 ts := httptest.NewServer(a.Handler())
35 t.Cleanup(ts.Close) 40 t.Cleanup(ts.Close)
36 return ts, a, st 41 return ts, a, st
internal/server/api/routes.go
Old New
@@ -71,7 +71,7 @@ var routeTable = []Route{
71 Kind: KindJSON, 71 Kind: KindJSON,
72 Response: (*types.SSHCAResponse)(nil), 72 Response: (*types.SSHCAResponse)(nil),
73 Success: http.StatusOK, 73 Success: http.StatusOK,
74 Doc: "The eitri SSH CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off.", 74 Doc: "The eitri SSH host CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off.",
75 handler: (*API).handleSSHCA, 75 handler: (*API).handleSSHCA,
76 }, 76 },
77 // SSE live status. EventSource cannot set headers, so the stream 77 // SSE live status. EventSource cannot set headers, so the stream
@@ -181,7 +181,7 @@ var routeTable = []Route{
181 Request: (*types.CreateVMRequest)(nil), 181 Request: (*types.CreateVMRequest)(nil),
182 Response: (*types.CreateVMResponse)(nil), 182 Response: (*types.CreateVMResponse)(nil),
183 Success: http.StatusCreated, 183 Success: http.StatusCreated,
184 Doc: "Create a VM on a host. Omitted fields get one-click defaults.", 184 Doc: "Create a VM on a host. Omitted fields get one-click defaults; the tenant must have a registered SSH user CA first.",
185 handler: (*API).handleCreateVM, 185 handler: (*API).handleCreateVM,
186 }, 186 },
187 { 187 {
@@ -223,18 +223,38 @@ var routeTable = []Route{
223 Doc: "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped.", 223 Doc: "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped.",
224 handler: (*API).handleListVMEvents, 224 handler: (*API).handleListVMEvents,
225 }, 225 },
226 // SSH jump gate: mint a short-lived user cert for the caller's public key. 226 // BYO per-tenant SSH user CAs: eitri stores only the CA pubkey and never
227 // 404s when the gate is off (no CA wired via SetCertMinter). 227 // holds a user signing key. Tenant-scoped (caller must act for {tenant}).
228 { 228 {
229 Method: "POST", 229 Method: "POST",
230 Path: "/api/v1/ssh-certs", 230 Path: "/api/v1/tenants/{tenant}/user-cas",
231 Auth: AuthAdmin, 231 Auth: AuthAdmin,
232 Kind: KindJSON, 232 Kind: KindJSON,
233 Request: (*types.SSHCertRequest)(nil), 233 Request: (*types.UserCARequest)(nil),
234 Response: (*types.SSHCertResponse)(nil), 234 Response: (*types.UserCAUploadResponse)(nil),
235 Success: http.StatusCreated,
236 Doc: "Register a BYO SSH user CA public key for the tenant; eitri never holds a user signing key.",
237 handler: (*API).handleUploadUserCA,
238 },
239 {
240 Method: "GET",
241 Path: "/api/v1/tenants/{tenant}/user-cas",
242 Auth: AuthAdmin,
243 Kind: KindJSON,
244 Response: []types.UserCA(nil),
235 Success: http.StatusOK, 245 Success: http.StatusOK,
236 Doc: "Mint a short-lived SSH user certificate for the caller's public key. 404 when the jump gate is off (no CA wired).", 246 Doc: "List the tenant's registered SSH user CAs (pubkey, label, fingerprint).",
237 handler: (*API).handleMintSSHCert, 247 handler: (*API).handleListUserCAs,
248 },
249 {
250 Method: "DELETE",
251 Path: "/api/v1/tenants/{tenant}/user-cas",
252 Auth: AuthAdmin,
253 Kind: KindJSON,
254 Request: (*types.UserCARequest)(nil),
255 Success: http.StatusNoContent,
256 Doc: "Remove a registered SSH user CA by its public_key line.",
257 handler: (*API).handleDeleteUserCA,
238 }, 258 },
239 // Revoke a minted user cert (by serial or cert line) and list revocations — 259 // Revoke a minted user cert (by serial or cert line) and list revocations —
240 // enforced at the gate before a cert's short TTL expires. Pure store ops, 260 // enforced at the gate before a cert's short TTL expires. Pure store ops,
internal/server/api/routes_test.go
Old New
@@ -34,7 +34,7 @@ func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type {
34 // `required` array for request schemas, so a type serving both roles would 34 // `required` array for request schemas, so a type serving both roles would
35 // get the wrong treatment on one of them). 35 // get the wrong treatment on one of them).
36 func TestRouteTable(t *testing.T) { 36 func TestRouteTable(t *testing.T) {
37 const wantRoutes = 19 37 const wantRoutes = 21
38 if len(routeTable) != wantRoutes { 38 if len(routeTable) != wantRoutes {
39 t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) 39 t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes)
40 } 40 }
internal/server/api/sshcert.go
Old New
@@ -1,8 +1,6 @@
1 package api 1 package api
2 2
3 import ( 3 import (
4 "crypto/rand"
5 "encoding/binary"
6 "net/http" 4 "net/http"
7 "strconv" 5 "strconv"
8 "time" 6 "time"
@@ -12,71 +10,6 @@ import (
12 "golang.org/x/crypto/ssh" 10 "golang.org/x/crypto/ssh"
13 ) 11 )
14 12
15 // certPrincipal is the VM login user carried by every minted user cert. eitri's
16 // default seed hardcodes `ubuntu`, so v1 mints exactly this principal (spec §B2).
17 // It is set server-side and a client-supplied principal is always ignored — the
18 // multi-user swap (per-owner principals, §9 C1/C2) is then a body change, not an
19 // interface change.
20 const certPrincipal = "ubuntu"
21
22 // CertMinter mints a short-lived SSH user certificate for a caller's public key
23 // (consumer-owned; the concrete implementation is *Minter, wired by main via
24 // SetCertMinter when the jump gate is enabled). Nil ⇒ the gate is off and the
25 // endpoint 404s.
26 type CertMinter interface {
27 Mint(pub ssh.PublicKey) (*ssh.Certificate, error)
28 }
29
30 // SetCertMinter wires the SSH cert minter. Called once by main when the jump
31 // gate is enabled (ssh_listen set); a nil minter leaves the endpoint 404ing.
32 func (a *API) SetCertMinter(m CertMinter) { a.certs = m }
33
34 // Minter signs short-lived user certificates with the persistent SSH user CA.
35 type Minter struct {
36 ca ssh.Signer
37 ttl time.Duration
38 now func() time.Time
39 }
40
41 // NewMinter builds a Minter that signs certs valid for ttl with ca.
42 func NewMinter(ca ssh.Signer, ttl time.Duration) *Minter {
43 return &Minter{ca: ca, ttl: ttl, now: time.Now}
44 }
45
46 // Mint signs a user cert for pub, valid from now for the configured TTL.
47 func (m *Minter) Mint(pub ssh.PublicKey) (*ssh.Certificate, error) {
48 return mintUserCert(m.ca, pub, m.now(), m.ttl)
49 }
50
51 // mintUserCert builds and CA-signs a user certificate for pub. Validity is set
52 // server-side (now .. now+ttl); the principal is fixed to certPrincipal. Kept
53 // free of HTTP so it is unit-testable in isolation.
54 func mintUserCert(ca ssh.Signer, pub ssh.PublicKey, now time.Time, ttl time.Duration) (*ssh.Certificate, error) {
55 var serial uint64
56 if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil {
57 return nil, err
58 }
59 cert := &ssh.Certificate{
60 Key: pub,
61 Serial: serial,
62 CertType: ssh.UserCert,
63 KeyId: certPrincipal,
64 ValidPrincipals: []string{certPrincipal},
65 ValidAfter: uint64(now.Unix()),
66 ValidBefore: uint64(now.Add(ttl).Unix()),
67 Permissions: ssh.Permissions{Extensions: map[string]string{
68 "permit-pty": "",
69 "permit-port-forwarding": "",
70 "permit-user-rc": "",
71 "permit-agent-forwarding": "",
72 }},
73 }
74 if err := cert.SignCert(rand.Reader, ca); err != nil {
75 return nil, err
76 }
77 return cert, nil
78 }
79
80 // HostCertMinter generates and signs a per-VM SSH HOST key + cert at VM 13 // HostCertMinter generates and signs a per-VM SSH HOST key + cert at VM
81 // create. The concrete implementation is *HostMinter, wired by main via 14 // create. The concrete implementation is *HostMinter, wired by main via
82 // SetHostCertMinter when the jump gate is enabled. Nil ⇒ the gate is off and 15 // SetHostCertMinter when the jump gate is enabled. Nil ⇒ the gate is off and
@@ -99,10 +32,10 @@ func (a *API) SetHostCertMinter(m HostCertMinter) { a.hostCerts = m }
99 // any credential. 32 // any credential.
100 func (a *API) SetSSHCAAuthorizedKey(line string) { a.sshCAKey = line } 33 func (a *API) SetSSHCAAuthorizedKey(line string) { a.sshCAKey = line }
101 34
102 // handleSSHCA returns the eitri CA public key so a client can write a 35 // handleSSHCA returns the eitri HOST CA public key so a client can write a
103 // `@cert-authority * <ca>` known_hosts entry and verify the gate and every VM 36 // `@cert-authority * <ca>` known_hosts entry and verify the gate and every VM
104 // by certificate instead of TOFU. Unauthenticated (it is public material); 37 // host key by certificate instead of TOFU. Unauthenticated (it is public
105 // 404s when the jump gate is off. 38 // material); 404s when the jump gate is off.
106 func (a *API) handleSSHCA(w http.ResponseWriter, r *http.Request) { 39 func (a *API) handleSSHCA(w http.ResponseWriter, r *http.Request) {
107 if a.sshCAKey == "" { 40 if a.sshCAKey == "" {
108 http.Error(w, "ssh jump gate not enabled", http.StatusNotFound) 41 http.Error(w, "ssh jump gate not enabled", http.StatusNotFound)
@@ -137,39 +70,6 @@ func (m *HostMinter) MintHostCert(principal string) (keyPEM, cert string, err er
137 return string(pem), string(ssh.MarshalAuthorizedKey(c)), nil 70 return string(pem), string(ssh.MarshalAuthorizedKey(c)), nil
138 } 71 }
139 72
140 // handleMintSSHCert mints a short-lived user cert for the caller's public key,
141 // signed by the user CA (spec §B2). Admin-authed. Returns 404 when the jump
142 // gate is off (no CA wired).
143 func (a *API) handleMintSSHCert(w http.ResponseWriter, r *http.Request) {
144 if a.certs == nil {
145 http.Error(w, "ssh jump gate not enabled", http.StatusNotFound)
146 return
147 }
148 var req types.SSHCertRequest
149 if !decodeJSON(w, r, &req) {
150 return
151 }
152 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey))
153 if err != nil {
154 http.Error(w, "invalid public_key", http.StatusBadRequest)
155 return
156 }
157 cert, err := a.certs.Mint(pub)
158 if err != nil {
159 http.Error(w, "internal error", http.StatusInternalServerError)
160 return
161 }
162 a.audit("ssh-cert.mint", map[string]string{
163 "remote": clientIP(r),
164 "principal": certPrincipal,
165 "fingerprint": ssh.FingerprintSHA256(pub),
166 })
167 writeJSON(w, http.StatusOK, types.SSHCertResponse{
168 Certificate: string(ssh.MarshalAuthorizedKey(cert)),
169 Tenant: principalFromContext(r).Tenant,
170 })
171 }
172
173 // handleRevokeSSHCert revokes a specific minted user cert by serial so the jump 73 // handleRevokeSSHCert revokes a specific minted user cert by serial so the jump
174 // gate rejects it at auth before its short TTL expires. Admin-authed, idempotent 74 // gate rejects it at auth before its short TTL expires. Admin-authed, idempotent
175 // (re-revoking a serial is a 204 no-op). Revocation is a pure store operation — 75 // (re-revoking a serial is a 204 no-op). Revocation is a pure store operation —
internal/server/api/sshcert_test.go
Old New
@@ -7,9 +7,7 @@ import (
7 "encoding/json" 7 "encoding/json"
8 "io" 8 "io"
9 "net/http" 9 "net/http"
10 "net/http/httptest"
11 "testing" 10 "testing"
12 "time"
13 11
14 "github.com/a73x/eitri/internal/server/store" 12 "github.com/a73x/eitri/internal/server/store"
15 "github.com/stretchr/testify/assert" 13 "github.com/stretchr/testify/assert"
@@ -17,7 +15,7 @@ import (
17 "golang.org/x/crypto/ssh" 15 "golang.org/x/crypto/ssh"
18 ) 16 )
19 17
20 // newCASigner returns a throwaway ed25519 ssh.Signer to stand in for the user CA. 18 // newCASigner returns a throwaway ed25519 ssh.Signer to stand in for the CA.
21 func newCASigner(t *testing.T) ssh.Signer { 19 func newCASigner(t *testing.T) ssh.Signer {
22 t.Helper() 20 t.Helper()
23 _, priv, err := ed25519.GenerateKey(rand.Reader) 21 _, priv, err := ed25519.GenerateKey(rand.Reader)
@@ -37,16 +35,6 @@ func genUserPubKey(t *testing.T) string {
37 return string(ssh.MarshalAuthorizedKey(sp)) 35 return string(ssh.MarshalAuthorizedKey(sp))
38 } 36 }
39 37
40 // newServerWithCertMinter builds a test server with the ssh-cert gate enabled,
41 // returning the server and the CA signer whose public key certs are checked against.
42 func newServerWithCertMinter(t *testing.T, ttl time.Duration) (*httptest.Server, ssh.Signer) {
43 t.Helper()
44 ts, _, _, _, a := newServer(t)
45 ca := newCASigner(t)
46 a.SetCertMinter(NewMinter(ca, ttl))
47 return ts, ca
48 }
49
50 // parseCert decodes an authorized-keys cert line into an *ssh.Certificate. 38 // parseCert decodes an authorized-keys cert line into an *ssh.Certificate.
51 func parseCert(t *testing.T, line string) *ssh.Certificate { 39 func parseCert(t *testing.T, line string) *ssh.Certificate {
52 t.Helper() 40 t.Helper()
@@ -57,115 +45,16 @@ func parseCert(t *testing.T, line string) *ssh.Certificate {
57 return cert 45 return cert
58 } 46 }
59 47
60 // mintCert POSTs to /api/v1/ssh-certs and returns the response + decoded cert line.
61 func mintCert(t *testing.T, ts *httptest.Server, token string, body map[string]any) (*http.Response, string) {
62 t.Helper()
63 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs", token, body)
64 if resp.StatusCode != http.StatusOK {
65 return resp, ""
66 }
67 var out map[string]string
68 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
69 return resp, out["certificate"]
70 }
71
72 func TestSSHCertMintReturnsSignedUserCert(t *testing.T) {
73 ttl := 10 * time.Minute
74 ts, ca := newServerWithCertMinter(t, ttl)
75
76 resp, line := mintCert(t, ts, "admintok", map[string]any{"public_key": genUserPubKey(t)})
77 require.Equal(t, http.StatusOK, resp.StatusCode)
78
79 cert := parseCert(t, line)
80 assert.Equal(t, uint32(ssh.UserCert), cert.CertType)
81 assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals)
82 assert.Equal(t, uint64(ttl.Seconds()), cert.ValidBefore-cert.ValidAfter)
83 for _, ext := range []string{
84 "permit-pty", "permit-port-forwarding", "permit-user-rc", "permit-agent-forwarding",
85 } {
86 _, ok := cert.Permissions.Extensions[ext]
87 assert.True(t, ok, "cert must carry extension %q", ext)
88 }
89
90 // The CA signature must verify for principal ubuntu.
91 checker := &ssh.CertChecker{IsUserAuthority: func(k ssh.PublicKey) bool {
92 return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal())
93 }}
94 require.NoError(t, checker.CheckCert("ubuntu", cert))
95 }
96
97 // TestSSHCertServedCertScopedToPrincipalUbuntu asserts the cert actually served
98 // by the HTTP handler (response body → re-parsed *ssh.Certificate) is scoped to
99 // exactly `ubuntu`. An empty ValidPrincipals would make the cert valid for ANY
100 // login user, so the guard is that it is both non-empty AND exactly ["ubuntu"].
101 func TestSSHCertServedCertScopedToPrincipalUbuntu(t *testing.T) {
102 ts, _ := newServerWithCertMinter(t, 10*time.Minute)
103
104 resp, line := mintCert(t, ts, "admintok", map[string]any{"public_key": genUserPubKey(t)})
105 require.Equal(t, http.StatusOK, resp.StatusCode)
106
107 cert := parseCert(t, line)
108 require.NotEmpty(t, cert.ValidPrincipals, "served cert must NOT be valid for ANY principal")
109 assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals)
110 }
111
112 func TestSSHCertMintIgnoresClientPrincipals(t *testing.T) {
113 ts, _ := newServerWithCertMinter(t, 10*time.Minute)
114
115 resp, line := mintCert(t, ts, "admintok", map[string]any{
116 "public_key": genUserPubKey(t),
117 "principals": []string{"root", "admin"},
118 })
119 require.Equal(t, http.StatusOK, resp.StatusCode)
120
121 cert := parseCert(t, line)
122 assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals,
123 "client-supplied principals must be ignored — always ubuntu")
124 }
125
126 // TestSSHCertMintReturnsTenant asserts the mint response carries the minting
127 // principal's tenant, which clients use to build <tenant>.<name> connect names.
128 func TestSSHCertMintReturnsTenant(t *testing.T) {
129 ts, _ := newServerWithCertMinter(t, 10*time.Minute)
130
131 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs", "admintok",
132 map[string]any{"public_key": genUserPubKey(t)})
133 require.Equal(t, http.StatusOK, resp.StatusCode)
134 var out map[string]string
135 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
136 assert.Equal(t, store.DefaultTenant, out["tenant"], "mint response must carry the tenant")
137 }
138
139 func TestSSHCertMintRequiresAdmin(t *testing.T) {
140 ts, _ := newServerWithCertMinter(t, 10*time.Minute)
141 body := map[string]any{"public_key": genUserPubKey(t)}
142 assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs", "", body).StatusCode)
143 assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs", "wrong", body).StatusCode)
144 }
145
146 func TestSSHCertMintMalformedPubKeyIs400(t *testing.T) {
147 ts, _ := newServerWithCertMinter(t, 10*time.Minute)
148 resp, _ := mintCert(t, ts, "admintok", map[string]any{"public_key": "not-a-key"})
149 assert.Equal(t, 400, resp.StatusCode)
150 }
151
152 func TestSSHCertMintGateOffIs404(t *testing.T) {
153 // No minter wired ⇒ the gate is off; the endpoint must not be reachable.
154 ts, _, _, _, _ := newServer(t)
155 resp, _ := mintCert(t, ts, "admintok", map[string]any{"public_key": genUserPubKey(t)})
156 assert.Equal(t, 404, resp.StatusCode)
157 }
158
159 func TestSSHCAEndpointServesCAWhenEnabled(t *testing.T) { 48 func TestSSHCAEndpointServesCAWhenEnabled(t *testing.T) {
160 ts, _, _, _, a := newServer(t) 49 ts, _, _, _, a := newServer(t)
161 a.SetSSHCAAuthorizedKey("ssh-ed25519 AAAAtestca eitri-user-ca") 50 a.SetSSHCAAuthorizedKey("ssh-ed25519 AAAAtestca eitri-host-ca")
162 51
163 // Unauthenticated: it is public material and clients need it before auth. 52 // Unauthenticated: it is public material and clients need it before auth.
164 resp := do(t, "GET", ts.URL+"/api/v1/ssh-ca", "", nil) 53 resp := do(t, "GET", ts.URL+"/api/v1/ssh-ca", "", nil)
165 require.Equal(t, http.StatusOK, resp.StatusCode) 54 require.Equal(t, http.StatusOK, resp.StatusCode)
166 var out map[string]string 55 var out map[string]string
167 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) 56 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
168 assert.Equal(t, "ssh-ed25519 AAAAtestca eitri-user-ca", out["ca"]) 57 assert.Equal(t, "ssh-ed25519 AAAAtestca eitri-host-ca", out["ca"])
169 } 58 }
170 59
171 func TestSSHCAEndpointGateOffIs404(t *testing.T) { 60 func TestSSHCAEndpointGateOffIs404(t *testing.T) {
@@ -269,15 +158,24 @@ func TestSSHCertRevokeBySerial(t *testing.T) {
269 assert.Equal(t, "lost yubikey", out[0]["reason"]) 158 assert.Equal(t, "lost yubikey", out[0]["reason"])
270 } 159 }
271 160
272 // TestSSHCertRevokeByCertLine revokes by pasting a minted cert authorized-key 161 // TestSSHCertRevokeByCertLine revokes by pasting a CA-signed cert authorized-key
273 // line; the server extracts the serial and the matching serial reads revoked. 162 // line; the server extracts the serial and the matching serial reads revoked.
163 // The cert is hand-built (eitri no longer mints user certs) — the revoke handler
164 // only reads the serial off the line, it does not verify the signature.
274 func TestSSHCertRevokeByCertLine(t *testing.T) { 165 func TestSSHCertRevokeByCertLine(t *testing.T) {
275 ts, st, _, _, a := newServer(t) 166 ts, st, _, _, _ := newServer(t)
276 ca := newCASigner(t) 167 ca := newCASigner(t)
277 a.SetCertMinter(NewMinter(ca, 10*time.Minute))
278 168
279 _, line := mintCert(t, ts, "admintok", map[string]any{"public_key": genUserPubKey(t)}) 169 pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(genUserPubKey(t)))
280 cert := parseCert(t, line) 170 require.NoError(t, err)
171 cert := &ssh.Certificate{
172 Key: pk,
173 Serial: 0x1234abcd,
174 CertType: ssh.UserCert,
175 ValidBefore: ssh.CertTimeInfinity,
176 }
177 require.NoError(t, cert.SignCert(rand.Reader, ca))
178 line := string(ssh.MarshalAuthorizedKey(cert))
281 179
282 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", 180 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok",
283 map[string]any{"certificate": line}) 181 map[string]any{"certificate": line})
@@ -285,7 +183,7 @@ func TestSSHCertRevokeByCertLine(t *testing.T) {
285 183
286 revoked, err := st.IsSSHCertRevoked(cert.Serial) 184 revoked, err := st.IsSSHCertRevoked(cert.Serial)
287 require.NoError(t, err) 185 require.NoError(t, err)
288 assert.True(t, revoked, "the minted cert's serial must be revoked") 186 assert.True(t, revoked, "the pasted cert's serial must be revoked")
289 } 187 }
290 188
291 // TestSSHCertRevokeIdempotent confirms re-revoking the same serial is a 204 189 // TestSSHCertRevokeIdempotent confirms re-revoking the same serial is a 204
@@ -321,37 +219,3 @@ func TestSSHCertRevokeRequiresAdmin(t *testing.T) {
321 assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "wrong", body).StatusCode) 219 assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "wrong", body).StatusCode)
322 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", "", nil).StatusCode) 220 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", "", nil).StatusCode)
323 } 221 }
324
325 // TestMintUserCert exercises the pure mint function without HTTP.
326 func TestMintUserCert(t *testing.T) {
327 ca := newCASigner(t)
328 pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(genUserPubKey(t)))
329 require.NoError(t, err)
330
331 now := time.Unix(1_700_000_000, 0)
332 cert, err := mintUserCert(ca, pk, now, 5*time.Minute)
333 require.NoError(t, err)
334
335 assert.Equal(t, uint32(ssh.UserCert), cert.CertType)
336 assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals)
337 assert.Equal(t, uint64(now.Unix()), cert.ValidAfter)
338 assert.Equal(t, uint64(now.Add(5*time.Minute).Unix()), cert.ValidBefore)
339 assert.NotZero(t, cert.Serial)
340
341 // The cert must grant pty + forwarding so guest sshd permits an interactive
342 // shell, scp, and port-forwarding over the tunnelled session.
343 for _, ext := range []string{
344 "permit-pty", "permit-port-forwarding", "permit-user-rc", "permit-agent-forwarding",
345 } {
346 _, ok := cert.Permissions.Extensions[ext]
347 assert.True(t, ok, "cert must carry extension %q", ext)
348 }
349
350 checker := &ssh.CertChecker{
351 Clock: func() time.Time { return now.Add(time.Minute) }, // inside validity
352 IsUserAuthority: func(k ssh.PublicKey) bool {
353 return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal())
354 },
355 }
356 require.NoError(t, checker.CheckCert("ubuntu", cert))
357 }
internal/server/api/testdata/ssh-cert-request.golden.json
Old New
@@ -1,6 +0,0 @@
1 {
2 "public_key": "ssh-ed25519 AAAAC3Nza key-comment",
3 "principals": [
4 "ubuntu"
5 ]
6 }
internal/server/api/testdata/ssh-cert-response.golden.json
Old New
@@ -1,4 +0,0 @@
1 {
2 "certificate": "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment",
3 "tenant": "default"
4 }
internal/server/api/testdata/user-ca-list.golden.json
Old New
@@ -0,0 +1,7 @@
1 [
2 {
3 "fingerprint": "SHA256:abcdefghijk",
4 "label": "team-alpha-ca",
5 "pubkey": "ssh-ed25519 AAAAC3Nza ca-comment"
6 }
7 ]
internal/server/api/testdata/user-ca-request.golden.json
Old New
@@ -0,0 +1,4 @@
1 {
2 "public_key": "ssh-ed25519 AAAAC3Nza ca-comment",
3 "label": "team-alpha-ca"
4 }
internal/server/api/testdata/user-ca-upload-response.golden.json
Old New
@@ -0,0 +1,3 @@
1 {
2 "fingerprint": "SHA256:abcdefghijk"
3 }
internal/server/api/types/types.go
Old New
@@ -152,19 +152,23 @@ type SSHCAResponse struct {
152 CA string `json:"ca"` 152 CA string `json:"ca"`
153 } 153 }
154 154
155 // SSHCertResponse answers POST /api/v1/ssh-certs: the CA-signed user
156 // certificate in authorized_keys form.
157 type SSHCertResponse struct {
158 Certificate string `json:"certificate"`
159 // The minting principal's tenant: clients dial VMs as <tenant>.<name>.
160 Tenant string `json:"tenant"`
161 }
162
163 // StreamTicketResponse answers POST /api/v1/stream-tickets. 155 // StreamTicketResponse answers POST /api/v1/stream-tickets.
164 type StreamTicketResponse struct { 156 type StreamTicketResponse struct {
165 Ticket string `json:"ticket"` 157 Ticket string `json:"ticket"`
166 } 158 }
167 159
160 // UserCAUploadResponse answers POST /api/v1/tenants/{tenant}/user-cas.
161 type UserCAUploadResponse struct {
162 Fingerprint string `json:"fingerprint"`
163 }
164
165 // UserCA is one entry in GET /api/v1/tenants/{tenant}/user-cas.
166 type UserCA struct {
167 Fingerprint string `json:"fingerprint"`
168 Label string `json:"label"`
169 PubKey string `json:"pubkey"`
170 }
171
168 // AuditEvent is the wire shape of one audit row, served by GET /api/v1/audit 172 // AuditEvent is the wire shape of one audit row, served by GET /api/v1/audit
169 // and GET /api/v1/vms/{id}/events; detail is embedded as raw JSON (it is 173 // and GET /api/v1/vms/{id}/events; detail is embedded as raw JSON (it is
170 // always a marshaled object). 174 // always a marshaled object).
@@ -184,15 +188,12 @@ type RevokedCert struct {
184 Reason string `json:"reason"` 188 Reason string `json:"reason"`
185 } 189 }
186 190
187 // SSHCertRequest is the POST /api/v1/ssh-certs body: the caller's public key 191 // UserCARequest is the body of both POST and DELETE
188 // for which the jump gate mints a short-lived user certificate. 192 // /api/v1/tenants/{tenant}/user-cas: a BYO user-CA public key (authorized_keys
189 type SSHCertRequest struct { 193 // line) to register or remove, with an optional label on upload.
194 type UserCARequest struct {
190 PublicKey string `json:"public_key"` 195 PublicKey string `json:"public_key"`
191 // Principals is accepted on the wire but DELIBERATELY IGNORED — principals 196 Label string `json:"label"`
192 // are set server-side. Kept as a field so a client that sends it gets a
193 // well-formed decode rather than a surprise, and so the ignore is explicit
194 // rather than implicit.
195 Principals []string `json:"principals"`
196 } 197 }
197 198
198 // RevokeSSHCertRequest is the POST /api/v1/ssh-certs/revoke body. It accepts 199 // RevokeSSHCertRequest is the POST /api/v1/ssh-certs/revoke body. It accepts
internal/server/api/usercas.go
Old New
@@ -0,0 +1,83 @@
1 package api
2
3 import (
4 "net/http"
5
6 "github.com/a73x/eitri/internal/server/api/types"
7 "github.com/a73x/eitri/internal/server/sshca"
8 "golang.org/x/crypto/ssh"
9 )
10
11 // handleUploadUserCA registers a BYO user-CA public key for the {tenant} in the
12 // path. eitri stores only the pubkey (canonical line) — it never holds a user
13 // signing key. Tenant-scoped: the caller must act for {tenant}.
14 func (a *API) handleUploadUserCA(w http.ResponseWriter, r *http.Request) {
15 tenant := r.PathValue("tenant")
16 if !mayActAs(principalFromContext(r), tenant) {
17 http.Error(w, "forbidden", http.StatusForbidden)
18 return
19 }
20 var req types.UserCARequest
21 if !decodeJSON(w, r, &req) {
22 return
23 }
24 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey))
25 if err != nil {
26 http.Error(w, "invalid public_key", http.StatusBadRequest)
27 return
28 }
29 line := sshca.AuthorizedKeyLine(pub)
30 if err := a.st.AddTenantUserCA(tenant, line, "tenant", req.Label, principalFromContext(r).Tenant); err != nil {
31 http.Error(w, "internal error", http.StatusInternalServerError)
32 return
33 }
34 a.audit("user-ca.upload", map[string]string{"tenant": tenant, "fingerprint": ssh.FingerprintSHA256(pub)})
35 writeJSON(w, http.StatusCreated, types.UserCAUploadResponse{Fingerprint: ssh.FingerprintSHA256(pub)})
36 }
37
38 // handleListUserCAs lists a tenant's registered user CAs (pubkey + label + fp).
39 func (a *API) handleListUserCAs(w http.ResponseWriter, r *http.Request) {
40 tenant := r.PathValue("tenant")
41 if !mayActAs(principalFromContext(r), tenant) {
42 http.Error(w, "forbidden", http.StatusForbidden)
43 return
44 }
45 cas, err := a.st.ListTenantUserCAs(tenant)
46 if err != nil {
47 http.Error(w, "internal error", http.StatusInternalServerError)
48 return
49 }
50 out := make([]types.UserCA, 0, len(cas))
51 for _, c := range cas {
52 pub, _, _, _, perr := ssh.ParseAuthorizedKey([]byte(c.Pubkey))
53 fp := ""
54 if perr == nil {
55 fp = ssh.FingerprintSHA256(pub)
56 }
57 out = append(out, types.UserCA{Fingerprint: fp, Label: c.Label, PubKey: c.Pubkey})
58 }
59 writeJSON(w, http.StatusOK, out)
60 }
61
62 // handleDeleteUserCA removes a registered CA by its public_key line.
63 func (a *API) handleDeleteUserCA(w http.ResponseWriter, r *http.Request) {
64 tenant := r.PathValue("tenant")
65 if !mayActAs(principalFromContext(r), tenant) {
66 http.Error(w, "forbidden", http.StatusForbidden)
67 return
68 }
69 var req types.UserCARequest
70 if !decodeJSON(w, r, &req) {
71 return
72 }
73 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey))
74 if err != nil {
75 http.Error(w, "invalid public_key", http.StatusBadRequest)
76 return
77 }
78 if err := a.st.RemoveTenantUserCA(tenant, sshca.AuthorizedKeyLine(pub)); err != nil {
79 http.Error(w, "internal error", http.StatusInternalServerError)
80 return
81 }
82 w.WriteHeader(http.StatusNoContent)
83 }
internal/server/api/usercas_test.go
Old New
@@ -0,0 +1,44 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6 "testing"
7
8 "github.com/a73x/eitri/internal/server/sshca"
9 "github.com/a73x/eitri/internal/server/store"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 )
13
14 // TestUploadUserCAStoresCanonicalLine posts a BYO user-CA public key and asserts
15 // eitri stores its canonical authorized_keys line under the tenant — the pubkey
16 // is registered (never a signing key), resolvable back to the tenant.
17 func TestUploadUserCAStoresCanonicalLine(t *testing.T) {
18 ts, st, _, _, _ := newServer(t)
19
20 // A throwaway CA: eitri only ever sees the public key.
21 _, signer, err := sshca.GenerateHostKey()
22 require.NoError(t, err)
23 line := sshca.AuthorizedKeyLine(signer.PublicKey())
24
25 resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+store.DefaultTenant+"/user-cas", "admintok",
26 map[string]any{"public_key": line, "label": "yubikey-ca"})
27 require.Equal(t, http.StatusCreated, resp.StatusCode)
28
29 var out map[string]string
30 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
31 assert.NotEmpty(t, out["fingerprint"], "upload must echo the CA fingerprint")
32
33 tenant, ok, err := st.TenantForUserCA(line)
34 require.NoError(t, err)
35 require.True(t, ok, "the uploaded CA line must resolve back to a tenant")
36 assert.Equal(t, store.DefaultTenant, tenant)
37 }
38
39 func TestUploadUserCAGarbageKeyIs400(t *testing.T) {
40 ts, _, _, _, _ := newServer(t)
41 resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+store.DefaultTenant+"/user-cas", "admintok",
42 map[string]any{"public_key": "not-a-key"})
43 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
44 }
internal/server/api/wire_golden_test.go
Old New
@@ -117,9 +117,9 @@ func TestWireGolden(t *testing.T) {
117 PowerState: "stopped", 117 PowerState: "stopped",
118 }) 118 })
119 119
120 goldenCheck(t, "ssh-cert-request", types.SSHCertRequest{ 120 goldenCheck(t, "user-ca-request", types.UserCARequest{
121 PublicKey: "ssh-ed25519 AAAAC3Nza key-comment", 121 PublicKey: "ssh-ed25519 AAAAC3Nza ca-comment",
122 Principals: []string{"ubuntu"}, 122 Label: "team-alpha-ca",
123 }) 123 })
124 124
125 serial := uint64(9007199254740993) 125 serial := uint64(9007199254740993)
@@ -153,15 +153,20 @@ func TestWireGolden(t *testing.T) {
153 CA: "ssh-ed25519 AAAAC3Nza eitri-host-ca", 153 CA: "ssh-ed25519 AAAAC3Nza eitri-host-ca",
154 }) 154 })
155 155
156 goldenCheck(t, "ssh-cert-response", types.SSHCertResponse{
157 Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment",
158 Tenant: "default",
159 })
160
161 goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{ 156 goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{
162 Ticket: "ticket-opaque-01", 157 Ticket: "ticket-opaque-01",
163 }) 158 })
164 159
160 goldenCheck(t, "user-ca-upload-response", types.UserCAUploadResponse{
161 Fingerprint: "SHA256:abcdefghijk",
162 })
163
164 goldenCheck(t, "user-ca-list", []types.UserCA{{
165 Fingerprint: "SHA256:abcdefghijk",
166 Label: "team-alpha-ca",
167 PubKey: "ssh-ed25519 AAAAC3Nza ca-comment",
168 }})
169
165 goldenCheck(t, "audit-event", types.AuditEvent{ 170 goldenCheck(t, "audit-event", types.AuditEvent{
166 At: base.Add(2 * time.Minute), 171 At: base.Add(2 * time.Minute),
167 Action: "host.enroll", 172 Action: "host.enroll",
internal/server/config/config.go
Old New
@@ -42,7 +42,4 @@ type Config struct {
42 // "localhost". It must match the host in EITRI_GATE so `@cert-authority` 42 // "localhost". It must match the host in EITRI_GATE so `@cert-authority`
43 // verification accepts the presented host cert. 43 // verification accepts the presented host cert.
44 SSHGateDomain string `json:"ssh_gate_domain"` 44 SSHGateDomain string `json:"ssh_gate_domain"`
45 // SSHCertTTL bounds minted user-cert validity (Go duration; default 10m).
46 // Set server-side; client-requested validity is never honored.
47 SSHCertTTL string `json:"ssh_cert_ttl"`
48 } 45 }
internal/server/sshca/sshca.go
Old New
@@ -17,6 +17,7 @@ import (
17 "encoding/pem" 17 "encoding/pem"
18 "fmt" 18 "fmt"
19 "os" 19 "os"
20 "strings"
20 "time" 21 "time"
21 22
22 "golang.org/x/crypto/ssh" 23 "golang.org/x/crypto/ssh"
@@ -50,20 +51,30 @@ func New(caPath, hostKeyPath string) (*CA, error) {
50 return &CA{userCA: userCA, hostKey: hostKey}, nil 51 return &CA{userCA: userCA, hostKey: hostKey}, nil
51 } 52 }
52 53
53 // UserCA returns the signer used to sign user (and, in v1, host) certificates.
54 func (c *CA) UserCA() ssh.Signer { return c.userCA }
55
56 // HostKey returns the gate's persistent host key. 54 // HostKey returns the gate's persistent host key.
57 func (c *CA) HostKey() ssh.Signer { return c.hostKey } 55 func (c *CA) HostKey() ssh.Signer { return c.hostKey }
58 56
59 // UserCAAuthorizedKey returns the user CA public key in authorized_keys / 57 // HostCA returns the signer used to sign VM + gate HOST certificates. In the
60 // known_hosts form (e.g. "ssh-ed25519 AAAA... \n"), suitable for 58 // BYO model eitri no longer signs USER certs — those are per-tenant, uploaded by
61 // TrustedUserCAKeys injection and "@cert-authority" known_hosts pinning. This 59 // members and never held here; the gate trusts the DB-registered set. This key
62 // is public material — safe to expose. 60 // is the persistent CA loaded from ssh_ca_key (the `userCA` field, retained as
63 func (c *CA) UserCAAuthorizedKey() []byte { 61 // the field name for the loaded key material).
62 func (c *CA) HostCA() ssh.Signer { return c.userCA }
63
64 // HostCAAuthorizedKey returns the host CA public key in authorized_keys form,
65 // for @cert-authority host pinning (served at GET /api/v1/ssh-ca). Public.
66 func (c *CA) HostCAAuthorizedKey() []byte {
64 return ssh.MarshalAuthorizedKey(c.userCA.PublicKey()) 67 return ssh.MarshalAuthorizedKey(c.userCA.PublicKey())
65 } 68 }
66 69
70 // AuthorizedKeyLine returns pub as a canonical single-line authorized_keys
71 // entry ("type base64"), with no comment or trailing newline. This is the
72 // stable key used to register/look up a CA (the store's tenant_user_cas.ca_pubkey
73 // and the gate's tenant lookup MUST agree byte-for-byte).
74 func AuthorizedKeyLine(pub ssh.PublicKey) string {
75 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
76 }
77
67 // LoadOrCreate returns a stable ssh.Signer for the key at path. If the file is 78 // LoadOrCreate returns a stable ssh.Signer for the key at path. If the file is
68 // absent it generates an ed25519 key, writes it 0600 (OpenSSH PEM), and returns 79 // absent it generates an ed25519 key, writes it 0600 (OpenSSH PEM), and returns
69 // its signer; if present it parses and returns the existing key. The public key 80 // its signer; if present it parses and returns the existing key. The public key
internal/server/sshca/sshca_test.go
Old New
@@ -4,6 +4,7 @@ import (
4 "bytes" 4 "bytes"
5 "os" 5 "os"
6 "path/filepath" 6 "path/filepath"
7 "strings"
7 "testing" 8 "testing"
8 "time" 9 "time"
9 10
@@ -60,31 +61,31 @@ func TestNew_AccessorsAndAuthorizedKey(t *testing.T) {
60 if err != nil { 61 if err != nil {
61 t.Fatalf("New: %v", err) 62 t.Fatalf("New: %v", err)
62 } 63 }
63 if ca.UserCA() == nil { 64 if ca.HostCA() == nil {
64 t.Fatal("UserCA() is nil") 65 t.Fatal("HostCA() is nil")
65 } 66 }
66 if ca.HostKey() == nil { 67 if ca.HostKey() == nil {
67 t.Fatal("HostKey() is nil") 68 t.Fatal("HostKey() is nil")
68 } 69 }
69 70
70 // The user CA and host key must be distinct key material. 71 // The host CA and gate host key must be distinct key material.
71 if bytes.Equal(ssh.MarshalAuthorizedKey(ca.UserCA().PublicKey()), 72 if bytes.Equal(ssh.MarshalAuthorizedKey(ca.HostCA().PublicKey()),
72 ssh.MarshalAuthorizedKey(ca.HostKey().PublicKey())) { 73 ssh.MarshalAuthorizedKey(ca.HostKey().PublicKey())) {
73 t.Fatal("UserCA and HostKey share the same public key") 74 t.Fatal("HostCA and HostKey share the same public key")
74 } 75 }
75 76
76 authLine := ca.UserCAAuthorizedKey() 77 authLine := ca.HostCAAuthorizedKey()
77 if len(authLine) == 0 { 78 if len(authLine) == 0 {
78 t.Fatal("UserCAAuthorizedKey() is empty") 79 t.Fatal("HostCAAuthorizedKey() is empty")
79 } 80 }
80 // It must be a parseable authorized_keys line matching the user CA. 81 // It must be a parseable authorized_keys line matching the host CA.
81 pub, _, _, _, err := ssh.ParseAuthorizedKey(authLine) 82 pub, _, _, _, err := ssh.ParseAuthorizedKey(authLine)
82 if err != nil { 83 if err != nil {
83 t.Fatalf("ParseAuthorizedKey(UserCAAuthorizedKey()): %v", err) 84 t.Fatalf("ParseAuthorizedKey(HostCAAuthorizedKey()): %v", err)
84 } 85 }
85 if !bytes.Equal(ssh.MarshalAuthorizedKey(pub), 86 if !bytes.Equal(ssh.MarshalAuthorizedKey(pub),
86 ssh.MarshalAuthorizedKey(ca.UserCA().PublicKey())) { 87 ssh.MarshalAuthorizedKey(ca.HostCA().PublicKey())) {
87 t.Fatal("UserCAAuthorizedKey() does not match UserCA public key") 88 t.Fatal("HostCAAuthorizedKey() does not match HostCA public key")
88 } 89 }
89 } 90 }
90 91
@@ -108,6 +109,22 @@ func TestGenerateHostKey_PEMParsesToSigner(t *testing.T) {
108 } 109 }
109 } 110 }
110 111
112 func TestAuthorizedKeyLineIsCanonical(t *testing.T) {
113 _, signer, err := GenerateHostKey()
114 if err != nil {
115 t.Fatal(err)
116 }
117 line := AuthorizedKeyLine(signer.PublicKey())
118 if strings.ContainsAny(line, "\n\r") {
119 t.Fatalf("line must have no newline: %q", line)
120 }
121 // Same key marshaled with a trailing newline trims to the same canonical line.
122 withComment := string(ssh.MarshalAuthorizedKey(signer.PublicKey()))
123 if AuthorizedKeyLine(signer.PublicKey()) != strings.TrimSpace(withComment) {
124 t.Fatal("AuthorizedKeyLine must equal the trimmed marshaled key")
125 }
126 }
127
111 func TestSignHostCert_SignedByCAAndScopedToPrincipal(t *testing.T) { 128 func TestSignHostCert_SignedByCAAndScopedToPrincipal(t *testing.T) {
112 ca, err := LoadOrCreate(filepath.Join(t.TempDir(), "ca")) 129 ca, err := LoadOrCreate(filepath.Join(t.TempDir(), "ca"))
113 if err != nil { 130 if err != nil {
internal/server/sshgate/gate.go
Old New
@@ -10,7 +10,6 @@
10 package sshgate 10 package sshgate
11 11
12 import ( 12 import (
13 "bytes"
14 "context" 13 "context"
15 "errors" 14 "errors"
16 "io" 15 "io"
@@ -76,13 +75,22 @@ type Gate struct {
76 // CA-rotation-push problem). 75 // CA-rotation-push problem).
77 type Revoker func(serial uint64) bool 76 type Revoker func(serial uint64) bool
78 77
79 // New builds a Gate that presents hostKey, trusts only certificates signed by 78 // UserCALookup resolves a cert signature key to the tenant that registered it.
80 // userCA, resolves VM names with resolve, gates them with authorize, rejects 79 // ok=false ⇒ the CA is not a registered tenant user CA ⇒ reject the cert.
81 // certs isRevoked flags, and tunnels through dial. A nil isRevoked disables 80 type UserCALookup func(sig ssh.PublicKey) (tenant string, ok bool)
82 // revocation checks (nothing is revoked). 81
83 func New(hostKey ssh.Signer, userCA ssh.PublicKey, caTenant string, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker) *Gate { 82 // New builds a Gate that presents hostKey, trusts every certificate signed by a
83 // CA that userCAs resolves to a tenant, resolves VM names with resolve, gates
84 // them with authorize, rejects certs isRevoked flags, and tunnels through dial.
85 // A nil isRevoked disables revocation checks (nothing is revoked).
86 //
87 // The connection's tenant is stamped from WHICH registered CA signed the cert
88 // (userCAs), making the downstream cert.tenant == vm.tenant authz a real
89 // cryptographic boundary — a cert can only ever carry the tenant of the CA that
90 // signed it.
91 func New(hostKey ssh.Signer, userCAs UserCALookup, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker) *Gate {
84 checker := &ssh.CertChecker{ 92 checker := &ssh.CertChecker{
85 IsUserAuthority: func(auth ssh.PublicKey) bool { return keysEqual(auth, userCA) }, 93 IsUserAuthority: func(auth ssh.PublicKey) bool { _, ok := userCAs(auth); return ok },
86 } 94 }
87 // CheckCert consults IsRevoked during validation: a true result fails the 95 // CheckCert consults IsRevoked during validation: a true result fails the
88 // cert authentication outright, so a revoked cert cannot open the tunnel. 96 // cert authentication outright, so a revoked cert cannot open the tunnel.
@@ -98,8 +106,12 @@ func New(hostKey ssh.Signer, userCA ssh.PublicKey, caTenant string, resolve Reso
98 if cert.CertType != ssh.UserCert { 106 if cert.CertType != ssh.UserCert {
99 return nil, errors.New("sshgate: not a user certificate") 107 return nil, errors.New("sshgate: not a user certificate")
100 } 108 }
101 if !checker.IsUserAuthority(cert.SignatureKey) { 109 // The tenant is derived from which registered CA signed the cert. An
102 return nil, errors.New("sshgate: certificate not signed by the eitri CA") 110 // unregistered signing key resolves to ok=false and is rejected before
111 // any further validation.
112 tenant, ok := userCAs(cert.SignatureKey)
113 if !ok {
114 return nil, errors.New("sshgate: certificate not signed by a registered tenant CA")
103 } 115 }
104 // A cert with an empty principal set is, per CheckCert's rules, valid 116 // A cert with an empty principal set is, per CheckCert's rules, valid
105 // for ANY principal — a wildcard. eitri always mints exactly one 117 // for ANY principal — a wildcard. eitri always mints exactly one
@@ -122,11 +134,11 @@ func New(hostKey ssh.Signer, userCA ssh.PublicKey, caTenant string, resolve Reso
122 } 134 }
123 return &ssh.Permissions{ 135 return &ssh.Permissions{
124 Extensions: map[string]string{ 136 Extensions: map[string]string{
125 // The tenant of the CA that signed this cert. v1 trusts ONE 137 // The tenant of the CA that signed this cert, resolved from the
126 // CA (IsUserAuthority is an equality check), so this is that 138 // registered tenant CA set. Downstream authz compares this against
127 // CA's tenant; the multi-CA future maps SignatureKey→tenant 139 // the target VM's tenant, so the cert's signing CA cryptographically
128 // here and NOTHING downstream changes. 140 // bounds which tenant's VMs the connection may reach.
129 tenantExt: caTenant, 141 tenantExt: tenant,
130 }, 142 },
131 }, nil 143 }, nil
132 }, 144 },
@@ -249,8 +261,3 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) {
249 <-done 261 <-done
250 slog.Debug("sshgate tunnel closed", "vm", vmID) 262 slog.Debug("sshgate tunnel closed", "vm", vmID)
251 } 263 }
252
253 // keysEqual reports whether two SSH public keys are byte-identical.
254 func keysEqual(a, b ssh.PublicKey) bool {
255 return a != nil && b != nil && bytes.Equal(a.Marshal(), b.Marshal())
256 }
internal/server/sshgate/gate_test.go
Old New
@@ -1,6 +1,7 @@
1 package sshgate 1 package sshgate
2 2
3 import ( 3 import (
4 "bytes"
4 "context" 5 "context"
5 "crypto/ed25519" 6 "crypto/ed25519"
6 "crypto/rand" 7 "crypto/rand"
@@ -14,6 +15,12 @@ import (
14 "golang.org/x/crypto/ssh" 15 "golang.org/x/crypto/ssh"
15 ) 16 )
16 17
18 // keysEqual reports whether two SSH public keys are byte-identical. Only tests
19 // compare keys this way; production matches via the certificate chain.
20 func keysEqual(a, b ssh.PublicKey) bool {
21 return a != nil && b != nil && bytes.Equal(a.Marshal(), b.Marshal())
22 }
23
17 // newSigner returns a throwaway ed25519 ssh.Signer. 24 // newSigner returns a throwaway ed25519 ssh.Signer.
18 func newSigner(t *testing.T) ssh.Signer { 25 func newSigner(t *testing.T) ssh.Signer {
19 t.Helper() 26 t.Helper()
@@ -93,7 +100,7 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate {
93 go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd 100 go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd
94 return a, nil 101 return a, nil
95 } 102 }
96 g := New(tg.hostKey, userCA, "default", resolve, authorize, dial, nil) 103 g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil)
97 104
98 l, err := net.Listen("tcp", "127.0.0.1:0") 105 l, err := net.Listen("tcp", "127.0.0.1:0")
99 require.NoError(t, err) 106 require.NoError(t, err)
@@ -103,6 +110,18 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate {
103 return tg 110 return tg
104 } 111 }
105 112
113 // singleCALookup builds a UserCALookup that trusts exactly one CA public key,
114 // stamping its connections with tenant. Any other signing key resolves to
115 // ok=false (rejected) — the test-side analogue of the DB-backed lookup.
116 func singleCALookup(ca ssh.PublicKey, tenant string) UserCALookup {
117 return func(pub ssh.PublicKey) (string, bool) {
118 if keysEqual(pub, ca) {
119 return tenant, true
120 }
121 return "", false
122 }
123 }
124
106 // startGateRevoked is startGate with an explicit revocation predicate wired, so 125 // startGateRevoked is startGate with an explicit revocation predicate wired, so
107 // a test can assert a revoked serial fails auth. 126 // a test can assert a revoked serial fails auth.
108 func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *testGate { 127 func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *testGate {
@@ -121,7 +140,7 @@ func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *te
121 go func() { _, _ = io.Copy(b, b); b.Close() }() 140 go func() { _, _ = io.Copy(b, b); b.Close() }()
122 return a, nil 141 return a, nil
123 } 142 }
124 g := New(tg.hostKey, userCA, "default", resolve, authorize, dial, isRevoked) 143 g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, isRevoked)
125 l, err := net.Listen("tcp", "127.0.0.1:0") 144 l, err := net.Listen("tcp", "127.0.0.1:0")
126 require.NoError(t, err) 145 require.NoError(t, err)
127 tg.addr = l.Addr().String() 146 tg.addr = l.Addr().String()
@@ -183,7 +202,7 @@ func startGateWithHostKey(t *testing.T, hostKey ssh.Signer, userCA ssh.PublicKey
183 go func() { _, _ = io.Copy(b, b); b.Close() }() 202 go func() { _, _ = io.Copy(b, b); b.Close() }()
184 return a, nil 203 return a, nil
185 } 204 }
186 g := New(hostKey, userCA, "default", resolve, authorize, dial, nil) 205 g := New(hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil)
187 l, err := net.Listen("tcp", "127.0.0.1:0") 206 l, err := net.Listen("tcp", "127.0.0.1:0")
188 require.NoError(t, err) 207 require.NoError(t, err)
189 tg.addr = l.Addr().String() 208 tg.addr = l.Addr().String()
@@ -461,3 +480,38 @@ func TestGateRejectsBarePublicKey(t *testing.T) {
461 _, err := ssh.Dial("tcp", tg.addr, cfg) 480 _, err := ssh.Dial("tcp", tg.addr, cfg)
462 require.Error(t, err, "bare public key (no cert) must fail auth") 481 require.Error(t, err, "bare public key (no cert) must fail auth")
463 } 482 }
483
484 // TestGateTrustsRegisteredCARejectsUnknown proves the multi-CA boundary: a cert
485 // signed by a REGISTERED tenant CA authenticates and is stamped with THAT CA's
486 // tenant, while a cert signed by an UNREGISTERED CA is rejected. This drives the
487 // gate's PublicKeyCallback directly so the stamped tenant (an unexported
488 // Permissions extension) is asserted at the source, not merely inferred.
489 func TestGateTrustsRegisteredCARejectsUnknown(t *testing.T) {
490 caAcme := newSigner(t) // registered as tenant "acme"
491 caEvil := newSigner(t) // not registered
492
493 lookup := func(pub ssh.PublicKey) (string, bool) {
494 if keysEqual(pub, caAcme.PublicKey()) {
495 return "acme", true
496 }
497 return "", false
498 }
499 resolve := func(string, string) (string, string, bool) { return "", "", false }
500 authorize := func(string, string) bool { return true }
501 dial := func(context.Context, string, string, uint32) (io.ReadWriteCloser, error) { return nil, nil }
502 g := New(newSigner(t), lookup, resolve, authorize, dial, nil)
503 cb := g.cfg.PublicKeyCallback
504
505 // A cert signed by the registered CA authenticates and is stamped "acme".
506 acmeCert := mintCertSigner(t, caAcme, newSigner(t)).PublicKey().(*ssh.Certificate)
507 perms, err := cb(nil, acmeCert)
508 require.NoError(t, err, "a cert signed by a registered tenant CA must authenticate")
509 require.NotNil(t, perms)
510 assert.Equal(t, "acme", perms.Extensions[tenantExt],
511 "the connection's tenant must be stamped from the signing CA")
512
513 // A cert signed by an unregistered CA is rejected — the crypto boundary.
514 evilCert := mintCertSigner(t, caEvil, newSigner(t)).PublicKey().(*ssh.Certificate)
515 _, err = cb(nil, evilCert)
516 require.Error(t, err, "a cert signed by an unregistered CA must be rejected")
517 }
internal/server/store/store.go
Old New
@@ -156,6 +156,26 @@ CREATE TABLE IF NOT EXISTS revoked_ssh_certs (
156 reason TEXT NOT NULL DEFAULT '' 156 reason TEXT NOT NULL DEFAULT ''
157 ); 157 );
158 158
159 -- tenant_user_cas: uploaded per-tenant USER CA public keys (BYO). eitri holds
160 -- NO user signing key; it only registers pubkeys. A VM bakes its tenant's set
161 -- into TrustedUserCAKeys at create; the gate trusts this set (mutable, live)
162 -- and stamps a connection's tenant from WHICH ca_pubkey verified the cert.
163 -- ca_pubkey is the canonical authorized_keys line ("type base64", no comment /
164 -- trailing newline — see sshca.AuthorizedKeyLine). scope is 'tenant' in v1
165 -- (per-user/fleet later). PRIMARY KEY(tenant, ca_pubkey) makes re-upload a
166 -- no-op. ca_pubkey is also UNIQUE across tenants so the gate lookup is
167 -- unambiguous.
168 CREATE TABLE IF NOT EXISTS tenant_user_cas (
169 tenant TEXT NOT NULL,
170 ca_pubkey TEXT NOT NULL,
171 scope TEXT NOT NULL DEFAULT 'tenant',
172 label TEXT NOT NULL DEFAULT '',
173 added_by TEXT NOT NULL DEFAULT '',
174 created_at DATETIME NOT NULL,
175 PRIMARY KEY (tenant, ca_pubkey)
176 );
177 CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_user_cas_pubkey ON tenant_user_cas(ca_pubkey);
178
159 -- append-only operational audit trail (enrollment, decommission). Read via 179 -- append-only operational audit trail (enrollment, decommission). Read via
160 -- ListAudit / GET /api/v1/audit; rows are never UPDATEd, and the only DELETE 180 -- ListAudit / GET /api/v1/audit; rows are never UPDATEd, and the only DELETE
161 -- is retention pruning (PruneAudit, driven by the server's audit_retention 181 -- is retention pruning (PruneAudit, driven by the server's audit_retention
@@ -756,6 +776,88 @@ func (s *Store) ListRevokedSSHCerts() ([]RevokedCert, error) {
756 return out, rows.Err() 776 return out, rows.Err()
757 } 777 }
758 778
779 // TenantUserCA is one registered per-tenant user-CA public key.
780 type TenantUserCA struct {
781 Tenant string
782 Pubkey string // canonical authorized_keys line (sshca.AuthorizedKeyLine)
783 Scope string
784 Label string
785 AddedBy string
786 CreatedAt time.Time
787 }
788
789 // AddTenantUserCA registers a user-CA pubkey for tenant. Idempotent: re-adding
790 // the same (tenant, ca_pubkey) keeps the original row (ON CONFLICT DO NOTHING).
791 func (s *Store) AddTenantUserCA(tenant, pubkey, scope, label, addedBy string) error {
792 if scope == "" {
793 scope = "tenant"
794 }
795 _, err := s.db.Exec(
796 `INSERT INTO tenant_user_cas(tenant, ca_pubkey, scope, label, added_by, created_at)
797 VALUES (?,?,?,?,?,?) ON CONFLICT(tenant, ca_pubkey) DO NOTHING`,
798 tenant, pubkey, scope, label, addedBy, time.Now().UTC().Format(time.RFC3339),
799 )
800 if err != nil {
801 return fmt.Errorf("add tenant user ca: %w", err)
802 }
803 return nil
804 }
805
806 // ListTenantUserCAs returns tenant's registered user CAs, newest first.
807 func (s *Store) ListTenantUserCAs(tenant string) ([]TenantUserCA, error) {
808 rows, err := s.db.Query(
809 `SELECT tenant, ca_pubkey, scope, label, added_by, created_at
810 FROM tenant_user_cas WHERE tenant=? ORDER BY created_at DESC, ca_pubkey`, tenant)
811 if err != nil {
812 return nil, err
813 }
814 defer rows.Close()
815 var out []TenantUserCA
816 for rows.Next() {
817 var c TenantUserCA
818 var created string
819 if err := rows.Scan(&c.Tenant, &c.Pubkey, &c.Scope, &c.Label, &c.AddedBy, &created); err != nil {
820 return nil, err
821 }
822 c.CreatedAt, _ = time.Parse(time.RFC3339, created)
823 out = append(out, c)
824 }
825 return out, rows.Err()
826 }
827
828 // RemoveTenantUserCA deletes a registered CA. Removing an absent one is a no-op.
829 func (s *Store) RemoveTenantUserCA(tenant, pubkey string) error {
830 _, err := s.db.Exec(`DELETE FROM tenant_user_cas WHERE tenant=? AND ca_pubkey=?`, tenant, pubkey)
831 if err != nil {
832 return fmt.Errorf("remove tenant user ca: %w", err)
833 }
834 return nil
835 }
836
837 // TenantHasUserCA reports whether tenant has ≥1 registered user CA. Gates VM
838 // create — a VM with no trusted CA would be unreachable.
839 func (s *Store) TenantHasUserCA(tenant string) (bool, error) {
840 var n int
841 if err := s.db.QueryRow(`SELECT COUNT(*) FROM tenant_user_cas WHERE tenant=?`, tenant).Scan(&n); err != nil {
842 return false, fmt.Errorf("count tenant user cas: %w", err)
843 }
844 return n > 0, nil
845 }
846
847 // TenantForUserCA returns the tenant that registered pubkey (canonical line).
848 // The gate calls this per cert auth to resolve the connection's tenant from the
849 // cert's signature key. ok=false ⇒ unregistered CA ⇒ reject.
850 func (s *Store) TenantForUserCA(pubkey string) (tenant string, ok bool, err error) {
851 err = s.db.QueryRow(`SELECT tenant FROM tenant_user_cas WHERE ca_pubkey=?`, pubkey).Scan(&tenant)
852 if err == sql.ErrNoRows {
853 return "", false, nil
854 }
855 if err != nil {
856 return "", false, fmt.Errorf("tenant for user ca: %w", err)
857 }
858 return tenant, true, nil
859 }
860
759 // RemoveHost finalizes decommission: it returns the host's bridge CIDR to the 861 // RemoveHost finalizes decommission: it returns the host's bridge CIDR to the
760 // pool and deletes the host row. It refuses (in-transaction) while any VM rows 862 // pool and deletes the host row. It refuses (in-transaction) while any VM rows
761 // remain for the host (not yet reaped). 863 // remain for the host (not yet reaped).
internal/server/store/store_test.go
Old New
@@ -161,6 +161,42 @@ func TestSSHCertRevocationLargeSerial(t *testing.T) {
161 require.Len(t, list, 2) 161 require.Len(t, list, 2)
162 } 162 }
163 163
164 func TestTenantUserCAs(t *testing.T) {
165 s := newStore(t)
166 const ca1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIONE"
167 const ca2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAATWO"
168
169 has, err := s.TenantHasUserCA(DefaultTenant)
170 require.NoError(t, err)
171 assert.False(t, has, "fresh tenant has no CA")
172
173 require.NoError(t, s.AddTenantUserCA(DefaultTenant, ca1, "tenant", "laptop", "admin"))
174 require.NoError(t, s.AddTenantUserCA(DefaultTenant, ca2, "tenant", "ci", "admin"))
175 require.NoError(t, s.AddTenantUserCA(DefaultTenant, ca1, "tenant", "dup", "admin")) // idempotent
176
177 has, err = s.TenantHasUserCA(DefaultTenant)
178 require.NoError(t, err)
179 assert.True(t, has)
180
181 list, err := s.ListTenantUserCAs(DefaultTenant)
182 require.NoError(t, err)
183 require.Len(t, list, 2, "duplicate insert is a no-op")
184
185 ten, ok, err := s.TenantForUserCA(ca1)
186 require.NoError(t, err)
187 assert.True(t, ok)
188 assert.Equal(t, DefaultTenant, ten)
189
190 _, ok, err = s.TenantForUserCA("ssh-ed25519 AAAAUNKNOWN")
191 require.NoError(t, err)
192 assert.False(t, ok)
193
194 require.NoError(t, s.RemoveTenantUserCA(DefaultTenant, ca1))
195 _, ok, err = s.TenantForUserCA(ca1)
196 require.NoError(t, err)
197 assert.False(t, ok, "removed CA no longer resolves")
198 }
199
164 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { 200 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) {
165 s := newStore(t) 201 s := newStore(t)
166 tok1, _ := s.CreateEnrollmentToken(DefaultTenant) 202 tok1, _ := s.CreateEnrollmentToken(DefaultTenant)
internal/server/syncsvc/syncsvc.go
Old New
@@ -58,20 +58,8 @@ type Service struct {
58 // console broker opens per-session streams on it. 58 // console broker opens per-session streams on it.
59 consoleMu sync.Mutex 59 consoleMu sync.Mutex
60 conns map[string]quic.Connection 60 conns map[string]quic.Connection
61 // sshUserCAKey is the eitri user-CA public key (authorized_keys form),
62 // gate-wide and set once at startup via SetSSHUserCAKey when the jump gate
63 // is enabled. Empty when the gate is off: no VM gets the CA drop-in. It is
64 // public material, so it is safe to fan out to every desired-VM snapshot.
65 sshUserCAKey string
66 } 61 }
67 62
68 // SetSSHUserCAKey installs the eitri user-CA public key (authorized_keys form)
69 // that every desired-VM snapshot advertises so guests trust CA-signed certs.
70 // Called once at startup when the SSH jump gate is enabled; a no-op (empty
71 // string) leaves CA injection off. Set before Serve; not safe for concurrent
72 // mutation once snapshots are being pushed.
73 func (s *Service) SetSSHUserCAKey(key string) { s.sshUserCAKey = key }
74
75 // New constructs a Service with the production-default down-stream write 63 // New constructs a Service with the production-default down-stream write
76 // timeout. maxCredAge zero disables the credential age check. 64 // timeout. maxCredAge zero disables the credential age check.
77 func New(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, maxCredAge time.Duration) *Service { 65 func New(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, maxCredAge time.Duration) *Service {
@@ -251,15 +239,28 @@ func (s *Service) pushSnapshot(down quic.Stream, hostID string) error {
251 return fmt.Errorf("desired for host: %w", err) 239 return fmt.Errorf("desired for host: %w", err)
252 } 240 }
253 snap := &pb.DesiredStateSnapshot{Epoch: epoch, Vms: make([]*pb.VMDesired, 0, len(vms))} 241 snap := &pb.DesiredStateSnapshot{Epoch: epoch, Vms: make([]*pb.VMDesired, 0, len(vms))}
242 caCache := map[string][]string{} // tenant -> canonical CA lines
254 for _, v := range vms { 243 for _, v := range vms {
244 cas, ok := caCache[v.Tenant]
245 if !ok {
246 list, err := s.st.ListTenantUserCAs(v.Tenant)
247 if err != nil {
248 return fmt.Errorf("list tenant user cas: %w", err)
249 }
250 cas = make([]string, 0, len(list))
251 for _, c := range list {
252 cas = append(cas, c.Pubkey)
253 }
254 caCache[v.Tenant] = cas
255 }
255 snap.Vms = append(snap.Vms, &pb.VMDesired{ 256 snap.Vms = append(snap.Vms, &pb.VMDesired{
256 VmId: v.ID, Name: v.Name, ImageUrl: v.ImageURL, ImageSha256: v.ImageSHA256, 257 VmId: v.ID, Name: v.Name, ImageUrl: v.ImageURL, ImageSha256: v.ImageSHA256,
257 CloudInit: v.CloudInit, Vcpus: v.VCPUs, MemMb: v.MemMB, DiskGb: v.DiskGB, 258 CloudInit: v.CloudInit, Vcpus: v.VCPUs, MemMb: v.MemMB, DiskGb: v.DiskGB,
258 Persistent: v.Persistent, PowerState: v.PowerState, Tombstoned: v.DeletedAt != nil, 259 Persistent: v.Persistent, PowerState: v.PowerState, Tombstoned: v.DeletedAt != nil,
259 SshAuthorizedKey: v.SSHAuthorizedKey, 260 SshAuthorizedKey: v.SSHAuthorizedKey,
260 SshUserCaAuthorizedKey: s.sshUserCAKey, 261 SshUserCaAuthorizedKeys: cas,
261 SshHostKeyPem: v.SSHHostKey, 262 SshHostKeyPem: v.SSHHostKey,
262 SshHostCert: v.SSHHostCert, 263 SshHostCert: v.SSHHostCert,
263 }) 264 })
264 } 265 }
265 // Bound the write: if a stalled agent stops reading the down-stream but keeps 266 // Bound the write: if a stalled agent stops reading the down-stream but keeps
internal/transport/contract_test.go
Old New
@@ -41,7 +41,7 @@ func TestServerMessageSnapshotRoundTrip(t *testing.T) {
41 VmId: "vm-1", Name: "web", ImageUrl: "https://img/x.qcow2", ImageSha256: "abc", 41 VmId: "vm-1", Name: "web", ImageUrl: "https://img/x.qcow2", ImageSha256: "abc",
42 CloudInit: "#cloud-config", Vcpus: 4, MemMb: 8192, DiskGb: 40, 42 CloudInit: "#cloud-config", Vcpus: 4, MemMb: 8192, DiskGb: 40,
43 Persistent: true, PowerState: "on", Tombstoned: false, SshAuthorizedKey: "ssh-ed25519 AAAA", 43 Persistent: true, PowerState: "on", Tombstoned: false, SshAuthorizedKey: "ssh-ed25519 AAAA",
44 SshUserCaAuthorizedKey: "ssh-ed25519 CAAAAA eitri-user-ca", 44 SshUserCaAuthorizedKeys: []string{"ssh-ed25519 CAAAAA eitri-user-ca"},
45 }}, 45 }},
46 }}} 46 }}}
47 47
proto/eitri/v1/sync.proto
Old New
@@ -77,9 +77,10 @@ message VMDesired {
77 bool tombstoned = 11; // present-but-tombstoned (drives quarantine + destroyed[]) 77 bool tombstoned = 11; // present-but-tombstoned (drives quarantine + destroyed[])
78 string ssh_authorized_key = 12; 78 string ssh_authorized_key = 12;
79 reserved 13, 14; // formerly mesh_invite / mesh_name (rayfish, removed) 79 reserved 13, 14; // formerly mesh_invite / mesh_name (rayfish, removed)
80 string ssh_user_ca_authorized_key = 15; // eitri user-CA public key (authorized_keys form); seed injects it as an sshd TrustedUserCAKeys drop-in. Empty when the jump gate is off. 80 reserved 15; // formerly ssh_user_ca_authorized_key (single gate-wide CA)
81 string ssh_host_key_pem = 16; // the VM's persistent ed25519 host private key (OpenSSH PEM); seed installs it as /etc/ssh/ssh_host_ed25519_key. WRITE-ONLY key material. Empty when the jump gate is off. 81 string ssh_host_key_pem = 16; // the VM's persistent ed25519 host private key (OpenSSH PEM); seed installs it as /etc/ssh/ssh_host_ed25519_key. WRITE-ONLY key material. Empty when the jump gate is off.
82 string ssh_host_cert = 17; // the VM's CA-signed host cert (authorized_keys form); seed installs it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty when the jump gate is off. 82 string ssh_host_cert = 17; // the VM's CA-signed host cert (authorized_keys form); seed installs it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty when the jump gate is off.
83 repeated string ssh_user_ca_authorized_keys = 18; // the VM's tenant user-CA set (canonical authorized_keys lines); seed writes them all into TrustedUserCAKeys. Empty when the gate is off / tenant has none.
83 } 84 }
84 85
85 message DesiredStateSnapshot { 86 message DesiredStateSnapshot {
web/src/lib/api-types.ts
Old New
@@ -336,7 +336,7 @@ export interface paths {
336 path?: never; 336 path?: never;
337 cookie?: never; 337 cookie?: never;
338 }; 338 };
339 /** The eitri SSH CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off. */ 339 /** The eitri SSH host CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off. */
340 get: { 340 get: {
341 parameters: { 341 parameters: {
342 query?: never; 342 query?: never;
@@ -374,7 +374,7 @@ export interface paths {
374 patch?: never; 374 patch?: never;
375 trace?: never; 375 trace?: never;
376 }; 376 };
377 "/api/v1/ssh-certs": { 377 "/api/v1/ssh-certs/revoke": {
378 parameters: { 378 parameters: {
379 query?: never; 379 query?: never;
380 header?: never; 380 header?: never;
@@ -383,7 +383,7 @@ export interface paths {
383 }; 383 };
384 get?: never; 384 get?: never;
385 put?: never; 385 put?: never;
386 /** Mint a short-lived SSH user certificate for the caller's public key. 404 when the jump gate is off (no CA wired). */ 386 /** Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent. */
387 post: { 387 post: {
388 parameters: { 388 parameters: {
389 query?: never; 389 query?: never;
@@ -393,18 +393,16 @@ export interface paths {
393 }; 393 };
394 requestBody: { 394 requestBody: {
395 content: { 395 content: {
396 "application/json": components["schemas"]["SSHCertRequest"]; 396 "application/json": components["schemas"]["RevokeSSHCertRequest"];
397 }; 397 };
398 }; 398 };
399 responses: { 399 responses: {
400 /** @description success */ 400 /** @description success */
401 200: { 401 204: {
402 headers: { 402 headers: {
403 [name: string]: unknown; 403 [name: string]: unknown;
404 }; 404 };
405 content: { 405 content?: never;
406 "application/json": components["schemas"]["SSHCertResponse"];
407 };
408 }; 406 };
409 /** @description error (plain text) */ 407 /** @description error (plain text) */
410 default: { 408 default: {
@@ -423,35 +421,31 @@ export interface paths {
423 patch?: never; 421 patch?: never;
424 trace?: never; 422 trace?: never;
425 }; 423 };
426 "/api/v1/ssh-certs/revoke": { 424 "/api/v1/ssh-certs/revoked": {
427 parameters: { 425 parameters: {
428 query?: never; 426 query?: never;
429 header?: never; 427 header?: never;
430 path?: never; 428 path?: never;
431 cookie?: never; 429 cookie?: never;
432 }; 430 };
433 get?: never; 431 /** List revoked SSH user certificate serials (with reason and time), newest first. */
434 put?: never; 432 get: {
435 /** Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent. */
436 post: {
437 parameters: { 433 parameters: {
438 query?: never; 434 query?: never;
439 header?: never; 435 header?: never;
440 path?: never; 436 path?: never;
441 cookie?: never; 437 cookie?: never;
442 }; 438 };
443 requestBody: { 439 requestBody?: never;
444 content: {
445 "application/json": components["schemas"]["RevokeSSHCertRequest"];
446 };
447 };
448 responses: { 440 responses: {
449 /** @description success */ 441 /** @description success */
450 204: { 442 200: {
451 headers: { 443 headers: {
452 [name: string]: unknown; 444 [name: string]: unknown;
453 }; 445 };
454 content?: never; 446 content: {
447 "application/json": components["schemas"]["RevokedCert"][];
448 };
455 }; 449 };
456 /** @description error (plain text) */ 450 /** @description error (plain text) */
457 default: { 451 default: {
@@ -464,21 +458,25 @@ export interface paths {
464 }; 458 };
465 }; 459 };
466 }; 460 };
461 put?: never;
462 post?: never;
467 delete?: never; 463 delete?: never;
468 options?: never; 464 options?: never;
469 head?: never; 465 head?: never;
470 patch?: never; 466 patch?: never;
471 trace?: never; 467 trace?: never;
472 }; 468 };
473 "/api/v1/ssh-certs/revoked": { 469 "/api/v1/stream-tickets": {
474 parameters: { 470 parameters: {
475 query?: never; 471 query?: never;
476 header?: never; 472 header?: never;
477 path?: never; 473 path?: never;
478 cookie?: never; 474 cookie?: never;
479 }; 475 };
480 /** List revoked SSH user certificate serials (with reason and time), newest first. */ 476 get?: never;
481 get: { 477 put?: never;
478 /** Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL. */
479 post: {
482 parameters: { 480 parameters: {
483 query?: never; 481 query?: never;
484 header?: never; 482 header?: never;
@@ -488,12 +486,12 @@ export interface paths {
488 requestBody?: never; 486 requestBody?: never;
489 responses: { 487 responses: {
490 /** @description success */ 488 /** @description success */
491 200: { 489 201: {
492 headers: { 490 headers: {
493 [name: string]: unknown; 491 [name: string]: unknown;
494 }; 492 };
495 content: { 493 content: {
496 "application/json": components["schemas"]["RevokedCert"][]; 494 "application/json": components["schemas"]["StreamTicketResponse"];
497 }; 495 };
498 }; 496 };
499 /** @description error (plain text) */ 497 /** @description error (plain text) */
@@ -507,32 +505,67 @@ export interface paths {
507 }; 505 };
508 }; 506 };
509 }; 507 };
510 put?: never;
511 post?: never;
512 delete?: never; 508 delete?: never;
513 options?: never; 509 options?: never;
514 head?: never; 510 head?: never;
515 patch?: never; 511 patch?: never;
516 trace?: never; 512 trace?: never;
517 }; 513 };
518 "/api/v1/stream-tickets": { 514 "/api/v1/tenants/{tenant}/user-cas": {
519 parameters: { 515 parameters: {
520 query?: never; 516 query?: never;
521 header?: never; 517 header?: never;
522 path?: never; 518 path?: never;
523 cookie?: never; 519 cookie?: never;
524 }; 520 };
525 get?: never; 521 /** List the tenant's registered SSH user CAs (pubkey, label, fingerprint). */
522 get: {
523 parameters: {
524 query?: never;
525 header?: never;
526 path: {
527 tenant: string;
528 };
529 cookie?: never;
530 };
531 requestBody?: never;
532 responses: {
533 /** @description success */
534 200: {
535 headers: {
536 [name: string]: unknown;
537 };
538 content: {
539 "application/json": components["schemas"]["UserCA"][];
540 };
541 };
542 /** @description error (plain text) */
543 default: {
544 headers: {
545 [name: string]: unknown;
546 };
547 content: {
548 "text/plain": string;
549 };
550 };
551 };
552 };
526 put?: never; 553 put?: never;
527 /** Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL. */ 554 /** Register a BYO SSH user CA public key for the tenant; eitri never holds a user signing key. */
528 post: { 555 post: {
529 parameters: { 556 parameters: {
530 query?: never; 557 query?: never;
531 header?: never; 558 header?: never;
532 path?: never; 559 path: {
560 tenant: string;
561 };
533 cookie?: never; 562 cookie?: never;
534 }; 563 };
535 requestBody?: never; 564 requestBody: {
565 content: {
566 "application/json": components["schemas"]["UserCARequest"];
567 };
568 };
536 responses: { 569 responses: {
537 /** @description success */ 570 /** @description success */
538 201: { 571 201: {
@@ -540,7 +573,7 @@ export interface paths {
540 [name: string]: unknown; 573 [name: string]: unknown;
541 }; 574 };
542 content: { 575 content: {
543 "application/json": components["schemas"]["StreamTicketResponse"]; 576 "application/json": components["schemas"]["UserCAUploadResponse"];
544 }; 577 };
545 }; 578 };
546 /** @description error (plain text) */ 579 /** @description error (plain text) */
@@ -554,7 +587,40 @@ export interface paths {
554 }; 587 };
555 }; 588 };
556 }; 589 };
557 delete?: never; 590 /** Remove a registered SSH user CA by its public_key line. */
591 delete: {
592 parameters: {
593 query?: never;
594 header?: never;
595 path: {
596 tenant: string;
597 };
598 cookie?: never;
599 };
600 requestBody: {
601 content: {
602 "application/json": components["schemas"]["UserCARequest"];
603 };
604 };
605 responses: {
606 /** @description success */
607 204: {
608 headers: {
609 [name: string]: unknown;
610 };
611 content?: never;
612 };
613 /** @description error (plain text) */
614 default: {
615 headers: {
616 [name: string]: unknown;
617 };
618 content: {
619 "text/plain": string;
620 };
621 };
622 };
623 };
558 options?: never; 624 options?: never;
559 head?: never; 625 head?: never;
560 patch?: never; 626 patch?: never;
@@ -598,7 +664,7 @@ export interface paths {
598 }; 664 };
599 }; 665 };
600 put?: never; 666 put?: never;
601 /** Create a VM on a host. Omitted fields get one-click defaults. */ 667 /** Create a VM on a host. Omitted fields get one-click defaults; the tenant must have a registered SSH user CA first. */
602 post: { 668 post: {
603 parameters: { 669 parameters: {
604 query?: never; 670 query?: never;
@@ -944,14 +1010,6 @@ export interface components {
944 SSHCAResponse: { 1010 SSHCAResponse: {
945 ca: string; 1011 ca: string;
946 }; 1012 };
947 SSHCertRequest: {
948 principals?: string[];
949 public_key?: string;
950 };
951 SSHCertResponse: {
952 certificate: string;
953 tenant: string;
954 };
955 StateSnapshot: { 1013 StateSnapshot: {
956 hosts: components["schemas"]["Host"][]; 1014 hosts: components["schemas"]["Host"][];
957 vms: components["schemas"]["VM"][]; 1015 vms: components["schemas"]["VM"][];
@@ -959,6 +1017,18 @@ export interface components {
959 StreamTicketResponse: { 1017 StreamTicketResponse: {
960 ticket: string; 1018 ticket: string;
961 }; 1019 };
1020 UserCA: {
1021 fingerprint: string;
1022 label: string;
1023 pubkey: string;
1024 };
1025 UserCARequest: {
1026 label?: string;
1027 public_key?: string;
1028 };
1029 UserCAUploadResponse: {
1030 fingerprint: string;
1031 };
962 VM: { 1032 VM: {
963 actual_power: string; 1033 actual_power: string;
964 assigned_ip: string; 1034 assigned_ip: string;
web/src/lib/fleet.svelte.ts
Old New
@@ -15,14 +15,23 @@ export type VM = components['schemas']['VM'];
15 * on the wire, so after res.json() it is already a decoded object. */ 15 * on the wire, so after res.json() it is already a decoded object. */
16 export type VMEvent = components['schemas']['AuditEvent']; 16 export type VMEvent = components['schemas']['AuditEvent'];
17 17
18 /** UserCA is one registered per-tenant SSH user CA (BYO). The server returns
19 * the canonical pubkey line, an optional label, and the SHA256 fingerprint. */
20 export type UserCA = components['schemas']['UserCA'];
21
18 export type CreateVMRequest = components['schemas']['CreateVMRequest']; 22 export type CreateVMRequest = components['schemas']['CreateVMRequest'];
19 23
20 const TOKEN_KEY = 'eitri_token'; 24 const TOKEN_KEY = 'eitri_token';
21 25
26 // The frontend is single-tenant today; this is the one place to change when it
27 // grows a tenant selector. Used to build /api/v1/tenants/<tenant>/user-cas.
28 export const TENANT = 'default';
29
22 export const fleet = $state({ 30 export const fleet = $state({
23 token: typeof localStorage !== 'undefined' ? (localStorage.getItem(TOKEN_KEY) ?? '') : '', 31 token: typeof localStorage !== 'undefined' ? (localStorage.getItem(TOKEN_KEY) ?? '') : '',
24 hosts: [] as Host[], 32 hosts: [] as Host[],
25 vms: [] as VM[], 33 vms: [] as VM[],
34 userCAs: [] as UserCA[],
26 connected: false, 35 connected: false,
27 error: '' 36 error: ''
28 }); 37 });
@@ -220,6 +229,32 @@ export async function vmEvents(id: string): Promise<VMEvent[]> {
220 return (await req('GET', `/api/v1/vms/${id}/events`)).json(); 229 return (await req('GET', `/api/v1/vms/${id}/events`)).json();
221 } 230 }
222 231
232 /** listUserCAs fetches the tenant's registered SSH user CAs. */
233 export async function listUserCAs(): Promise<UserCA[]> {
234 return (await req('GET', `/api/v1/tenants/${TENANT}/user-cas`)).json();
235 }
236
237 /** uploadUserCA registers a CA public key for the tenant. label is optional. */
238 export async function uploadUserCA(body: { public_key: string; label?: string }) {
239 await req('POST', `/api/v1/tenants/${TENANT}/user-cas`, body);
240 }
241
242 /** deleteUserCA removes a registered CA by its public-key line. The endpoint
243 * takes a JSON body (unlike deleteVM). */
244 export async function deleteUserCA(public_key: string) {
245 await req('DELETE', `/api/v1/tenants/${TENANT}/user-cas`, { public_key });
246 }
247
248 /** refreshUserCAs loads the tenant's CAs into fleet.userCAs. CAs are NOT part
249 * of the SSE snapshot, so callers invoke this on mount and after each mutation. */
250 export async function refreshUserCAs() {
251 try {
252 fleet.userCAs = await listUserCAs();
253 } catch (err) {
254 fleet.error = String(err);
255 }
256 }
257
223 export async function decommissionHost(id: string) { 258 export async function decommissionHost(id: string) {
224 await req('DELETE', `/api/v1/hosts/${id}`); 259 await req('DELETE', `/api/v1/hosts/${id}`);
225 } 260 }
web/src/routes/+page.svelte
Old New
@@ -1,4 +1,5 @@
1 <script lang="ts"> 1 <script lang="ts">
2 import { onMount } from 'svelte';
2 import { 3 import {
3 fleet, 4 fleet,
4 action, 5 action,
@@ -12,6 +13,9 @@
12 vmPower, 13 vmPower,
13 vmIP, 14 vmIP,
14 vmIsRunning, 15 vmIsRunning,
16 uploadUserCA,
17 deleteUserCA,
18 refreshUserCAs,
15 type CreateVMRequest 19 type CreateVMRequest
16 } from '$lib/fleet.svelte'; 20 } from '$lib/fleet.svelte';
17 21
@@ -58,6 +62,31 @@
58 62
59 let form = $state<CreateVMRequest>({ host_id: '' }); 63 let form = $state<CreateVMRequest>({ host_id: '' });
60 64
65 let caForm = $state<{ public_key: string; label: string }>({ public_key: '', label: '' });
66 let caBusy = $state(false);
67
68 onMount(refreshUserCAs);
69
70 async function submitUploadCA(e: Event) {
71 e.preventDefault();
72 caBusy = true;
73 if (
74 await action(() =>
75 uploadUserCA({ public_key: caForm.public_key.trim(), label: caForm.label.trim() || undefined })
76 )
77 ) {
78 caForm = { public_key: '', label: '' };
79 await refreshUserCAs();
80 }
81 caBusy = false;
82 }
83
84 async function removeCA(pubkey: string) {
85 if (!confirm('Remove this SSH CA? New connections signed by it will be rejected; existing VMs keep trusting it until recreated.'))
86 return;
87 if (await action(() => deleteUserCA(pubkey))) await refreshUserCAs();
88 }
89
61 function openCreate() { 90 function openCreate() {
62 form = { host_id: fleet.hosts[0]?.id ?? '' }; 91 form = { host_id: fleet.hosts[0]?.id ?? '' };
63 showCreate = true; 92 showCreate = true;
@@ -213,6 +242,43 @@
213 {/if} 242 {/if}
214 </section> 243 </section>
215 244
245 <section>
246 <div class="row">
247 <h2>SSH Access ({fleet.userCAs.length})</h2>
248 </div>
249
250 {#if fleet.userCAs.length === 0}
251 <p class="hint">No SSH CA registered — a VM cannot be created until the tenant has one.</p>
252 <div class="enroll">
253 Generate a CA, paste its public key below, then connect:
254 <code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code>
255 <code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code>
256 <code>EITRI_CA=~/.ssh/eitri_user_ca eitri-ssh &lt;vm-name&gt;</code>
257 </div>
258 {:else}
259 <table>
260 <thead>
261 <tr><th>Fingerprint</th><th>Label</th><th></th></tr>
262 </thead>
263 <tbody>
264 {#each fleet.userCAs as ca (ca.pubkey)}
265 <tr>
266 <td>{ca.fingerprint}</td>
267 <td>{ca.label || '—'}</td>
268 <td><button class="danger" onclick={() => removeCA(ca.pubkey)}>Remove</button></td>
269 </tr>
270 {/each}
271 </tbody>
272 </table>
273 {/if}
274
275 <form class="ca-add" onsubmit={submitUploadCA}>
276 <label>SSH CA public key<input bind:value={caForm.public_key} placeholder="ssh-ed25519 AAAA… (contents of your ~/.ssh/eitri_user_ca.pub)" /></label>
277 <label>Label (optional)<input bind:value={caForm.label} placeholder="laptop" /></label>
278 <button type="submit" disabled={caBusy || !caForm.public_key.trim()}>{caBusy ? 'Adding…' : 'Add CA'}</button>
279 </form>
280 </section>
281
216 {#if showCreate} 282 {#if showCreate}
217 <div class="modal" role="dialog"> 283 <div class="modal" role="dialog">
218 <form class="card" onsubmit={submitCreate}> 284 <form class="card" onsubmit={submitCreate}>
@@ -242,7 +308,6 @@
242 </div> 308 </div>
243 <label>Image URL<input bind:value={form.image_url} placeholder="server default" /></label> 309 <label>Image URL<input bind:value={form.image_url} placeholder="server default" /></label>
244 <label>Image sha256<input bind:value={form.image_sha256} placeholder="paired with URL" /></label> 310 <label>Image sha256<input bind:value={form.image_sha256} placeholder="paired with URL" /></label>
245 <label>SSH key<input bind:value={form.ssh_authorized_key} placeholder="ssh-ed25519 …" /></label>
246 <label 311 <label
247 >cloud-init<textarea bind:value={form.cloud_init} rows="3" placeholder="#cloud-config …" 312 >cloud-init<textarea bind:value={form.cloud_init} rows="3" placeholder="#cloud-config …"
248 ></textarea></label 313 ></textarea></label
@@ -352,4 +417,18 @@
352 color: #f0b429; 417 color: #f0b429;
353 font-weight: 600; 418 font-weight: 600;
354 } 419 }
420 .ca-add {
421 display: flex;
422 gap: 0.5rem;
423 align-items: flex-end;
424 flex-wrap: wrap;
425 margin-top: 0.5rem;
426 }
427 .ca-add label {
428 flex: 1;
429 min-width: 200px;
430 }
431 .ca-add input {
432 width: 100%;
433 }
355 </style> 434 </style>