Merge pull request #2382 from smallstep/herman/step-managed-device

Support managed device ID OID for `step` attestation format
This commit is contained in:
Herman Slatman
2025-09-04 23:30:05 +02:00
committed by GitHub
2 changed files with 202 additions and 59 deletions

View File

@@ -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"
@@ -1412,9 +1413,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
@@ -1520,25 +1526,47 @@ 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 !ext.Id.Equal(oidYubicoSerialNumber) {
continue
}
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
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 "", WrapError(ErrorBadAttestationStatementType, err, "error parsing serial number")
}
return strconv.Itoa(serialNumber), nil
}
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 "", WrapError(ErrorBadAttestationStatementType, err, "error parsing serial number")
}
return md.DeviceID, nil
}
}
return "", nil
}
// serverName determines the SNI HostName to set based on an acme.Challenge
// for TLS-ALPN-01 challenges RFC8738 states that, if HostName is an IP, it
// should be the ARPA address https://datatracker.ietf.org/doc/html/rfc8738#section-6.

View File

@@ -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 {
@@ -200,6 +202,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 +3555,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 +3567,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 +3650,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 +4824,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,