Certificate-Only Browser Enforcement with Android TEE by 1311-hack1 · Pull Request #373 · wootzapp/wootz-browser

8 min read Original article ↗

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
High-level
Eliminate server-supplied private keys

The current implementation incorrectly accepts a private key from the enrollment
server, which contradicts the security goal of using non-exportable hardware
keys. The suggestion is to remove the handling of server-provided private keys.
Instead, the client should only store the certificate chain after verifying that
the certificate's public key matches the public key of the hardware-generated
key. The WootzKeyAssociation logic should also be updated to enforce this
cryptographic link.

Examples:

src/net/android/java/src/org/chromium/net/WootzDeviceEnrollment.java [301-309]
            String dicCertificate = WootzEnrollmentUtils.extractJsonValue(response, "dicCertificate");
            String dicPrivateKey = WootzEnrollmentUtils.extractJsonValue(response, "dicPrivateKey");
            String expiresAt = WootzEnrollmentUtils.extractJsonValue(response, "expiresAt");
            String issuedAt = WootzEnrollmentUtils.extractJsonValue(response, "issuedAt");
            String stepCaUrl = WootzEnrollmentUtils.extractJsonValue(response, "stepCaUrl");
            
            // Store the DIC certificate and associate it with the hardware key
            boolean dicStored = WootzHardwareKeyStore.storeDicCertificate(
                deviceId, dicCertificate, dicPrivateKey, expiresAt, issuedAt, stepCaUrl);
