diff --git a/cli/start.go b/cli/start.go index 87ac0f4d14df4..43876f0a6d09f 100644 --- a/cli/start.go +++ b/cli/start.go @@ -31,6 +31,7 @@ import ( "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/tunnel" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/terraform" @@ -57,6 +58,7 @@ func start() *cobra.Command { useTunnel bool traceDatadog bool secureAuthCookie bool + sshKeygenAlgorithmRaw string ) root := &cobra.Command{ Use: "start", @@ -126,6 +128,12 @@ func start() *cobra.Command { if err != nil { return xerrors.Errorf("parse access url %q: %w", accessURL, err) } + + sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(sshKeygenAlgorithmRaw) + if err != nil { + return xerrors.Errorf("parse ssh keygen algorithm %s: %w", sshKeygenAlgorithmRaw, err) + } + logger := slog.Make(sloghuman.Sink(os.Stderr)) options := &coderd.Options{ AccessURL: accessURLParsed, @@ -134,6 +142,7 @@ func start() *cobra.Command { Pubsub: database.NewPubsubInMemory(), GoogleTokenValidator: validator, SecureAuthCookie: secureAuthCookie, + SSHKeygenAlgorithm: sshKeygenAlgorithm, } if !dev { @@ -337,6 +346,8 @@ func start() *cobra.Command { _ = root.Flags().MarkHidden("tunnel") cliflag.BoolVarP(root.Flags(), &traceDatadog, "trace-datadog", "", "CODER_TRACE_DATADOG", false, "Send tracing data to a datadog agent") cliflag.BoolVarP(root.Flags(), &secureAuthCookie, "secure-auth-cookie", "", "CODER_SECURE_AUTH_COOKIE", false, "Specifies if the 'Secure' property is set on browser session cookies") + cliflag.StringVarP(root.Flags(), &sshKeygenAlgorithmRaw, "ssh-keygen-algorithm", "", "CODER_SSH_KEYGEN_ALGORITHM", "ed25519", "Specifies the algorithm to use for generating ssh keys. "+ + `Accepted values are "ed25519", "ecdsa", or "rsa4096"`) return root } diff --git a/coderd/coderd.go b/coderd/coderd.go index 0eac01793eb76..f2290e23dde42 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -14,6 +14,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/site" @@ -30,7 +31,8 @@ type Options struct { AWSCertificates awsidentity.Certificates GoogleTokenValidator *idtoken.Validator - SecureAuthCookie bool + SecureAuthCookie bool + SSHKeygenAlgorithm gitsshkey.Algorithm } // New constructs the Coder API into an HTTP handler. @@ -137,6 +139,8 @@ func New(options *Options) (http.Handler, func()) { r.Get("/", api.workspacesByUser) r.Get("/{workspacename}", api.workspaceByUserAndName) }) + r.Get("/gitsshkey", api.gitSSHKey) + r.Put("/gitsshkey", api.regenerateGitSSHKey) }) }) }) @@ -148,6 +152,7 @@ func New(options *Options) (http.Handler, func()) { r.Route("/agent", func(r chi.Router) { r.Use(httpmw.ExtractWorkspaceAgent(options.Database)) r.Get("/", api.workspaceAgentListen) + r.Get("/gitsshkey", api.agentGitSSHKey) }) r.Route("/{workspaceresource}", func(r chi.Router) { r.Use( diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 88ef33639ff59..1a4df644a1ee5 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -38,6 +38,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/database/postgres" + "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/codersdk" "github.com/coder/coder/cryptorand" "github.com/coder/coder/provisioner/echo" @@ -49,6 +50,7 @@ import ( type Options struct { AWSInstanceIdentity awsidentity.Certificates GoogleInstanceIdentity *idtoken.Validator + SSHKeygenAlgorithm gitsshkey.Algorithm } // New constructs an in-memory coderd instance and returns @@ -98,6 +100,12 @@ func New(t *testing.T, options *Options) *codersdk.Client { serverURL, err := url.Parse(srv.URL) require.NoError(t, err) var closeWait func() + + // match default with cli default + if options.SSHKeygenAlgorithm == "" { + options.SSHKeygenAlgorithm = gitsshkey.AlgorithmEd25519 + } + // We set the handler after server creation for the access URL. srv.Config.Handler, closeWait = coderd.New(&coderd.Options{ AgentConnectionUpdateFrequency: 25 * time.Millisecond, @@ -108,6 +116,7 @@ func New(t *testing.T, options *Options) *codersdk.Client { AWSCertificates: options.AWSInstanceIdentity, GoogleTokenValidator: options.GoogleInstanceIdentity, + SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, }) t.Cleanup(func() { srv.Close() diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index f338a05bd8ee2..5003843cfa561 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -31,6 +31,7 @@ func New() database.Store { provisionerJobResource: make([]database.WorkspaceResource, 0), workspaceBuild: make([]database.WorkspaceBuild, 0), provisionerJobAgent: make([]database.WorkspaceAgent, 0), + GitSSHKey: make([]database.GitSSHKey, 0), } } @@ -57,6 +58,7 @@ type fakeQuerier struct { provisionerJobLog []database.ProvisionerJobLog workspace []database.Workspace workspaceBuild []database.WorkspaceBuild + GitSSHKey []database.GitSSHKey } // InTx doesn't rollback data properly for in-memory yet. @@ -1239,3 +1241,63 @@ func (q *fakeQuerier) UpdateWorkspaceDeletedByID(_ context.Context, arg database } return sql.ErrNoRows } + +func (q *fakeQuerier) InsertGitSSHKey(_ context.Context, arg database.InsertGitSSHKeyParams) (database.GitSSHKey, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + //nolint:gosimple + gitSSHKey := database.GitSSHKey{ + UserID: arg.UserID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + PrivateKey: arg.PrivateKey, + PublicKey: arg.PublicKey, + } + q.GitSSHKey = append(q.GitSSHKey, gitSSHKey) + return gitSSHKey, nil +} + +func (q *fakeQuerier) GetGitSSHKey(_ context.Context, userID uuid.UUID) (database.GitSSHKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, key := range q.GitSSHKey { + if key.UserID == userID { + return key, nil + } + } + return database.GitSSHKey{}, sql.ErrNoRows +} + +func (q *fakeQuerier) UpdateGitSSHKey(_ context.Context, arg database.UpdateGitSSHKeyParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, key := range q.GitSSHKey { + if key.UserID.String() != arg.UserID.String() { + continue + } + key.UpdatedAt = arg.UpdatedAt + key.PrivateKey = arg.PrivateKey + key.PublicKey = arg.PublicKey + q.GitSSHKey[index] = key + return nil + } + return sql.ErrNoRows +} + +func (q *fakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, key := range q.GitSSHKey { + if key.UserID.String() != userID.String() { + continue + } + q.GitSSHKey[index] = q.GitSSHKey[len(q.GitSSHKey)-1] + q.GitSSHKey = q.GitSSHKey[:len(q.GitSSHKey)-1] + return nil + } + return sql.ErrNoRows +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 3ee32b5f98d07..8df6305c7d37a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -89,6 +89,14 @@ CREATE TABLE files ( data bytea NOT NULL ); +CREATE TABLE gitsshkeys ( + user_id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + private_key text NOT NULL, + public_key text NOT NULL +); + CREATE TABLE licenses ( id integer NOT NULL, license jsonb NOT NULL, @@ -283,6 +291,9 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (hash); +ALTER TABLE ONLY gitsshkeys + ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); + ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); @@ -379,6 +390,9 @@ CREATE UNIQUE INDEX workspaces_owner_id_name_idx ON workspaces USING btree (owne ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY gitsshkeys + ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000005_gitsshkey.down.sql b/coderd/database/migrations/000005_gitsshkey.down.sql new file mode 100644 index 0000000000000..29d97a70f2c54 --- /dev/null +++ b/coderd/database/migrations/000005_gitsshkey.down.sql @@ -0,0 +1 @@ +DROP TABLE gitsshkeys; diff --git a/coderd/database/migrations/000005_gitsshkey.up.sql b/coderd/database/migrations/000005_gitsshkey.up.sql new file mode 100644 index 0000000000000..efb59e8f484e9 --- /dev/null +++ b/coderd/database/migrations/000005_gitsshkey.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS gitsshkeys ( + user_id uuid PRIMARY KEY NOT NULL REFERENCES users (id), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + private_key text NOT NULL, + public_key text NOT NULL +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 7ecf5e7d0d58c..26517c03b68cc 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -255,6 +255,14 @@ type File struct { Data []byte `db:"data" json:"data"` } +type GitSSHKey struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + PrivateKey string `db:"private_key" json:"private_key"` + PublicKey string `db:"public_key" json:"public_key"` +} + type License struct { ID int32 `db:"id" json:"id"` License json.RawMessage `db:"license" json:"license"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index abaed68e8049a..9582490162cc0 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -10,9 +10,11 @@ import ( type querier interface { AcquireProvisionerJob(ctx context.Context, arg AcquireProvisionerJobParams) (ProvisionerJob, error) + DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) GetFileByHash(ctx context.Context, hash string) (File, error) + GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, name string) (Organization, error) GetOrganizationMemberByUserID(ctx context.Context, arg GetOrganizationMemberByUserIDParams) (OrganizationMember, error) @@ -54,6 +56,7 @@ type querier interface { GetWorkspacesByUserID(ctx context.Context, arg GetWorkspacesByUserIDParams) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) + InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) InsertParameterSchema(ctx context.Context, arg InsertParameterSchemaParams) (ParameterSchema, error) @@ -69,6 +72,7 @@ type querier interface { InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) (WorkspaceBuild, error) InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) error UpdateProjectActiveVersionByID(ctx context.Context, arg UpdateProjectActiveVersionByIDParams) error UpdateProjectDeletedByID(ctx context.Context, arg UpdateProjectDeletedByIDParams) error UpdateProjectVersionByID(ctx context.Context, arg UpdateProjectVersionByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 49f0669143f38..85855a1ead817 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -235,6 +235,108 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File return i, err } +const deleteGitSSHKey = `-- name: DeleteGitSSHKey :exec +DELETE FROM + gitsshkeys +WHERE + user_id = $1 +` + +func (q *sqlQuerier) DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteGitSSHKey, userID) + return err +} + +const getGitSSHKey = `-- name: GetGitSSHKey :one +SELECT + user_id, created_at, updated_at, private_key, public_key +FROM + gitsshkeys +WHERE + user_id = $1 +` + +func (q *sqlQuerier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) { + row := q.db.QueryRowContext(ctx, getGitSSHKey, userID) + var i GitSSHKey + err := row.Scan( + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.PrivateKey, + &i.PublicKey, + ) + return i, err +} + +const insertGitSSHKey = `-- name: InsertGitSSHKey :one +INSERT INTO + gitsshkeys ( + user_id, + created_at, + updated_at, + private_key, + public_key + ) +VALUES + ($1, $2, $3, $4, $5) RETURNING user_id, created_at, updated_at, private_key, public_key +` + +type InsertGitSSHKeyParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + PrivateKey string `db:"private_key" json:"private_key"` + PublicKey string `db:"public_key" json:"public_key"` +} + +func (q *sqlQuerier) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) { + row := q.db.QueryRowContext(ctx, insertGitSSHKey, + arg.UserID, + arg.CreatedAt, + arg.UpdatedAt, + arg.PrivateKey, + arg.PublicKey, + ) + var i GitSSHKey + err := row.Scan( + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.PrivateKey, + &i.PublicKey, + ) + return i, err +} + +const updateGitSSHKey = `-- name: UpdateGitSSHKey :exec +UPDATE + gitsshkeys +SET + updated_at = $2, + private_key = $3, + public_key = $4 +WHERE + user_id = $1 +` + +type UpdateGitSSHKeyParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + PrivateKey string `db:"private_key" json:"private_key"` + PublicKey string `db:"public_key" json:"public_key"` +} + +func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) error { + _, err := q.db.ExecContext(ctx, updateGitSSHKey, + arg.UserID, + arg.UpdatedAt, + arg.PrivateKey, + arg.PublicKey, + ) + return err +} + const getOrganizationMemberByUserID = `-- name: GetOrganizationMemberByUserID :one SELECT user_id, organization_id, created_at, updated_at, roles diff --git a/coderd/database/queries/gitsshkeys.sql b/coderd/database/queries/gitsshkeys.sql new file mode 100644 index 0000000000000..1fe9c97fa16ff --- /dev/null +++ b/coderd/database/queries/gitsshkeys.sql @@ -0,0 +1,35 @@ +-- name: InsertGitSSHKey :one +INSERT INTO + gitsshkeys ( + user_id, + created_at, + updated_at, + private_key, + public_key + ) +VALUES + ($1, $2, $3, $4, $5) RETURNING *; + +-- name: GetGitSSHKey :one +SELECT + * +FROM + gitsshkeys +WHERE + user_id = $1; + +-- name: UpdateGitSSHKey :exec +UPDATE + gitsshkeys +SET + updated_at = $2, + private_key = $3, + public_key = $4 +WHERE + user_id = $1; + +-- name: DeleteGitSSHKey :exec +DELETE FROM + gitsshkeys +WHERE + user_id = $1; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 8248ad8a72259..a009644cdf520 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -27,3 +27,4 @@ rename: oidc_refresh_token: OIDCRefreshToken parameter_type_system_hcl: ParameterTypeSystemHCL userstatus: UserStatus + gitsshkey: GitSSHKey diff --git a/coderd/gitsshkey.go b/coderd/gitsshkey.go new file mode 100644 index 0000000000000..743101c23beae --- /dev/null +++ b/coderd/gitsshkey.go @@ -0,0 +1,118 @@ +package coderd + +import ( + "fmt" + "net/http" + + "github.com/go-chi/render" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" +) + +func (api *api) regenerateGitSSHKey(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("regenerate key pair: %s", err), + }) + return + } + + err = api.Database.UpdateGitSSHKey(r.Context(), database.UpdateGitSSHKeyParams{ + UserID: user.ID, + UpdatedAt: database.Now(), + PrivateKey: privateKey, + PublicKey: publicKey, + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("update git SSH key: %s", err), + }) + return + } + + newKey, err := api.Database.GetGitSSHKey(r.Context(), user.ID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get git SSH key: %s", err), + }) + return + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, codersdk.GitSSHKey{ + UserID: newKey.UserID, + CreatedAt: newKey.CreatedAt, + UpdatedAt: newKey.UpdatedAt, + // No need to return the private key to the user + PublicKey: newKey.PublicKey, + }) +} + +func (api *api) gitSSHKey(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + gitSSHKey, err := api.Database.GetGitSSHKey(r.Context(), user.ID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("update git SSH key: %s", err), + }) + return + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, codersdk.GitSSHKey{ + UserID: gitSSHKey.UserID, + CreatedAt: gitSSHKey.CreatedAt, + UpdatedAt: gitSSHKey.UpdatedAt, + // No need to return the private key to the user + PublicKey: gitSSHKey.PublicKey, + }) +} + +func (api *api) agentGitSSHKey(rw http.ResponseWriter, r *http.Request) { + agent := httpmw.WorkspaceAgent(r) + resource, err := api.Database.GetWorkspaceResourceByID(r.Context(), agent.ResourceID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("getting workspace resources: %s", err), + }) + return + } + + job, err := api.Database.GetWorkspaceBuildByJobID(r.Context(), resource.JobID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("getting workspace build: %s", err), + }) + return + } + + workspace, err := api.Database.GetWorkspaceByID(r.Context(), job.WorkspaceID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("getting workspace: %s", err), + }) + return + } + + gitSSHKey, err := api.Database.GetGitSSHKey(r.Context(), workspace.OwnerID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("getting git SSH key: %s", err), + }) + return + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, codersdk.AgentGitSSHKey{ + UserID: gitSSHKey.UserID, + CreatedAt: gitSSHKey.CreatedAt, + UpdatedAt: gitSSHKey.UpdatedAt, + PrivateKey: gitSSHKey.PrivateKey, + }) +} diff --git a/coderd/gitsshkey/ed25519.go b/coderd/gitsshkey/ed25519.go new file mode 100644 index 0000000000000..076027d8c45a1 --- /dev/null +++ b/coderd/gitsshkey/ed25519.go @@ -0,0 +1,125 @@ +// This file contains an adapted version of the original implementation available +// under the following URL: https://github.com/mikesmitty/edkey/blob/3356ea4e686a1d47ae5d2d4c3cbc1832ce2df626/edkey.go + +// The following changes have been made: +// * Replaced usage of math/rand with crypto/rand + +// This should be removed soon as support for marshaling ED25519 private keys +// is added to the Golang standard library. +// See: https://github.com/golang/go/issues/37132 + +// --- BEGIN ORIGINAL LICENSE --- +// MIT License + +// Copyright (c) 2017 Michael Smith + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// --- END ORIGINAL LICENSE --- + +package gitsshkey + +import ( + "crypto/rand" + "encoding/binary" + + "golang.org/x/crypto/ed25519" + "golang.org/x/crypto/ssh" + "golang.org/x/xerrors" +) + +func MarshalED25519PrivateKey(key ed25519.PrivateKey) ([]byte, error) { + // Add our key header (followed by a null byte) + magic := append([]byte("openssh-key-v1"), 0) + + var msg struct { + CipherName string + KdfName string + KdfOpts string + NumKeys uint32 + PubKey []byte + PrivKeyBlock []byte + } + + // Fill out the private key fields + pk1 := struct { + Check1 uint32 + Check2 uint32 + Keytype string + Pub []byte + Priv []byte + Comment string + Pad []byte `ssh:"rest"` + }{} + + // Random check bytes + var check uint32 + if err := binary.Read(rand.Reader, binary.BigEndian, &check); err != nil { + return nil, xerrors.Errorf("generate random bytes: %w", err) + } + + pk1.Check1 = check + pk1.Check2 = check + + // Set our key type + pk1.Keytype = ssh.KeyAlgoED25519 + + // Add the pubkey to the optionally-encrypted block + pk, ok := key.Public().(ed25519.PublicKey) + if !ok { + return nil, xerrors.Errorf("ed25519.PublicKey type assertion failed on an ed25519 public key") + } + pubKey := []byte(pk) + pk1.Pub = pubKey + + // Add our private key + pk1.Priv = []byte(key) + + // Might be useful to put something in here at some point + pk1.Comment = "" + + // Add some padding to match the encryption block size within PrivKeyBlock (without Pad field) + // 8 doesn't match the documentation, but that's what ssh-keygen uses for unencrypted keys. *shrug* + bs := 8 + blockLen := len(ssh.Marshal(pk1)) + padLen := (bs - (blockLen % bs)) % bs + pk1.Pad = make([]byte, padLen) + + // Padding is a sequence of bytes like: 1, 2, 3... + for i := 0; i < padLen; i++ { + pk1.Pad[i] = byte(i + 1) + } + + // Generate the pubkey prefix "\0\0\0\nssh-ed25519\0\0\0 " + prefix := []byte{0x0, 0x0, 0x0, 0x0b} + prefix = append(prefix, []byte(ssh.KeyAlgoED25519)...) + prefix = append(prefix, []byte{0x0, 0x0, 0x0, 0x20}...) + prefix = append(prefix, pubKey...) + + // Only going to support unencrypted keys for now + msg.CipherName = "none" + msg.KdfName = "none" + msg.KdfOpts = "" + msg.NumKeys = 1 + msg.PubKey = prefix + msg.PrivKeyBlock = ssh.Marshal(pk1) + + magic = append(magic, ssh.Marshal(msg)...) + + return magic, nil +} diff --git a/coderd/gitsshkey/gitsshkey.go b/coderd/gitsshkey/gitsshkey.go new file mode 100644 index 0000000000000..f122205b1e6e5 --- /dev/null +++ b/coderd/gitsshkey/gitsshkey.go @@ -0,0 +1,127 @@ +package gitsshkey + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "strings" + + "golang.org/x/crypto/ssh" + "golang.org/x/xerrors" +) + +type Algorithm string + +const ( + // AlgorithmEd25519 is the Edwards-curve Digital Signature Algorithm using Curve25519 + AlgorithmEd25519 Algorithm = "ed25519" + // AlgorithmECDSA is the Digital Signature Algorithm (DSA) using NIST Elliptic Curve + AlgorithmECDSA Algorithm = "ecdsa" + // AlgorithmRSA4096 is the venerable Rivest-Shamir-Adleman algorithm + // and creates a key with a fixed size of 4096-bit. + AlgorithmRSA4096 Algorithm = "rsa4096" +) + +// ParseAlgorithm returns a valid Algorithm or error if input is not a valid. +func ParseAlgorithm(t string) (Algorithm, error) { + ok := []string{ + string(AlgorithmEd25519), + string(AlgorithmECDSA), + string(AlgorithmRSA4096), + } + + for _, a := range ok { + if strings.EqualFold(a, t) { + return Algorithm(a), nil + } + } + + return "", xerrors.Errorf(`invalid key type: %s, must be one of: %s`, t, strings.Join(ok, ",")) +} + +// Generate creates a private key in the OpenSSH PEM format and public key in +// the authorized key format. +func Generate(algo Algorithm) (privateKey string, publicKey string, err error) { + switch algo { + case AlgorithmEd25519: + return ed25519KeyGen() + case AlgorithmECDSA: + return ecdsaKeyGen() + case AlgorithmRSA4096: + return rsa4096KeyGen() + default: + return "", "", xerrors.Errorf("invalid algorithm: %s", algo) + } +} + +// ed25519KeyGen returns an ED25519-based SSH private key. +func ed25519KeyGen() (privateKey string, publicKey string, err error) { + _, privateKeyRaw, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", "", xerrors.Errorf("generate ed25519 private key: %w", err) + } + + // NOTE: as of the time of writing, x/crypto/ssh is unable to marshal an ED25519 private key + // into the format expected by OpenSSH. See: https://github.com/golang/go/issues/37132 + // Until this support is added, using a third-party implementation. + byt, err := MarshalED25519PrivateKey(privateKeyRaw) + if err != nil { + return "", "", xerrors.Errorf("marshal ed25519 private key: %w", err) + } + + return generateKeys(pem.Block{ + Type: "OPENSSH PRIVATE KEY", + Bytes: byt, + }, privateKeyRaw) +} + +// ecdsaKeyGen returns an ECDSA-based SSH private key. +func ecdsaKeyGen() (privateKey string, publicKey string, err error) { + privateKeyRaw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", xerrors.Errorf("generate ecdsa private key: %w", err) + } + byt, err := x509.MarshalECPrivateKey(privateKeyRaw) + if err != nil { + return "", "", xerrors.Errorf("marshal private key: %w", err) + } + + return generateKeys(pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: byt, + }, privateKeyRaw) +} + +// rsaKeyGen returns an RSA-based SSH private key of size 4096. +// +// Administrators may configure this for SSH key compatibility with Azure DevOps. +func rsa4096KeyGen() (privateKey string, publicKey string, err error) { + privateKeyRaw, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return "", "", xerrors.Errorf("generate RSA4096 private key: %w", err) + } + + return generateKeys(pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKeyRaw), + }, privateKeyRaw) +} + +func generateKeys(block pem.Block, cp crypto.Signer) (privateKey string, publicKey string, err error) { + pkBytes := pem.EncodeToMemory(&block) + privateKey = string(pkBytes) + + publicKeyRaw := cp.Public() + p, err := ssh.NewPublicKey(publicKeyRaw) + if err != nil { + return "", "", err + } + publicKey = string(ssh.MarshalAuthorizedKey(p)) + + return privateKey, publicKey, nil +} diff --git a/coderd/gitsshkey/gitsshkey_test.go b/coderd/gitsshkey/gitsshkey_test.go new file mode 100644 index 0000000000000..df9502ee43955 --- /dev/null +++ b/coderd/gitsshkey/gitsshkey_test.go @@ -0,0 +1,57 @@ +package gitsshkey_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + + "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/cryptorand" +) + +func TestGitSSHKeys(t *testing.T) { + t.Parallel() + + verifyKeyPair := func(t *testing.T, private, public string) { + signer, err := ssh.ParsePrivateKey([]byte(private)) + require.NoError(t, err) + p, err := ssh.ParsePublicKey(signer.PublicKey().Marshal()) + require.NoError(t, err) + publicKey := string(ssh.MarshalAuthorizedKey(p)) + require.Equal(t, publicKey, public) + } + + t.Run("Ed25519", func(t *testing.T) { + t.Parallel() + pv, pb, err := gitsshkey.Generate(gitsshkey.AlgorithmEd25519) + require.NoError(t, err) + verifyKeyPair(t, pv, pb) + }) + t.Run("ECDSA", func(t *testing.T) { + t.Parallel() + pv, pb, err := gitsshkey.Generate(gitsshkey.AlgorithmECDSA) + require.NoError(t, err) + verifyKeyPair(t, pv, pb) + }) + t.Run("RSA4096", func(t *testing.T) { + t.Parallel() + pv, pb, err := gitsshkey.Generate(gitsshkey.AlgorithmRSA4096) + require.NoError(t, err) + verifyKeyPair(t, pv, pb) + }) + t.Run("ParseAlgorithm", func(t *testing.T) { + t.Parallel() + _, err := gitsshkey.ParseAlgorithm(string(gitsshkey.AlgorithmEd25519)) + require.NoError(t, err) + _, err = gitsshkey.ParseAlgorithm(string(gitsshkey.AlgorithmECDSA)) + require.NoError(t, err) + _, err = gitsshkey.ParseAlgorithm(string(gitsshkey.AlgorithmRSA4096)) + require.NoError(t, err) + r, _ := cryptorand.String(6) + _, err = gitsshkey.ParseAlgorithm(r) + require.Error(t, err, "random string should fail") + _, err = gitsshkey.ParseAlgorithm("") + require.Error(t, err, "empty string should fail") + }) +} diff --git a/coderd/gitsshkey_test.go b/coderd/gitsshkey_test.go new file mode 100644 index 0000000000000..8748b2fcb7aab --- /dev/null +++ b/coderd/gitsshkey_test.go @@ -0,0 +1,129 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" +) + +func TestGitSSHKey(t *testing.T) { + t.Parallel() + t.Run("None", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := coderdtest.New(t, nil) + res := coderdtest.CreateFirstUser(t, client) + key, err := client.GitSSHKey(ctx, res.UserID) + require.NoError(t, err) + require.NotEmpty(t, key.PublicKey) + }) + t.Run("Ed25519", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := coderdtest.New(t, &coderdtest.Options{ + SSHKeygenAlgorithm: gitsshkey.AlgorithmEd25519, + }) + res := coderdtest.CreateFirstUser(t, client) + key, err := client.GitSSHKey(ctx, res.UserID) + require.NoError(t, err) + require.NotEmpty(t, key.PublicKey) + }) + t.Run("ECDSA", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := coderdtest.New(t, &coderdtest.Options{ + SSHKeygenAlgorithm: gitsshkey.AlgorithmECDSA, + }) + res := coderdtest.CreateFirstUser(t, client) + key, err := client.GitSSHKey(ctx, res.UserID) + require.NoError(t, err) + require.NotEmpty(t, key.PublicKey) + }) + t.Run("RSA4096", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := coderdtest.New(t, &coderdtest.Options{ + SSHKeygenAlgorithm: gitsshkey.AlgorithmRSA4096, + }) + res := coderdtest.CreateFirstUser(t, client) + key, err := client.GitSSHKey(ctx, res.UserID) + require.NoError(t, err) + require.NotEmpty(t, key.PublicKey) + }) + t.Run("Regenerate", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := coderdtest.New(t, &coderdtest.Options{ + SSHKeygenAlgorithm: gitsshkey.AlgorithmEd25519, + }) + res := coderdtest.CreateFirstUser(t, client) + key1, err := client.GitSSHKey(ctx, res.UserID) + require.NoError(t, err) + require.NotEmpty(t, key1.PublicKey) + key2, err := client.RegenerateGitSSHKey(ctx, res.UserID) + require.NoError(t, err) + require.Greater(t, key2.UpdatedAt, key1.UpdatedAt) + require.NotEmpty(t, key2.PublicKey) + require.NotEqual(t, key2.PublicKey, key1.PublicKey) + }) +} + +func TestAgentGitSSHKey(t *testing.T) { + t.Parallel() + + agentClient := func(algo gitsshkey.Algorithm) *codersdk.Client { + client := coderdtest.New(t, &coderdtest.Options{ + SSHKeygenAlgorithm: algo, + }) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agent: &proto.Agent{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }, + }}, + }, + }, + }}, + }) + project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitProjectVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + + return agentClient + } + + t.Run("AgentKey", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := agentClient(gitsshkey.AlgorithmEd25519) + agentKey, err := client.AgentGitSSHKey(ctx) + require.NoError(t, err) + require.NotEmpty(t, agentKey.PrivateKey) + }) +} diff --git a/coderd/users.go b/coderd/users.go index 336d9091ce733..e490ce5d08e3a 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -16,6 +16,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/userpassword" @@ -80,7 +81,7 @@ func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) { // Create the user, organization, and membership to the user. var user database.User var organization database.Organization - err = api.Database.InTx(func(s database.Store) error { + err = api.Database.InTx(func(db database.Store) error { user, err = api.Database.InsertUser(r.Context(), database.InsertUserParams{ ID: uuid.New(), Email: createUser.Email, @@ -93,6 +94,22 @@ func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("create user: %w", err) } + + privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm) + if err != nil { + return xerrors.Errorf("generate user gitsshkey: %w", err) + } + _, err = db.InsertGitSSHKey(r.Context(), database.InsertGitSSHKeyParams{ + UserID: user.ID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + PrivateKey: privateKey, + PublicKey: publicKey, + }) + if err != nil { + return xerrors.Errorf("insert user gitsshkey: %w", err) + } + organization, err = api.Database.InsertOrganization(r.Context(), database.InsertOrganizationParams{ ID: uuid.New(), Name: createUser.OrganizationName, @@ -206,6 +223,22 @@ func (api *api) postUsers(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("create user: %w", err) } + + privateKey, publicKey, err := gitsshkey.Generate(api.SSHKeygenAlgorithm) + if err != nil { + return xerrors.Errorf("generate user gitsshkey: %w", err) + } + _, err = db.InsertGitSSHKey(r.Context(), database.InsertGitSSHKeyParams{ + UserID: user.ID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + PrivateKey: privateKey, + PublicKey: publicKey, + }) + if err != nil { + return xerrors.Errorf("insert user gitsshkey: %w", err) + } + _, err = db.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{ OrganizationID: organization.ID, UserID: user.ID, diff --git a/codersdk/gitsshkey.go b/codersdk/gitsshkey.go new file mode 100644 index 0000000000000..faade33da90a9 --- /dev/null +++ b/codersdk/gitsshkey.go @@ -0,0 +1,74 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +type GitSSHKey struct { + UserID uuid.UUID `json:"user_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PublicKey string `json:"public_key"` +} + +type AgentGitSSHKey struct { + UserID uuid.UUID `json:"user_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PrivateKey string `json:"private_key"` +} + +// GitSSHKey returns the user's git SSH public key. +func (c *Client) GitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) { + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/gitsshkey", userID.String()), nil) + if err != nil { + return GitSSHKey{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return GitSSHKey{}, readBodyAsError(res) + } + + var gitsshkey GitSSHKey + return gitsshkey, json.NewDecoder(res.Body).Decode(&gitsshkey) +} + +// RegenerateGitSSHKey will create a new SSH key pair for the user and return it. +func (c *Client) RegenerateGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) { + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/gitsshkey", userID.String()), nil) + if err != nil { + return GitSSHKey{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return GitSSHKey{}, readBodyAsError(res) + } + + var gitsshkey GitSSHKey + return gitsshkey, json.NewDecoder(res.Body).Decode(&gitsshkey) +} + +// AgentGitSSHKey will return the user's SSH key pair for the workspace. +func (c *Client) AgentGitSSHKey(ctx context.Context) (AgentGitSSHKey, error) { + res, err := c.request(ctx, http.MethodGet, "/api/v2/workspaceresources/agent/gitsshkey", nil) + if err != nil { + return AgentGitSSHKey{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return AgentGitSSHKey{}, readBodyAsError(res) + } + + var agentgitsshkey AgentGitSSHKey + return agentgitsshkey, json.NewDecoder(res.Body).Decode(&agentgitsshkey) +}