Skip to content

Commit

Permalink
Allow flagship attestation from the Play Integrity API (#4293)
Browse files Browse the repository at this point in the history
  • Loading branch information
nono authored Jan 17, 2024
2 parents b94ab5a + 2e94034 commit f4b4d15
Show file tree
Hide file tree
Showing 10 changed files with 355 additions and 27 deletions.
7 changes: 7 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package cmd is where the CLI commands and options are defined.
package cmd

import (
Expand Down Expand Up @@ -191,6 +192,12 @@ func init() {
flags.StringSlice("flagship-apk-certificate-digests", []string{"u2eUUnfB4Y7k7eqQL7u2jiYDJeVBwZoSV3PZSs8pttc="}, "SHA-256 hash (base64 encoded) of the flagship app's signing certificate on android")
checkNoErr(viper.BindPFlag("flagship.apk_certificate_digests", flags.Lookup("flagship-apk-certificate-digests")))

flags.StringSlice("flagship-play-integrity-decryption-keys", []string{"bVcBAv0eO64NKIvDoRHpnTOZVxAkhMuFwRHrTEMr23U="}, "Decryption key for the Google Play Integrity API")
checkNoErr(viper.BindPFlag("flagship.play_integrity_decryption_keys", flags.Lookup("flagship-play-integrity-decryption-keys")))

flags.StringSlice("flagship-play-integrity-verification-keys", []string{"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElTF2uARN7oxfoDWyERYMe6QutI2NqS+CAtVmsPDIRjBBxF96fYojFVXRRsMb86PjkE21Ol+sO1YuspY+YuDRMw=="}, "Verification key for the Google Play Integrity API")
checkNoErr(viper.BindPFlag("flagship.play_integrity_verification_keys", flags.Lookup("flagship-play-integrity-verification-keys")))

flags.StringSlice("flagship-apple-app-ids", []string{"3AKXFMV43J.io.cozy.drive.mobile", "3AKXFMV43J.io.cozy.flagship.mobile"}, "App ID of the flagship app on iOS")
checkNoErr(viper.BindPFlag("flagship.apple_app_ids", flags.Lookup("flagship-apple-app-ids")))

Expand Down
4 changes: 4 additions & 0 deletions cozy.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,10 @@ flagship:
- io.cozy.flagship.mobile
apk_certificate_digests:
- 'xNnH7T1BSDh6erMzNysfakBVLLacbSbOMxVk8jEPgdU='
play_integrity_decryption_keys:
- 'bVcBAv0eO64NKIvDoRHpnTOZVxAkhMuFwRHrTEMr23U='
play_integrity_verification_keys:
- 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElTF2uARN7oxfoDWyERYMe6QutI2NqS+CAtVmsPDIRjBBxF96fYojFVXRRsMb86PjkE21Ol+sO1YuspY+YuDRMw=='
apple_app_ids:
- 3AKXFMV43J.io.cozy.drive.mobile
- 3AKXFMV43J.io.cozy.flagship.mobile
Expand Down
12 changes: 7 additions & 5 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,8 +766,9 @@ HTTP/1.1 204 No Content
### POST /auth/clients/:client-id/challenge

This route can be used to start the process for certifying that an app is
really what it tells to be by using the android/iOS APIs (SafetyNet). It
returns a nonce that must be used in the certificate.
really what it tells to be by using the android/iOS APIs
(PlayIntegrity/SafetyNet/AppleAttestation). It returns a nonce that must be
used in the certificate.

The client must send its registration access token to use this endpoint.

Expand All @@ -791,8 +792,8 @@ Content-Type: application/json
### POST /auth/clients/:client-id/attestation

This route can be used to finish the process for certifying that an app is
really what it tells to be by using the android/iOS APIs (SafetyNet). The
client can send its attestation.
really what it tells to be by using the android/iOS APIs. The client can send
its attestation.

```http
POST /auth/clients/64ce5cb0-bd4c-11e6-880e-b3b7dfda89d3/attestation HTTP/1.1
Expand All @@ -809,7 +810,8 @@ Content-Type: application/json
```

Note: the `platform` parameter can be `"android"` or `"ios"`. For `ios`, a
`"keyId"` parameter is also required.
`"keyId"` parameter is also required. For `android`, the `"issuer"` can be
`"playintegrity"` to use the Play Integrity API instead of the SafetyNet API.

```http
HTTP/1.1 204 No Content
Expand Down
9 changes: 5 additions & 4 deletions docs/flagship.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ routes from the stack:
3. `POST /auth/clients/:client-id/attestation`

Between 2 and 3, the app will ask the mobile OS to certify that this is really
the flagship app. It is done via the [SafetyNet attestation
API](https://developer.android.com/training/safetynet/attestation) on Android,
and the [AppAttest API](https://developer.apple.com/documentation/devicecheck)
on iOS.
the flagship app. It is done via the [Play Integrity
API](https://developer.android.com/google/play/integrity) (or [SafetyNet
attestation API](https://developer.android.com/training/safetynet/attestation))
on Android, and the [AppAttest
API](https://developer.apple.com/documentation/devicecheck) on iOS.

## New Cozy instance

Expand Down
223 changes: 223 additions & 0 deletions model/oauth/android_play_integrity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package oauth

import (
"crypto/aes"
"crypto/cipher"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"strings"

"github.com/cozy/cozy-stack/model/instance"
"github.com/cozy/cozy-stack/pkg/config/config"
"github.com/cozy/cozy-stack/pkg/crypto"
"github.com/cozy/cozy-stack/pkg/logger"
jwt "github.com/golang-jwt/jwt/v5"
)

// checkPlayIntegrityAttestation will check an attestation made by the Play
// Integrity API.
// https://developer.android.com/google/play/integrity
func (c *Client) checkPlayIntegrityAttestation(inst *instance.Instance, req AttestationRequest) error {
store := GetStore()
if ok := store.CheckAndClearChallenge(inst, c.ID(), req.Challenge); !ok {
return errors.New("invalid challenge")
}

token, err := decryptPlayIntegrityToken(req)
if err != nil {
inst.Logger().Debugf("cannot decrypt the play integrity token: %s", err)
return fmt.Errorf("cannot parse attestation: %s", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return errors.New("invalid claims type")
}
inst.Logger().Debugf("checkPlayIntegrityAttestation claims = %#v", claims)

nonce, ok := getFromClaims(claims, "requestDetails.nonce").(string)
if !ok || len(nonce) == 0 {
return errors.New("missing nonce")
}
if req.Challenge != nonce {
return errors.New("invalid nonce")
}

if err := checkPlayIntegrityPackageName(claims); err != nil {
return err
}
if err := checkPlayIntegrityCertificateDigest(claims); err != nil {
return err
}
return nil
}

// CheckPlayIntegrityAttestationForTestingPurpose is only used for testing
// purpose. It is a simplified version of checkPlayIntegrityAttestation. In
// particular, it doesn't return an error for invalid package name with a test
// attestation.
func CheckPlayIntegrityAttestationForTestingPurpose(req AttestationRequest) error {
token, err := decryptPlayIntegrityToken(req)
if err != nil {
return fmt.Errorf("cannot parse attestation: %s", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return errors.New("invalid claims type")
}

nonce, ok := getFromClaims(claims, "requestDetails.nonce").(string)
if !ok || len(nonce) == 0 {
return errors.New("missing nonce")
}
if req.Challenge != nonce {
return errors.New("invalid nonce")
}
return nil
}

func decryptPlayIntegrityToken(req AttestationRequest) (*jwt.Token, error) {
lastErr := errors.New("no decryption key")
for _, key := range config.GetConfig().Flagship.PlayIntegrityDecryptionKeys {
decrypted, err := decryptPlayIntegrityJWE(req.Attestation, key)
if err == nil {
return parsePlayIntegrityToken(decrypted)
}
lastErr = err
}
return nil, lastErr
}

func decryptPlayIntegrityJWE(attestation string, rawKey string) ([]byte, error) {
parts := strings.Split(attestation, ".")
if len(parts) != 5 {
return nil, errors.New("invalid integrity token")
}
header := []byte(parts[0])
encryptedKey, err := base64.RawURLEncoding.DecodeString(parts[1])
// AES Key wrap works with 64 bits block, and the wrapped version has n+1
// blocks (for integrity check). The kek key is 256bits, thus the
// encryptedKey is 320bits => 40bytes.
if err != nil || len(encryptedKey) != 40 {
return nil, fmt.Errorf("invalid encrypted key: %w", err)
}
initVector, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("invalid initialization vector: %w", err)
}
cipherText, err := base64.RawURLEncoding.DecodeString(parts[3])
if err != nil {
return nil, fmt.Errorf("invalid ciphertext: %w", err)
}
authTag, err := base64.RawURLEncoding.DecodeString(parts[4])
if err != nil || len(authTag) != 16 { // GCM uses 128bits => 16bytes
return nil, fmt.Errorf("invalid authentication tag: %w", err)
}

kek, err := base64.StdEncoding.DecodeString(rawKey) // kek means Key-encryption key, cf RFC-3394
if err != nil {
return nil, fmt.Errorf("invalid decryption key: %w", err)
}
block, err := aes.NewCipher(kek)
if err != nil {
return nil, fmt.Errorf("invalid decryption key: %w", err)
}
contentKey, err := crypto.UnwrapA256KW(block, encryptedKey)
if err != nil {
return nil, fmt.Errorf("cannot unwrap the key: %w", err)
}
if len(contentKey) != 32 { // AES256 means 256bits => 32bytes
return nil, fmt.Errorf("invalid encrypted key: %w", err)
}

cek, err := aes.NewCipher(contentKey)
if err != nil {
return nil, fmt.Errorf("cannot load the cek: %w", err)
}
aesgcm, err := cipher.NewGCM(cek)
if err != nil {
return nil, fmt.Errorf("cannot initialize AES-GCM: %w", err)
}
if len(initVector) != aesgcm.NonceSize() {
return nil, fmt.Errorf("invalid initialization vector: %w", err)
}
decrypted, err := aesgcm.Open(nil, initVector, append(cipherText, authTag...), header)
if err != nil {
return nil, fmt.Errorf("cannot decrypt: %w", err)
}

return decrypted, nil
}

func parsePlayIntegrityToken(decrypted []byte) (*jwt.Token, error) {
lastErr := errors.New("no verification key")
for _, key := range config.GetConfig().Flagship.PlayIntegrityVerificationKeys {
token, err := parsePlayIntegrityJWT(decrypted, key)
if err == nil {
return token, err
}
lastErr = err
}
return nil, lastErr
}

func parsePlayIntegrityJWT(decrypted []byte, rawKey string) (*jwt.Token, error) {
return jwt.Parse(string(decrypted), func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
key, err := base64.StdEncoding.DecodeString(rawKey)
if err != nil {
return nil, fmt.Errorf("invalid verification key: %w", err)
}
pubKey, err := x509.ParsePKIXPublicKey(key)
if err != nil {
return nil, fmt.Errorf("invalid verification key: %w", err)
}
return pubKey, nil
})
}

func checkPlayIntegrityPackageName(claims jwt.MapClaims) error {
packageName, ok := getFromClaims(claims, "appIntegrity.packageName").(string)
if !ok || len(packageName) == 0 {
return errors.New("missing appIntegrity.packageName")
}
names := config.GetConfig().Flagship.APKPackageNames
for _, name := range names {
if name == packageName {
return nil
}
}
return fmt.Errorf("%s is not the package name of the flagship app", packageName)
}

func checkPlayIntegrityCertificateDigest(claims jwt.MapClaims) error {
certDigest, ok := getFromClaims(claims, "appIntegrity.certificateSha256Digest").([]interface{})
if !ok || len(certDigest) == 0 {
return errors.New("missing appIntegrity.certificateSha256Digest")
}
digests := config.GetConfig().Flagship.APKCertificateDigests
for _, digest := range digests {
if digest == certDigest[0] {
return nil
}
}
logger.WithNamespace("oauth").
Debugf("Invalid certificate digest, expected %s, got %s", digests[0], certDigest)
return errors.New("invalid certificate digest")
}

func getFromClaims(claims jwt.MapClaims, key string) interface{} {
parts := strings.Split(key, ".")
var obj interface{} = map[string]interface{}(claims)
for _, part := range parts {
m, ok := obj.(map[string]interface{})
if !ok {
return nil
}
obj = m[part]
}
return obj
}
18 changes: 9 additions & 9 deletions model/oauth/android.go → model/oauth/android_safety_net.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,23 @@ import (
jwt "github.com/golang-jwt/jwt/v5"
)

// checkAndroidAttestation will check an attestation made by the SafetyNet API.
// checkSafetyNetAttestation will check an attestation made by the SafetyNet API.
// Cf https://developer.android.com/training/safetynet/attestation#use-response-server
func (c *Client) checkAndroidAttestation(inst *instance.Instance, req AttestationRequest) error {
func (c *Client) checkSafetyNetAttestation(inst *instance.Instance, req AttestationRequest) error {
store := GetStore()
if ok := store.CheckAndClearChallenge(inst, c.ID(), req.Challenge); !ok {
return errors.New("invalid challenge")
}

token, err := jwt.Parse(req.Attestation, androidKeyFunc)
token, err := jwt.Parse(req.Attestation, safetyNetKeyFunc)
if err != nil {
return fmt.Errorf("cannot parse attestation: %s", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return errors.New("invalid claims type")
}
inst.Logger().Debugf("checkAndroidAttestation claims = %#v", claims)
inst.Logger().Debugf("checkSafetyNetAttestation claims = %#v", claims)

nonce, ok := claims["nonce"].(string)
if !ok || len(nonce) == 0 {
Expand All @@ -39,16 +39,16 @@ func (c *Client) checkAndroidAttestation(inst *instance.Instance, req Attestatio
return errors.New("invalid nonce")
}

if err := checkPackageName(claims); err != nil {
if err := checkSafetyNetPackageName(claims); err != nil {
return err
}
if err := checkCertificateDigest(claims); err != nil {
if err := checkSafetyNetCertificateDigest(claims); err != nil {
return err
}
return nil
}

func checkPackageName(claims jwt.MapClaims) error {
func checkSafetyNetPackageName(claims jwt.MapClaims) error {
packageName, ok := claims["apkPackageName"].(string)
if !ok || len(packageName) == 0 {
return errors.New("missing apkPackageName")
Expand All @@ -62,7 +62,7 @@ func checkPackageName(claims jwt.MapClaims) error {
return fmt.Errorf("%s is not the package name of the flagship app", packageName)
}

func checkCertificateDigest(claims jwt.MapClaims) error {
func checkSafetyNetCertificateDigest(claims jwt.MapClaims) error {
certDigest, ok := claims["apkCertificateDigestSha256"].([]interface{})
if !ok || len(certDigest) == 0 {
return errors.New("missing apkCertificateDigestSha256")
Expand All @@ -78,7 +78,7 @@ func checkCertificateDigest(claims jwt.MapClaims) error {
return errors.New("invalid certificate digest")
}

func androidKeyFunc(token *jwt.Token) (interface{}, error) {
func safetyNetKeyFunc(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
Expand Down
10 changes: 9 additions & 1 deletion model/oauth/client.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Package oauth declares the OAuth client, and things related to them, from
// the certification of the flagship app to the creation of the access codes in
// the OAuth2 flow.
package oauth

import (
Expand Down Expand Up @@ -663,6 +666,7 @@ func (c *Client) CreateChallenge(inst *instance.Instance) (string, error) {
// flagship app.
type AttestationRequest struct {
Platform string `json:"platform"`
Issuer string `json:"issuer"`
Challenge string `json:"challenge"`
Attestation string `json:"attestation"`
KeyID []byte `json:"keyId"`
Expand All @@ -673,7 +677,11 @@ func (c *Client) Attest(inst *instance.Instance, req AttestationRequest) error {
var err error
switch req.Platform {
case "android":
err = c.checkAndroidAttestation(inst, req)
if req.Issuer == "playintegrity" {
err = c.checkPlayIntegrityAttestation(inst, req)
} else {
err = c.checkSafetyNetAttestation(inst, req)
}
case "ios":
err = c.checkAppleAttestation(inst, req)
default:
Expand Down
Loading

0 comments on commit f4b4d15

Please sign in to comment.