src/net/android/java/src/org/chromium/net/WootzHardwareKeyStore.java [432-471]
    public static boolean storeDicCertificate(String deviceId, String dicCertificatePem, 
            String dicPrivateKeyPem, String expiresAt, String issuedAt, String stepCaUrl) {
        
        try {
            // Parse and validate the DIC certificate
            X509Certificate dicCert = WootzCertificateUtils.parsePemCertificate(dicCertificatePem);
            if (dicCert == null) {
                return false;
            }
            

 ... (clipped 30 lines)

Solution Walkthrough:

Before:

// In WootzDeviceEnrollment.java
handleEnrollmentSuccessResponse(String response) {
    // ...
    String dicPrivateKey = extractJsonValue(response, "dicPrivateKey");
    // ...
    WootzHardwareKeyStore.storeDicCertificate(
        deviceId, dicCertificate, dicPrivateKey, ...);
}

// In WootzHardwareKeyStore.java
public static boolean storeDicCertificate(String deviceId, String dicCertificatePem, 
        String dicPrivateKeyPem, ...) {
    // ...
    PrivateKey dicPrivateKey = WootzCertificateUtils.parsePemPrivateKey(dicPrivateKeyPem);
    // ...
    keyStore.setKeyEntry(WOOTZ_DIC_ALIAS, dicPrivateKey, null, certChain);
    // ...
}

After:

// In WootzDeviceEnrollment.java
handleEnrollmentSuccessResponse(String response) {
    // ...
    // String dicPrivateKey = ...; // REMOVED
    // ...
    WootzHardwareKeyStore.storeDicCertificate(
        deviceId, dicCertificate, ...); // No private key passed
}

// In WootzHardwareKeyStore.java
public static boolean storeDicCertificate(String deviceId, String dicCertificatePem, ...) {
    // ...
    // PrivateKey dicPrivateKey = ...; // REMOVED
    // ...
    // Verify the public key in `dicCert` matches the public key of `WOOTZ_KEY_ALIAS`
    PublicKey hardwarePubKey = keyStore.getCertificate(WOOTZ_KEY_ALIAS).getPublicKey();
    if (!hardwarePubKey.equals(dicCert.getPublicKey())) {
        return false; // Public keys do not match!
    }

    // Store only the certificate chain against the hardware key alias
    keyStore.setKeyEntry(WOOTZ_KEY_ALIAS, getPrivateKeyInternal(), null, certChain);
    // ...
}
Suggestion importance[1-10]: 10

__

Why: The suggestion correctly identifies a critical security flaw where a server-provided private key is imported, completely undermining the non-exportable TEE key guarantee and the entire attestation-based security model.

High
Possible issue
Use matching key for mTLS

The TLS handshake signature must be verifiable with the certificate presented to
the server. Currently the code signs with the hardware key while presenting the
DIC certificate, causing verification failure. Sign the handshake using the DIC
private key instead, keeping the hardware key for attestation/binding only.

src/net/android/java/src/org/chromium/net/WootzHardwareKeyStore.java [697-721]

 @CalledByNative
 private static byte[] signMTLSHandshake(byte[] handshakeData) {
     try {
         if (!isDicAvailableForMTLS()) {
             Log.e(TAG, "DIC not available for mTLS handshake signing");
             return null;
         }
-        
-        // Use the hardware key to sign the handshake data
-        // This ensures the private key never leaves the TEE/Strongbox
-        byte[] signature = signWithHardwareKey(handshakeData);
-        
+
+        // Sign using the DIC private key so the signature matches the presented certificate.
+        byte[] signature = signWithDicKey(handshakeData);
+
         if (signature != null) {
-            Log.i(TAG, "Successfully signed mTLS handshake with hardware key");
+            Log.i(TAG, "Successfully signed mTLS handshake with DIC private key");
         } else {
-            Log.e(TAG, "Failed to sign mTLS handshake with hardware key");
+            Log.e(TAG, "Failed to sign mTLS handshake with DIC private key");
         }
-        
+
         return signature;
-        
+
     } catch (Exception e) {
         Log.e(TAG, "Error during mTLS handshake signing", e);
         return null;
     }
 }
  • Apply / Chat
Suggestion importance[1-10]: 10

__

Why: This suggestion correctly identifies a critical bug where the mTLS handshake is signed with the hardware attestation key instead of the DIC private key, which would cause all mTLS authentications to fail.

High
Enforce supported TLS algorithms

Only advertise and accept algorithms actually supported by the presented key.
Since the implementation signs with an ECDSA P-256 key, remove RSA and P-384 and
reject unsupported algorithms in Sign() to prevent TLS negotiation failures.

src/chrome/browser/wootz_client_cert_identity.cc [38-69]

 std::vector<uint16_t> GetAlgorithmPreferences() override {
-  // Support common signature algorithms for hardware keys
+  // Only advertise the algorithm we actually support with the DIC key.
   return {
     SSL_SIGN_ECDSA_SECP256R1_SHA256,
-    SSL_SIGN_ECDSA_SECP384R1_SHA384,
-    SSL_SIGN_RSA_PKCS1_SHA256,
-    SSL_SIGN_RSA_PKCS1_SHA384,
   };
 }
 
 void Sign(uint16_t algorithm,
           base::span<const uint8_t> input,
           SignCallback callback) override {
   DVLOG(1) << "WootzSSLPrivateKey::Sign called";
-  
+
 #if BUILDFLAG(IS_ANDROID)
-  // Delegate directly to our existing hardware signing function
+  if (algorithm != SSL_SIGN_ECDSA_SECP256R1_SHA256) {
+    LOG(ERROR) << "Unsupported signature algorithm: " << algorithm;
+    std::move(callback).Run(net::ERR_SSL_CLIENT_AUTH_SIGNATURE_FAILED, std::vector<uint8_t>());
+    return;
+  }
+
+  // Delegate to mTLS signer (now using DIC key on the Java side).
   std::vector<uint8_t> signature = net::android::wootz::SignMTLSHandshake(input);
-  
   if (!signature.empty()) {
-    DVLOG(1) << "Wootz hardware key signing successful";
+    DVLOG(1) << "Wootz key signing successful";
     std::move(callback).Run(net::OK, std::move(signature));
   } else {
-    LOG(ERROR) << "Wootz hardware key signing failed";
-    std::move(callback).Run(net::ERR_SSL_CLIENT_AUTH_SIGNATURE_FAILED, 
-                           std::vector<uint8_t>());
+    LOG(ERROR) << "Wootz key signing failed";
+    std::move(callback).Run(net::ERR_SSL_CLIENT_AUTH_SIGNATURE_FAILED, std::vector<uint8_t>());
   }
 #else
   LOG(ERROR) << "Wootz hardware key only supported on Android";
   std::move(callback).Run(net::ERR_NOT_IMPLEMENTED, std::vector<uint8_t>());
 #endif
 }
  • Apply / Chat
Suggestion importance[1-10]: 9

__

Why: The suggestion correctly points out that the code advertises unsupported signature algorithms, which could lead to TLS negotiation failures, and provides a robust fix by restricting advertised algorithms and verifying the selected one.

High
Fix hardware backing detection

On Android < S, both methods currently return false, falsely treating TEE-backed
keys as non-hardware and disabling mTLS. Use KeyInfo.isInsideSecureHardware() as
a fallback on API ≥ M to correctly detect TEE-backed keys; keep StrongBox
detection to API ≥ S.

src/net/android/java/src/org/chromium/net/WootzHardwareKeyStore.java [305-356]

 private static boolean isStrongBoxBacked(PrivateKey privateKey) {
-    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // API 31+ for getSecurityLevel in KeyInfo
+    // StrongBox security level is only available on API 31+.
+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || privateKey == null) {
         return false;
     }
-    
     try {
-        // Use the public KeyInfo API to get security level
         KeyFactory factory = KeyFactory.getInstance(privateKey.getAlgorithm(), ANDROID_KEYSTORE);
         KeyInfo keyInfo = factory.getKeySpec(privateKey, KeyInfo.class);
-        
         int securityLevel = keyInfo.getSecurityLevel();
         return securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX;
-        
-    } catch (InvalidKeySpecException e) {
-        Log.w(TAG, "Key does not expose KeyInfo for Strongbox check", e);
-        return false;
     } catch (Exception e) {
         Log.w(TAG, "Could not determine Strongbox backing: " + e.getMessage());
         return false;
     }
 }
 
 private static boolean isHardwareBacked(PrivateKey privateKey) {
     if (privateKey == null) {
         return false;
     }
-    
-    // Check if it's AndroidKeyStore key
+    // Must be an AndroidKeyStore key.
     String className = privateKey.getClass().getName();
     if (!className.contains("AndroidKeyStore")) {
         return false;
     }
-    
-    // For API 31+, use KeyInfo.getSecurityLevel() - the proper public API
-    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-        try {
-            KeyFactory factory = KeyFactory.getInstance(privateKey.getAlgorithm(), ANDROID_KEYSTORE);
-            KeyInfo keyInfo = factory.getKeySpec(privateKey, KeyInfo.class);
-            
+    try {
+        KeyFactory factory = KeyFactory.getInstance(privateKey.getAlgorithm(), ANDROID_KEYSTORE);
+        KeyInfo keyInfo = factory.getKeySpec(privateKey, KeyInfo.class);
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
             int securityLevel = keyInfo.getSecurityLevel();
-            return securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX || 
-                   securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT;
-                   
-        } catch (InvalidKeySpecException e) {
-            Log.w(TAG, "Key does not expose KeyInfo for hardware backing check", e);
-        } catch (Exception e) {
-            Log.w(TAG, "Could not determine security level: " + e.getMessage());
+            return securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX
+                    || securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT;
         }
+        // Fallback for API 23–30: detect TEE via isInsideSecureHardware().
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+            return keyInfo.isInsideSecureHardware();
+        }
+    } catch (Exception e) {
+        Log.w(TAG, "Could not determine hardware backing: " + e.getMessage());
     }
-
     return false;
 }
  • Apply / Chat
Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies that hardware-backing detection fails on Android versions older than S (API 31), which would incorrectly disable the feature, and proposes a valid fallback using isInsideSecureHardware() for older APIs.

