commit 8b5efed0a1f1597961c4fb8f11c8f7711197c365 parent 64e33d4d18c6fcc3426b3ae068eb0efd25c80a21 Author: triesap <tyson@radroots.org> Date: Sat, 21 Mar 2026 04:39:44 +0000 build: add shared android security library - add the `RadRootsAndroidSecurity` Kotlin library under `native/android/kotlin` - add Android Keystore secret storage, policy models, storage paths, and bridge entrypoints for later Rust integration - link the Android host project to the shared security library and initialize it from `MainActivity` - add Android library tests and ignore rules for local Gradle and build artifacts Diffstat:
15 files changed, 820 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore @@ -4,6 +4,9 @@ /crates/web/dist/ /native/apple/swift/**/.build/ /native/apple/swift/**/.swiftpm/ +/native/android/kotlin/**/.gradle/ +/native/android/kotlin/**/build/ +/native/android/kotlin/**/local.properties /platforms/android/.gradle/ /platforms/android/.tooling/ /platforms/android/app/build/ diff --git a/native/android/kotlin/RadRootsAndroidSecurity/build.gradle.kts b/native/android/kotlin/RadRootsAndroidSecurity/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "org.radroots.app.android.security" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + testOptions { + unitTests.isIncludeAndroidResources = false + } +} + +dependencies { + testImplementation("junit:junit:4.13.2") +} diff --git a/native/android/kotlin/RadRootsAndroidSecurity/consumer-rules.pro b/native/android/kotlin/RadRootsAndroidSecurity/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/native/android/kotlin/RadRootsAndroidSecurity/settings.gradle.kts b/native/android/kotlin/RadRootsAndroidSecurity/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(org.gradle.api.initialization.resolve.RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "RadRootsAndroidSecurity" diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/AndroidManifest.xml b/native/android/kotlin/RadRootsAndroidSecurity/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" /> diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeySecurityLevel.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeySecurityLevel.kt @@ -0,0 +1,73 @@ +package org.radroots.app.android.security + +import android.os.Build +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties + +internal enum class RadRootsAndroidKeySecurityLevel { + STRONGBOX, + TRUSTED_ENVIRONMENT, + SOFTWARE_OR_UNKNOWN, +} + +internal object RadRootsAndroidKeySecurityLevels { + fun fromKeyInfo(keyInfo: KeyInfo): RadRootsAndroidKeySecurityLevel { + return fromPlatformValues( + sdkInt = Build.VERSION.SDK_INT, + securityLevel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + keyInfo.securityLevel + } else { + null + }, + isInsideSecureHardware = isInsideSecureHardwareFallback(keyInfo), + ) + } + + fun fromPlatformValues( + sdkInt: Int, + securityLevel: Int?, + isInsideSecureHardware: Boolean, + ): RadRootsAndroidKeySecurityLevel { + if (sdkInt >= Build.VERSION_CODES.S && securityLevel != null) { + return when (securityLevel) { + KeyProperties.SECURITY_LEVEL_STRONGBOX -> RadRootsAndroidKeySecurityLevel.STRONGBOX + KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT, + KeyProperties.SECURITY_LEVEL_UNKNOWN_SECURE, + -> RadRootsAndroidKeySecurityLevel.TRUSTED_ENVIRONMENT + else -> RadRootsAndroidKeySecurityLevel.SOFTWARE_OR_UNKNOWN + } + } + + return if (isInsideSecureHardware) { + RadRootsAndroidKeySecurityLevel.TRUSTED_ENVIRONMENT + } else { + RadRootsAndroidKeySecurityLevel.SOFTWARE_OR_UNKNOWN + } + } + + @Suppress("DEPRECATION") + private fun isInsideSecureHardwareFallback(keyInfo: KeyInfo): Boolean { + return keyInfo.isInsideSecureHardware + } +} + +internal fun shouldRequestStrongBox( + policy: RadRootsAndroidSecretAccessPolicy, + sdkInt: Int, + hasStrongBoxFeature: Boolean, +): Boolean { + return policy.preferStrongBox && + sdkInt >= Build.VERSION_CODES.P && + hasStrongBoxFeature +} + +internal fun acceptsStrongBoxVerificationResult( + sdkInt: Int, + securityLevel: RadRootsAndroidKeySecurityLevel, +): Boolean { + return when (securityLevel) { + RadRootsAndroidKeySecurityLevel.STRONGBOX -> true + RadRootsAndroidKeySecurityLevel.TRUSTED_ENVIRONMENT -> sdkInt < Build.VERSION_CODES.S + RadRootsAndroidKeySecurityLevel.SOFTWARE_OR_UNKNOWN -> false + } +} diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeystoreSecretStore.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeystoreSecretStore.kt @@ -0,0 +1,319 @@ +package org.radroots.app.android.security + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import java.io.File +import java.nio.ByteBuffer +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec + +class RadRootsAndroidKeystoreSecretStore( + private val context: Context, +) { + fun putSecret( + servicePrefix: String, + namespace: String, + name: String, + value: ByteArray, + policy: RadRootsAndroidSecretAccessPolicy, + ) { + validateIdentifiers(servicePrefix, namespace, name) + requireSupportedPolicy(policy) + val key = getOrCreateKey(masterKeyAlias(servicePrefix, namespace), policy) + val cipher = Cipher.getInstance(cipherTransformation) + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val ciphertext = cipher.doFinal(value) + val target = RadRootsAndroidStoragePaths.secretFile(context, servicePrefix, namespace, name) + writeSecretFile(target, encodeSecretBlob(iv, ciphertext)) + } + + fun getSecret( + servicePrefix: String, + namespace: String, + name: String, + ): ByteArray? { + validateIdentifiers(servicePrefix, namespace, name) + val target = RadRootsAndroidStoragePaths.secretFile(context, servicePrefix, namespace, name) + if (!target.exists()) { + return null + } + val secretBlob = readSecretFile(target) + val (iv, ciphertext) = decodeSecretBlob(secretBlob) + val cipher = Cipher.getInstance(cipherTransformation) + cipher.init( + Cipher.DECRYPT_MODE, + getOrCreateKey( + masterKeyAlias(servicePrefix, namespace), + RadRootsAndroidSecretAccessPolicy.SECURE_LOCAL_SECRET, + ), + GCMParameterSpec(gcmTagBits, iv), + ) + return try { + cipher.doFinal(ciphertext) + } catch (cause: Throwable) { + throw RadRootsAndroidSecurityError.KeystoreFailure( + "failed to decrypt secret", + cause, + ) + } + } + + fun deleteSecret( + servicePrefix: String, + namespace: String, + name: String, + ) { + validateIdentifiers(servicePrefix, namespace, name) + val target = RadRootsAndroidStoragePaths.secretFile(context, servicePrefix, namespace, name) + if (!target.exists()) { + return + } + if (!target.delete()) { + throw RadRootsAndroidSecurityError.StorageFailure("failed to delete encrypted secret file") + } + } + + fun resolveNostrStorageRoot(): File = RadRootsAndroidStoragePaths.nostrRoot(context) + + private fun validateIdentifiers(servicePrefix: String, namespace: String, name: String) { + if (servicePrefix.isBlank()) { + throw RadRootsAndroidSecurityError.InvalidInput("service prefix must not be blank") + } + if (namespace.isBlank()) { + throw RadRootsAndroidSecurityError.InvalidInput("namespace must not be blank") + } + if (name.isBlank()) { + throw RadRootsAndroidSecurityError.InvalidInput("name must not be blank") + } + } + + private fun requireSupportedPolicy(policy: RadRootsAndroidSecretAccessPolicy) { + if (!policy.deviceLocalOnly) { + throw RadRootsAndroidSecurityError.InvalidInput( + "android security store supports only device-local secrets", + ) + } + } + + private fun masterKeyAlias(servicePrefix: String, namespace: String): String = + "org.radroots.app.android.security.v1.${RadRootsAndroidStoragePaths.secretFileId(servicePrefix, namespace, "master")}" + + private fun getOrCreateKey( + alias: String, + policy: RadRootsAndroidSecretAccessPolicy, + ): SecretKey { + val keyStore = KeyStore.getInstance(androidKeystoreProvider).apply { load(null) } + val existing = keyStore.getKey(alias, null) + if (existing is SecretKey) { + return existing + } + return createKey(alias, policy) + } + + private fun createKey( + alias: String, + policy: RadRootsAndroidSecretAccessPolicy, + ): SecretKey { + val requestStrongBox = shouldRequestStrongBox( + policy = policy, + sdkInt = Build.VERSION.SDK_INT, + hasStrongBoxFeature = canRequestStrongBox(), + ) + + return try { + val generated = generateKey(alias, policy, requestStrongBox = requestStrongBox) + if (requestStrongBox && !isAcceptableStrongBoxResult(generated.securityLevel)) { + deleteKey(alias) + return generateKey(alias, policy, requestStrongBox = false).key + } + generated.key + } catch (cause: StrongBoxUnavailableException) { + if (!requestStrongBox) { + throw keystoreFailure(cause) + } + deleteKey(alias) + generateKey(alias, policy, requestStrongBox = false).key + } catch (cause: Throwable) { + throw keystoreFailure(cause) + } + } + + private fun generateKey( + alias: String, + policy: RadRootsAndroidSecretAccessPolicy, + requestStrongBox: Boolean, + ): AndroidKeyCreationResult { + val builder = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + + if (policy.userPresenceRequired) { + builder.setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + 0, + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL, + ) + } + } + + if (requestStrongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(true) + } + + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + androidKeystoreProvider, + ) + keyGenerator.init(builder.build()) + val key = keyGenerator.generateKey() + return AndroidKeyCreationResult( + key = key, + securityLevel = resolveKeySecurityLevel(key), + ) + } + + private fun writeSecretFile(target: File, encoded: ByteArray) { + val parent = target.parentFile + ?: throw RadRootsAndroidSecurityError.StorageFailure("secret file has no parent directory") + if (!parent.exists() && !parent.mkdirs()) { + throw RadRootsAndroidSecurityError.StorageFailure("failed to create secret directory") + } + val temp = File(parent, "${target.name}.tmp") + try { + temp.writeBytes(encoded) + try { + Files.move( + temp.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temp.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + } catch (cause: Throwable) { + temp.delete() + throw RadRootsAndroidSecurityError.StorageFailure( + "failed to write encrypted secret file", + cause, + ) + } + } + + private fun readSecretFile(target: File): ByteArray { + return try { + target.readBytes() + } catch (cause: Throwable) { + throw RadRootsAndroidSecurityError.StorageFailure( + "failed to read encrypted secret file", + cause, + ) + } + } + + private fun encodeSecretBlob(iv: ByteArray, ciphertext: ByteArray): ByteArray { + val buffer = ByteBuffer.allocate(1 + Int.SIZE_BYTES + iv.size + ciphertext.size) + buffer.put(secretBlobVersion) + buffer.putInt(iv.size) + buffer.put(iv) + buffer.put(ciphertext) + return buffer.array() + } + + private fun decodeSecretBlob(blob: ByteArray): Pair<ByteArray, ByteArray> { + try { + val buffer = ByteBuffer.wrap(blob) + val version = buffer.get() + if (version != secretBlobVersion) { + throw RadRootsAndroidSecurityError.StorageFailure("unsupported encrypted secret version") + } + val ivLength = buffer.int + if (ivLength <= 0 || ivLength > buffer.remaining()) { + throw RadRootsAndroidSecurityError.StorageFailure("invalid encrypted secret iv length") + } + val iv = ByteArray(ivLength) + buffer.get(iv) + val ciphertext = ByteArray(buffer.remaining()) + buffer.get(ciphertext) + return iv to ciphertext + } catch (error: RadRootsAndroidSecurityError.StorageFailure) { + throw error + } catch (cause: Throwable) { + throw RadRootsAndroidSecurityError.StorageFailure( + "failed to decode encrypted secret file", + cause, + ) + } + } + + private fun canRequestStrongBox(): Boolean { + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + } + + private fun isAcceptableStrongBoxResult( + securityLevel: RadRootsAndroidKeySecurityLevel, + ): Boolean { + return acceptsStrongBoxVerificationResult( + sdkInt = Build.VERSION.SDK_INT, + securityLevel = securityLevel, + ) + } + + private fun resolveKeySecurityLevel(key: SecretKey): RadRootsAndroidKeySecurityLevel { + val keyFactory = SecretKeyFactory.getInstance(key.algorithm, androidKeystoreProvider) + val keyInfo = keyFactory.getKeySpec(key, KeyInfo::class.java) as KeyInfo + return RadRootsAndroidKeySecurityLevels.fromKeyInfo(keyInfo) + } + + private fun deleteKey(alias: String) { + val keyStore = KeyStore.getInstance(androidKeystoreProvider).apply { load(null) } + if (keyStore.containsAlias(alias)) { + keyStore.deleteEntry(alias) + } + } + + private fun keystoreFailure(cause: Throwable): RadRootsAndroidSecurityError.KeystoreFailure { + return when (cause) { + is RadRootsAndroidSecurityError.KeystoreFailure -> cause + else -> RadRootsAndroidSecurityError.KeystoreFailure( + "failed to create keystore secret key", + cause, + ) + } + } + + private companion object { + const val androidKeystoreProvider = "AndroidKeyStore" + const val cipherTransformation = "AES/GCM/NoPadding" + const val gcmTagBits = 128 + const val secretBlobVersion: Byte = 1 + } +} + +private data class AndroidKeyCreationResult( + val key: SecretKey, + val securityLevel: RadRootsAndroidKeySecurityLevel, +) diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecretAccessPolicy.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecretAccessPolicy.kt @@ -0,0 +1,15 @@ +package org.radroots.app.android.security + +data class RadRootsAndroidSecretAccessPolicy( + val deviceLocalOnly: Boolean, + val userPresenceRequired: Boolean, + val preferStrongBox: Boolean, +) { + companion object { + val SECURE_LOCAL_SECRET = RadRootsAndroidSecretAccessPolicy( + deviceLocalOnly = true, + userPresenceRequired = false, + preferStrongBox = true, + ) + } +} diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt @@ -0,0 +1,120 @@ +package org.radroots.app.android.security + +import android.content.Context + +object RadRootsAndroidSecurityBridge { + const val STATUS_SUCCESS = 0 + const val STATUS_NOT_FOUND = 1 + const val STATUS_INVALID_INPUT = 2 + const val STATUS_ERROR = 3 + + @Volatile + private var applicationContext: Context? = null + + @Volatile + private var lastErrorMessage: String? = null + + @JvmStatic + fun initialize(context: Context) { + applicationContext = context.applicationContext + clearError() + } + + @JvmStatic + fun putSecret( + servicePrefix: String, + namespace: String, + name: String, + value: ByteArray, + deviceLocalOnly: Boolean, + userPresenceRequired: Boolean, + preferStrongBox: Boolean, + ): Int { + return try { + secretStore().putSecret( + servicePrefix = servicePrefix, + namespace = namespace, + name = name, + value = value, + policy = RadRootsAndroidSecretAccessPolicy( + deviceLocalOnly = deviceLocalOnly, + userPresenceRequired = userPresenceRequired, + preferStrongBox = preferStrongBox, + ), + ) + clearError() + STATUS_SUCCESS + } catch (cause: Throwable) { + captureError(cause) + } + } + + @JvmStatic + fun getSecret( + servicePrefix: String, + namespace: String, + name: String, + ): ByteArray? { + return try { + val secret = secretStore().getSecret(servicePrefix, namespace, name) + clearError() + secret + } catch (cause: Throwable) { + captureError(cause) + null + } + } + + @JvmStatic + fun deleteSecret( + servicePrefix: String, + namespace: String, + name: String, + ): Int { + return try { + secretStore().deleteSecret(servicePrefix, namespace, name) + clearError() + STATUS_SUCCESS + } catch (cause: Throwable) { + captureError(cause) + } + } + + @JvmStatic + fun resolveNostrStorageRoot(): String? { + return try { + val path = secretStore().resolveNostrStorageRoot().absolutePath + clearError() + path + } catch (cause: Throwable) { + captureError(cause) + null + } + } + + @JvmStatic + fun takeLastErrorMessage(): String? { + val message = lastErrorMessage + lastErrorMessage = null + return message + } + + private fun secretStore(): RadRootsAndroidKeystoreSecretStore { + val context = applicationContext + ?: throw RadRootsAndroidSecurityError.InvalidInput("android security bridge is not initialized") + return RadRootsAndroidKeystoreSecretStore(context) + } + + private fun captureError(cause: Throwable): Int { + lastErrorMessage = cause.message ?: cause.toString() + return when (cause) { + is RadRootsAndroidSecurityError.NotFound -> STATUS_NOT_FOUND + is RadRootsAndroidSecurityError.InvalidInput -> STATUS_INVALID_INPUT + else -> STATUS_ERROR + } + } + + private fun clearError() { + lastErrorMessage = null + } +} diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityError.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityError.kt @@ -0,0 +1,16 @@ +package org.radroots.app.android.security + +sealed class RadRootsAndroidSecurityError( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) { + class InvalidInput(message: String) : RadRootsAndroidSecurityError(message) + + class NotFound(message: String) : RadRootsAndroidSecurityError(message) + + class KeystoreFailure(message: String, cause: Throwable? = null) : + RadRootsAndroidSecurityError(message, cause) + + class StorageFailure(message: String, cause: Throwable? = null) : + RadRootsAndroidSecurityError(message, cause) +} diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidStoragePaths.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidStoragePaths.kt @@ -0,0 +1,62 @@ +package org.radroots.app.android.security + +import android.content.Context +import java.io.File +import java.security.MessageDigest + +object RadRootsAndroidStoragePaths { + private const val rootDirName = "RadRoots" + private const val productDirName = "app" + private const val platformDirName = "android" + private const val nostrDirName = "nostr" + private const val secretsDirName = "secrets" + private const val accountsFileName = "accounts.json" + + fun nostrRoot(context: Context): File = nostrRoot(context.noBackupFilesDir) + + fun nostrRoot(baseDir: File): File = + File( + File( + File( + File(baseDir, rootDirName), + productDirName, + ), + platformDirName, + ), + nostrDirName, + ) + + fun secretsDir(context: Context): File = secretsDir(context.noBackupFilesDir) + + fun secretsDir(baseDir: File): File = File(nostrRoot(baseDir), secretsDirName) + + fun accountsFile(context: Context): File = accountsFile(context.noBackupFilesDir) + + fun accountsFile(baseDir: File): File = File(nostrRoot(baseDir), accountsFileName) + + fun secretFile( + context: Context, + servicePrefix: String, + namespace: String, + name: String, + ): File = secretFile(context.noBackupFilesDir, servicePrefix, namespace, name) + + fun secretFile( + baseDir: File, + servicePrefix: String, + namespace: String, + name: String, + ): File = File(secretsDir(baseDir), "${secretFileId(servicePrefix, namespace, name)}.bin") + + fun secretFileId(servicePrefix: String, namespace: String, name: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val encoded = buildString { + append(servicePrefix) + append('\u0000') + append(namespace) + append('\u0000') + append(name) + }.toByteArray(Charsets.UTF_8) + return digest.digest(encoded).joinToString("") { "%02x".format(it) } + } +} diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/test/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityTests.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/test/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityTests.kt @@ -0,0 +1,149 @@ +package org.radroots.app.android.security + +import android.os.Build +import android.security.keystore.KeyProperties +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RadRootsAndroidSecurityTests { + @Test + fun secureLocalSecretPolicyDefaultsAreStable() { + val policy = RadRootsAndroidSecretAccessPolicy.SECURE_LOCAL_SECRET + + assertTrue(policy.deviceLocalOnly) + assertFalse(policy.userPresenceRequired) + assertTrue(policy.preferStrongBox) + } + + @Test + fun nostrRootUsesNoBackupLayout() { + val baseDir = File("/data/user/0/org.radroots.app.android/no_backup") + + assertEquals( + File("/data/user/0/org.radroots.app.android/no_backup/RadRoots/app/android/nostr"), + RadRootsAndroidStoragePaths.nostrRoot(baseDir), + ) + assertEquals( + File("/data/user/0/org.radroots.app.android/no_backup/RadRoots/app/android/nostr/accounts.json"), + RadRootsAndroidStoragePaths.accountsFile(baseDir), + ) + } + + @Test + fun secretFileIdIsDeterministic() { + val first = RadRootsAndroidStoragePaths.secretFileId( + servicePrefix = "org.radroots.app.nostr", + namespace = "nostr", + name = "account-1", + ) + val second = RadRootsAndroidStoragePaths.secretFileId( + servicePrefix = "org.radroots.app.nostr", + namespace = "nostr", + name = "account-1", + ) + + assertEquals(first, second) + assertEquals(64, first.length) + } + + @Test + fun strongBoxIsRequestedOnlyWhenSupported() { + val policy = RadRootsAndroidSecretAccessPolicy.SECURE_LOCAL_SECRET + + assertTrue( + shouldRequestStrongBox( + policy = policy, + sdkInt = Build.VERSION_CODES.P, + hasStrongBoxFeature = true, + ), + ) + assertFalse( + shouldRequestStrongBox( + policy = policy, + sdkInt = Build.VERSION_CODES.O_MR1, + hasStrongBoxFeature = true, + ), + ) + assertFalse( + shouldRequestStrongBox( + policy = policy.copy(preferStrongBox = false), + sdkInt = Build.VERSION_CODES.P, + hasStrongBoxFeature = true, + ), + ) + assertFalse( + shouldRequestStrongBox( + policy = policy, + sdkInt = Build.VERSION_CODES.P, + hasStrongBoxFeature = false, + ), + ) + } + + @Test + fun securityLevelMappingPrefersVerifiedPlatformTier() { + assertEquals( + RadRootsAndroidKeySecurityLevel.STRONGBOX, + RadRootsAndroidKeySecurityLevels.fromPlatformValues( + sdkInt = Build.VERSION_CODES.S, + securityLevel = KeyProperties.SECURITY_LEVEL_STRONGBOX, + isInsideSecureHardware = true, + ), + ) + assertEquals( + RadRootsAndroidKeySecurityLevel.TRUSTED_ENVIRONMENT, + RadRootsAndroidKeySecurityLevels.fromPlatformValues( + sdkInt = Build.VERSION_CODES.S, + securityLevel = KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT, + isInsideSecureHardware = true, + ), + ) + assertEquals( + RadRootsAndroidKeySecurityLevel.SOFTWARE_OR_UNKNOWN, + RadRootsAndroidKeySecurityLevels.fromPlatformValues( + sdkInt = Build.VERSION_CODES.S, + securityLevel = KeyProperties.SECURITY_LEVEL_SOFTWARE, + isInsideSecureHardware = false, + ), + ) + assertEquals( + RadRootsAndroidKeySecurityLevel.TRUSTED_ENVIRONMENT, + RadRootsAndroidKeySecurityLevels.fromPlatformValues( + sdkInt = Build.VERSION_CODES.R, + securityLevel = null, + isInsideSecureHardware = true, + ), + ) + } + + @Test + fun strongBoxVerificationAcceptsOnlyBestAvailableTier() { + assertTrue( + acceptsStrongBoxVerificationResult( + sdkInt = Build.VERSION_CODES.S, + securityLevel = RadRootsAndroidKeySecurityLevel.STRONGBOX, + ), + ) + assertFalse( + acceptsStrongBoxVerificationResult( + sdkInt = Build.VERSION_CODES.S, + securityLevel = RadRootsAndroidKeySecurityLevel.TRUSTED_ENVIRONMENT, + ), + ) + assertTrue( + acceptsStrongBoxVerificationResult( + sdkInt = Build.VERSION_CODES.R, + securityLevel = RadRootsAndroidKeySecurityLevel.TRUSTED_ENVIRONMENT, + ), + ) + assertFalse( + acceptsStrongBoxVerificationResult( + sdkInt = Build.VERSION_CODES.R, + securityLevel = RadRootsAndroidKeySecurityLevel.SOFTWARE_OR_UNKNOWN, + ), + ) + } +} diff --git a/platforms/android/app/build.gradle.kts b/platforms/android/app/build.gradle.kts @@ -80,4 +80,5 @@ dependencies { implementation("androidx.games:games-activity:2.0.2") implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.core:core-ktx:1.13.1") + implementation(project(":radrootsAndroidSecurity")) } diff --git a/platforms/android/app/src/main/kotlin/org/radroots/app/android/MainActivity.kt b/platforms/android/app/src/main/kotlin/org/radroots/app/android/MainActivity.kt @@ -1,5 +1,12 @@ package org.radroots.app.android +import android.os.Bundle import com.google.androidgamesdk.GameActivity +import org.radroots.app.android.security.RadRootsAndroidSecurityBridge -class MainActivity : GameActivity() +class MainActivity : GameActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + RadRootsAndroidSecurityBridge.initialize(this) + super.onCreate(savedInstanceState) + } +} diff --git a/platforms/android/settings.gradle.kts b/platforms/android/settings.gradle.kts @@ -17,3 +17,6 @@ dependencyResolutionManagement { rootProject.name = "RadRootsAndroid" include(":app") +include(":radrootsAndroidSecurity") + +project(":radrootsAndroidSecurity").projectDir = file("../../native/android/kotlin/RadRootsAndroidSecurity")