feat: Add user scoped git ssh keys (#834)

This commit is contained in:
Garrett Delfosse 2022-04-05 19:18:26 -05:00 committed by GitHub
parent 1e9e5f7c76
commit 9da17be61e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 924 additions and 2 deletions

View File

@ -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
}

View File

@ -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.
@ -146,6 +148,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)
})
})
})
@ -157,6 +161,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(

View File

@ -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()

View File

@ -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
}

View File

@ -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;

View File

@ -0,0 +1 @@
DROP TABLE gitsshkeys;

View File

@ -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
);

View File

@ -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"`

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -27,3 +27,4 @@ rename:
oidc_refresh_token: OIDCRefreshToken
parameter_type_system_hcl: ParameterTypeSystemHCL
userstatus: UserStatus
gitsshkey: GitSSHKey

118
coderd/gitsshkey.go Normal file
View File

@ -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,
})
}

125
coderd/gitsshkey/ed25519.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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")
})
}

129
coderd/gitsshkey_test.go Normal file
View File

@ -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)
})
}

View File

@ -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,

74
codersdk/gitsshkey.go Normal file
View File

@ -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)
}