From c2e04f4a416f4069b654c9cf7151a0cfcc2d6e37 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Thu, 4 Sep 2025 00:03:35 +0200 Subject: [PATCH 1/3] Support managed device ID OID for `step` attestation format --- acme/challenge.go | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 05f018cb..9a3f8574 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1412,9 +1412,14 @@ roRcFD354g7rKfu67qFAw9gC4yi0xBTPrY95rh4/HqaUYCA/L8ldRk6H7Xk35D+W Vpmq2Sh/xT5HiFuhf4wJb0bK -----END CERTIFICATE-----` -// Serial number of the YubiKey, encoded as an integer. -// https://developers.yubico.com/PIV/Introduction/PIV_attestation.html -var oidYubicoSerialNumber = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 7} +var ( + // serial number of the YubiKey, encoded as an integer. + // https://developers.yubico.com/PIV/Introduction/PIV_attestation.html + oidYubicoSerialNumber = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482, 3, 7} + + // custom Smallstep managed device extension carrying a device ID or serial number + oidStepManagedDevice = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 37476, 9000, 64, 4} +) type stepAttestationData struct { Certificate *x509.Certificate @@ -1524,16 +1529,27 @@ func doStepAttestationFormat(_ context.Context, prov Provisioner, ch *Challenge, return nil, WrapErrorISE(err, "error calculating key fingerprint") } for _, ext := range leaf.Extensions { - if !ext.Id.Equal(oidYubicoSerialNumber) { - continue + if ext.Id.Equal(oidYubicoSerialNumber) { + var serialNumber int + rest, err := asn1.Unmarshal(ext.Value, &serialNumber) + if err != nil || len(rest) > 0 { + return nil, WrapError(ErrorBadAttestationStatementType, err, "error parsing serial number") + } + data.SerialNumber = strconv.Itoa(serialNumber) + break } - var serialNumber int - rest, err := asn1.Unmarshal(ext.Value, &serialNumber) - if err != nil || len(rest) > 0 { - return nil, WrapError(ErrorBadAttestationStatementType, err, "error parsing serial number") + if ext.Id.Equal(oidStepManagedDevice) { + type stepManagedDevice struct { + DeviceID string + } + var md stepManagedDevice + rest, err := asn1.Unmarshal(ext.Value, &md) + if err != nil || len(rest) > 0 { + return nil, WrapError(ErrorBadAttestationStatementType, err, "error parsing serial number") + } + data.SerialNumber = md.DeviceID + break } - data.SerialNumber = strconv.Itoa(serialNumber) - break } return data, nil From c5d3578373ddc916bb19ce5adfd4ba8fcbbc0a70 Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Thu, 4 Sep 2025 15:19:15 +0200 Subject: [PATCH 2/3] Add test case for device attestation with `step` managed device ID --- acme/challenge_test.go | 195 ++++++++++++++++++++++++++++++++--------- 1 file changed, 154 insertions(+), 41 deletions(-) diff --git a/acme/challenge_test.go b/acme/challenge_test.go index f0c7ae28..05a47d48 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -200,6 +200,60 @@ func mustAttestYubikey(t *testing.T, _, keyAuthorization string, serial int) ([] return payload, leaf, ca.Root } +type stepManagedDevice struct { + DeviceID string +} + +func mustAttestStepManagedDeviceID(t *testing.T, _, keyAuthorization, serialNumber string) ([]byte, *x509.Certificate, *x509.Certificate) { + t.Helper() + + ca, err := minica.New() + require.NoError(t, err) + + keyAuthSum := sha256.Sum256([]byte(keyAuthorization)) + + signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + sig, err := signer.Sign(rand.Reader, keyAuthSum[:], crypto.SHA256) + require.NoError(t, err) + cborSig, err := cbor.Marshal(sig) + require.NoError(t, err) + + v, err := asn1.Marshal(stepManagedDevice{DeviceID: serialNumber}) + require.NoError(t, err) + + leaf, err := ca.Sign(&x509.Certificate{ + Subject: pkix.Name{CommonName: "attestation cert"}, + PublicKey: signer.Public(), + ExtraExtensions: []pkix.Extension{ + {Id: oidStepManagedDevice, Value: v}, + }, + }) + require.NoError(t, err) + + attObj, err := cbor.Marshal(struct { + Format string `json:"fmt"` + AttStatement map[string]interface{} `json:"attStmt,omitempty"` + }{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leaf.Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }) + require.NoError(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 newWireProvisionerWithOptions(t *testing.T, options *provisioner.Options) *provisioner.ACME { t.Helper() prov := &provisioner.ACME{ @@ -3499,9 +3553,8 @@ func Test_doAppleAttestationFormat(t *testing.T) { func Test_doStepAttestationFormat(t *testing.T) { ctx := context.Background() ca, err := minica.New() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Root.Raw}) makeLeaf := func(signer crypto.Signer, serialNumber []byte) *x509.Certificate { @@ -3512,63 +3565,63 @@ func Test_doStepAttestationFormat(t *testing.T) { {Id: oidYubicoSerialNumber, Value: serialNumber}, }, }) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) return leaf } + + makeLeafWithStepManagedDeviceID := func(signer crypto.Signer, serialNumber string) *x509.Certificate { + v, err := asn1.Marshal(stepManagedDevice{DeviceID: serialNumber}) + require.NoError(t, err) + leaf, err := ca.Sign(&x509.Certificate{ + Subject: pkix.Name{CommonName: "attestation cert"}, + PublicKey: signer.Public(), + ExtraExtensions: []pkix.Extension{ + {Id: oidStepManagedDevice, Value: v}, + }, + }) + require.NoError(t, err) + return leaf + } + mustSigner := func(kty, crv string, size int) crypto.Signer { s, err := keyutil.GenerateSigner(kty, crv, size) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) return s } signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - serialNumber, err := asn1.Marshal(1234) - if err != nil { - t.Fatal(err) - } - leaf := makeLeaf(signer, serialNumber) + require.NoError(t, err) + fingerprint, err := keyutil.Fingerprint(signer.Public()) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + + serialNumber, err := asn1.Marshal(1234) + require.NoError(t, err) + + leaf := makeLeaf(signer, serialNumber) + leafWithStepManagedDeviceID := makeLeafWithStepManagedDeviceID(signer, "1234") jwk, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + keyAuth, err := KeyAuthorization("token", jwk) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + keyAuthSum := sha256.Sum256([]byte(keyAuth)) sig, err := signer.Sign(rand.Reader, keyAuthSum[:], crypto.SHA256) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + cborSig, err := cbor.Marshal(sig) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) otherSigner, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + otherSig, err := otherSigner.Sign(rand.Reader, keyAuthSum[:], crypto.SHA256) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + otherCBORSig, err := cbor.Marshal(otherSig) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) type args struct { ctx context.Context @@ -3595,6 +3648,18 @@ func Test_doStepAttestationFormat(t *testing.T) { Certificate: leaf, Fingerprint: fingerprint, }, false}, + {"ok/step-managed-device-id", args{ctx, mustAttestationProvisioner(t, caRoot), &Challenge{Token: "token"}, jwk, &attestationObject{ + Format: "step", + AttStatement: map[string]interface{}{ + "x5c": []interface{}{leafWithStepManagedDeviceID.Raw, ca.Intermediate.Raw}, + "alg": -7, + "sig": cborSig, + }, + }}, &stepAttestationData{ + SerialNumber: "1234", + Certificate: leafWithStepManagedDeviceID, + Fingerprint: fingerprint, + }, false}, {"fail yubico issuer", args{ctx, mustAttestationProvisioner(t, nil), &Challenge{Token: "token"}, jwk, &attestationObject{ Format: "step", AttStatement: map[string]interface{}{ @@ -4757,6 +4822,54 @@ func Test_deviceAttest01Validate(t *testing.T) { 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: "token", + Type: "device-attest-01", + Status: StatusPending, + Value: "12345678", + }, + payload: payload, + db: &MockDB{ + MockGetAuthorization: func(ctx context.Context, id string) (*Authorization, error) { + assert.Equal(t, "azID", id) + return &Authorization{ID: "azID"}, nil + }, + MockUpdateAuthorization: func(ctx context.Context, az *Authorization) error { + fingerprint, err := keyutil.Fingerprint(leaf.PublicKey) + assert.NoError(t, err) + assert.Equal(t, "azID", az.ID) + assert.Equal(t, fingerprint, az.Fingerprint) + return nil + }, + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("device-attest-01"), updch.Type) + assert.Equal(t, "12345678", updch.Value) + assert.Equal(t, payload, updch.Payload) + assert.Equal(t, "step", updch.PayloadFormat) + + return nil + }, + }, + }, + wantErr: nil, + } + }, + "ok/step-managed-device-id": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + payload, leaf, root := mustAttestStepManagedDeviceID(t, "nonce", keyAuth, "12345678") + + caRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw}) + ctx := NewProvisionerContext(context.Background(), mustAttestationProvisioner(t, caRoot)) + return test{ args: args{ ctx: ctx, From eb475e0f7ca82e40659e93f6f0f33b1e2d92fa0a Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Thu, 4 Sep 2025 22:32:54 +0200 Subject: [PATCH 3/3] Refactor searching for serial number into function --- acme/challenge.go | 30 +++++++++++++++++++++--------- acme/challenge_test.go | 8 +++++--- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/acme/challenge.go b/acme/challenge.go index 9a3f8574..c0f9425c 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -22,6 +22,7 @@ import ( "net" "net/url" "reflect" + "slices" "strconv" "strings" "time" @@ -29,12 +30,12 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/fxamacker/cbor/v2" "github.com/google/go-tpm/legacy/tpm2" + "github.com/smallstep/go-attestation/attest" "go.step.sm/crypto/jose" "go.step.sm/crypto/keyutil" "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x509util" - "golang.org/x/exp/slices" "github.com/smallstep/certificates/acme/wire" "github.com/smallstep/certificates/authority/provisioner" @@ -1525,18 +1526,30 @@ func doStepAttestationFormat(_ context.Context, prov Provisioner, ch *Challenge, data := &stepAttestationData{ Certificate: leaf, } + if data.Fingerprint, err = keyutil.Fingerprint(leaf.PublicKey); err != nil { return nil, WrapErrorISE(err, "error calculating key fingerprint") } - for _, ext := range leaf.Extensions { + + if data.SerialNumber, err = searchSerialNumber(leaf); err != nil { + return nil, WrapErrorISE(err, "error finding serial number") + } + + return data, nil +} + +// searchSerialNumber searches the certificate extensions, looking for a serial +// number encoded in one of them. It is not guaranteed that a certificate contains +// an extension carrying a serial number, so the result can be empty. +func searchSerialNumber(cert *x509.Certificate) (string, error) { + for _, ext := range cert.Extensions { if ext.Id.Equal(oidYubicoSerialNumber) { var serialNumber int rest, err := asn1.Unmarshal(ext.Value, &serialNumber) if err != nil || len(rest) > 0 { - return nil, WrapError(ErrorBadAttestationStatementType, err, "error parsing serial number") + return "", WrapError(ErrorBadAttestationStatementType, err, "error parsing serial number") } - data.SerialNumber = strconv.Itoa(serialNumber) - break + return strconv.Itoa(serialNumber), nil } if ext.Id.Equal(oidStepManagedDevice) { type stepManagedDevice struct { @@ -1545,14 +1558,13 @@ func doStepAttestationFormat(_ context.Context, prov Provisioner, ch *Challenge, var md stepManagedDevice rest, err := asn1.Unmarshal(ext.Value, &md) if err != nil || len(rest) > 0 { - return nil, WrapError(ErrorBadAttestationStatementType, err, "error parsing serial number") + return "", WrapError(ErrorBadAttestationStatementType, err, "error parsing serial number") } - data.SerialNumber = md.DeviceID - break + return md.DeviceID, nil } } - return data, nil + return "", nil } // serverName determines the SNI HostName to set based on an acme.Challenge diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 05a47d48..0e630637 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -31,16 +31,18 @@ import ( "time" "github.com/fxamacker/cbor/v2" - "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" + "go.step.sm/crypto/jose" "go.step.sm/crypto/keyutil" "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 {