Move SSD pin equality checks to typed Kotlin
ober
6ca04b50ef969a50bdd12848f23a23afc0432649
--- a/templates/ssd-review.ss +++ b/templates/ssd-review.ss @@ -1616,6 +1616,7 @@ (java net URL) (java nio file Files) (java nio file Path) + (java security MessageDigest) (java util zip ZipEntry)) (typed-library (com sfb ssdreview) (export make-RemoteConfig RemoteConfig? @@ -1644,6 +1645,7 @@ byteArraySizeBytes byteArrayBytesWithinLimit textUtf8Bytes textUtf8SizeBytes textUtf8BytesWithinLimit oldDestinationBytes urlEffectivePort + constantTimeBytesEqual constantTimeNullableBytesEqual remoteBearerTokenPresent remoteBearerTokenValid remoteEndpointIsHttps safeRemoteUrlParts remotePinPresent remotePinOrNull remotePinSha256Length @@ -1674,6 +1676,8 @@ (kotlin-member-get size)) (extern (base64Decode (text : String) (flags : Int32)) : Bytes (kotlin-call android util Base64 decode)) + (extern (messageDigestIsEqual (actual : Bytes) (expected : Bytes)) : Bool + (kotlin-call java security MessageDigest isEqual)) (extern (urlPort (url : URL)) : Int32 (kotlin-member-get port)) (extern (urlDefaultPort (url : URL)) : Int32 @@ -1805,6 +1809,13 @@ (def (urlEffectivePort (url : URL)) : Int32 (let ((port (urlPort url))) (if (>= port (int32 0)) port (urlDefaultPort url)))) + (def (constantTimeBytesEqual (actual : Bytes) (expected : Bytes)) : Bool + (messageDigestIsEqual actual expected)) + (def (constantTimeNullableBytesEqual (actual : Bytes) + (expected : (Nullable Bytes))) : Bool + (if (nullable-null? expected) + #f + (constantTimeBytesEqual actual (nullable-get expected)))) (def (remoteEndpointIsHttps (protocol : String) (host : String)) : Bool (and (equal? protocol "https") (not (string-blank? host)))) (def (safeRemoteUrlParts (userInfo : (Nullable String)) @@ -8446,7 +8457,7 @@ " val certificate = connection.serverCertificates.firstOrNull()" " ?: throw SSLPeerUnverifiedException(\"Server provided no certificate\")" " val actualPin = MessageDigest.getInstance(\"SHA-256\").digest(certificate.publicKey.encoded)" - " if (!MessageDigest.isEqual(actualPin, config.spkiSha256)) {" + " if (!constantTimeNullableBytesEqual(actualPin, config.spkiSha256)) {" " connection.disconnect()" " throw SSLPeerUnverifiedException(\"SSD service identity pin mismatch\")" " }" @@ -8470,7 +8481,7 @@ " platform.checkServerTrusted(chain, authType)" " val leaf = chain.firstOrNull() ?: throw CertificateException(\"Server provided no certificate\")" " val actual = MessageDigest.getInstance(\"SHA-256\").digest(leaf.publicKey.encoded)" - " if (!MessageDigest.isEqual(actual, expectedPin)) {" + " if (!constantTimeBytesEqual(actual, expectedPin)) {" " throw CertificateException(\"SSD service identity pin mismatch\")" " }" " }" --- a/tests/ssd-security-test.sh +++ b/tests/ssd-security-test.sh @@ -12,9 +12,12 @@ cd "$repo" JERBOA=${JERBOA:-jerboa} $JERBOA jandroid.ss generate tests/fixtures/ssd-app.ss "$tmp/project" \ >"$tmp/generate.log" 2>&1 -source_file="$tmp/project/app/src/main/java/com/sfb/ssdreview/TruthStore.kt" +source_dir="$tmp/project/app/src/main/java/com/sfb/ssdreview" +source_file="$source_dir/TruthStore.kt" +all_sources="$tmp/all-kotlin-sources.txt" manifest="$tmp/project/app/src/main/AndroidManifest.xml" test -f "$source_file" +find "$source_dir" -name '*.kt' -type f -exec cat {} + >"$all_sources" cmp supply-chain/gradle-verification-metadata.xml \ "$tmp/project/gradle/verification-metadata.xml" if grep -Fq 'android:usesCleartextTraffic="true"' "$manifest"; then @@ -29,6 +32,13 @@ require_text() { } } +require_generated_text() { + grep -Fq "$1" "$all_sources" || { + printf '%s\n' "missing SSD generated security control: $1" >&2 + exit 1 + } +} + reject_text() { if grep -Fq "$1" "$source_file"; then printf '%s\n' "forbidden SSD API or transport: $1" >&2 @@ -38,7 +48,7 @@ reject_text() { require_text 'HttpsURLConnection' require_text 'X509TrustManager' -require_text 'MessageDigest.isEqual' +require_generated_text 'MessageDigest.isEqual' require_text 'setRequestProperty("Authorization", bearerAuthorizationHeader(config.bearerToken))' require_text 'context.getSharedPreferences("ssd_review_remote", Context.MODE_PRIVATE)' require_text 'Remote SSD service is not locally configured'