diff --git a/go.mod b/go.mod index 1124a539..5855b110 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/brevdev/brev-cli go 1.25.0 require ( - buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260708012811-ecba52f49600.1 - buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260708012811-ecba52f49600.1 + buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260820222245-1cfc91443320.1 + buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260820222245-1cfc91443320.1 connectrpc.com/connect v1.20.0 github.com/NVIDIA/go-nvml v0.13.0-1 github.com/alessio/shellescape v1.4.1 @@ -44,12 +44,13 @@ require ( github.com/tweekmonster/luser v0.0.0-20161003172636-3fa38070dbd7 github.com/wk8/go-ordered-map/v2 v2.0.0 github.com/writeas/go-strip-markdown v2.0.1+incompatible + golang.org/x/crypto v0.55.0 golang.org/x/text v0.41.0 k8s.io/cli-runtime v0.31.1 ) require ( - buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1 // indirect + buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.1 // indirect dario.cat/mergo v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -100,7 +101,6 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/sync v0.22.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect @@ -152,7 +152,7 @@ require ( golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.12.0 // indirect - google.golang.org/protobuf v1.36.11 + google.golang.org/protobuf v1.36.12 gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index d6abb04d..4af12529 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260708012811-ecba52f49600.1 h1:xanul5g4JQ0OPAQ3tjN8bTznw+aA6B/oq3pzOy8kC8Q= -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260708012811-ecba52f49600.1/go.mod h1:ZxWENaPM6882Wtl2z6rZYVpXoagSyF6DiY/6m4BjGMU= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260708012811-ecba52f49600.1 h1:KMs3AGf1zys1H8TnjBCorCd12zzWoUQae956KgsNfRM= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260708012811-ecba52f49600.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo= -buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1 h1:6amhprQmCKJ4wgJ6ngkh32d9V+dQcOLUZ/SfHdOnYgo= -buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1/go.mod h1:O+pnSHMru/naTMrm4tmpBoH3wz6PHa+R75HR7Mv8X2g= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260820222245-1cfc91443320.1 h1:PKIsaGilewnQUSHNUn+Ir4sagWne713vJS3Ys7h9vAY= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260820222245-1cfc91443320.1/go.mod h1:r4xfuOy9bpAXm13ugDRO+JNmFVlXecGRuKtn1X7os/k= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260820222245-1cfc91443320.1 h1:gmAgE9NC+BAovZIs9CNmjgExqM+Gox8AZ6ud3eVMxfA= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260820222245-1cfc91443320.1/go.mod h1:N18pnR0HL6srurI7G19FpSEki71wA1u4e2c5zbfeTV8= +buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.1 h1:Qk/4GJyWVWvWsfEFeX4T+k7KouZdRUxxUnIUwJ3hmZg= +buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.1/go.mod h1:SacJAYqnICCQAsBA46cSA/hxhqhxYkiYzseucf6/fhQ= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -785,8 +785,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/cmd/refresh/sshaccess.go b/pkg/cmd/refresh/sshaccess.go index aad5c3e9..1ee4dbc2 100644 --- a/pkg/cmd/refresh/sshaccess.go +++ b/pkg/cmd/refresh/sshaccess.go @@ -13,6 +13,7 @@ import ( "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/sshcert" ) const sshAccessLookupTimeout = 10 * time.Second @@ -117,6 +118,11 @@ func resolveWorkspaceSSH( workspace.SSHUser = access.GetLinuxUser() workspace.SSHProxyHostname = "" + // Retain port_id and cert-eligibility for the SSH config generator's Match + // exec block. Empty when SSH access wasn't resolved via the Environment API. + workspace.PortID = access.GetPortId() + workspace.SSHCertEligible = sshcert.EnvironmentCertEligible(environment.GetLabels()) + // To support the "--host" fallback, preserve the legacy hostname information returned by the initial workspace query. if providerHostname := providerSSHHostname(environment.GetInstance(), port.GetHostname()); providerHostname != "" { workspace.HostSSHHostname = providerHostname diff --git a/pkg/cmd/refresh/sshaccess_test.go b/pkg/cmd/refresh/sshaccess_test.go index 0695be95..91bed28d 100644 --- a/pkg/cmd/refresh/sshaccess_test.go +++ b/pkg/cmd/refresh/sshaccess_test.go @@ -82,6 +82,8 @@ func TestEnrichWorkspacesWithSSHAccess_UsesCurrentUsersPort(t *testing.T) { want.SSHProxyHostname = "" want.HostSSHHostname = "203.0.113.10" want.HostSSHProxyHostname = "" + want.PortID = "ssh-port" + want.SSHCertEligible = false // mock environment has no certauth label if diff := cmp.Diff([]entity.Workspace{want}, got); diff != "" { t.Fatalf("unexpected workspace (-want +got): %s", diff) @@ -138,3 +140,36 @@ func TestEnrichWorkspacesWithSSHAccess_FallsBackWithoutPortBackedAccess(t *testi t.Fatal("network info should not be fetched without port-backed access") } } + +func TestEnrichWorkspacesWithSSHAccess_MarksCertEligibleFromLabels(t *testing.T) { + workspace := entity.Workspace{ + ID: "env-1", + Name: "cert-env", + Status: entity.Running, + } + client := &stubEnvironmentSSHClient{ + environment: &devplanev1.Environment{ + Labels: map[string]string{"sshprovider": "certauth"}, + Instance: &devplanev1.Instance{SshHostname: "203.0.113.10", SshPort: 22, PublicIp: "203.0.113.10"}, + SshAccess: []*devplanev1.SSHAccess{ + {UserId: "user-1", LinuxUser: "ubuntu", PortId: "ssh-port"}, + }, + }, + networkInfo: &devplanev1.EnvironmentNetworkInfo{ + Ports: []*devplanev1.Port{ + {PortId: "ssh-port", Hostname: strPtr("skybridge.example.com"), PortNumber: 41234, ServerPort: 22}, + }, + }, + } + + got := enrichWorkspacesWithSSHAccess(context.Background(), client, "user-1", []entity.Workspace{workspace}) + if len(got) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(got)) + } + if got[0].PortID != "ssh-port" { + t.Errorf("PortID = %q, want %q", got[0].PortID, "ssh-port") + } + if !got[0].SSHCertEligible { + t.Errorf("SSHCertEligible = false, want true (labels have sshprovider=certauth)") + } +} diff --git a/pkg/cmd/shell/certonly.go b/pkg/cmd/shell/certonly.go new file mode 100644 index 00000000..cf1bcfbc --- /dev/null +++ b/pkg/cmd/shell/certonly.go @@ -0,0 +1,165 @@ +package shell + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/spf13/afero" + "github.com/spf13/cobra" + + "github.com/brevdev/brev-cli/pkg/cmd/register" + "github.com/brevdev/brev-cli/pkg/config" + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/externalnode" + "github.com/brevdev/brev-cli/pkg/sshcert" +) + +// certOnlyTimeout bounds the wait for one issuance; bounds the worst-case +// delay ssh sees before a login. +const certOnlyTimeout = 15 * time.Second + +type certOnlyRequest struct { + EnvironmentID string + PortID string + LinuxUser string + OutKey string // absolute path to write the private key (cert goes to -cert.pub) +} + +type CertIssuer interface { + Issue(ctx context.Context, req certIssueRequest) (certIssueResult, error) +} + +type certIssueRequest struct { + EnvironmentID string + PortID string + LinuxUser string + PublicKey string +} + +type certIssueResult struct { + Certificate string +} + +type environmentCertClient interface { + IssueEnvironmentSSHCertificate(ctx context.Context, req *connect.Request[devplanev1.IssueEnvironmentSSHCertificateRequest]) (*connect.Response[devplanev1.IssueEnvironmentSSHCertificateResponse], error) +} + +type rpcCertIssuer struct { + client environmentCertClient +} + +func (r rpcCertIssuer) Issue(ctx context.Context, req certIssueRequest) (certIssueResult, error) { + res, err := r.client.IssueEnvironmentSSHCertificate(ctx, connect.NewRequest(&devplanev1.IssueEnvironmentSSHCertificateRequest{ + EnvironmentId: req.EnvironmentID, + LinuxUser: req.LinuxUser, + PortId: req.PortID, + PublicKey: req.PublicKey, + })) + if err != nil { + return certIssueResult{}, breverrors.WrapAndTrace(err) + } + return certIssueResult{Certificate: res.Msg.GetCertificate()}, nil +} + +func newCertIssuer(provider externalnode.TokenProvider, baseURL string) CertIssuer { + return rpcCertIssuer{client: register.NewEnvironmentServiceClient(provider, baseURL)} +} + +type certOnlyStore interface { + GetAccessToken() (string, error) +} + +// runCertOnly is invoked by the ssh config's Match exec hook. On any failure it returns non-zero +// so ssh falls back to the static brev.pem. Must not prompt, as that would hang ssh. +func runCertOnly(store ShellStore, req certOnlyRequest) error { + return runCertOnlyWith(store, afero.NewOsFs(), newCertIssuer(store, config.GlobalConfig.GetBrevPublicAPIURL()), req) +} + +func runCertOnlyWith(store certOnlyStore, fs afero.Fs, issuer CertIssuer, req certOnlyRequest) error { + if _, err := store.GetAccessToken(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, "brev: no auth method found. Run `brev login` and retry.") + return breverrors.WrapAndTrace(err) + } + certPath := req.OutKey + "-cert.pub" + if ok, err := sshcert.HasValidCertAt(fs, certPath, time.Now(), sshcert.DefaultRenewalMargin); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "brev: failed to check cached cert: %v\n", err) + return breverrors.WrapAndTrace(err) + } else if ok { + return nil + } + privKeyPEM, pubKeyOpenSSH, err := sshcert.GenerateKeyPair() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "brev: failed to generate keypair: %v\n", err) + return breverrors.WrapAndTrace(err) + } + ctx, cancel := context.WithTimeout(context.Background(), certOnlyTimeout) + defer cancel() + res, err := issuer.Issue(ctx, certIssueRequest{ + EnvironmentID: req.EnvironmentID, + PortID: req.PortID, + LinuxUser: req.LinuxUser, + PublicKey: pubKeyOpenSSH, + }) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "brev: could not issue ssh certificate: %v\n", err) + return breverrors.WrapAndTrace(err) + } + if err := sshcert.WriteFiles(fs, req.OutKey, certPath, privKeyPEM, res.Certificate); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "brev: failed to write cert files: %v\n", err) + return breverrors.WrapAndTrace(err) + } + return nil +} + +type certOnlyFlags struct { + certOnly bool + env string + port string + user string + outKey string +} + +// addCertOnlyFlags registers the hidden --cert-only flags. They are an +// implementation detail of the ssh config's Match exec hook, not user-facing. +func addCertOnlyFlags(cmd *cobra.Command, f *certOnlyFlags) { + cmd.Flags().BoolVar(&f.certOnly, "cert-only", false, "mint an SSH certificate and write it to disk, then exit (used by the ssh config Match exec hook)") + cmd.Flags().StringVar(&f.env, "env", "", "(--cert-only) environment ID to mint a certificate for") + cmd.Flags().StringVar(&f.port, "port", "", "(--cert-only) network-member port ID for the SSH access") + cmd.Flags().StringVar(&f.user, "user", "", "(--cert-only) linux user for the certificate principal") + cmd.Flags().StringVar(&f.outKey, "out-key", "", "(--cert-only) absolute path to write the private key (certificate goes to -cert.pub)") + + for _, name := range []string{"cert-only", "env", "port", "user", "out-key"} { + _ = cmd.Flags().MarkHidden(name) + } +} + +// validateCertOnly errors if --cert-only is set without all four required +// params. The flags are hidden and only set by the generated ssh config, so the +// inverse (params without --cert-only) isn't a real scenario. +func validateCertOnly(f certOnlyFlags) error { + if !f.certOnly { + return nil + } + var missing []string + if f.env == "" { + missing = append(missing, "--env") + } + if f.port == "" { + missing = append(missing, "--port") + } + if f.user == "" { + missing = append(missing, "--user") + } + if f.outKey == "" { + missing = append(missing, "--out-key") + } + if len(missing) > 0 { + return fmt.Errorf("--cert-only requires %s", strings.Join(missing, ", ")) + } + return nil +} diff --git a/pkg/cmd/shell/certonly_test.go b/pkg/cmd/shell/certonly_test.go new file mode 100644 index 00000000..15373895 --- /dev/null +++ b/pkg/cmd/shell/certonly_test.go @@ -0,0 +1,197 @@ +package shell + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "errors" + "strings" + "testing" + "time" + + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/spf13/afero" + "golang.org/x/crypto/ssh" + + "github.com/brevdev/brev-cli/pkg/sshcert" +) + +// fakeShellStore satisfies the certOnlyStore interface (GetAccessToken only). +type fakeShellStore struct { + token string + err error +} + +func (f fakeShellStore) GetAccessToken() (string, error) { + if f.err != nil { + return "", f.err + } + return f.token, nil +} + +// certIssuerFunc adapts a closure into a CertIssuer for tests. +type certIssuerFunc struct { + fn func(context.Context, certIssueRequest) (certIssueResult, error) +} + +func (c *certIssuerFunc) Issue(ctx context.Context, req certIssueRequest) (certIssueResult, error) { + return c.fn(ctx, req) +} + +// mintCertForTest mints a real user certificate over an in-memory CA for the +// given public key, so the written cert parses as a valid ssh.Certificate. +func mintCertForTest(t *testing.T, pubKeyOpenSSH string) string { + t.Helper() + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubKeyOpenSSH)) + if err != nil { + t.Fatalf("parse pub key: %v", err) + } + _, privCA, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate ca: %v", err) + } + signer, err := ssh.NewSignerFromKey(privCA) + if err != nil { + t.Fatalf("new signer: %v", err) + } + cert := &ssh.Certificate{ + Key: pubKey, + Serial: 42, + CertType: ssh.UserCert, + KeyId: "brev:v1:user:test", + ValidPrincipals: []string{"brev:v1:vm:test-env:login:ubuntu"}, + ValidAfter: uint64(1), + ValidBefore: uint64(1<<63 - 1), // far future for cache tests + Permissions: ssh.Permissions{Extensions: map[string]string{"permit-pty": ""}}, + } + if err := cert.SignCert(rand.Reader, signer); err != nil { + t.Fatalf("sign cert: %v", err) + } + return strings.TrimRight(string(ssh.MarshalAuthorizedKey(cert)), "\n") +} + +func TestRunCertOnly_MintsAndWrites(t *testing.T) { + fs := afero.NewMemMapFs() + outKey := "/home/u/.brev/ssh-certs/env-1" + issuer := &certIssuerFunc{fn: func(_ context.Context, req certIssueRequest) (certIssueResult, error) { + return certIssueResult{Certificate: mintCertForTest(t, req.PublicKey)}, nil + }} + if err := runCertOnlyWith(fakeShellStore{token: "tok"}, fs, issuer, certOnlyRequest{ + EnvironmentID: "env-1", PortID: "port-1", LinuxUser: "ubuntu", OutKey: outKey, + }); err != nil { + t.Fatalf("runCertOnlyWith: %v", err) + } + for _, p := range []string{outKey, outKey + "-cert.pub"} { + if ok, _ := afero.Exists(fs, p); !ok { + t.Errorf("not written: %s", p) + } + } + if ok, err := sshcert.HasValidCertAt(fs, outKey+"-cert.pub", time.Now(), 0); err != nil || !ok { + t.Errorf("written cert not valid: ok=%v err=%v", ok, err) + } +} + +func TestRunCertOnly_ReusesCachedCert(t *testing.T) { + fs := afero.NewMemMapFs() + outKey := "/home/u/.brev/ssh-certs/env-1" + _, pub, _ := sshcert.GenerateKeyPair() + if err := sshcert.WriteFiles(fs, outKey, outKey+"-cert.pub", []byte("priv"), mintCertForTest(t, pub)); err != nil { + t.Fatalf("seed: %v", err) + } + issuer := &certIssuerFunc{fn: func(_ context.Context, _ certIssueRequest) (certIssueResult, error) { + t.Error("issuer should not be called when cache is valid") + return certIssueResult{}, nil + }} + if err := runCertOnlyWith(fakeShellStore{token: "tok"}, fs, issuer, certOnlyRequest{ + EnvironmentID: "env-1", PortID: "port-1", LinuxUser: "ubuntu", OutKey: outKey, + }); err != nil { + t.Fatalf("expected reuse, got err: %v", err) + } +} + +func TestRunCertOnly_FallsBackOnIssueError(t *testing.T) { + fs := afero.NewMemMapFs() + issuer := &certIssuerFunc{fn: func(_ context.Context, _ certIssueRequest) (certIssueResult, error) { + return certIssueResult{}, errors.New("CA unavailable") + }} + err := runCertOnlyWith(fakeShellStore{token: "tok"}, fs, issuer, certOnlyRequest{ + EnvironmentID: "env-1", PortID: "port-1", LinuxUser: "ubuntu", + OutKey: "/home/u/.brev/ssh-certs/env-1", + }) + if err == nil { + t.Fatal("expected error on issue failure") + } + if ok, _ := afero.Exists(fs, "/home/u/.brev/ssh-certs/env-1"); ok { + t.Error("private key should not be written on issue failure") + } +} + +func TestRunCertOnly_FallsBackOnAuthError(t *testing.T) { + fs := afero.NewMemMapFs() + issuer := &certIssuerFunc{fn: func(_ context.Context, _ certIssueRequest) (certIssueResult, error) { + t.Error("issuer should not be called when not authenticated") + return certIssueResult{}, nil + }} + if err := runCertOnlyWith(fakeShellStore{err: errors.New("no token")}, fs, issuer, certOnlyRequest{ + EnvironmentID: "env-1", PortID: "port-1", LinuxUser: "ubuntu", + OutKey: "/home/u/.brev/ssh-certs/env-1", + }); err == nil { + t.Fatal("expected error on auth failure") + } +} + +// fakeEnvCertClient is a controllable environmentCertClient for testing rpcCertIssuer. +type fakeEnvCertClient struct { + resp *devplanev1.IssueEnvironmentSSHCertificateResponse + err error + got *devplanev1.IssueEnvironmentSSHCertificateRequest +} + +func (f *fakeEnvCertClient) IssueEnvironmentSSHCertificate(_ context.Context, req *connect.Request[devplanev1.IssueEnvironmentSSHCertificateRequest]) (*connect.Response[devplanev1.IssueEnvironmentSSHCertificateResponse], error) { + f.got = req.Msg + if f.err != nil { + return nil, f.err + } + return connect.NewResponse(f.resp), nil +} + +func TestRpcCertIssuer_MapsRequestAndResponse(t *testing.T) { + client := &fakeEnvCertClient{resp: &devplanev1.IssueEnvironmentSSHCertificateResponse{ + Certificate: "ssh-ed25519-cert-v01@openssh.com AAAA cert", + Principal: "brev:v1:vm:env-1:login:ubuntu", + }} + issuer := rpcCertIssuer{client: client} + res, err := issuer.Issue(context.Background(), certIssueRequest{ + EnvironmentID: "env-1", PortID: "port-1", LinuxUser: "ubuntu", PublicKey: "ssh-ed25519 AAAA pub", + }) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if res.Certificate != "ssh-ed25519-cert-v01@openssh.com AAAA cert" { + t.Errorf("unexpected certificate: %s", res.Certificate) + } + if client.got.GetEnvironmentId() != "env-1" || client.got.GetPortId() != "port-1" || client.got.GetLinuxUser() != "ubuntu" || client.got.GetPublicKey() != "ssh-ed25519 AAAA pub" { + t.Errorf("request fields wrong: %+v", client.got) + } +} + +func TestRpcCertIssuer_PropagatesError(t *testing.T) { + client := &fakeEnvCertClient{err: errors.New("permission denied")} + issuer := rpcCertIssuer{client: client} + if _, err := issuer.Issue(context.Background(), certIssueRequest{}); err == nil { + t.Fatal("expected error to propagate") + } +} + +func TestValidateCertOnly(t *testing.T) { + if err := validateCertOnly(certOnlyFlags{}); err != nil { + t.Errorf("empty flags should be valid: %v", err) + } + if err := validateCertOnly(certOnlyFlags{certOnly: true}); err == nil { + t.Error("cert-only without params should error") + } + if err := validateCertOnly(certOnlyFlags{certOnly: true, env: "e", port: "p", user: "u", outKey: "/k"}); err != nil { + t.Errorf("complete cert-only should be valid: %v", err) + } +} diff --git a/pkg/cmd/shell/shell.go b/pkg/cmd/shell/shell.go index a9760719..4f775fc6 100644 --- a/pkg/cmd/shell/shell.go +++ b/pkg/cmd/shell/shell.go @@ -53,6 +53,7 @@ type ShellStore interface { func NewCmdShell(t *terminal.Terminal, store ShellStore, noLoginStartStore ShellStore) *cobra.Command { var host bool + var certFlags certOnlyFlags cmd := &cobra.Command{ Annotations: map[string]string{"access": ""}, Use: "shell ", @@ -64,6 +65,17 @@ func NewCmdShell(t *terminal.Terminal, store ShellStore, noLoginStartStore Shell Args: cobra.ExactArgs(1), ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), RunE: func(cmd *cobra.Command, args []string) error { + if err := validateCertOnly(certFlags); err != nil { + return breverrors.WrapAndTrace(err) + } + if certFlags.certOnly { + return runCertOnly(store, certOnlyRequest{ + EnvironmentID: certFlags.env, + PortID: certFlags.port, + LinuxUser: certFlags.user, + OutKey: certFlags.outKey, + }) + } instanceName := args[0] err := runShellCommand(t, store, instanceName, host) if err != nil { @@ -73,6 +85,7 @@ func NewCmdShell(t *terminal.Terminal, store ShellStore, noLoginStartStore Shell }, } cmd.Flags().BoolVarP(&host, "host", "", false, "ssh into the host machine instead of the container") + addCertOnlyFlags(cmd, &certFlags) return cmd } diff --git a/pkg/entity/entity.go b/pkg/entity/entity.go index 1efe288c..c22e1ee4 100644 --- a/pkg/entity/entity.go +++ b/pkg/entity/entity.go @@ -294,16 +294,14 @@ type Workspace struct { HostSSHProxyHostname string `json:"hostSshProxyHostname"` VerbBuildStatus VerbBuildStatus `json:"verbBuildStatus"` VerbYaml string `json:"verbYaml"` - // PrimaryApplicationId string `json:"primaryApplicationId,omitempty"` - // LastOnlineAt string `json:"lastOnlineAt,omitempty"` - // CreatedAt string `json:"createdAt,omitempty"` - // UpdatedAt string `json:"updatedAt,omitempty"` - HealthStatus string `json:"healthStatus"` - IsStoppable bool `json:"isStoppable"` // used for autopstop only - StatusMessage string `json:"statusMessage"` - StopTimeout time.Duration `json:"stopTimeout"` - AdditionalUsers []string `json:"additionalUsers"` - Tunnel Tunnel `json:"tunnel"` + PortID string `json:"portId,omitempty"` + SSHCertEligible bool `json:"sshCertEligible,omitempty"` + HealthStatus string `json:"healthStatus"` + IsStoppable bool `json:"isStoppable"` // used for autopstop only + StatusMessage string `json:"statusMessage"` + StopTimeout time.Duration `json:"stopTimeout"` + AdditionalUsers []string `json:"additionalUsers"` + Tunnel Tunnel `json:"tunnel"` } type APIKey struct { diff --git a/pkg/ssh/sshconfigurer.go b/pkg/ssh/sshconfigurer.go index 5d785625..946548fa 100644 --- a/pkg/ssh/sshconfigurer.go +++ b/pkg/ssh/sshconfigurer.go @@ -13,6 +13,7 @@ import ( "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/files" + "github.com/brevdev/brev-cli/pkg/sshcert" "github.com/brevdev/brev-cli/pkg/tasks" "github.com/hashicorp/go-multierror" ) @@ -173,6 +174,7 @@ type SSHConfigurerV2Store interface { GetWSLUserSSHConfig() (string, error) WriteWSLUserSSHConfig(config string) error GetBrevCloudflaredBinaryPath() (string, error) + UserHomeDir() (string, error) } var _ Config = SSHConfigurerV2{} @@ -238,7 +240,7 @@ func (s SSHConfigurerV2) CreateWSLConfig(workspaces []entity.Workspace) (string, return "", breverrors.WrapAndTrace(err) } - sshConfig, err := makeNewSSHConfig(toWindowsPath(configPath), workspaces, toWindowsPath(pkpath), toWindowsPath(cloudflaredBinaryPath)) + sshConfig, err := makeNewSSHConfig(toWindowsPath(configPath), workspaces, toWindowsPath(pkpath), toWindowsPath(cloudflaredBinaryPath), "") if err != nil { return "", breverrors.WrapAndTrace(err) } @@ -256,12 +258,17 @@ func (s SSHConfigurerV2) CreateNewSSHConfig(workspaces []entity.Workspace, nodes return "", breverrors.WrapAndTrace(err) } + home, err := s.store.UserHomeDir() + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + cloudflaredBinaryPath, err := s.store.GetBrevCloudflaredBinaryPath() if err != nil { return "", breverrors.WrapAndTrace(err) } - sshConfig, err := makeNewSSHConfig(configPath, workspaces, pkPath, cloudflaredBinaryPath) + sshConfig, err := makeNewSSHConfig(configPath, workspaces, pkPath, cloudflaredBinaryPath, home) if err != nil { return "", breverrors.WrapAndTrace(err) } @@ -277,11 +284,11 @@ func (s SSHConfigurerV2) CreateNewSSHConfig(workspaces []entity.Workspace, nodes return sshConfig, nil } -func makeNewSSHConfig(configPath string, workspaces []entity.Workspace, pkpath string, cloudflaredBinaryPath string) (string, error) { +func makeNewSSHConfig(configPath string, workspaces []entity.Workspace, pkpath string, cloudflaredBinaryPath string, home string) (string, error) { sshConfig := fmt.Sprintf("# included in %s\n", configPath) for _, w := range workspaces { - entry, err := makeSSHConfigEntryV2(w, pkpath, cloudflaredBinaryPath) + entry, err := makeSSHConfigEntryV2(w, pkpath, cloudflaredBinaryPath, home) if err != nil { return "", breverrors.WrapAndTrace(err) } @@ -353,7 +360,7 @@ func tmplAndValToString(tmpl *template.Template, val interface{}) (string, error return buf.String(), nil } -func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string, cloudflaredBinaryPath string) (string, error) { //nolint:funlen,gocyclo // ok +func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string, cloudflaredBinaryPath string, home string) (string, error) { //nolint:funlen,gocyclo // ok alias := string(workspace.GetLocalIdentifier()) privateKeyPath = "\"" + privateKeyPath + "\"" var sshVal string @@ -454,6 +461,14 @@ func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string, clo } val := fmt.Sprintf("%s%s", sshVal, hostSSHVal) + + // ssh accumulates IdentityFile across the Match block and the Host block + // below when the exec succeeds (cert first, static brev.pem as fallback); + // when the exec fails the Match block's IdentityFile is dropped, so ssh + // falls back to the static key. + if certMatch := makeCertMatchEntry(workspace, home); certMatch != "" { + val = certMatch + val + } return val, nil } @@ -461,6 +476,19 @@ func makeCloudflareSSHProxyCommand(cloudflaredBinaryPath string, hostname string return fmt.Sprintf("%s access ssh --hostname %s", cloudflaredBinaryPath, hostname) } +// makeCertMatchEntry returns the Match exec block for a cert-eligible workspace, +// or "" if not eligible or home is empty (WSL, deferred). +func makeCertMatchEntry(workspace entity.Workspace, home string) string { + if home == "" || !workspace.SSHCertEligible || workspace.PortID == "" { + return "" + } + alias := string(workspace.GetLocalIdentifier()) + certKeyPath := sshcert.KeyPath(home, workspace.ID) + exec := fmt.Sprintf("brev shell --cert-only --env %s --port %s --user %s --out-key '%s'", + workspace.ID, workspace.PortID, workspace.GetSSHUser(), certKeyPath) + return fmt.Sprintf("Match host %s exec %q\n IdentityFile %q\n", alias, exec, certKeyPath) +} + func (s SSHConfigurerV2) EnsureWSLConfigHasInclude() error { // openssh-7.3 diff --git a/pkg/ssh/sshconfigurer_test.go b/pkg/ssh/sshconfigurer_test.go index 4acb67f8..2a487e01 100644 --- a/pkg/ssh/sshconfigurer_test.go +++ b/pkg/ssh/sshconfigurer_test.go @@ -2,6 +2,7 @@ package ssh import ( "fmt" + "strings" "testing" "github.com/brevdev/brev-cli/pkg/entity" @@ -125,6 +126,10 @@ func (d DummySSHConfigurerV2Store) GetBrevCloudflaredBinaryPath() (string, error return "", nil } +func (d DummySSHConfigurerV2Store) UserHomeDir() (string, error) { + return "/home/test-user", nil +} + func TestCreateNewSSHConfig(t *testing.T) { c := NewSSHConfigurerV2(DummySSHConfigurerV2Store{}) cStr, err := c.CreateNewSSHConfig(somePlainWorkspaces, nil) @@ -512,7 +517,7 @@ Host testName2-host } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := makeSSHConfigEntryV2(tt.args.workspace, tt.args.privateKeyPath, tt.args.cloudflaredBinaryPath) + got, err := makeSSHConfigEntryV2(tt.args.workspace, tt.args.privateKeyPath, tt.args.cloudflaredBinaryPath, "/home/test-user") if (err != nil) != tt.wantErr { t.Errorf("makeSSHConfigEntryV2() error = %v, wantErr %v", err, tt.wantErr) return @@ -924,3 +929,110 @@ Host testName1-host }) } } + +func TestMakeCertMatchEntry_EligibleWorkspace(t *testing.T) { + w := entity.Workspace{ + ID: "env-abc", + Name: "my-env", + SSHUser: "ubuntu", + SSHCertEligible: true, + PortID: "port-1", + } + got := makeCertMatchEntry(w, "/home/u") + // Must be a Match block with the workspace alias and the brev --cert-only exec. + if !strings.HasPrefix(got, "Match host my-env exec \"") { + t.Errorf("expected Match host my-env exec block, got: %s", got) + } + if !strings.Contains(got, "--env env-abc") { + t.Errorf("missing --env env-abc: %s", got) + } + if !strings.Contains(got, "--port port-1") { + t.Errorf("missing --port port-1: %s", got) + } + if !strings.Contains(got, "--user ubuntu") { + t.Errorf("missing --user ubuntu: %s", got) + } + if !strings.Contains(got, "--out-key '/home/u/.brev/ssh-certs/env-abc'") { + t.Errorf("missing out-key path: %s", got) + } + // IdentityFile must point at the cert key path (quoted). + if !strings.Contains(got, "IdentityFile \"/home/u/.brev/ssh-certs/env-abc\"") { + t.Errorf("missing IdentityFile cert path: %s", got) + } +} + +func TestMakeCertMatchEntry_IneligibleWorkspace(t *testing.T) { + // No SSHCertEligible flag -> no Match block. + w := entity.Workspace{ID: "env-1", Name: "n", SSHUser: "u", PortID: "p"} + if got := makeCertMatchEntry(w, "/home/u"); got != "" { + t.Errorf("ineligible workspace should produce no Match block, got: %s", got) + } + // Eligible but no PortID -> no Match block (can't mint without port_id). + w2 := entity.Workspace{ID: "env-1", Name: "n", SSHUser: "u", SSHCertEligible: true} + if got := makeCertMatchEntry(w2, "/home/u"); got != "" { + t.Errorf("eligible without PortID should produce no Match block, got: %s", got) + } + // Empty home (WSL) -> no Match block. + w3 := entity.Workspace{ID: "env-1", Name: "n", SSHUser: "u", SSHCertEligible: true, PortID: "p"} + if got := makeCertMatchEntry(w3, ""); got != "" { + t.Errorf("empty home should produce no Match block, got: %s", got) + } +} + +func TestMakeSSHConfigEntryV2_EligibleWorkspaceIncludesCertMatch(t *testing.T) { + w := entity.Workspace{ + ID: "env-cert", + Name: "cert-env", + Status: entity.Running, + SSHUser: "ubuntu", + SSHPort: 22, + SSHHostname: "10.0.0.1", + SSHCertEligible: true, + PortID: "port-1", + } + got, err := makeSSHConfigEntryV2(w, "/home/u/.brev/brev.pem", "/tmp/cf", "/home/u") + if err != nil { + t.Fatalf("makeSSHConfigEntryV2: %v", err) + } + // The Match block must precede the Host block. + matchIdx := strings.Index(got, "Match host cert-env exec") + hostIdx := strings.Index(got, "Host cert-env") + if matchIdx < 0 { + t.Fatal("expected Match block for cert-eligible workspace") + } + if hostIdx < 0 { + t.Fatal("expected Host block") + } + if matchIdx >= hostIdx { + t.Errorf("Match block must precede Host block (match=%d host=%d)", matchIdx, hostIdx) + } + // Both the cert IdentityFile (in Match) and static brev.pem (in Host) must be present. + if !strings.Contains(got, "/home/u/.brev/ssh-certs/env-cert") { + t.Error("missing cert key path in Match block") + } + if !strings.Contains(got, "/home/u/.brev/brev.pem") { + t.Error("missing static key path in Host block") + } +} + +func TestMakeSSHConfigEntryV2_IneligibleWorkspaceNoCertMatch(t *testing.T) { + w := entity.Workspace{ + ID: "env-old", + Name: "old-env", + Status: entity.Running, + SSHUser: "ubuntu", + SSHPort: 22, + SSHHostname: "10.0.0.1", + // SSHCertEligible false, PortID empty + } + got, err := makeSSHConfigEntryV2(w, "/home/u/.brev/brev.pem", "/tmp/cf", "/home/u") + if err != nil { + t.Fatalf("makeSSHConfigEntryV2: %v", err) + } + if strings.Contains(got, "Match host") { + t.Errorf("ineligible workspace should have no Match block: %s", got) + } + if !strings.Contains(got, "/home/u/.brev/brev.pem") { + t.Error("static key should still be present") + } +} diff --git a/pkg/sshcert/sshcert.go b/pkg/sshcert/sshcert.go new file mode 100644 index 00000000..0d2fd790 --- /dev/null +++ b/pkg/sshcert/sshcert.go @@ -0,0 +1,184 @@ +// Package sshcert manages short-lived, per-environment SSH certificates and +// their backing ephemeral keypairs on disk for use by the OpenSSH client. +package sshcert + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/afero" + "golang.org/x/crypto/ssh" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +const certSubDir = "ssh-certs" + +// DefaultRenewalMargin is how long before expiry we renew, to avoid a race +// where the cert expires between mint and the subsequent ssh use. +const DefaultRenewalMargin = 60 * time.Second + +// Label constants mirroring dev-plane's internal/labels package (internal to +// dev-plane, so duplicated here). +const ( + LabelKeySSHProvider = "sshprovider" + SSHProviderCertAuth = "certauth" +) + +func EnvironmentCertEligible(labels map[string]string) bool { + return labels[LabelKeySSHProvider] == SSHProviderCertAuth +} + +func Dir(home string) string { + return filepath.Join(home, ".brev", certSubDir) +} + +func safeFilename(envID string) string { + s := strings.TrimSpace(envID) + if s == "" { + return "default" + } + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + out := b.String() + if out == "" { + return "default" + } + return out +} + +func KeyPath(home, envID string) string { + return filepath.Join(Dir(home), safeFilename(envID)) +} + +// CertPath follows OpenSSH's -cert.pub convention, so a single +// IdentityFile directive loads both the key and the cert. +func CertPath(home, envID string) string { + return KeyPath(home, envID) + "-cert.pub" +} + +// GenerateKeyPair returns the private key in OpenSSH PEM format (for +// IdentityFile) and the public key as a single-line authorized-key string (the +// format the issuance RPC expects as its public_key field). +func GenerateKeyPair() (privKeyPEM []byte, pubKeyOpenSSH string, err error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, "", breverrors.WrapAndTrace(err) + } + sshPubKey, err := ssh.NewPublicKey(pub) + if err != nil { + return nil, "", breverrors.WrapAndTrace(err) + } + pubKeyOpenSSH = strings.TrimRight(string(ssh.MarshalAuthorizedKey(sshPubKey)), "\n") + block, err := ssh.MarshalPrivateKey(priv, "brev") + if err != nil { + return nil, "", breverrors.WrapAndTrace(err) + } + return pem.EncodeToMemory(block), pubKeyOpenSSH, nil +} + +func ParseCertificate(certOpenSSH string) (*ssh.Certificate, error) { + certOpenSSH = strings.TrimSpace(certOpenSSH) + if certOpenSSH == "" { + return nil, fmt.Errorf("certificate is empty") + } + pubKey, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(certOpenSSH)) + if err != nil { + return nil, breverrors.WrapAndTrace(fmt.Errorf("parse certificate: %w", err)) + } + if len(strings.TrimSpace(string(rest))) != 0 { + return nil, fmt.Errorf("certificate has trailing data; expected exactly one key") + } + cert, ok := pubKey.(*ssh.Certificate) + if !ok { + return nil, fmt.Errorf("public key is not a certificate") + } + if cert.CertType != ssh.UserCert { + return nil, fmt.Errorf("certificate is not a user certificate (type=%d)", cert.CertType) + } + return cert, nil +} + +// CertValidAt: a ValidBefore of 0 or ^uint64(0) means "forever" per the SSH spec. +func CertValidAt(cert *ssh.Certificate, now time.Time, margin time.Duration) bool { + if cert == nil { + return false + } + notBefore := int64(cert.ValidAfter) + notAfter := int64(cert.ValidBefore) + if notAfter == 0 || notAfter == -1 { + return now.Add(margin).Unix() >= notBefore + } + return now.Add(margin).Unix() < notAfter +} + +// HasValidCertAt returns (false, nil) for a missing or corrupt cert so the +// caller mints a fresh one rather than failing the whole ssh attempt. +func HasValidCertAt(fs afero.Fs, certPath string, now time.Time, margin time.Duration) (bool, error) { + exists, err := afero.Exists(fs, certPath) + if err != nil { + return false, breverrors.WrapAndTrace(err) + } + if !exists { + return false, nil + } + certBytes, err := afero.ReadFile(fs, certPath) + if err != nil { + return false, breverrors.WrapAndTrace(err) + } + cert, err := ParseCertificate(string(certBytes)) + if err != nil { + return false, nil // corrupt cert -> mint fresh + } + return CertValidAt(cert, now, margin), nil +} + +func WriteFiles(fs afero.Fs, keyPath, certPath string, privKeyPEM []byte, certOpenSSH string) error { + if err := fs.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil { + return breverrors.WrapAndTrace(err) + } + if err := writeAtomic(fs, keyPath, privKeyPEM, 0o600); err != nil { + return breverrors.WrapAndTrace(err) + } + if !strings.HasSuffix(certOpenSSH, "\n") { + certOpenSSH += "\n" + } + return writeAtomic(fs, certPath, []byte(certOpenSSH), 0o644) +} + +// writeAtomic renames a temp file in the same directory into place, so a +// reader never observes a partial write. +func writeAtomic(fs afero.Fs, path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := afero.TempFile(fs, dir, ".brev-cert-*.tmp") + if err != nil { + return breverrors.WrapAndTrace(err) + } + tmpName := tmp.Name() + defer func() { _ = fs.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return breverrors.WrapAndTrace(err) + } + if err := tmp.Close(); err != nil { + return breverrors.WrapAndTrace(err) + } + if err := fs.Chmod(tmpName, mode); err != nil { + return breverrors.WrapAndTrace(err) + } + return breverrors.WrapAndTrace(fs.Rename(tmpName, path)) +} diff --git a/pkg/sshcert/sshcert_test.go b/pkg/sshcert/sshcert_test.go new file mode 100644 index 00000000..4d249a18 --- /dev/null +++ b/pkg/sshcert/sshcert_test.go @@ -0,0 +1,209 @@ +package sshcert + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" + "time" + + "github.com/spf13/afero" + "golang.org/x/crypto/ssh" +) + +// mintTestCert mints a real user certificate signed by an in-memory CA, +// exercising parse/cache logic against genuine ssh.Certificate objects. +func mintTestCert(t *testing.T, validBefore time.Time) string { + t.Helper() + _, privCA, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate ca: %v", err) + } + signer, err := ssh.NewSignerFromKey(privCA) + if err != nil { + t.Fatalf("new signer: %v", err) + } + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate user key: %v", err) + } + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatalf("new public key: %v", err) + } + cert := &ssh.Certificate{ + Key: sshPub, + Serial: 1, + CertType: ssh.UserCert, + KeyId: "test:user", + ValidPrincipals: []string{"brev:v1:vm:test-env:login:ubuntu"}, + ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()), + ValidBefore: uint64(validBefore.Unix()), + Permissions: ssh.Permissions{Extensions: map[string]string{"permit-pty": ""}}, + } + if err := cert.SignCert(rand.Reader, signer); err != nil { + t.Fatalf("sign cert: %v", err) + } + return strings.TrimRight(string(ssh.MarshalAuthorizedKey(cert)), "\n") +} + +func TestGenerateKeyPair_Format(t *testing.T) { + privPEM, pubOpenSSH, err := GenerateKeyPair() + if err != nil { + t.Fatalf("GenerateKeyPair: %v", err) + } + if !bytes.HasPrefix(privPEM, []byte("-----BEGIN OPENSSH PRIVATE KEY-----")) { + t.Errorf("private key not OpenSSH PEM: %q", privPEM[:40]) + } + if !strings.HasPrefix(pubOpenSSH, "ssh-ed25519 ") { + t.Errorf("public key not ssh-ed25519: %q", pubOpenSSH) + } + if strings.ContainsAny(pubOpenSSH, "\r\n") { + t.Errorf("public key must be a single line: %q", pubOpenSSH) + } + // Must parse with no options and no trailing data (the CA requires this). + _, _, options, rest, err := ssh.ParseAuthorizedKey([]byte(pubOpenSSH)) + if err != nil { + t.Fatalf("ParseAuthorizedKey: %v", err) + } + if len(options) != 0 || len(bytes.TrimSpace(rest)) != 0 { + t.Errorf("pub key has options/trailing data: options=%v rest=%q", options, rest) + } + signer, err := ssh.ParsePrivateKey(privPEM) + if err != nil { + t.Fatalf("ParsePrivateKey: %v", err) + } + if signer.PublicKey().Type() != ssh.KeyAlgoED25519 { + t.Errorf("expected ed25519, got %s", signer.PublicKey().Type()) + } +} + +func TestParseCertificate(t *testing.T) { + cert, err := ParseCertificate(mintTestCert(t, time.Now().Add(10*time.Minute))) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + if cert.CertType != ssh.UserCert { + t.Errorf("expected user cert, got type %d", cert.CertType) + } + if len(cert.ValidPrincipals) != 1 || cert.ValidPrincipals[0] != "brev:v1:vm:test-env:login:ubuntu" { + t.Errorf("unexpected principals: %v", cert.ValidPrincipals) + } + for _, bad := range []string{"", "not a cert"} { + if _, err := ParseCertificate(bad); err == nil { + t.Errorf("expected error for %q", bad) + } + } +} + +func TestCertValidAt(t *testing.T) { + now := time.Now() + valid := &ssh.Certificate{ + ValidAfter: uint64(now.Add(-time.Hour).Unix()), + ValidBefore: uint64(now.Add(10 * time.Minute).Unix()), + } + if !CertValidAt(valid, now, time.Minute) { + t.Error("cert with 10m left should be valid with 1m margin") + } + if CertValidAt(valid, now, 10*time.Minute) { + t.Error("cert with 10m left should need renewal with 10m margin") + } + expired := &ssh.Certificate{ValidAfter: uint64(now.Add(-time.Hour).Unix()), ValidBefore: uint64(now.Add(-time.Minute).Unix())} + if CertValidAt(expired, now, time.Minute) { + t.Error("expired cert should not be valid") + } + forever := &ssh.Certificate{ValidAfter: uint64(now.Add(-time.Hour).Unix()), ValidBefore: 0} + if !CertValidAt(forever, now, time.Minute) { + t.Error("forever cert within ValidAfter should be valid") + } + if CertValidAt(nil, now, time.Minute) { + t.Error("nil cert should not be valid") + } +} + +func TestHasValidCertAt(t *testing.T) { + fs := afero.NewMemMapFs() + certPath := CertPath("/home/u", "env-1") + + // Missing -> not valid, no error. + if ok, err := HasValidCertAt(fs, certPath, time.Now(), DefaultRenewalMargin); ok || err != nil { + t.Fatalf("missing cert: ok=%v err=%v", ok, err) + } + // Written -> valid. + privPEM, _ := mustGen(t) + if err := WriteFiles(fs, KeyPath("/home/u", "env-1"), certPath, privPEM, mintTestCert(t, time.Now().Add(10*time.Minute))); err != nil { + t.Fatalf("WriteFiles: %v", err) + } + if ok, _ := HasValidCertAt(fs, certPath, time.Now(), DefaultRenewalMargin); !ok { + t.Error("expected valid after write") + } + // Different env -> not valid. + if ok, _ := HasValidCertAt(fs, CertPath("/home/u", "env-2"), time.Now(), DefaultRenewalMargin); ok { + t.Error("env-2 should have no cert") + } + // Corrupt -> not valid, no error (mint fresh). + if err := afero.WriteFile(fs, certPath, []byte("garbage"), 0o644); err != nil { + t.Fatal(err) + } + if ok, err := HasValidCertAt(fs, certPath, time.Now(), DefaultRenewalMargin); ok || err != nil { + t.Errorf("corrupt cert: ok=%v err=%v (want false,nil)", ok, err) + } +} + +func TestWriteFiles_NoLeftoverTemp(t *testing.T) { + fs := afero.NewMemMapFs() + privPEM, _ := mustGen(t) + if err := WriteFiles(fs, KeyPath("/h", "x"), CertPath("/h", "x"), privPEM, mintTestCert(t, time.Now().Add(5*time.Minute))); err != nil { + t.Fatalf("WriteFiles: %v", err) + } + entries, _ := afero.ReadDir(fs, Dir("/h")) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".brev-cert-") { + t.Errorf("leftover temp file: %s", e.Name()) + } + } + b, _ := afero.ReadFile(fs, CertPath("/h", "x")) + if !strings.HasSuffix(string(b), "\n") { + t.Error("cert file should end with newline") + } +} + +func TestEnvironmentCertEligible(t *testing.T) { + cases := []struct { + labels map[string]string + want bool + }{ + {map[string]string{"sshprovider": "certauth"}, true}, + {map[string]string{"sshprovider": "other"}, false}, + {map[string]string{}, false}, + } + for _, c := range cases { + if got := EnvironmentCertEligible(c.labels); got != c.want { + t.Errorf("EnvironmentCertEligible(%v)=%v, want %v", c.labels, got, c.want) + } + } +} + +func TestSafeFilename(t *testing.T) { + cases := map[string]string{ + "env_123": "env_123", + "env/evil": "env-evil", + "": "default", + "../etc/pw": "..-etc-pw", + } + for in, want := range cases { + if got := safeFilename(in); got != want { + t.Errorf("safeFilename(%q)=%q, want %q", in, got, want) + } + } +} + +func mustGen(t *testing.T) ([]byte, string) { + t.Helper() + priv, pub, err := GenerateKeyPair() + if err != nil { + t.Fatalf("GenerateKeyPair: %v", err) + } + return priv, pub +}