Skip to content
339 changes: 338 additions & 1 deletion acme/challenge.go

Large diffs are not rendered by default.

171 changes: 167 additions & 4 deletions acme/challenge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import (
"time"

"github.com/fxamacker/cbor/v2"
"github.com/mbreban/attestation"
"github.com/smallstep/certificates/authority/config"
"github.com/smallstep/certificates/authority/provisioner"
wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand All @@ -39,10 +43,6 @@ import (
"go.step.sm/crypto/minica"
"go.step.sm/crypto/pemutil"
"go.step.sm/crypto/x509util"

"github.com/smallstep/certificates/authority/config"
"github.com/smallstep/certificates/authority/provisioner"
wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire"
)

type mockClient struct {
Expand Down Expand Up @@ -98,6 +98,24 @@ func mustAttestationProvisioner(t *testing.T, roots []byte) Provisioner {
return prov
}

func mustNonCRLAttestationProvisioner(t *testing.T, roots []byte, CRLs []string) Provisioner {
t.Helper()

prov := &provisioner.ACME{
Type: "ACME",
Name: "acme",
Challenges: []provisioner.ACMEChallenge{provisioner.DEVICE_ATTEST_01},
AttestationRoots: roots,
RevokedCertificateSerials: CRLs,
}
if err := prov.Init(provisioner.Config{
Claims: config.GlobalProvisionerClaims,
}); err != nil {
t.Fatal(err)
}
return prov
}

func mustAccountAndKeyAuthorization(t *testing.T, token string) (*jose.JSONWebKey, string) {
t.Helper()

Expand All @@ -109,6 +127,62 @@ func mustAccountAndKeyAuthorization(t *testing.T, token string) (*jose.JSONWebKe
return jwk, keyAuth
}

func mustAttestAndroid(t *testing.T, keyAuthorization string) ([]byte, *x509.Certificate, *x509.Certificate) {
t.Helper()

ca, err := minica.New()
fatalError(t, err)

signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
fatalError(t, err)

keyAuthSum := sha256.Sum256([]byte(keyAuthorization))
fatalError(t, err)

sig, err := signer.Sign(rand.Reader, keyAuthSum[:], crypto.SHA256)
fatalError(t, err)

atts := attestation.KeyDescription{
AttestationVersion: 300,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which versions does the code support?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since is based on the library it support version 1 to 300. It seems the new 400 version is not yet supported by the lib. If we decide to rewrite part of the lib we may implements version 400

AttestationSecurityLevel: 1,
AttestationChallenge: sig,
TeeEnforced: attestation.AuthorizationList{
AttestationIdSerial: []byte("serial-number"),
},
}
attestByte, err := attestation.CreateKeyDescription(&atts)
fatalError(t, err)

leaf, err := ca.Sign(&x509.Certificate{
Subject: pkix.Name{CommonName: "attestation cert"},
PublicKey: signer.Public(),
ExtraExtensions: []pkix.Extension{
{Id: oidAndroidAttestation, Value: attestByte},
},
})
fatalError(t, err)

attObj, err := cbor.Marshal(struct {
Format string `json:"fmt"`
AttStatement map[string]any `json:"attStmt,omitempty"`
}{
Format: "android-key",
AttStatement: map[string]any{
"x5c": []any{leaf.Raw, ca.Intermediate.Raw, ca.Root.Raw},
},
})
fatalError(t, err)

payload, err := json.Marshal(struct {
AttObj string `json:"attObj"`
}{
AttObj: base64.RawURLEncoding.EncodeToString(attObj),
})
require.NoError(t, err)

return payload, leaf, ca.Root
}

func mustAttestApple(t *testing.T, nonce string) ([]byte, *x509.Certificate, *x509.Certificate) {
t.Helper()

Expand Down Expand Up @@ -4503,6 +4577,95 @@ func Test_deviceAttest01Validate(t *testing.T) {
wantErr: nil,
}
},
"ok/doAndroidAttestationFormat": func(t *testing.T) test {

jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token")
payload, _, root := mustAttestAndroid(t, keyAuth)

caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw})
ctx := NewProvisionerContext(context.Background(), mustAttestationProvisioner(t, caRoot))
return test{
args: args{
ctx: ctx,
jwk: jwk,
ch: &Challenge{
ID: "chID",
AuthorizationID: "azID",
Token: "nonce",
Type: "device-attest-01",
Status: StatusPending,
Value: "serial-number",
},
payload: payload,
db: &MockDB{
MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) {
assert.Equal(t, "azID", id)
return &Authorization{ID: "azID"}, nil
},
MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error {
assert.Equal(t, "chID", updch.ID)
assert.Equal(t, "nonce", updch.Token)
assert.Equal(t, StatusInvalid, updch.Status)
assert.Equal(t, ChallengeType("device-attest-01"), updch.Type)
assert.Equal(t, "serial-number", updch.Value)
assert.Nil(t, updch.Payload)
assert.Empty(t, updch.PayloadFormat)

return nil
},
},
},
wantErr: nil,
}
},
"ok/doAndroidAttestationFormat-invalid-root": func(t *testing.T) test {

jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token")
payload, _, root := mustAttestAndroid(t, keyAuth)
caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw})
ctx := NewProvisionerContext(context.Background(), mustNonCRLAttestationProvisioner(t, caRoot, []string{root.SerialNumber.String()}))
return test{
args: args{
ctx: ctx,
jwk: jwk,
ch: &Challenge{
ID: "chID",
AuthorizationID: "azID",
Token: "nonce",
Type: "device-attest-01",
Status: StatusPending,
Value: "serial-number",
},
payload: payload,
db: &MockDB{
MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) {
assert.Equal(t, "azID", id)
return &Authorization{ID: "azID"}, nil
},
MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error {
assert.Equal(t, "chID", updch.ID)
assert.Equal(t, "nonce", updch.Token)
assert.Equal(t, StatusInvalid, updch.Status)
assert.Equal(t, ChallengeType("device-attest-01"), updch.Type)
assert.Equal(t, "serial-number", updch.Value)
assert.Nil(t, updch.Payload)
assert.Empty(t, updch.PayloadFormat)

err := NewDetailedError(ErrorBadAttestationStatementType, "x5c element contain a revoked certificate")

assert.EqualError(t, updch.Error.Err, err.Err.Error())
assert.Equal(t, err.Type, updch.Error.Type)
assert.Equal(t, err.Detail, updch.Error.Detail)
assert.Equal(t, err.Status, updch.Error.Status)
assert.Equal(t, err.Subproblems, updch.Error.Subproblems)

return nil
},
},
},
wantErr: nil,
}
},
"ok/doStepAttestationFormat-storeError": func(t *testing.T) test {
ca, err := minica.New()
require.NoError(t, err)
Expand Down
76 changes: 69 additions & 7 deletions authority/provisioner/acme.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ package provisioner
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log"
"net"
"net/http"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -53,6 +58,10 @@ func (c ACMEChallenge) Validate() error {
type ACMEAttestationFormat string

const (
// ANDROIDKEY is the format used to enable device-attest-01 for Android
// devices using Android Key Attestation.
ANDROIDKEY ACMEAttestationFormat = "android-key"

// APPLE is the format used to enable device-attest-01 on Apple devices.
APPLE ACMEAttestationFormat = "apple"

Expand All @@ -74,7 +83,7 @@ func (f ACMEAttestationFormat) String() string {
// Validate returns an error if the attestation format is not a valid one.
func (f ACMEAttestationFormat) Validate() error {
switch ACMEAttestationFormat(f.String()) {
case APPLE, STEP, TPM:
case APPLE, STEP, TPM, ANDROIDKEY:
return nil
default:
return fmt.Errorf("acme attestation format %q is not supported", f)
Expand Down Expand Up @@ -117,11 +126,13 @@ type ACME struct {
// AttestationRoots contains a bundle of root certificates in PEM format
// that will be used to verify the attestation certificates. If provided,
// this bundle will be used even for well-known CAs like Apple and Yubico.
AttestationRoots []byte `json:"attestationRoots,omitempty"`
Claims *Claims `json:"claims,omitempty"`
Options *Options `json:"options,omitempty"`
attestationRootPool *x509.CertPool
ctl *Controller
AttestationRoots []byte `json:"attestationRoots,omitempty"`
Claims *Claims `json:"claims,omitempty"`
Options *Options `json:"options,omitempty"`
RevokedCertificateSerials []string `json:"revokedCertificateSerials,omitempty"`
androidCRLTimeout time.Time
attestationRootPool *x509.CertPool
ctl *Controller
}

// GetID returns the provisioner unique identifier.
Expand Down Expand Up @@ -217,10 +228,51 @@ func (p *ACME) Init(config Config) (err error) {
return fmt.Errorf("failed initializing Wire options: %w", err)
}

if slices.Contains(p.AttestationFormats, ANDROIDKEY) && len(p.RevokedCertificateSerials) == 0 {
p.fetchAndroidCRL()
}

p.ctl, err = NewController(p, p.Claims, config, p.Options)
return
}

const androidAttestationStatusURL = "https://android.googleapis.com/attestation/status"

// fetch CRL https://android.googleapis.com/attestation/status and build a list of revoked serial numbers
func (p *ACME) fetchAndroidCRL() error {
log.Printf("Updating Android CRL list for %s ACME provisioner", p.Name)
var crlResponse struct {
Entries map[string]struct {
Status string `json:"status"`
Reason string `json:"reason"`
} `json:"entries"`
}
// res, err := p.ctl.GetHTTPClient().Get(androidAttestationStatusURL)
res, err := http.Get(androidAttestationStatusURL)
if err != nil {
return fmt.Errorf("client: error making Android CRL request: %s\n", err)
}
defer res.Body.Close()

if res.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(res.Body)
return fmt.Errorf("unexpected Android CRL response %d: %s", res.StatusCode, string(bodyBytes))
}

if err := json.NewDecoder(res.Body).Decode(&crlResponse); err != nil {
return fmt.Errorf("error decoding Android CRL JSON: %w", err)
}

// Extract keys into a slice
keys := make([]string, 0, len(crlResponse.Entries))
for k := range crlResponse.Entries {
keys = append(keys, k)
}
p.RevokedCertificateSerials = keys
p.androidCRLTimeout = time.Now().Add(24 * time.Hour)
return nil
}

// initializeWireOptions initializes the options for the ACME Wire
// integration. It'll return early if no Wire challenge types are
// enabled.
Expand Down Expand Up @@ -372,7 +424,7 @@ func (p *ACME) IsChallengeEnabled(_ context.Context, challenge ACMEChallenge) bo
// AttestationFormat provisioner property should have at least one element.
func (p *ACME) IsAttestationFormatEnabled(_ context.Context, format ACMEAttestationFormat) bool {
enabledFormats := []ACMEAttestationFormat{
APPLE, STEP, TPM,
APPLE, STEP, TPM, ANDROIDKEY,
}
if len(p.AttestationFormats) > 0 {
enabledFormats = p.AttestationFormats
Expand All @@ -393,3 +445,13 @@ func (p *ACME) IsAttestationFormatEnabled(_ context.Context, format ACMEAttestat
func (p *ACME) GetAttestationRoots() (*x509.CertPool, bool) {
return p.attestationRootPool, p.attestationRootPool != nil
}

// IsCertificateRevoked returns true if the provided serialNumber is in the list of revoked
// certificate serial number.
// It will also be in charge of updating the list periodically if no CRL list is provided at configuration.
func (p *ACME) IsCertificateRevoked(serialNumber string) bool {
if slices.Contains(p.AttestationFormats, ANDROIDKEY) && !p.androidCRLTimeout.IsZero() && time.Now().After(p.androidCRLTimeout) {
p.fetchAndroidCRL()
}
return slices.Contains(p.RevokedCertificateSerials, serialNumber)
}
7 changes: 5 additions & 2 deletions authority/provisioner/acme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func TestACMEAttestationFormat_Validate(t *testing.T) {
f ACMEAttestationFormat
wantErr bool
}{
{"android", ANDROIDKEY, false},
{"apple", APPLE, false},
{"step", STEP, false},
{"tpm", TPM, false},
Expand Down Expand Up @@ -201,7 +202,7 @@ MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k=
Name: "foo",
Type: "ACME",
Challenges: []ACMEChallenge{DNS_01, DEVICE_ATTEST_01},
AttestationFormats: []ACMEAttestationFormat{APPLE, STEP},
AttestationFormats: []ACMEAttestationFormat{APPLE, STEP, ANDROIDKEY},
AttestationRoots: bytes.Join([][]byte{appleCA, yubicoCA}, []byte("\n")),
},
}
Expand Down Expand Up @@ -429,14 +430,16 @@ func TestACME_IsAttestationFormatEnabled(t *testing.T) {
args args
want bool
}{
{"ok", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM}}, args{ctx, TPM}, true},
{"ok", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM, ANDROIDKEY}}, args{ctx, TPM}, true},
{"ok empty apple", fields{nil}, args{ctx, APPLE}, true},
{"ok empty step", fields{nil}, args{ctx, STEP}, true},
{"ok empty tpm", fields{[]ACMEAttestationFormat{}}, args{ctx, "tpm"}, true},
{"ok empty android", fields{[]ACMEAttestationFormat{}}, args{ctx, "android-key"}, true},
{"ok uppercase", fields{[]ACMEAttestationFormat{APPLE, STEP, TPM}}, args{ctx, "STEP"}, true},
{"fail apple", fields{[]ACMEAttestationFormat{STEP, TPM}}, args{ctx, APPLE}, false},
{"fail step", fields{[]ACMEAttestationFormat{APPLE, TPM}}, args{ctx, STEP}, false},
{"fail step", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, TPM}, false},
{"fail android", fields{[]ACMEAttestationFormat{APPLE, STEP}}, args{ctx, ANDROIDKEY}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
4 changes: 4 additions & 0 deletions authority/provisioners.go
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,8 @@ func attestationFormatsToCertificates(formats []linkedca.ACMEProvisioner_Attesta
ret := make([]provisioner.ACMEAttestationFormat, 0, len(formats))
for _, f := range formats {
switch f {
case 4:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 👍

ret = append(ret, provisioner.ANDROIDKEY)
case linkedca.ACMEProvisioner_APPLE:
ret = append(ret, provisioner.APPLE)
case linkedca.ACMEProvisioner_STEP:
Expand All @@ -1377,6 +1379,8 @@ func attestationFormatsToLinkedca(formats []provisioner.ACMEAttestationFormat) [
ret := make([]linkedca.ACMEProvisioner_AttestationFormatType, 0, len(formats))
for _, f := range formats {
switch provisioner.ACMEAttestationFormat(f.String()) {
case provisioner.ANDROIDKEY:
ret = append(ret, 4)
case provisioner.APPLE:
ret = append(ret, linkedca.ACMEProvisioner_APPLE)
case provisioner.STEP:
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/hashicorp/vault/api/auth/approle v0.11.0
github.com/hashicorp/vault/api/auth/aws v0.11.0
github.com/hashicorp/vault/api/auth/kubernetes v0.10.0
github.com/mbreban/attestation v0.1.0
github.com/newrelic/go-agent/v3 v3.42.0
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
Expand Down
Loading