Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -216,10 +227,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 @@ -371,7 +423,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 @@ -392,3 +444,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 @@ -198,7 +199,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 @@ -426,14 +427,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 @@ -1358,6 +1358,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 @@ -1375,6 +1377,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 @@ -21,6 +21,7 @@ require (
github.com/hashicorp/vault/api/auth/approle v0.10.0
github.com/hashicorp/vault/api/auth/aws v0.10.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.40.1
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
Expand Down
Loading