-
Notifications
You must be signed in to change notification settings - Fork 881
feat: Add Azure instance identitity authentication #1064
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -55,6 +55,7 @@ | |
"trimprefix", | ||
"unconvert", | ||
"Untar", | ||
"VMID", | ||
"webrtc", | ||
"xerrors", | ||
"yamux" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
package azureidentity | ||
|
||
import ( | ||
"context" | ||
"crypto/x509" | ||
"encoding/base64" | ||
"encoding/json" | ||
"io" | ||
"net/http" | ||
"regexp" | ||
|
||
"go.mozilla.org/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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = `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` | ||
) | ||
|
||
func TestValidate(t *testing.T) { | ||
t.Parallel() | ||
vm, err := azureidentity.Validate(context.Background(), signature, x509.VerifyOptions{}) | ||
require.NoError(t, err) | ||
require.Equal(t, "bd8e7443-24a0-41f3-b949-8baf4fd1c573", vm) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggest using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. |
||
"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" { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (comment): I'm always divided on panicking in test code versus just calling There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm honestly fine with either. If you have a preference, I'm happy to take it. |
||
} | ||
}), | ||
} | ||
} | ||
|
||
func randomUsername() string { | ||
return strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-") | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😎