From 5ba90b53505b6a9fd26a27059cd137501ff05456 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 19 Apr 2022 02:24:46 +0000 Subject: [PATCH] feat: Add Azure instance identitity authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This enables zero-trust authentication for Azure instances. Now we support the three major clouds: AWS, Azure, and GCP 😎. --- .vscode/settings.json | 1 + cli/agent.go | 14 +++- cli/agent_test.go | 58 +++++++++++++++- coderd/azureidentity/azureidentity.go | 76 +++++++++++++++++++++ coderd/azureidentity/azureidentity_test.go | 22 +++++++ coderd/coderd.go | 3 + coderd/coderdtest/coderdtest.go | 77 ++++++++++++++++++++-- coderd/workspaceresourceauth.go | 18 +++++ coderd/workspaceresourceauth_test.go | 45 +++++++++++-- codersdk/workspaceagents.go | 37 +++++++++++ go.mod | 1 + go.sum | 1 + 12 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 coderd/azureidentity/azureidentity.go create mode 100644 coderd/azureidentity/azureidentity_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 2988daa9f0d75..5319d54048241 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -55,6 +55,7 @@ "trimprefix", "unconvert", "Untar", + "VMID", "webrtc", "xerrors", "yamux" diff --git a/cli/agent.go b/cli/agent.go index cbe7ab1483a98..d17aa60e82491 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -76,7 +76,19 @@ func workspaceAgent() *cobra.Command { return client.AuthWorkspaceAWSInstanceIdentity(ctx) } case "azure-instance-identity": - return xerrors.Errorf("not implemented") + // This is *only* done for testing to mock client authentication. + // This will never be set in a production scenario. + var azureClient *http.Client + azureClientRaw := cmd.Context().Value("azure-client") + if azureClientRaw != nil { + azureClient, _ = azureClientRaw.(*http.Client) + if azureClient != nil { + client.HTTPClient = azureClient + } + } + exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) { + return client.AuthWorkspaceAzureInstanceIdentity(ctx) + } } if exchangeToken != nil { diff --git a/cli/agent_test.go b/cli/agent_test.go index d8bede0e89007..32cfd6dfbce57 100644 --- a/cli/agent_test.go +++ b/cli/agent_test.go @@ -15,12 +15,66 @@ import ( func TestWorkspaceAgent(t *testing.T) { t.Parallel() + t.Run("Azure", func(t *testing.T) { + t.Parallel() + instanceID := "instanceidentifier" + certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID) + client := coderdtest.New(t, &coderdtest.Options{ + AzureCertificates: certificates, + }) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "somename", + Type: "someinstance", + Agents: []*proto.Agent{{ + Auth: &proto.Agent_InstanceId{ + InstanceId: instanceID, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + cmd, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--url", client.URL.String()) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + go func() { + // A linting error occurs for weakly typing the context value here, + // but it seems reasonable for a one-off test. + // nolint + ctx = context.WithValue(ctx, "azure-client", metadataClient) + err := cmd.ExecuteContext(ctx) + require.NoError(t, err) + }() + coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) + require.NoError(t, err) + defer dialer.Close() + _, err = dialer.Ping() + require.NoError(t, err) + cancelFunc() + }) + t.Run("AWS", func(t *testing.T) { t.Parallel() instanceID := "instanceidentifier" certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID) client := coderdtest.New(t, &coderdtest.Options{ - AWSInstanceIdentity: certificates, + AWSCertificates: certificates, }) user := coderdtest.CreateFirstUser(t, client) coderdtest.NewProvisionerDaemon(t, client) @@ -74,7 +128,7 @@ func TestWorkspaceAgent(t *testing.T) { instanceID := "instanceidentifier" validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) client := coderdtest.New(t, &coderdtest.Options{ - GoogleInstanceIdentity: validator, + GoogleTokenValidator: validator, }) user := coderdtest.CreateFirstUser(t, client) coderdtest.NewProvisionerDaemon(t, client) diff --git a/coderd/azureidentity/azureidentity.go b/coderd/azureidentity/azureidentity.go new file mode 100644 index 0000000000000..7d6e70cfffb13 --- /dev/null +++ b/coderd/azureidentity/azureidentity.go @@ -0,0 +1,76 @@ +package azureidentity + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "regexp" + + "github.com/fullsailor/pkcs7" + "golang.org/x/xerrors" +) + +// allowedSigners matches valid common names listed here: +// https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#tabgroup_14 +var allowedSigners = regexp.MustCompile(`^(.*\.)?metadata\.(azure\.(com|us|cn)|microsoftazure\.de)$`) + +type metadata struct { + VMID string `json:"vmId"` +} + +// Validate ensures the signature was signed by an Azure certificate. +// It returns the associated VM ID if successful. +func Validate(ctx context.Context, signature string, options x509.VerifyOptions) (string, error) { + data, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return "", xerrors.Errorf("decode base64: %w", err) + } + pkcs7Data, err := pkcs7.Parse(data) + if err != nil { + return "", xerrors.Errorf("parse pkcs7: %w", err) + } + signer := pkcs7Data.GetOnlySigner() + if signer == nil { + return "", xerrors.New("no signers for signature") + } + if !allowedSigners.MatchString(signer.Subject.CommonName) { + return "", xerrors.Errorf("unmatched common name of signer: %q", signer.Subject.CommonName) + } + if options.Intermediates == nil { + options.Intermediates = x509.NewCertPool() + for _, certURL := range signer.IssuingCertificateURL { + req, err := http.NewRequestWithContext(ctx, "GET", certURL, nil) + if err != nil { + return "", xerrors.Errorf("new request %q: %w", certURL, err) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", xerrors.Errorf("perform request %q: %w", certURL, err) + } + data, err := io.ReadAll(res.Body) + if err != nil { + return "", xerrors.Errorf("read body %q: %w", certURL, err) + } + cert, err := x509.ParseCertificate(data) + if err != nil { + return "", xerrors.Errorf("parse certificate %q: %w", certURL, err) + } + options.Intermediates.AddCert(cert) + } + } + + _, err = signer.Verify(options) + if err != nil { + return "", xerrors.Errorf("verify certificates: %w", err) + } + + var metadata metadata + err = json.Unmarshal(pkcs7Data.Content, &metadata) + if err != nil { + return "", xerrors.Errorf("unmarshal metadata: %w", err) + } + return metadata.VMID, nil +} diff --git a/coderd/azureidentity/azureidentity_test.go b/coderd/azureidentity/azureidentity_test.go new file mode 100644 index 0000000000000..c1b526ca765d0 --- /dev/null +++ b/coderd/azureidentity/azureidentity_test.go @@ -0,0 +1,22 @@ +package azureidentity_test + +import ( + "context" + "crypto/x509" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/azureidentity" +) + +const ( + signature = `MIILPQYJKoZIhvcNAQcCoIILLjCCCyoCAQExDzANBgkqhkiG9w0BAQsFADCCAUUGCSqGSIb3DQEHAaCCATYEggEyeyJsaWNlbnNlVHlwZSI6IiIsIm5vbmNlIjoiMjAyMjA0MTktMDcyNzIxIiwicGxhbiI6eyJuYW1lIjoiIiwicHJvZHVjdCI6IiIsInB1Ymxpc2hlciI6IiJ9LCJza3UiOiIyMF8wNC1sdHMtZ2VuMiIsInN1YnNjcmlwdGlvbklkIjoiNWYxMzBmZmMtMGEzZS00Nzk1LWI2OTEtNGY1NmExMmE1NTQ3IiwidGltZVN0YW1wIjp7ImNyZWF0ZWRPbiI6IjA0LzE5LzIyIDAxOjI3OjIxIC0wMDAwIiwiZXhwaXJlc09uIjoiMDQvMTkvMjIgMDc6Mjc6MjEgLTAwMDAifSwidm1JZCI6ImJkOGU3NDQzLTI0YTAtNDFmMy1iOTQ5LThiYWY0ZmQxYzU3MyJ9oIIINDCCCDAwggYYoAMCAQICExIAI9QuEyMQ3mYyynwAAAAj1C4wDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEgMB4GA1UEAxMXTWljcm9zb2Z0IFJTQSBUTFMgQ0EgMDEwHhcNMjIwMjIwMTAyMjAyWhcNMjMwMjIwMTAyMjAyWjAdMRswGQYDVQQDExJtZXRhZGF0YS5henVyZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC1t3H5nZ+3x/6jlnf82B8u7GFtMxz2BX6leQhuDQnbReTGXlxsOizZmZcABJHLFG7GROn+pIXJY2mt0AYx1zDEjjmbW65BeUvmOSEj/64+Vc+X7L7ofaO+XxgegDdVqu8H0kwMJO1LPnj1g/47DSuWb+Dm2BqGKRSqvDgM56WuLsZHkCBUC0W2IVZvkOGrUSv1wfMf3vDTl26yB1zr0n9h+uxZfOOaLaKLerzYik/begJbqmUtNTCWpr+llqY+xHf1UShXuv1Bhyq+QzPi66d3WCfzvePm4704j2pZsyHiw/IxndXqdPUX8VEQJkWAw21YFnuabE1cfnnx+VIkBUA5AgMBAAGjggQ1MIIEMTCCAX0GCisGAQQB1nkCBAIEggFtBIIBaQFnAHYArfe++nz/EMiLnT2cHj4YarRnKV3PsQwkyoWGNOvcgooAAAF/FrBJlgAABAMARzBFAiAxACMcHfnjY0aDr7lOfviB2O/XGHCrpyfsCXkgkbW07wIhANwIsAt9JOSeFiirXfKKYJAOHZTnZaF6mzqsiY9QZb/qAHYAs3N3B+GEUPhjhtYFqdwRCUp5LbFnDAuH3PADDnk2pZoAAAF/FrBKsgAABAMARzBFAiAeGLAsEwbtemha4hXZhbhkuGXVjAY36mtFzVj/UMneUAIhAOpOjmAuCvVphrDDR8C76lDV7BOHSP1C/lQCtv6dISccAHUA6D7Q2j71BjUy51covIlryQPTy9ERa+zraeF3fW0GvW4AAAF/FrBJoAAABAMARjBEAiBn3xayoXdrWNpxuq4nHgD4l7h9tTvqXo3rdOPeoihIcgIgczj0VkMqtmw1RP7ezYiB2/KqCz4KN/P5RYfxdByWWzkwJwYJKwYBBAGCNxUKBBowGDAKBggrBgEFBQcDATAKBggrBgEFBQcDAjA+BgkrBgEEAYI3FQcEMTAvBicrBgEEAYI3FQiH2oZ1g+7ZAYLJhRuBtZ5hhfTrYIFdhYaOQYfCmFACAWQCAScwgYcGCCsGAQUFBwEBBHsweTBTBggrBgEFBQcwAoZHaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraS9tc2NvcnAvTWljcm9zb2Z0JTIwUlNBJTIwVExTJTIwQ0ElMjAwMS5jcnQwIgYIKwYBBQUHMAGGFmh0dHA6Ly9vY3NwLm1zb2NzcC5jb20wHQYDVR0OBBYEFO08JtykconiZxO7lGCvQwKSvCLWMA4GA1UdDwEB/wQEAwIEsDBABgNVHREEOTA3ghJtZXRhZGF0YS5henVyZS5jb22CIXNvdXRoY2VudHJhbHVzLm1ldGFkYXRhLmF6dXJlLmNvbTCBsAYDVR0fBIGoMIGlMIGioIGfoIGchk1odHRwOi8vbXNjcmwubWljcm9zb2Z0LmNvbS9wa2kvbXNjb3JwL2NybC9NaWNyb3NvZnQlMjBSU0ElMjBUTFMlMjBDQSUyMDAxLmNybIZLaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3BraS9tc2NvcnAvY3JsL01pY3Jvc29mdCUyMFJTQSUyMFRMUyUyMENBJTIwMDEuY3JsMFcGA1UdIARQME4wQgYJKwYBBAGCNyoBMDUwMwYIKwYBBQUHAgEWJ2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvbXNjb3JwL2NwczAIBgZngQwBAgEwHwYDVR0jBBgwFoAUtXYMMBHOx5JCTUzHXCzIqQzoC2QwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMA0GCSqGSIb3DQEBCwUAA4ICAQCYIcFM1ac5B1ak7eVaJz0RMcBxMPPcubCoooeIkZmDbCo4B9MLoxdRcvlaqSTZZsiKrn4fgIaj6oPpXKNHsSdHCPp64XItFNTa7Nvwkv6D2SCbd3smLhR85U8gqriFmoY0jgrzpHwD+P//yzJL9gGVis4kVzecNPjVApwY3rSPbZP1wXjyK++MHLjL8L0rZnal2WV6ktO50LExR5DNG1WmoDWw9EZSDHL6RlxRYnxjmp/7mjDSy8qrDFf3YKKft43jNSkCC2Yc+8xiQLZ1ibfdRIScWK3kcE423qLqm26mVaY6nXpn1IFnXEV3bD/46OKo/Y89mUNB3/MbZVnhn4o+BU7yQk8Q0ZUHqj6lNmrM56v4pwelAS1ab6Dmuf4gq9Q+Q9n0z7wdM0466V7ZbFd4Zd335pyhFyqysNLL6n7bCqQzlM+I2v/z/SsqW26lHvvlo/lycBLu5SbZ5j1TS+H4I+Ph9gH8uus9xRSbUT/lDXGK3qge3ClwnMvB1ffZH3MNppfQEOBJDQumVuk2Ag0oz0LqM/jKmEWOcfybAg8NrwARdDrhLK8Ma/QwbhstQqJXieqzmJJaSfQXwhLkyhTNk09hwJEKg/K4KasSliYU/pA4ts1XEvUKOk3vAXb+y30oQuaiJqA6KI6tg+O2XkBTCPQPI0CPQhAVvjZc37bRqTGCAZEwggGNAgEBMGYwTzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEgMB4GA1UEAxMXTWljcm9zb2Z0IFJTQSBUTFMgQ0EgMDECExIAI9QuEyMQ3mYyynwAAAAj1C4wDQYJKoZIhvcNAQELBQAwDQYJKoZIhvcNAQEBBQAEggEAKpu78aO06Z3AjxN5SOmv3kVPHPxqiWZPeuG+PcGfhAyu7kmuaorPW/xgAtiZCd7gJ5ILxdlFc7TBvY0Ar8ctpF5yk8OFp88cHkxFdWjoC/S9OhqiG7N50Cai8rje3rgJxuFPmptZMhlcVco6GisuV+gy2fZY+SleU4hSOXkAZ5oTDNycDONW3gGqGFV1/7KW+y0dYAyXZCq6nnMDLvIuIRqSXuns1WBV2FSFmj2vyGPoy5+AYuRTkG6izce+xFj+tGaSJLo+hFfLkJARV1r2BzMsZIEyKQ/6ZfFsoFW3kAkyZc0CokJarIESBIEGD2/sPlw650lT5Ohphtj5VFyp+Q==` +) + +func TestValidate(t *testing.T) { + t.Parallel() + vm, err := azureidentity.Validate(context.Background(), signature, x509.VerifyOptions{}) + require.NoError(t, err) + t.Log(vm) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index bb9a6e2bdb641..b0bd052dac65d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2,6 +2,7 @@ package coderd import ( "context" + "crypto/x509" "fmt" "net/http" "net/url" @@ -35,6 +36,7 @@ type Options struct { AgentConnectionUpdateFrequency time.Duration AWSCertificates awsidentity.Certificates + AzureCertificates x509.VerifyOptions GoogleTokenValidator *idtoken.Validator ICEServers []webrtc.ICEServer SecureAuthCookie bool @@ -172,6 +174,7 @@ func New(options *Options) (http.Handler, func()) { }) }) r.Route("/workspaceagents", func(r chi.Router) { + r.Post("/azure-instance-identity", api.postWorkspaceAuthAzureInstanceIdentity) r.Post("/aws-instance-identity", api.postWorkspaceAuthAWSInstanceIdentity) r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity) r.Route("/me", func(r chi.Router) { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 67e3febcf5cab..d96f2250e87cb 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -8,6 +8,7 @@ import ( "crypto/rsa" "crypto/sha256" "crypto/x509" + "crypto/x509/pkix" "database/sql" "encoding/base64" "encoding/json" @@ -24,6 +25,7 @@ import ( "time" "cloud.google.com/go/compute/metadata" + "github.com/fullsailor/pkcs7" "github.com/golang-jwt/jwt" "github.com/google/uuid" "github.com/moby/moby/pkg/namesgenerator" @@ -49,9 +51,10 @@ import ( ) type Options struct { - AWSInstanceIdentity awsidentity.Certificates - GoogleInstanceIdentity *idtoken.Validator - SSHKeygenAlgorithm gitsshkey.Algorithm + AWSCertificates awsidentity.Certificates + AzureCertificates x509.VerifyOptions + GoogleTokenValidator *idtoken.Validator + SSHKeygenAlgorithm gitsshkey.Algorithm } // New constructs an in-memory coderd instance and returns @@ -60,11 +63,11 @@ func New(t *testing.T, options *Options) *codersdk.Client { if options == nil { options = &Options{} } - if options.GoogleInstanceIdentity == nil { + if options.GoogleTokenValidator == nil { ctx, cancelFunc := context.WithCancel(context.Background()) t.Cleanup(cancelFunc) var err error - options.GoogleInstanceIdentity, err = idtoken.NewValidator(ctx, option.WithoutAuthentication()) + options.GoogleTokenValidator, err = idtoken.NewValidator(ctx, option.WithoutAuthentication()) require.NoError(t, err) } @@ -117,8 +120,9 @@ func New(t *testing.T, options *Options) *codersdk.Client { Database: db, Pubsub: pubsub, - AWSCertificates: options.AWSInstanceIdentity, - GoogleTokenValidator: options.GoogleInstanceIdentity, + AWSCertificates: options.AWSCertificates, + AzureCertificates: options.AzureCertificates, + GoogleTokenValidator: options.GoogleTokenValidator, SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, TURNServer: turnServer, }) @@ -414,6 +418,65 @@ func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certif } } +// NewAzureInstanceIdentity returns a metadata client and ID token validator for faking +// instance authentication for Azure. +func NewAzureInstanceIdentity(t *testing.T, instanceID string) (x509.VerifyOptions, *http.Client) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + rawCertificate, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(2022), + NotAfter: time.Now().AddDate(1, 0, 0), + Subject: pkix.Name{ + CommonName: "metadata.azure.com", + }, + }, &x509.Certificate{}, &privateKey.PublicKey, privateKey) + require.NoError(t, err) + + certificate, err := x509.ParseCertificate(rawCertificate) + require.NoError(t, err) + + signed, err := pkcs7.NewSignedData([]byte(`{"vmId":"` + instanceID + `"}`)) + require.NoError(t, err) + err = signed.AddSigner(certificate, privateKey, pkcs7.SignerInfoConfig{}) + require.NoError(t, err) + signatureRaw, err := signed.Finish() + require.NoError(t, err) + signature := make([]byte, base64.StdEncoding.EncodedLen(len(signatureRaw))) + base64.StdEncoding.Encode(signature, signatureRaw) + + payload, err := json.Marshal(codersdk.AzureInstanceIdentityToken{ + Signature: string(signature), + Encoding: "pkcs7", + }) + require.NoError(t, err) + + certPool := x509.NewCertPool() + certPool.AddCert(certificate) + + return x509.VerifyOptions{ + Intermediates: certPool, + Roots: certPool, + }, &http.Client{ + Transport: roundTripper(func(r *http.Request) (*http.Response, error) { + // Only handle metadata server requests. + if r.URL.Host != "169.254.169.254" { + return http.DefaultTransport.RoundTrip(r) + } + switch r.URL.Path { + case "/metadata/attested/document": + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + panic("unhandled route: " + r.URL.Path) + } + }), + } +} + func randomUsername() string { return strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-") } diff --git a/coderd/workspaceresourceauth.go b/coderd/workspaceresourceauth.go index 01be2f66b534b..f9d8f701f8daf 100644 --- a/coderd/workspaceresourceauth.go +++ b/coderd/workspaceresourceauth.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/coder/coder/coderd/awsidentity" + "github.com/coder/coder/coderd/azureidentity" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" @@ -15,6 +16,23 @@ import ( "github.com/mitchellh/mapstructure" ) +// Azure supports instance identity verification: +// https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#tabgroup_14 +func (api *api) postWorkspaceAuthAzureInstanceIdentity(rw http.ResponseWriter, r *http.Request) { + var req codersdk.AzureInstanceIdentityToken + if !httpapi.Read(rw, r, &req) { + return + } + instanceID, err := azureidentity.Validate(r.Context(), req.Signature, api.AzureCertificates) + if err != nil { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: fmt.Sprintf("validate: %s", err), + }) + return + } + api.handleAuthInstanceID(rw, r, instanceID) +} + // AWS supports instance identity verification: // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html // Using this, we can exchange a signed instance payload for an agent token. diff --git a/coderd/workspaceresourceauth_test.go b/coderd/workspaceresourceauth_test.go index 3bd9dbd4551ed..034b73bf44ffc 100644 --- a/coderd/workspaceresourceauth_test.go +++ b/coderd/workspaceresourceauth_test.go @@ -13,6 +13,43 @@ import ( "github.com/coder/coder/provisionersdk/proto" ) +func TestPostWorkspaceAuthAzureInstanceIdentity(t *testing.T) { + t.Parallel() + instanceID := "instanceidentifier" + certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID) + client := coderdtest.New(t, &coderdtest.Options{ + AzureCertificates: certificates, + }) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "somename", + Type: "someinstance", + Agents: []*proto.Agent{{ + Auth: &proto.Agent_InstanceId{ + InstanceId: instanceID, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + client.HTTPClient = metadataClient + _, err := client.AuthWorkspaceAzureInstanceIdentity(context.Background()) + require.NoError(t, err) +} + func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { @@ -20,7 +57,7 @@ func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) { instanceID := "instanceidentifier" certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID) client := coderdtest.New(t, &coderdtest.Options{ - AWSInstanceIdentity: certificates, + AWSCertificates: certificates, }) user := coderdtest.CreateFirstUser(t, client) coderdtest.NewProvisionerDaemon(t, client) @@ -60,7 +97,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { instanceID := "instanceidentifier" validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true) client := coderdtest.New(t, &coderdtest.Options{ - GoogleInstanceIdentity: validator, + GoogleTokenValidator: validator, }) _, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata) var apiErr *codersdk.Error @@ -73,7 +110,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { instanceID := "instanceidentifier" validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) client := coderdtest.New(t, &coderdtest.Options{ - GoogleInstanceIdentity: validator, + GoogleTokenValidator: validator, }) _, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata) var apiErr *codersdk.Error @@ -86,7 +123,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { instanceID := "instanceidentifier" validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) client := coderdtest.New(t, &coderdtest.Options{ - GoogleInstanceIdentity: validator, + GoogleTokenValidator: validator, }) user := coderdtest.CreateFirstUser(t, client) coderdtest.NewProvisionerDaemon(t, client) diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 1deb1fdb94f73..51eb79bbf4bc5 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -37,6 +37,11 @@ type AWSInstanceIdentityToken struct { Document string `json:"document" validate:"required"` } +type AzureInstanceIdentityToken struct { + Signature string `json:"signature" validate:"required"` + Encoding string `json:"encoding" validate:"required"` +} + // WorkspaceAgentAuthenticateResponse is returned when an instance ID // has been exchanged for a session token. type WorkspaceAgentAuthenticateResponse struct { @@ -139,6 +144,38 @@ func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (Workspac return resp, json.NewDecoder(res.Body).Decode(&resp) } +// AuthWorkspaceAzureInstanceIdentity uses the Azure Instance Metadata Service to +// fetch a signed payload, and exchange it for a session token for a workspace agent. +func (c *Client) AuthWorkspaceAzureInstanceIdentity(ctx context.Context) (WorkspaceAgentAuthenticateResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/metadata/attested/document?api-version=2020-09-01", nil) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, nil + } + req.Header.Set("Metadata", "true") + res, err := c.HTTPClient.Do(req) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, err + } + defer res.Body.Close() + + var token AzureInstanceIdentityToken + err = json.NewDecoder(res.Body).Decode(&token) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, err + } + + res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceagents/azure-instance-identity", token) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res) + } + var resp WorkspaceAgentAuthenticateResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // ListenWorkspaceAgent connects as a workspace agent identifying with the session token. // On each inbound connection request, connection info is fetched. func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) (*peerbroker.Listener, error) { diff --git a/go.mod b/go.mod index fee0628557fa4..54feaa59e42cd 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/fatedier/frp v0.36.2-0.20220414032436-21240ed96251 github.com/fatedier/golib v0.1.1-0.20220321042308-c306138b83ac github.com/fatih/color v1.13.0 + github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa github.com/gliderlabs/ssh v0.3.3 github.com/go-chi/chi/v5 v5.0.7 github.com/go-chi/httprate v0.5.3 diff --git a/go.sum b/go.sum index 685d3ead2998b..59d61099d3491 100644 --- a/go.sum +++ b/go.sum @@ -558,6 +558,7 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw= +github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa h1:RDBNVkRviHZtvDvId8XSGPu3rmpmSe+wKRcEWNgsfWU= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/gabriel-vasile/mimetype v1.3.1/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=