9f7b4f69
feat(ssh): bring-your-own per-tenant SSH user CA
a73x 2026-07-25 15:28
Commit message
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 <vm-name></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> |