From 2e940346a5e1a6dc41827342801d888fb812589a Mon Sep 17 00:00:00 2001 From: Bruno Michel Date: Tue, 16 Jan 2024 18:25:17 +0100 Subject: [PATCH] Allow flagship attestation from the Play Integrity API --- cmd/serve.go | 7 + cozy.example.yaml | 4 + docs/auth.md | 12 +- docs/flagship.md | 9 +- model/oauth/android_play_integrity.go | 223 ++++++++++++++++++ .../{android.go => android_safety_net.go} | 18 +- model/oauth/client.go | 10 +- model/oauth/client_test.go | 14 ++ pkg/config/config/config.go | 23 +- pkg/crypto/aes.go | 62 +++++ 10 files changed, 355 insertions(+), 27 deletions(-) create mode 100644 model/oauth/android_play_integrity.go rename model/oauth/{android.go => android_safety_net.go} (82%) diff --git a/cmd/serve.go b/cmd/serve.go index 666dc479596..bd1e4317b4c 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -1,3 +1,4 @@ +// Package cmd is where the CLI commands and options are defined. package cmd import ( @@ -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"))) diff --git a/cozy.example.yaml b/cozy.example.yaml index a5ca7f75cff..30d947f0ccb 100644 --- a/cozy.example.yaml +++ b/cozy.example.yaml @@ -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 diff --git a/docs/auth.md b/docs/auth.md index 1851e6675df..c89385a5819 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -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. @@ -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 @@ -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 diff --git a/docs/flagship.md b/docs/flagship.md index b5a6bd52977..b8297367673 100644 --- a/docs/flagship.md +++ b/docs/flagship.md @@ -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 diff --git a/model/oauth/android_play_integrity.go b/model/oauth/android_play_integrity.go new file mode 100644 index 00000000000..9cb3f0aa9bc --- /dev/null +++ b/model/oauth/android_play_integrity.go @@ -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 +} diff --git a/model/oauth/android.go b/model/oauth/android_safety_net.go similarity index 82% rename from model/oauth/android.go rename to model/oauth/android_safety_net.go index a23798af3d5..682084116c7 100644 --- a/model/oauth/android.go +++ b/model/oauth/android_safety_net.go @@ -13,15 +13,15 @@ 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) } @@ -29,7 +29,7 @@ func (c *Client) checkAndroidAttestation(inst *instance.Instance, req Attestatio 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 { @@ -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") @@ -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") @@ -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"]) } diff --git a/model/oauth/client.go b/model/oauth/client.go index c9b9fafa53d..96619bb0414 100644 --- a/model/oauth/client.go +++ b/model/oauth/client.go @@ -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 ( @@ -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"` @@ -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: diff --git a/model/oauth/client_test.go b/model/oauth/client_test.go index 361497528a6..e3d7cbb6f7a 100644 --- a/model/oauth/client_test.go +++ b/model/oauth/client_test.go @@ -363,6 +363,20 @@ func TestClient(t *testing.T) { require.False(t, reached) require.False(t, exceeded) }) + + t.Run("checkPlayIntegrityAttestation", func(t *testing.T) { + config := config.GetConfig() + config.Flagship.PlayIntegrityDecryptionKeys = []string{"bVcBAv0eO64NKIvDoRHpnTOZVxAkhMuFwRHrTEMr23U="} + config.Flagship.PlayIntegrityVerificationKeys = []string{"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElTF2uARN7oxfoDWyERYMe6QutI2NqS+CAtVmsPDIRjBBxF96fYojFVXRRsMb86PjkE21Ol+sO1YuspY+YuDRMw=="} + + req := oauth.AttestationRequest{ + Platform: "android", + Issuer: "playintegrity", + Challenge: "testtesttesttesttesttesttest", + Attestation: "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2R0NNIn0.MZIbC3rTckzCtg4rdAatQObifb3hkgJSq7-_XYTLItiCjkOyEjORlQ.-Z-6QJyEx4Bf4fNp.4vFq2XQvgQESouc5fF-oSixpYwWL2FBDzHfw1ay8nHmCXAgYfJ1yRPJm09dvJWJ5Iez4-HvfRWkwstZ4gtGYr4SX42h7L0vWkcv8yJ-12X9kUAFM_7ylpBLWiDEHnd0SeqpSeiAut_XXD81A_SncaenicMzDi0QKqeD6bdAkY67h46hnuyektYU4AsK9nVRPStaEfNiREJ017PuRVP3JQZVk4vAvg0jMfdY3BnaQ3AiEMb6uredrgP29gIIs0mGwcvc7ONyVRZ4_gSDSmfqKBjG-7HuC_rmC9CL2cUoz_JRxY0njvJi7isyfoTVZMyI4TKbUQckTKvv1Ysv11FxlTVsQqmOkVKtHOemS-G9ji23rq-LcGHG1DyriNqd3aFjMD6s1p5tFpxg7Eyc3pEm4f1Ig4S-sOC6BsTjqM_cNyqCuNbfwtQSE1pnh7yI7pcsfLPRisoODng0wTYXAqA4mvATf60eKSrPGb6vD47owlV-CbxLkG3PpVhjIpLIGknFSJnkzeIdgTR5XWUsQKVJ6ppW4mq8tO_C4KNHNISKimUhmFekG1w1rZ_suAvaC5Oz6NKn4iVMXpNm3N8nuBCkwbenN_A7334rSMHS12Ye1QRiH54VuUksUmzeUiFxaubkEJGVHwxYDN_lwQZ7bzSZbMfW46_-rK98SC3JNkif4Ucdl52fWY8Mpaf41PYGv6H7QAnY94wkAZGJPmaCzicDs5UbAiCI.fqVFSJEaY7GiqCga4-CMuw", + } + require.NoError(t, oauth.CheckPlayIntegrityAttestationForTestingPurpose(req)) + }) } func assertClientsLimitAlertMailWasNotSent(t *testing.T, instance *instance.Instance) { diff --git a/pkg/config/config/config.go b/pkg/config/config/config.go index 8a3c7adcb4c..ecfbccb6bf5 100644 --- a/pkg/config/config/config.go +++ b/pkg/config/config/config.go @@ -1,3 +1,6 @@ +// Package config is where the configuration from the configuration files, the +// command line parameters, and the environment variables is used to fill some +// structs, and initializes connections (to Swift for example). package config import ( @@ -242,10 +245,12 @@ type Notifications struct { // Flagship contains the configuration for the flagship app. type Flagship struct { - Contexts map[string]interface{} - APKPackageNames []string - APKCertificateDigests []string - AppleAppIDs []string + Contexts map[string]interface{} + APKPackageNames []string + APKCertificateDigests []string + PlayIntegrityDecryptionKeys []string + PlayIntegrityVerificationKeys []string + AppleAppIDs []string } // SMS contains the configuration to send notifications by SMS. @@ -810,10 +815,12 @@ func UseViper(v *viper.Viper) error { Contexts: makeSMS(v.GetStringMap("notifications.contexts")), }, Flagship: Flagship{ - Contexts: v.GetStringMap("flagship.contexts"), - APKPackageNames: v.GetStringSlice("flagship.apk_package_names"), - APKCertificateDigests: v.GetStringSlice("flagship.apk_certificate_digests"), - AppleAppIDs: v.GetStringSlice("flagship.apple_app_ids"), + Contexts: v.GetStringMap("flagship.contexts"), + APKPackageNames: v.GetStringSlice("flagship.apk_package_names"), + APKCertificateDigests: v.GetStringSlice("flagship.apk_certificate_digests"), + PlayIntegrityDecryptionKeys: v.GetStringSlice("flagship.play_integrity_decryption_keys"), + PlayIntegrityVerificationKeys: v.GetStringSlice("flagship.play_integrity_verification_keys"), + AppleAppIDs: v.GetStringSlice("flagship.apple_app_ids"), }, Lock: lock.New(lockRedis), SessionStorage: sessionsRedis, diff --git a/pkg/crypto/aes.go b/pkg/crypto/aes.go index 219585fe1f3..2ffefc115b7 100644 --- a/pkg/crypto/aes.go +++ b/pkg/crypto/aes.go @@ -5,7 +5,10 @@ import ( "crypto/cipher" "crypto/hmac" "crypto/sha256" + "crypto/subtle" "encoding/base64" + "encoding/binary" + "errors" ) func addPadding(payload []byte) []byte { @@ -71,3 +74,62 @@ func EncryptWithAES256HMAC(encKey, macKey, payload, iv []byte) (string, error) { cipherString := "2." + iv64 + "|" + dst64 + "|" + h64 return cipherString, nil } + +// UnwrapA256KW decrypts the provided cipher text with the given AES cipher +// (and corresponding key), using the AES Key Wrap algorithm (RFC-3394). The +// decrypted cipher text is verified using the default IV and will return an +// error if validation fails. +// +// Taken from https://github.com/NickBall/go-aes-key-wrap/blob/master/keywrap.go +func UnwrapA256KW(block cipher.Block, cipherText []byte) ([]byte, error) { + a := make([]byte, 8) + n := (len(cipherText) / 8) - 1 + + r := make([][]byte, n) + for i := range r { + r[i] = make([]byte, 8) + copy(r[i], cipherText[(i+1)*8:]) + } + copy(a, cipherText[:8]) + + for j := 5; j >= 0; j-- { + for i := n; i >= 1; i-- { + t := (n * j) + i + tBytes := make([]byte, 8) + binary.BigEndian.PutUint64(tBytes, uint64(t)) + + b := arrConcat(arrXor(a, tBytes), r[i-1]) + block.Decrypt(b, b) + + copy(a, b[:len(b)/2]) + copy(r[i-1], b[len(b)/2:]) + } + } + + if subtle.ConstantTimeCompare(a, defaultIV) != 1 { + return nil, errors.New("integrity check failed - unexpected IV") + } + + c := arrConcat(r...) + return c, nil +} + +// defaultIV as specified in RFC-3394 +var defaultIV = []byte{0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6} + +func arrConcat(arrays ...[]byte) []byte { + out := make([]byte, len(arrays[0])) + copy(out, arrays[0]) + for _, array := range arrays[1:] { + out = append(out, array...) + } + return out +} + +func arrXor(arrL []byte, arrR []byte) []byte { + out := make([]byte, len(arrL)) + for x := range arrL { + out[x] = arrL[x] ^ arrR[x] + } + return out +}