From f8e007e818c6657157d62b837e288f4ca83441e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Tremblay?= <1619947+marctrem@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:37:15 -0500 Subject: [PATCH] best effort parsing of device info asn.1 oid --- appattest/appattest_impl_test.go | 11 +++ appattest/device_info.go | 134 +++++++++++++++++++++++++++++++ appattest/verify_attestation.go | 6 ++ go.sum | 8 -- 4 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 appattest/device_info.go diff --git a/appattest/appattest_impl_test.go b/appattest/appattest_impl_test.go index 8438de4..102d2ca 100644 --- a/appattest/appattest_impl_test.go +++ b/appattest/appattest_impl_test.go @@ -43,6 +43,13 @@ func TestAppAttest(t *testing.T) { assert.True(t, validInstant.Before(res.LeafCert.NotAfter)) assert.True(t, validInstant.After(res.LeafCert.NotBefore)) assert.Equal(t, keyIdentifier, res.KeyID) + + require.NotNil(t, res.DeviceInfo) + assert.Equal(t, "18.0", res.DeviceInfo.OSVersion) + assert.Equal(t, "22A244b", res.DeviceInfo.OSBuild) + assert.Equal(t, "iphoneos", res.DeviceInfo.Platform) + assert.Equal(t, "Internal", res.DeviceInfo.BuildVariant) + require.NotNil(t, res.DeviceInfo.DeviceClass) } func TestAppAttestDev(t *testing.T) { @@ -76,6 +83,10 @@ func TestAppAttestDev(t *testing.T) { assert.True(t, validInstant.Before(res.LeafCert.NotAfter)) assert.True(t, validInstant.After(res.LeafCert.NotBefore)) assert.Equal(t, keyIdentifier, res.KeyID) + + require.NotNil(t, res.DeviceInfo) + assert.Equal(t, "17.6.1", res.DeviceInfo.OSVersion) + assert.Equal(t, "21G93", res.DeviceInfo.OSBuild) } func FuzzAttestationData(f *testing.F) { diff --git a/appattest/device_info.go b/appattest/device_info.go new file mode 100644 index 0000000..a527d22 --- /dev/null +++ b/appattest/device_info.go @@ -0,0 +1,134 @@ +package appattest + +import ( + "crypto/x509" + "encoding/asn1" +) + +var ( + oidDeviceInfo = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 8, 7} +) + +// DeviceClass represents the device class value from the attestation +// certificate (ASN.1 tag [1104]). +type DeviceClass = int + +const ( + // DeviceClassiPad indicates an iPad device. + DeviceClassiPad DeviceClass = 0 + + // DeviceClassiPhone indicates an iPhone device. + DeviceClassiPhone DeviceClass = 2 +) + +// DeviceInfo contains device and OS information extracted from the Apple App +// Attest leaf certificate extension OID 1.2.840.113635.100.8.7. This extension +// is undocumented by Apple; the field meanings are reverse-engineered and +// best-effort. All fields are optional. +type DeviceInfo struct { + // OSVersion is the iOS/iPadOS version string (e.g. "18.0", "26.3"). + // ASN.1 tag [1400]. + OSVersion string + + // OSBuild is the OS build identifier (e.g. "22A244b", "23D127"). + // ASN.1 tag [1403]. + OSBuild string + + // DeviceClass indicates the device type (DeviceClassiPad, + // DeviceClassiPhone). May contain other values in non-production + // environments. Nil if absent. ASN.1 tag [1104]. + DeviceClass *DeviceClass + + // IBootVersion is the iBoot/SEP version string (e.g. "1.0.213"). + // Only observed on newer OS versions. ASN.1 tag [1401]. + IBootVersion string + + // SEPVersion is the SEP firmware version string (e.g. "23.4.127.0.0,0"). + // ASN.1 tag [1418]. + SEPVersion string + + // Platform is the platform identifier (e.g. "iphoneos"). + // ASN.1 tag [1026]. + Platform string + + // BuildVariant is the build variant (e.g. "Internal"). Only present on + // Apple internal builds. ASN.1 tag [1029]. + BuildVariant string +} + +// parseDeviceInfo attempts to extract DeviceInfo from the leaf certificate. +// Returns nil if the extension is not present. Parsing is best-effort: +// unrecognized or malformed fields are silently skipped. +func parseDeviceInfo(cert *x509.Certificate) *DeviceInfo { + var extValue []byte + for _, ext := range cert.Extensions { + if ext.Id.Equal(oidDeviceInfo) { + extValue = ext.Value + break + } + } + if extValue == nil { + return nil + } + + // The extension value is a SEQUENCE of context-specific constructed + // tagged values. We parse the outer SEQUENCE then walk each child. + var seq asn1.RawValue + rest, err := asn1.Unmarshal(extValue, &seq) + if err != nil || len(rest) > 0 || !seq.IsCompound { + return nil + } + + di := &DeviceInfo{} + data := seq.Bytes + for len(data) > 0 { + var item asn1.RawValue + data, err = asn1.Unmarshal(data, &item) + if err != nil { + break + } + if item.Class != asn1.ClassContextSpecific || !item.IsCompound { + continue + } + + switch item.Tag { + case 1400: + di.OSVersion = extractString(item.Bytes) + case 1403: + di.OSBuild = extractString(item.Bytes) + case 1104: + if v, ok := extractInt(item.Bytes); ok { + di.DeviceClass = &v + } + case 1401: + di.IBootVersion = extractString(item.Bytes) + case 1418: + di.SEPVersion = extractString(item.Bytes) + case 1026: + di.Platform = extractString(item.Bytes) + case 1029: + di.BuildVariant = extractString(item.Bytes) + } + } + + return di +} + +// extractString parses a single ASN.1 OCTET STRING from the given bytes +// and returns it as a string. +func extractString(b []byte) string { + var raw asn1.RawValue + if _, err := asn1.Unmarshal(b, &raw); err != nil { + return "" + } + return string(raw.Bytes) +} + +// extractInt parses a single ASN.1 INTEGER from the given bytes. +func extractInt(b []byte) (int, bool) { + var val int + if _, err := asn1.Unmarshal(b, &val); err != nil { + return 0, false + } + return val, true +} diff --git a/appattest/verify_attestation.go b/appattest/verify_attestation.go index 6fc1807..1a33dac 100644 --- a/appattest/verify_attestation.go +++ b/appattest/verify_attestation.go @@ -42,6 +42,10 @@ type VerifyAttestationOutput struct { EnvironmentGUID Environment BundleDigest []byte KeyID []byte + + // DeviceInfo contains device and OS metadata extracted from the leaf + // certificate. Nil if the extension is absent or unparseable. + DeviceInfo *DeviceInfo } // AttestedPubkey returns the key from the leaf certificate @@ -130,6 +134,8 @@ func VerifyAttestationPure(in *VerifyAttestationInputPure) (VerifyAttestationOut EnvironmentGUID: authenticatorData.AttestedCredentialData.AAGUID, BundleDigest: authenticatorData.RelayingPartyHash, KeyID: computedPubkeyHash[:], + + DeviceInfo: parseDeviceInfo(leafCert), }, nil } diff --git a/go.sum b/go.sum index d759142..be5c6c0 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 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/ldclabs/cose v1.3.1 h1:LjcmFqBWkClOWtAThmGkzGBN/7XAOjRWRaXRx+oObdo= -github.com/ldclabs/cose v1.3.1/go.mod h1:YSSHRSTwm58TgSbG9YiZ7YMkm5R7lKuKdUioLCsnDx0= github.com/ldclabs/cose v1.3.2 h1:9M5l1zTvOyZONRsNj2PWJjmLdRqkcrsp80tyuNkOHdE= github.com/ldclabs/cose v1.3.2/go.mod h1:X1srvv76GKudjv85VCUgka049gaK5aozbBhMDaCEbpc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -16,12 +12,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=