mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix: verify PKCS7 signature on Azure instance identity tokens (backport 2.29) (#25307)
Backport to #25286 Migrates Azure instance identity verification from `go.mozilla.org/pkcs7` and `github.com/fullsailor/pkcs7` to `github.com/smallstep/pkcs7`, using `VerifyWithChainAtTime` to validate both the PKCS7 signature and the certificate chain in one call. The previous code only verified the signer certificate against a set of intermediates/roots but did not verify that the PKCS7 signature itself covered the content, meaning tampered payloads could be accepted. The `Options` struct is restructured to accept `Roots`, `Intermediates`, and `CurrentTime` as explicit fields instead of embedding `x509.VerifyOptions`. The test helper `NewAzureInstanceIdentity` now builds a realistic 3-level certificate chain (Root CA -> Intermediate CA -> Signing Cert) matching real Azure trust hierarchy. New tests (`TestValidate_TamperedContent`, `TestValidate_UntrustedCertWithValidSignature`) confirm tampered and untrusted envelopes are rejected. Addresses GHSA-6x44-w3xg-hqqf. > [!NOTE] > This PR was authored by Coder Agents. <details> <summary>Implementation Plan</summary> | File | Summary | |------|---------| | `coderd/azureidentity/azureidentity.go` | Replace `signer.Verify()` with `VerifyWithChainAtTime`; restructure `Options` struct; add `ParseCertificates()` helper | | `coderd/azureidentity/azureidentity_test.go` | Add `testCertChain` builder, tampered-content and untrusted-cert tests; update existing tests for new `Options` API | | `coderd/coderd.go` | Change `AzureCertificates` field from `x509.VerifyOptions` to `azureidentity.Options` | | `coderd/workspaceresourceauth.go` | Pass `api.AzureCertificates` directly instead of wrapping | | `coderd/coderdtest/coderdtest.go` | Migrate to `smallstep/pkcs7`; build 3-level cert chain in test helper | | `go.mod` / `go.sum` | Add `github.com/smallstep/pkcs7`; remove `fullsailor/pkcs7` and `go.mozilla.org/pkcs7` | </details> <!-- If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting. --> Co-authored-by: Jakub Domeracki <jakub@coder.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
"github.com/smallstep/pkcs7"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
@@ -184,12 +183,31 @@ type metadata struct {
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
x509.VerifyOptions
|
||||
// Roots is the trusted root certificate pool. If nil,
|
||||
// the embedded root certificate pool is used.
|
||||
Roots *x509.CertPool
|
||||
// Intermediates are additional intermediate certificates to
|
||||
// inject into the PKCS7 object for chain verification. Azure
|
||||
// PKCS7 envelopes typically only contain the signing cert, so
|
||||
// intermediates must be supplied externally. When nil, the
|
||||
// hardcoded Azure intermediate certificates are used.
|
||||
Intermediates []*x509.Certificate
|
||||
// CurrentTime, if non-zero, overrides the verification
|
||||
// timestamp for certificate chain validation.
|
||||
CurrentTime time.Time
|
||||
// Offline disables fetching of issuing certificates when
|
||||
// chain verification fails.
|
||||
Offline bool
|
||||
}
|
||||
|
||||
// Validate ensures the signature was signed by an Azure certificate.
|
||||
// It returns the associated VM ID if successful.
|
||||
//
|
||||
// Verification has two parts, both handled by VerifyWithChainAtTime:
|
||||
// 1. PKCS7 signature check: proves the content was signed by the
|
||||
// private key corresponding to the certificate in the envelope.
|
||||
// 2. Certificate chain check: proves the signing certificate
|
||||
// chains to a trusted root through known intermediates.
|
||||
func Validate(ctx context.Context, signature string, options Options) (string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
@@ -208,30 +226,48 @@ func Validate(ctx context.Context, signature string, options Options) (string, e
|
||||
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 _, cert := range Certificates {
|
||||
block, rest := pem.Decode([]byte(cert))
|
||||
if len(rest) != 0 {
|
||||
return "", xerrors.Errorf("invalid certificate. %d bytes remain", len(rest))
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
options.Intermediates.AddCert(cert)
|
||||
// Azure PKCS7 envelopes typically contain only the signing
|
||||
// certificate. Inject intermediate certificates so the
|
||||
// library can build a chain from signer to trusted root.
|
||||
intermediates := options.Intermediates
|
||||
if intermediates == nil {
|
||||
intermediates, err = ParseCertificates()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("parse hardcoded certificates: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = signer.Verify(options.VerifyOptions)
|
||||
if err != nil {
|
||||
if !errors.As(err, &x509.UnknownAuthorityError{}) {
|
||||
return "", xerrors.Errorf("verify signature: %w", err)
|
||||
pkcs7Data.Certificates = append(pkcs7Data.Certificates, intermediates...)
|
||||
// Resolve root trust store. VerifyWithChainAtTime skips
|
||||
// chain verification when the trust store is nil, so we
|
||||
// must always provide one.
|
||||
roots := options.Roots
|
||||
if roots == nil {
|
||||
roots, err = x509.SystemCertPool()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("load roots: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
currentTime := options.CurrentTime
|
||||
if currentTime.IsZero() {
|
||||
currentTime = time.Now()
|
||||
}
|
||||
|
||||
// VerifyWithChainAtTime validates both the PKCS7 signature
|
||||
// (proving the content was signed by the certificate's
|
||||
// private key) and the certificate chain (proving the signer
|
||||
// chains to a trusted root).
|
||||
err = pkcs7Data.VerifyWithChainAtTime(roots, currentTime)
|
||||
if err != nil {
|
||||
if options.Offline {
|
||||
return "", xerrors.Errorf("certificate from %v is not cached: %w", signer.IssuingCertificateURL, err)
|
||||
return "", xerrors.Errorf("verify pkcs7: %w", err)
|
||||
}
|
||||
|
||||
// The chain verification may fail when the signing
|
||||
// certificate was issued by an intermediate not yet in
|
||||
// our hardcoded list. Fetch the issuing certificates
|
||||
// and retry.
|
||||
ctx, cancelFunc := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancelFunc()
|
||||
for _, certURL := range signer.IssuingCertificateURL {
|
||||
@@ -247,17 +283,17 @@ func Validate(ctx context.Context, signature string, options Options) (string, e
|
||||
return "", xerrors.New("certificate fetch unsuccessful")
|
||||
}
|
||||
limited := io.LimitReader(res.Body, maxCertResponseBytes+1)
|
||||
data, err := io.ReadAll(limited)
|
||||
certData, err := io.ReadAll(limited)
|
||||
_ = res.Body.Close()
|
||||
if err != nil {
|
||||
return "", xerrors.New("read certificate response body")
|
||||
}
|
||||
if int64(len(data)) > maxCertResponseBytes {
|
||||
if int64(len(certData)) > maxCertResponseBytes {
|
||||
return "", xerrors.New(
|
||||
"certificate response exceeds maximum size",
|
||||
)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(data)
|
||||
cert, err := x509.ParseCertificate(certData)
|
||||
if err != nil {
|
||||
// Do not wrap the parse error; it may contain
|
||||
// fragments of the HTTP response body, which
|
||||
@@ -266,9 +302,9 @@ func Validate(ctx context.Context, signature string, options Options) (string, e
|
||||
"fetched data is not a valid certificate",
|
||||
)
|
||||
}
|
||||
options.Intermediates.AddCert(cert)
|
||||
pkcs7Data.Certificates = append(pkcs7Data.Certificates, cert)
|
||||
}
|
||||
_, err = signer.Verify(options.VerifyOptions)
|
||||
err = pkcs7Data.VerifyWithChainAtTime(roots, currentTime)
|
||||
if err != nil {
|
||||
return "", xerrors.New("signature verification failed after fetching issuing certificates")
|
||||
}
|
||||
@@ -282,6 +318,24 @@ func Validate(ctx context.Context, signature string, options Options) (string, e
|
||||
return metadata.VMID, nil
|
||||
}
|
||||
|
||||
// ParseCertificates parses the hardcoded Azure intermediate
|
||||
// certificates and returns them as x509.Certificate values.
|
||||
func ParseCertificates() ([]*x509.Certificate, error) {
|
||||
var certs []*x509.Certificate
|
||||
for _, certPEM := range Certificates {
|
||||
block, rest := pem.Decode([]byte(certPEM))
|
||||
if len(rest) != 0 {
|
||||
return nil, xerrors.Errorf("invalid certificate. %d bytes remain", len(rest))
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// Certificates are manually downloaded from Azure, then processed with OpenSSL
|
||||
// and added here. See: https://learn.microsoft.com/en-us/azure/security/fundamentals/azure-ca-details
|
||||
//
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package azureidentity_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/smallstep/pkcs7"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/azureidentity"
|
||||
@@ -50,10 +56,8 @@ func TestValidate(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
vm, err := azureidentity.Validate(context.Background(), tc.payload, azureidentity.Options{
|
||||
VerifyOptions: x509.VerifyOptions{
|
||||
CurrentTime: tc.date,
|
||||
},
|
||||
Offline: true,
|
||||
CurrentTime: tc.date,
|
||||
Offline: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.vmID, vm)
|
||||
@@ -69,12 +73,10 @@ func TestExpiresSoon(t *testing.T) {
|
||||
t.Skip()
|
||||
const threshold = 1
|
||||
|
||||
for _, c := range azureidentity.Certificates {
|
||||
block, rest := pem.Decode([]byte(c))
|
||||
require.Zero(t, len(rest))
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
certs, err := azureidentity.ParseCertificates()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, cert := range certs {
|
||||
expiresSoon := cert.NotAfter.Before(time.Now().AddDate(0, threshold, 0))
|
||||
if expiresSoon {
|
||||
t.Errorf("certificate expires within %d months %s: %s", threshold, cert.NotAfter, cert.Subject.CommonName)
|
||||
@@ -121,3 +123,172 @@ func TestIsAllowedCertificateURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testCertChain holds a three-level certificate hierarchy (Root CA,
|
||||
// Intermediate CA, Signing/leaf) together with their private keys.
|
||||
type testCertChain struct {
|
||||
RootCert *x509.Certificate
|
||||
RootKey *rsa.PrivateKey
|
||||
IntermediateCert *x509.Certificate
|
||||
IntermediateKey *rsa.PrivateKey
|
||||
SigningCert *x509.Certificate
|
||||
SigningKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// newTestCertChain creates a fresh three-level certificate chain for
|
||||
// testing. All certificates are valid at time.Now().
|
||||
func newTestCertChain(t *testing.T) testCertChain {
|
||||
t.Helper()
|
||||
|
||||
// Smaller key sizes are fine for tests; keeps them fast.
|
||||
const keyBits = 2048
|
||||
|
||||
// ---- Root CA ----
|
||||
rootKey, err := rsa.GenerateKey(rand.Reader, keyBits)
|
||||
require.NoError(t, err)
|
||||
rootTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test Root CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey)
|
||||
require.NoError(t, err)
|
||||
rootCert, err := x509.ParseCertificate(rootDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
// ---- Intermediate CA ----
|
||||
intermediateKey, err := rsa.GenerateKey(rand.Reader, keyBits)
|
||||
require.NoError(t, err)
|
||||
intermediateTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "Test Intermediate CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
intermediateDER, err := x509.CreateCertificate(rand.Reader, intermediateTmpl, rootCert, &intermediateKey.PublicKey, rootKey)
|
||||
require.NoError(t, err)
|
||||
intermediateCert, err := x509.ParseCertificate(intermediateDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
// ---- Signing (leaf) certificate ----
|
||||
signingKey, err := rsa.GenerateKey(rand.Reader, keyBits)
|
||||
require.NoError(t, err)
|
||||
signingTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(3),
|
||||
Subject: pkix.Name{CommonName: "metadata.azure.com"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
signingDER, err := x509.CreateCertificate(rand.Reader, signingTmpl, intermediateCert, &signingKey.PublicKey, intermediateKey)
|
||||
require.NoError(t, err)
|
||||
signingCert, err := x509.ParseCertificate(signingDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
return testCertChain{
|
||||
RootCert: rootCert,
|
||||
RootKey: rootKey,
|
||||
IntermediateCert: intermediateCert,
|
||||
IntermediateKey: intermediateKey,
|
||||
SigningCert: signingCert,
|
||||
SigningKey: signingKey,
|
||||
}
|
||||
}
|
||||
|
||||
// createSignedPKCS7 produces a base64-encoded PKCS7 SignedData
|
||||
// envelope over content, signed by the chain's leaf certificate.
|
||||
func (tc *testCertChain) createSignedPKCS7(t *testing.T, content []byte) string {
|
||||
t.Helper()
|
||||
|
||||
sd, err := pkcs7.NewSignedData(content)
|
||||
require.NoError(t, err)
|
||||
err = sd.AddSignerChain(tc.SigningCert, tc.SigningKey, []*x509.Certificate{tc.IntermediateCert}, pkcs7.SignerInfoConfig{})
|
||||
require.NoError(t, err)
|
||||
der, err := sd.Finish()
|
||||
require.NoError(t, err)
|
||||
return base64.StdEncoding.EncodeToString(der)
|
||||
}
|
||||
|
||||
// validationOptions returns azureidentity.Options that trust only this
|
||||
// chain's Root CA.
|
||||
func (tc *testCertChain) validationOptions() azureidentity.Options {
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(tc.RootCert)
|
||||
return azureidentity.Options{
|
||||
Roots: roots,
|
||||
Intermediates: []*x509.Certificate{tc.IntermediateCert},
|
||||
Offline: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_TamperedContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "darwin" {
|
||||
t.Skip("pkcs7 signing uses SHA1 which may be restricted on macOS")
|
||||
}
|
||||
|
||||
chain := newTestCertChain(t)
|
||||
|
||||
// Build a valid PKCS7 envelope.
|
||||
original := []byte(`{"vmId":"tamper-test-vm"}`)
|
||||
signed := chain.createSignedPKCS7(t, original)
|
||||
|
||||
// Decode, tamper with the content, re-encode.
|
||||
raw, err := base64.StdEncoding.DecodeString(signed)
|
||||
require.NoError(t, err)
|
||||
tampered := bytes.Replace(raw, []byte("tamper-test-vm"), []byte("tampered!!!!!!"), 1)
|
||||
require.NotEqual(t, raw, tampered, "payload should have changed")
|
||||
tamperedB64 := base64.StdEncoding.EncodeToString(tampered)
|
||||
|
||||
opts := chain.validationOptions()
|
||||
_, err = azureidentity.Validate(context.Background(), tamperedB64, opts)
|
||||
require.Error(t, err, "tampered content must not pass validation")
|
||||
}
|
||||
|
||||
func TestValidate_UntrustedCertWithValidSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "darwin" {
|
||||
t.Skip("pkcs7 signing uses SHA1 which may be restricted on macOS")
|
||||
}
|
||||
|
||||
chain := newTestCertChain(t)
|
||||
|
||||
content := []byte(`{"vmId":"untrusted-test-vm"}`)
|
||||
signed := chain.createSignedPKCS7(t, content)
|
||||
|
||||
// Build options that trust a DIFFERENT root, so the chain
|
||||
// should not verify.
|
||||
otherRoot, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
otherRootTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(99),
|
||||
Subject: pkix.Name{CommonName: "Other Root CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
otherRootDER, err := x509.CreateCertificate(rand.Reader, otherRootTmpl, otherRootTmpl, &otherRoot.PublicKey, otherRoot)
|
||||
require.NoError(t, err)
|
||||
otherRootCert, err := x509.ParseCertificate(otherRootDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
untrustedRoots := x509.NewCertPool()
|
||||
untrustedRoots.AddCert(otherRootCert)
|
||||
opts := azureidentity.Options{
|
||||
Roots: untrustedRoots,
|
||||
Intermediates: []*x509.Certificate{chain.IntermediateCert},
|
||||
Offline: true,
|
||||
}
|
||||
|
||||
_, err = azureidentity.Validate(context.Background(), signed, opts)
|
||||
require.Error(t, err, "signature from untrusted CA must not pass validation")
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,7 +3,6 @@ package coderd
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"expvar"
|
||||
@@ -67,6 +66,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/appearance"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/awsidentity"
|
||||
"github.com/coder/coder/v2/coderd/azureidentity"
|
||||
"github.com/coder/coder/v2/coderd/connectionlog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
@@ -168,7 +168,7 @@ type Options struct {
|
||||
AgentInactiveDisconnectTimeout time.Duration
|
||||
AWSCertificates awsidentity.Certificates
|
||||
Authorizer rbac.Authorizer
|
||||
AzureCertificates x509.VerifyOptions
|
||||
AzureCertificates azureidentity.Options
|
||||
GoogleTokenValidator *idtoken.Validator
|
||||
GithubOAuth2Config *GithubOAuth2Config
|
||||
OIDCConfig *OIDCConfig
|
||||
|
||||
@@ -32,11 +32,11 @@ import (
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
"github.com/fullsailor/pkcs7"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/smallstep/pkcs7"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/cases"
|
||||
@@ -63,6 +63,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/autobuild"
|
||||
"github.com/coder/coder/v2/coderd/awsidentity"
|
||||
"github.com/coder/coder/v2/coderd/azureidentity"
|
||||
"github.com/coder/coder/v2/coderd/connectionlog"
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
@@ -116,7 +117,7 @@ type Options struct {
|
||||
AppHostname string
|
||||
AWSCertificates awsidentity.Certificates
|
||||
Authorizer rbac.Authorizer
|
||||
AzureCertificates x509.VerifyOptions
|
||||
AzureCertificates azureidentity.Options
|
||||
GithubOAuth2Config *coderd.GithubOAuth2Config
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
OIDCConfig *coderd.OIDCConfig
|
||||
@@ -1519,27 +1520,63 @@ func NewAWSInstanceIdentity(t testing.TB, instanceID string) (awsidentity.Certif
|
||||
}
|
||||
}
|
||||
|
||||
// NewAzureInstanceIdentity returns a metadata client and ID token validator for faking
|
||||
// instance authentication for Azure.
|
||||
func NewAzureInstanceIdentity(t testing.TB, instanceID string) (x509.VerifyOptions, *http.Client) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
// NewAzureInstanceIdentity returns a metadata client and ID token
|
||||
// validator for faking instance authentication for Azure. It builds
|
||||
// a realistic 3-level certificate chain (Root CA -> Intermediate ->
|
||||
// Signing Cert) to match the real Azure trust hierarchy.
|
||||
func NewAzureInstanceIdentity(t testing.TB, instanceID string) (azureidentity.Options, *http.Client) {
|
||||
// Root CA (self-signed, trusted).
|
||||
rootKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
rootTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test Root CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
}
|
||||
rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey)
|
||||
require.NoError(t, err)
|
||||
rootCert, err := x509.ParseCertificate(rootDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
rawCertificate, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2022),
|
||||
// Intermediate CA (signed by root).
|
||||
interKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
interTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "Test Intermediate CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(5, 0, 0),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
}
|
||||
interDER, err := x509.CreateCertificate(rand.Reader, interTmpl, rootCert, &interKey.PublicKey, rootKey)
|
||||
require.NoError(t, err)
|
||||
interCert, err := x509.ParseCertificate(interDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Signing cert (leaf, signed by intermediate).
|
||||
signKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
signTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(3),
|
||||
Subject: pkix.Name{CommonName: "metadata.azure.com"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
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)
|
||||
}
|
||||
signDER, err := x509.CreateCertificate(rand.Reader, signTmpl, interCert, &signKey.PublicKey, interKey)
|
||||
require.NoError(t, err)
|
||||
signCert, err := x509.ParseCertificate(signDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build PKCS7 signed data with only the signing cert.
|
||||
signed, err := pkcs7.NewSignedData([]byte(`{"vmId":"` + instanceID + `"}`))
|
||||
require.NoError(t, err)
|
||||
err = signed.AddSigner(certificate, privateKey, pkcs7.SignerInfoConfig{})
|
||||
err = signed.AddSigner(signCert, signKey, pkcs7.SignerInfoConfig{})
|
||||
require.NoError(t, err)
|
||||
signatureRaw, err := signed.Finish()
|
||||
require.NoError(t, err)
|
||||
@@ -1552,12 +1589,12 @@ func NewAzureInstanceIdentity(t testing.TB, instanceID string) (x509.VerifyOptio
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
certPool := x509.NewCertPool()
|
||||
certPool.AddCert(certificate)
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(rootCert)
|
||||
|
||||
return x509.VerifyOptions{
|
||||
Intermediates: certPool,
|
||||
Roots: certPool,
|
||||
return azureidentity.Options{
|
||||
Roots: roots,
|
||||
Intermediates: []*x509.Certificate{interCert},
|
||||
}, &http.Client{
|
||||
Transport: roundTripper(func(r *http.Request) (*http.Response, error) {
|
||||
// Only handle metadata server requests.
|
||||
|
||||
@@ -36,9 +36,7 @@ func (api *API) postWorkspaceAuthAzureInstanceIdentity(rw http.ResponseWriter, r
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
instanceID, err := azureidentity.Validate(r.Context(), req.Signature, azureidentity.Options{
|
||||
VerifyOptions: api.AzureCertificates,
|
||||
})
|
||||
instanceID, err := azureidentity.Validate(r.Context(), req.Signature, api.AzureCertificates)
|
||||
if err != nil {
|
||||
// Log the full error for operators but return only a
|
||||
// generic message to the caller. Errors from the
|
||||
|
||||
@@ -78,7 +78,6 @@ require (
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
|
||||
github.com/adrg/xdg v0.5.0
|
||||
github.com/ammario/tlru v0.4.0
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/aquasecurity/trivy-iac v0.8.0
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
|
||||
github.com/awalterschulze/gographviz v2.0.3+incompatible
|
||||
@@ -117,7 +116,6 @@ require (
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/fatih/structtag v1.2.0
|
||||
github.com/fergusstrange/embedded-postgres v1.32.0
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa
|
||||
github.com/gen2brain/beeep v0.11.1
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/go-chi/chi/v5 v5.2.4
|
||||
@@ -175,7 +173,6 @@ require (
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/sqlc-dev/pqtype v0.3.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/http-swagger/v2 v2.0.1
|
||||
github.com/swaggo/swag v1.16.2
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/u-root/u-root v0.14.0
|
||||
@@ -183,7 +180,6 @@ require (
|
||||
github.com/valyala/fasthttp v1.68.0
|
||||
github.com/wagslane/go-password-validator v0.3.0
|
||||
github.com/zclconf/go-cty-yaml v1.1.0
|
||||
go.mozilla.org/pkcs7 v0.9.0
|
||||
go.nhat.io/otelsql v0.16.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0
|
||||
@@ -396,7 +392,6 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.0 // indirect
|
||||
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
|
||||
github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d // indirect
|
||||
github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e // indirect
|
||||
@@ -468,6 +463,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/anthropics/anthropic-sdk-go v1.18.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.9.0
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
|
||||
@@ -481,6 +477,8 @@ require (
|
||||
github.com/go-git/go-git/v5 v5.19.0
|
||||
github.com/icholy/replace v0.6.0
|
||||
github.com/mark3labs/mcp-go v0.38.0
|
||||
github.com/smallstep/pkcs7 v0.2.1
|
||||
github.com/swaggo/http-swagger/v2 v2.0.2
|
||||
gonum.org/v1/gonum v0.16.0
|
||||
)
|
||||
|
||||
@@ -565,6 +563,7 @@ require (
|
||||
github.com/sergeymakinen/go-bmp v1.0.0 // indirect
|
||||
github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.0 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tmaxmax/go-sse v0.11.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
|
||||
@@ -459,8 +459,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
@@ -1029,6 +1027,8 @@ github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnB
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/smallstep/pkcs7 v0.2.1 h1:6Kfzr/QizdIuB6LSv8y1LJdZ3aPSfTNhTLqAx9CTLfA=
|
||||
github.com/smallstep/pkcs7 v0.2.1/go.mod h1:RcXHsMfL+BzH8tRhmrF1NkkpebKpq3JEM66cOFxanf0=
|
||||
github.com/sosedoff/gitkit v0.4.0 h1:opyQJ/h9xMRLsz2ca/2CRXtstePcpldiZN8DpLLF8Os=
|
||||
github.com/sosedoff/gitkit v0.4.0/go.mod h1:V3EpGZ0nvCBhXerPsbDeqtyReNb48cwP9KtkUYTKT5I=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
@@ -1065,8 +1065,8 @@ github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7
|
||||
github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
|
||||
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
|
||||
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
|
||||
github.com/swaggo/http-swagger/v2 v2.0.1 h1:mNOBLxDjSNwCKlMxcErjjvct/xhc9t2KIO48xzz/V/k=
|
||||
github.com/swaggo/http-swagger/v2 v2.0.1/go.mod h1:XYhrQVIKz13CxuKD4p4kvpaRB4jJ1/MlfQXVOE+CX8Y=
|
||||
github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
|
||||
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
|
||||
github.com/swaggo/swag v1.16.2 h1:28Pp+8DkQoV+HLzLx8RGJZXNGKbFqnuvSbAAtoxiY04=
|
||||
github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E=
|
||||
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
|
||||
@@ -1208,8 +1208,6 @@ github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI=
|
||||
go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
|
||||
go.nhat.io/otelsql v0.16.0 h1:MUKhNSl7Vk1FGyopy04FBDimyYogpRFs0DBB9frQal0=
|
||||
go.nhat.io/otelsql v0.16.0/go.mod h1:YB2ocf0Q8+kK4kxzXYUOHj7P2Km8tNmE2QlRS0frUtc=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -1307,6 +1305,7 @@ golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
@@ -1353,6 +1352,7 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1393,6 +1393,7 @@ golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
@@ -1407,6 +1408,7 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1421,6 +1423,7 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
|
||||
Reference in New Issue
Block a user