Skip to content
Merged
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
11 changes: 11 additions & 0 deletions appattest/appattest_impl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
134 changes: 134 additions & 0 deletions appattest/device_info.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 6 additions & 0 deletions appattest/verify_attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,6 +134,8 @@ func VerifyAttestationPure(in *VerifyAttestationInputPure) (VerifyAttestationOut
EnvironmentGUID: authenticatorData.AttestedCredentialData.AAGUID,
BundleDigest: authenticatorData.RelayingPartyHash,
KeyID: computedPubkeyHash[:],

DeviceInfo: parseDeviceInfo(leafCert),
}, nil
}

Expand Down
8 changes: 0 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand Down