High
Export keystore API symbols

Export these functions from the net component to avoid unresolved symbols in
component builds. Include the export header and annotate the public API with
NET_EXPORT.

src/net/android/wootz_keystore.h [31-124]

 #include "base/android/scoped_java_ref.h"
 #include "base/containers/span.h"
+#include "net/base/net_export.h"
 
 // Wootz hardware keystore functions for secure key operations.
 // These functions provide access to hardware-backed keys (Strongbox/TEE)
 // with proper attestation support for maximum security.
 
 namespace net::android::wootz {
 
-bool InitializeHardwareKeystore();
-bool GenerateHardwareBackedKeyWithAttestation(
+NET_EXPORT bool InitializeHardwareKeystore();
+NET_EXPORT bool GenerateHardwareBackedKeyWithAttestation(
     base::span<const uint8_t> attestation_challenge);
-std::string GetDevicePublicKeyPem();
-std::string GetAttestationChainPem();
-bool IsKeyStrongboxBacked();
-bool IsKeyHardwareBacked();
-std::vector<uint8_t> SignWithHardwareKey(base::span<const uint8_t> data);
+NET_EXPORT std::string GetDevicePublicKeyPem();
+NET_EXPORT std::string GetAttestationChainPem();
+NET_EXPORT bool IsKeyStrongboxBacked();
+NET_EXPORT bool IsKeyHardwareBacked();
+NET_EXPORT std::vector<uint8_t> SignWithHardwareKey(base::span<const uint8_t> data);
 ...

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 9

__

Why: This is a critical fix for component builds, as the new functions in the net component must be exported with NET_EXPORT to be visible to other components, preventing linker errors.

High
  • Update