From 37f0ef5b9fc9c45f062ca41448d7b17aaf90c2a1 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 11:39:26 -0800 Subject: [PATCH 01/12] re-add swift and kotlin credential storage tests --- .../world/walletkit/AtomicBlobStoreTests.kt | 25 ++ .../world/walletkit/CredentialStoreTests.kt | 288 +++++++++++++++ .../world/walletkit/DeviceKeystoreTests.kt | 44 +++ .../kotlin/org/world/walletkit/SimpleTest.kt | 6 +- .../kotlin/org/world/walletkit/TestHelpers.kt | 134 +++++++ .../storage/AndroidAtomicBlobStore.kt | 57 +++ .../storage/AndroidDeviceKeystore.kt | 91 +++++ .../storage/AndroidStorageProvider.kt | 38 ++ swift/support/IOSAtomicBlobStore.swift | 48 +++ swift/support/IOSDeviceKeystore.swift | 131 +++++++ swift/support/IOSStorageProvider.swift | 59 +++ .../WalletKitTests/AtomicBlobStoreTests.swift | 23 ++ .../WalletKitTests/CredentialStoreTests.swift | 339 ++++++++++++++++++ .../WalletKitTests/DeviceKeystoreTests.swift | 70 ++++ swift/tests/WalletKitTests/SimpleTest.swift | 4 +- swift/tests/WalletKitTests/TestHelpers.swift | 40 +++ .../TestIOSAtomicBlobStore.swift | 49 +++ .../TestIOSDeviceKeystore.swift | 132 +++++++ 18 files changed, 1571 insertions(+), 7 deletions(-) create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt create mode 100644 kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt create mode 100644 kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt create mode 100644 kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt create mode 100644 swift/support/IOSAtomicBlobStore.swift create mode 100644 swift/support/IOSDeviceKeystore.swift create mode 100644 swift/support/IOSStorageProvider.swift create mode 100644 swift/tests/WalletKitTests/AtomicBlobStoreTests.swift create mode 100644 swift/tests/WalletKitTests/CredentialStoreTests.swift create mode 100644 swift/tests/WalletKitTests/DeviceKeystoreTests.swift create mode 100644 swift/tests/WalletKitTests/TestHelpers.swift create mode 100644 swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift create mode 100644 swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt new file mode 100644 index 000000000..30fb959b1 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt @@ -0,0 +1,25 @@ +package org.world.walletkit + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AtomicBlobStoreTests { + @Test + fun writeReadDelete() { + val root = tempDirectory() + val store = FileBlobStore(root) + val path = "account_keys.bin" + val payload = byteArrayOf(1, 2, 3, 4) + + store.writeAtomic(path, payload) + val readBack = store.read(path) + assertEquals(payload.toList(), readBack?.toList()) + + store.delete(path) + assertNull(store.read(path)) + + root.deleteRecursively() + } +} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt new file mode 100644 index 000000000..3b3b6da31 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt @@ -0,0 +1,288 @@ +package org.world.walletkit + +import uniffi.walletkit_core.CredentialStore +import uniffi.walletkit_core.StorageException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CredentialStoreTests { + @Test + fun methodsRequireInit() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + assertFailsWith { + store.listCredentials(issuerSchemaId = null, now = 100UL) + } + assertFailsWith { + store.merkleCacheGet(validUntil = 100UL) + } + + root.deleteRecursively() + } + + @Test + fun initRejectsLeafIndexMismatch() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + val error = + assertFailsWith { + store.`init`(leafIndex = 43UL, now = 101UL) + } + assertEquals(42UL, error.`expected`) + assertEquals(43UL, error.`provided`) + + root.deleteRecursively() + } + + @Test + fun initIsIdempotentForSameLeafIndex() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + val credentialId = + store.storeCredential( + credential = sampleCredential(), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 100UL, + ) + + store.`init`(leafIndex = 42UL, now = 101UL) + + val records = store.listCredentials(issuerSchemaId = null, now = 102UL) + assertEquals(1, records.size) + assertEquals(credentialId, records.single().credentialId) + + root.deleteRecursively() + } + + @Test + fun storeAndCacheFlows() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + assertNull(store.merkleCacheGet(validUntil = 100UL)) + + val credentialId = + store.storeCredential( + credential = sampleCredential(), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = byteArrayOf(4, 5, 6), + now = 100UL, + ) + + val records = store.listCredentials(issuerSchemaId = null, now = 101UL) + assertEquals(1, records.size) + val record = records[0] + assertEquals(credentialId, record.credentialId) + assertEquals(7UL, record.issuerSchemaId) + assertEquals(1_800_000_000UL, record.expiresAt) + + val proofBytes = byteArrayOf(9, 9, 9) + store.merkleCachePut( + proofBytes = proofBytes, + now = 100UL, + ttlSeconds = 60UL, + ) + val cached = + store.merkleCacheGet( + validUntil = 110UL, + ) + assertEquals(proofBytes.toList(), cached?.toList()) + val expired = store.merkleCacheGet(validUntil = 161UL) + assertNull(expired) + + root.deleteRecursively() + } + + @Test + fun storeCredentialReturnsStableDistinctIds() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + val firstCredentialId = + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 7UL, expiresAt = 1_800_000_000UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 100UL, + ) + val secondCredentialId = + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 8UL, expiresAt = 1_900_000_000UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_900_000_000UL, + associatedData = null, + now = 101UL, + ) + + assertNotEquals(firstCredentialId, secondCredentialId) + + val records = store.listCredentials(issuerSchemaId = null, now = 102UL) + assertEquals(2, records.size) + assertEquals( + setOf(firstCredentialId, secondCredentialId), + records.map { it.credentialId }.toSet(), + ) + + root.deleteRecursively() + } + + @Test + fun listCredentialsFiltersByIssuerSchemaId() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 7UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 100UL, + ) + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 8UL, expiresAt = 1_900_000_000UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_900_000_000UL, + associatedData = null, + now = 101UL, + ) + + val filtered = store.listCredentials(issuerSchemaId = 7UL, now = 102UL) + assertEquals(1, filtered.size) + assertEquals(7UL, filtered.single().issuerSchemaId) + + root.deleteRecursively() + } + + @Test + fun expiredCredentialsAreFilteredOut() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 7UL, expiresAt = 120UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 120UL, + associatedData = null, + now = 100UL, + ) + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 8UL, expiresAt = 1_800_000_000UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 101UL, + ) + + val records = store.listCredentials(issuerSchemaId = null, now = 121UL) + assertEquals(1, records.size) + assertEquals(8UL, records.single().issuerSchemaId) + + root.deleteRecursively() + } + + @Test + fun storagePathsMatchWorldIdLayout() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + val paths = store.storagePaths() + assertEquals(root.absolutePath, paths.rootPathString()) + assertTrue(paths.worldidDirPathString().endsWith("/worldid")) + assertTrue(paths.vaultDbPathString().endsWith("/worldid/account.vault.sqlite")) + assertTrue(paths.cacheDbPathString().endsWith("/worldid/account.cache.sqlite")) + assertTrue(paths.lockPathString().endsWith("/worldid/lock")) + + root.deleteRecursively() + } + + @Test + fun merkleCachePutRefreshesExistingEntry() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + val firstProof = byteArrayOf(1, 2, 3) + val refreshedProof = byteArrayOf(4, 5, 6) + store.merkleCachePut( + proofBytes = firstProof, + now = 100UL, + ttlSeconds = 10UL, + ) + store.merkleCachePut( + proofBytes = refreshedProof, + now = 101UL, + ttlSeconds = 60UL, + ) + + val cached = store.merkleCacheGet(validUntil = 120UL) + assertContentEquals(refreshedProof, cached) + + root.deleteRecursively() + } + + @Test + fun reopenPersistsVaultAndCache() { + val root = tempDirectory() + val keyBytes = randomKeystoreKeyBytes() + val firstStore = + CredentialStore.fromProviderArc( + InMemoryStorageProvider(root, InMemoryDeviceKeystore(keyBytes)), + ) + + firstStore.`init`(leafIndex = 42UL, now = 100UL) + val credentialId = + firstStore.storeCredential( + credential = sampleCredential(), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 100UL, + ) + val proofBytes = byteArrayOf(9, 9, 9) + firstStore.merkleCachePut( + proofBytes = proofBytes, + now = 100UL, + ttlSeconds = 60UL, + ) + + val reopenedStore = + CredentialStore.fromProviderArc( + InMemoryStorageProvider(root, InMemoryDeviceKeystore(keyBytes)), + ) + reopenedStore.`init`(leafIndex = 42UL, now = 101UL) + + val records = reopenedStore.listCredentials(issuerSchemaId = null, now = 102UL) + assertEquals(1, records.size) + assertEquals(credentialId, records.single().credentialId) + assertContentEquals(proofBytes, reopenedStore.merkleCacheGet(validUntil = 120UL)) + + root.deleteRecursively() + } +} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt new file mode 100644 index 000000000..ac19ea5a4 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt @@ -0,0 +1,44 @@ +package org.world.walletkit + +import kotlin.test.Test +import kotlin.test.assertFails +import kotlin.test.assertTrue + +class DeviceKeystoreTests { + @Test + fun sealAndOpenRoundTrip() { + val keystore = InMemoryDeviceKeystore() + val associatedData = "ad".encodeToByteArray() + val plaintext = "hello".encodeToByteArray() + + val ciphertext = keystore.seal(associatedData, plaintext) + val opened = keystore.openSealed(associatedData, ciphertext) + + assertTrue(opened.contentEquals(plaintext)) + } + + @Test + fun associatedDataMismatchFails() { + val keystore = InMemoryDeviceKeystore() + val plaintext = "secret".encodeToByteArray() + val ciphertext = keystore.seal("ad-1".encodeToByteArray(), plaintext) + + assertFails { + keystore.openSealed("ad-2".encodeToByteArray(), ciphertext) + } + } + + @Test + fun reopenWithSameKeyMaterialCanOpenCiphertext() { + val keyBytes = randomKeystoreKeyBytes() + val firstKeystore = InMemoryDeviceKeystore(keyBytes) + val secondKeystore = InMemoryDeviceKeystore(keyBytes) + val associatedData = "ad".encodeToByteArray() + val plaintext = "hello".encodeToByteArray() + + val ciphertext = firstKeystore.seal(associatedData, plaintext) + val opened = secondKeystore.openSealed(associatedData, ciphertext) + + assertTrue(opened.contentEquals(plaintext)) + } +} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt index a8180250f..91270e6cb 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt @@ -1,11 +1,11 @@ package org.world.walletkit +import kotlin.test.Test +import kotlin.test.assertTrue import uniffi.walletkit_core.LogLevel import uniffi.walletkit_core.Logger import uniffi.walletkit_core.emitLog import uniffi.walletkit_core.initLogging -import kotlin.test.Test -import kotlin.test.assertTrue private class CapturingLogger : Logger { private val lock = Any() @@ -33,8 +33,6 @@ class SimpleTest { initLogging(logger, LogLevel.INFO) emitLog(LogLevel.INFO, "bridge test") - // Log delivery happens on a dedicated background thread, so give it - // a moment to flush through the channel. Thread.sleep(50) val entries = logger.snapshot() diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt new file mode 100644 index 000000000..fc22656b5 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt @@ -0,0 +1,134 @@ +package org.world.walletkit + +import java.io.File +import java.security.SecureRandom +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import uniffi.walletkit_core.AtomicBlobStore +import uniffi.walletkit_core.Credential +import uniffi.walletkit_core.DeviceKeystore +import uniffi.walletkit_core.FieldElement +import uniffi.walletkit_core.StorageException +import uniffi.walletkit_core.StoragePaths +import uniffi.walletkit_core.StorageProvider + +fun tempDirectory(): File { + val dir = File(System.getProperty("java.io.tmpdir"), "walletkit-tests-${UUID.randomUUID()}") + dir.mkdirs() + return dir +} + +fun randomKeystoreKeyBytes(): ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) } + +fun sampleCredential( + issuerSchemaId: ULong = 7UL, + expiresAt: ULong = 1_800_000_000UL, +): Credential { + val credentialJson = + """ + {"id":13758530325042616850,"version":"V1","issuer_schema_id":$issuerSchemaId,"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":$expiresAt,"claims":["0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000"],"associated_data_hash":"0x0000000000000000000000000000000000000000000000000000000000000000","signature":null,"issuer":"0100000000000000000000000000000000000000000000000000000000000000"} + """.trimIndent() + return Credential.fromBytes(credentialJson.encodeToByteArray()) +} + +fun sampleBlindingFactor(): FieldElement = FieldElement.fromU64(17UL) + +class InMemoryDeviceKeystore( + keyBytes: ByteArray = randomKeystoreKeyBytes(), +) : DeviceKeystore { + private val keyBytes = keyBytes.copyOf() + + override fun seal( + associatedData: ByteArray, + plaintext: ByteArray, + ): ByteArray = + try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val key = SecretKeySpec(keyBytes, "AES") + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(associatedData) + val ciphertext = cipher.doFinal(plaintext) + val iv = cipher.iv + val output = ByteArray(1 + iv.size + ciphertext.size) + output[0] = iv.size.toByte() + System.arraycopy(iv, 0, output, 1, iv.size) + System.arraycopy(ciphertext, 0, output, 1 + iv.size, ciphertext.size) + output + } catch (error: Exception) { + throw StorageException.Keystore("keystore seal failed: ${error.message}") + } + + override fun openSealed( + associatedData: ByteArray, + ciphertext: ByteArray, + ): ByteArray { + if (ciphertext.isEmpty()) { + throw StorageException.Keystore("keystore ciphertext is empty") + } + val ivLen = ciphertext[0].toInt() and 0xFF + if (ciphertext.size < 1 + ivLen) { + throw StorageException.Keystore("keystore ciphertext too short") + } + return try { + val iv = ciphertext.copyOfRange(1, 1 + ivLen) + val payload = ciphertext.copyOfRange(1 + ivLen, ciphertext.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val key = SecretKeySpec(keyBytes, "AES") + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + cipher.updateAAD(associatedData) + cipher.doFinal(payload) + } catch (error: Exception) { + throw StorageException.Keystore("keystore open failed: ${error.message}") + } + } +} + +class FileBlobStore( + private val baseDir: File, +) : AtomicBlobStore { + override fun read(path: String): ByteArray? { + val file = File(baseDir, path) + return if (file.exists()) file.readBytes() else null + } + + override fun writeAtomic( + path: String, + bytes: ByteArray, + ) { + val file = File(baseDir, path) + file.parentFile?.mkdirs() + val temp = File(file.parentFile ?: baseDir, "${file.name}.tmp-${UUID.randomUUID()}") + temp.writeBytes(bytes) + if (file.exists()) { + file.delete() + } + if (!temp.renameTo(file)) { + temp.copyTo(file, overwrite = true) + temp.delete() + } + } + + override fun delete(path: String) { + val file = File(baseDir, path) + if (file.exists() && !file.delete()) { + throw StorageException.BlobStore("delete failed") + } + } +} + +class InMemoryStorageProvider( + private val root: File, + private val keystoreImpl: DeviceKeystore = InMemoryDeviceKeystore(), +) : StorageProvider { + private val blobStore = FileBlobStore(File(root, "worldid")) + private val paths = StoragePaths.fromRoot(root.absolutePath) + + override fun keystore(): DeviceKeystore = keystoreImpl + + override fun blobStore(): AtomicBlobStore = blobStore + + override fun paths(): StoragePaths = paths +} diff --git a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt new file mode 100644 index 000000000..2b3a30405 --- /dev/null +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt @@ -0,0 +1,57 @@ +package org.world.walletkit.storage + +import java.io.File +import java.io.IOException +import java.util.UUID +import uniffi.walletkit_core.AtomicBlobStore +import uniffi.walletkit_core.StorageException + +class AndroidAtomicBlobStore( + private val baseDir: File +) : AtomicBlobStore { + override fun read(path: String): ByteArray? { + val file = File(baseDir, path) + if (!file.exists()) { + return null + } + return try { + file.readBytes() + } catch (error: IOException) { + throw StorageException.BlobStore("read failed: ${error.message}") + } + } + + override fun writeAtomic(path: String, bytes: ByteArray) { + val file = File(baseDir, path) + val parent = file.parentFile + if (parent != null && !parent.exists()) { + parent.mkdirs() + } + val temp = File( + parent ?: baseDir, + "${file.name}.tmp-${UUID.randomUUID()}" + ) + try { + temp.writeBytes(bytes) + if (file.exists() && !file.delete()) { + throw StorageException.BlobStore("failed to remove existing file") + } + if (!temp.renameTo(file)) { + temp.copyTo(file, overwrite = true) + temp.delete() + } + } catch (error: Exception) { + throw StorageException.BlobStore("write failed: ${error.message}") + } + } + + override fun delete(path: String) { + val file = File(baseDir, path) + if (!file.exists()) { + return + } + if (!file.delete()) { + throw StorageException.BlobStore("delete failed") + } + } +} diff --git a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt new file mode 100644 index 000000000..98f6a8e8b --- /dev/null +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt @@ -0,0 +1,91 @@ +package org.world.walletkit.storage + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import uniffi.walletkit_core.DeviceKeystore +import uniffi.walletkit_core.StorageException + +class AndroidDeviceKeystore( + private val alias: String = "walletkit_device_key" +) : DeviceKeystore { + private val lock = Any() + + override fun seal(associatedData: ByteArray, plaintext: ByteArray): ByteArray { + try { + val key = getOrCreateKey() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(associatedData) + val ciphertext = cipher.doFinal(plaintext) + val iv = cipher.iv + val output = ByteArray(1 + iv.size + ciphertext.size) + output[0] = iv.size.toByte() + System.arraycopy(iv, 0, output, 1, iv.size) + System.arraycopy(ciphertext, 0, output, 1 + iv.size, ciphertext.size) + return output + } catch (error: Exception) { + throw StorageException.Keystore("keystore seal failed: ${error.message}") + } + } + + override fun openSealed( + associatedData: ByteArray, + ciphertext: ByteArray + ): ByteArray { + if (ciphertext.isEmpty()) { + throw StorageException.Keystore("keystore ciphertext is empty") + } + val ivLen = ciphertext[0].toInt() and 0xFF + if (ciphertext.size < 1 + ivLen) { + throw StorageException.Keystore("keystore ciphertext too short") + } + try { + val key = getOrCreateKey() + val iv = ciphertext.copyOfRange(1, 1 + ivLen) + val payload = ciphertext.copyOfRange(1 + ivLen, ciphertext.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + cipher.updateAAD(associatedData) + return cipher.doFinal(payload) + } catch (error: Exception) { + throw StorageException.Keystore("keystore open failed: ${error.message}") + } + } + + private fun getOrCreateKey(): SecretKey { + synchronized(lock) { + try { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + val existing = keyStore.getKey(alias, null) as? SecretKey + if (existing != null) { + return existing + } + + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore" + ) + val spec = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .build() + keyGenerator.init(spec) + return keyGenerator.generateKey() + } catch (error: Exception) { + throw StorageException.Keystore("keystore init failed: ${error.message}") + } + } + } +} diff --git a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt new file mode 100644 index 000000000..f8eaa6bb4 --- /dev/null +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt @@ -0,0 +1,38 @@ +package org.world.walletkit.storage + +import android.content.Context +import java.io.File +import uniffi.walletkit_core.AtomicBlobStore +import uniffi.walletkit_core.DeviceKeystore +import uniffi.walletkit_core.StorageException +import uniffi.walletkit_core.StoragePaths +import uniffi.walletkit_core.StorageProvider + +class AndroidStorageProvider( + private val rootDir: File, + private val keystoreImpl: AndroidDeviceKeystore = AndroidDeviceKeystore(), + private val blobStoreImpl: AndroidAtomicBlobStore = + AndroidAtomicBlobStore(File(rootDir, "worldid")) +) : StorageProvider { + private val pathsImpl: StoragePaths = StoragePaths.fromRoot(rootDir.absolutePath) + + init { + val worldidDir = File(rootDir, "worldid") + if (!worldidDir.exists() && !worldidDir.mkdirs()) { + throw StorageException.BlobStore("failed to create storage directory") + } + } + + override fun keystore(): DeviceKeystore = keystoreImpl + + override fun blobStore(): AtomicBlobStore = blobStoreImpl + + override fun paths(): StoragePaths = pathsImpl +} + +object WalletKitStorage { + fun defaultProvider(context: Context): AndroidStorageProvider { + val root = File(context.filesDir, "walletkit") + return AndroidStorageProvider(root) + } +} diff --git a/swift/support/IOSAtomicBlobStore.swift b/swift/support/IOSAtomicBlobStore.swift new file mode 100644 index 000000000..b7a350015 --- /dev/null +++ b/swift/support/IOSAtomicBlobStore.swift @@ -0,0 +1,48 @@ +import Foundation + +public final class IOSAtomicBlobStore: AtomicBlobStore { + private let baseURL: URL + private let fileManager = FileManager.default + + public init(baseURL: URL) { + self.baseURL = baseURL + } + + public func read(path: String) throws -> Data? { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + return nil + } + do { + return try Data(contentsOf: url) + } catch { + throw StorageError.BlobStore("read failed: \(error)") + } + } + + public func writeAtomic(path: String, bytes: Data) throws { + let url = baseURL.appendingPathComponent(path) + let parent = url.deletingLastPathComponent() + do { + try fileManager.createDirectory( + at: parent, + withIntermediateDirectories: true + ) + try bytes.write(to: url, options: .atomic) + } catch { + throw StorageError.BlobStore("write failed: \(error)") + } + } + + public func delete(path: String) throws { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + throw StorageError.BlobStore("delete failed: file not found") + } + do { + try fileManager.removeItem(at: url) + } catch { + throw StorageError.BlobStore("delete failed: \(error)") + } + } +} diff --git a/swift/support/IOSDeviceKeystore.swift b/swift/support/IOSDeviceKeystore.swift new file mode 100644 index 000000000..d94753e14 --- /dev/null +++ b/swift/support/IOSDeviceKeystore.swift @@ -0,0 +1,131 @@ +import CryptoKit +import Foundation +import Security + +public final class IOSDeviceKeystore: DeviceKeystore { + private let service: String + private let account: String + private let lock = NSLock() + private static let fallbackLock = NSLock() + private static var fallbackKeys: [String: Data] = [:] + + public init( + service: String = "walletkit.devicekeystore", + account: String = "default" + ) { + self.service = service + self.account = account + } + + public func seal(associatedData: Data, plaintext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.seal( + plaintext, + using: key, + authenticating: associatedData + ) + guard let combined = sealedBox.combined else { + throw StorageError.Keystore("missing AES-GCM combined payload") + } + return combined + } + + public func openSealed(associatedData: Data, ciphertext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) + return try AES.GCM.open( + sealedBox, + using: key, + authenticating: associatedData + ) + } + + private func loadOrCreateKey() throws -> SymmetricKey { + lock.lock() + defer { lock.unlock() } + + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StorageError.Keystore("random key generation failed: \(status)") + } + let keyData = Data(bytes) + + let addStatus = SecItemAdd( + keychainAddQuery(keyData: keyData) as CFDictionary, + nil + ) + if addStatus == errSecDuplicateItem { + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + throw StorageError.Keystore("keychain item duplicated but unreadable") + } + if addStatus == errSecMissingEntitlement { + Self.setFallbackKey(id: fallbackKeyId(), data: keyData) + return SymmetricKey(data: keyData) + } + guard addStatus == errSecSuccess else { + throw StorageError.Keystore("keychain add failed: \(addStatus)") + } + + return SymmetricKey(data: keyData) + } + + private func loadKeyData() throws -> Data? { + var query = keychainBaseQuery() + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + if status == errSecMissingEntitlement { + return Self.fallbackKey(id: fallbackKeyId()) + } + guard status == errSecSuccess else { + throw StorageError.Keystore("keychain read failed: \(status)") + } + guard let data = item as? Data else { + throw StorageError.Keystore("keychain read returned non-data") + } + return data + } + + private func keychainBaseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + } + + private func keychainAddQuery(keyData: Data) -> [String: Any] { + var query = keychainBaseQuery() + query[kSecValueData as String] = keyData + return query + } + + private func fallbackKeyId() -> String { + "\(service)::\(account)" + } + + private static func fallbackKey(id: String) -> Data? { + fallbackLock.lock() + defer { fallbackLock.unlock() } + return fallbackKeys[id] + } + + private static func setFallbackKey(id: String, data: Data) { + fallbackLock.lock() + defer { fallbackLock.unlock() } + fallbackKeys[id] = data + } +} diff --git a/swift/support/IOSStorageProvider.swift b/swift/support/IOSStorageProvider.swift new file mode 100644 index 000000000..5d8bd807a --- /dev/null +++ b/swift/support/IOSStorageProvider.swift @@ -0,0 +1,59 @@ +import Foundation + +public final class IOSStorageProvider: StorageProvider { + private let keystoreImpl: IOSDeviceKeystore + private let blobStoreImpl: IOSAtomicBlobStore + private let pathsImpl: StoragePaths + + public init( + rootDirectory: URL, + keystoreService: String = "walletkit.devicekeystore", + keystoreAccount: String = "default" + ) throws { + let worldidDir = rootDirectory.appendingPathComponent("worldid", isDirectory: true) + do { + try FileManager.default.createDirectory( + at: worldidDir, + withIntermediateDirectories: true + ) + } catch { + throw StorageError.BlobStore("failed to create storage directory: \(error)") + } + + self.pathsImpl = StoragePaths.fromRoot(root: rootDirectory.path) + self.keystoreImpl = IOSDeviceKeystore( + service: keystoreService, + account: keystoreAccount + ) + self.blobStoreImpl = IOSAtomicBlobStore(baseURL: worldidDir) + } + + public func keystore() -> DeviceKeystore { + keystoreImpl + } + + public func blobStore() -> AtomicBlobStore { + blobStoreImpl + } + + public func paths() -> StoragePaths { + pathsImpl + } +} + +public enum WalletKitStorage { + public static func makeDefaultProvider( + bundleIdentifier: String? = Bundle.main.bundleIdentifier + ) throws -> IOSStorageProvider { + let fileManager = FileManager.default + guard let appSupport = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw StorageError.BlobStore("missing application support directory") + } + let bundleId = bundleIdentifier ?? "walletkit" + let root = appSupport.appendingPathComponent(bundleId, isDirectory: true) + return try IOSStorageProvider(rootDirectory: root) + } +} diff --git a/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift b/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift new file mode 100644 index 000000000..ff9f285df --- /dev/null +++ b/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift @@ -0,0 +1,23 @@ +import Foundation +import XCTest +@testable import WalletKit + +final class AtomicBlobStoreTests: XCTestCase { + func testWriteReadDelete() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let store = TestIOSAtomicBlobStore(baseURL: root) + let path = "account_keys.bin" + let payload = Data([1, 2, 3, 4]) + + try store.writeAtomic(path: path, bytes: payload) + let readBack = try store.read(path: path) + + XCTAssertEqual(readBack, payload) + + try store.delete(path: path) + let afterDelete = try store.read(path: path) + XCTAssertNil(afterDelete) + } +} diff --git a/swift/tests/WalletKitTests/CredentialStoreTests.swift b/swift/tests/WalletKitTests/CredentialStoreTests.swift new file mode 100644 index 000000000..800d25be3 --- /dev/null +++ b/swift/tests/WalletKitTests/CredentialStoreTests.swift @@ -0,0 +1,339 @@ +import Foundation +import XCTest +@testable import WalletKit + +final class CredentialStoreTests: XCTestCase { + private let account = "test-account" + + func testMethodsRequireInit() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + XCTAssertThrowsError(try store.listCredentials( + issuerSchemaId: Optional.none, + now: 100 + )) { error in + XCTAssertEqual(error as? StorageError, .NotInitialized) + } + XCTAssertThrowsError(try store.merkleCacheGet(validUntil: 100)) { error in + XCTAssertEqual(error as? StorageError, .NotInitialized) + } + } + + func testInitRejectsLeafIndexMismatch() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + + XCTAssertThrowsError(try store.`init`(leafIndex: 43, now: 101)) { error in + guard case let .InvalidLeafIndex(expected, provided) = error as? StorageError else { + return XCTFail("Expected InvalidLeafIndex, got \(error)") + } + XCTAssertEqual(expected, 42) + XCTAssertEqual(provided, 43) + } + } + + func testInitIsIdempotentForSameLeafIndex() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + let credentialId = try store.storeCredential( + credential: sampleCredential(), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 100 + ) + + try store.`init`(leafIndex: 42, now: 101) + + let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 102) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0].credentialId, credentialId) + } + + func testStoreAndCacheFlows() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = TestIOSDeviceKeystore(service: service, account: account) + let worldidDir = root.appendingPathComponent("worldid", isDirectory: true) + let blobStore = TestIOSAtomicBlobStore(baseURL: worldidDir) + let paths = StoragePaths.fromRoot(root: root.path) + + let store = try CredentialStore.newWithComponents( + paths: paths, + keystore: keystore, + blobStore: blobStore + ) + + try store.`init`(leafIndex: 42, now: 100) + XCTAssertNil(try store.merkleCacheGet(validUntil: 100)) + + let credentialId = try store.storeCredential( + credential: sampleCredential(), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: Data([4, 5, 6]), + now: 100 + ) + + let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 101) + XCTAssertEqual(records.count, 1) + let record = records[0] + XCTAssertEqual(record.credentialId, credentialId) + XCTAssertEqual(record.issuerSchemaId, 7) + XCTAssertEqual(record.expiresAt, 1_800_000_000) + + let proofBytes = Data([9, 9, 9]) + try store.merkleCachePut( + proofBytes: proofBytes, + now: 100, + ttlSeconds: 60 + ) + let cached = try store.merkleCacheGet( + validUntil: 110 + ) + XCTAssertEqual(cached, proofBytes) + let expired = try store.merkleCacheGet(validUntil: 161) + XCTAssertNil(expired) + } + + func testStoreCredentialReturnsStableDistinctIds() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + let firstCredentialId = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 7, expiresAt: 1_800_000_000), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 100 + ) + let secondCredentialId = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 8, expiresAt: 1_900_000_000), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_900_000_000, + associatedData: nil, + now: 101 + ) + + XCTAssertNotEqual(firstCredentialId, secondCredentialId) + + let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 102) + XCTAssertEqual(records.count, 2) + XCTAssertEqual(Set(records.map(\.credentialId)), Set([firstCredentialId, secondCredentialId])) + } + + func testListCredentialsFiltersByIssuerSchemaId() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + _ = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 7, expiresAt: 1_800_000_000), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 100 + ) + _ = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 8, expiresAt: 1_900_000_000), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_900_000_000, + associatedData: nil, + now: 101 + ) + + let filtered = try store.listCredentials(issuerSchemaId: 7, now: 102) + XCTAssertEqual(filtered.count, 1) + XCTAssertEqual(filtered[0].issuerSchemaId, 7) + } + + func testExpiredCredentialsAreFilteredOut() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + _ = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 7, expiresAt: 120), + blindingFactor: sampleBlindingFactor(), + expiresAt: 120, + associatedData: nil, + now: 100 + ) + _ = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 8, expiresAt: 1_800_000_000), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 101 + ) + + let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 121) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0].issuerSchemaId, 8) + } + + func testStoragePathsMatchWorldIdLayout() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + let paths = try store.storagePaths() + XCTAssertEqual(paths.rootPathString(), root.path) + XCTAssertTrue(paths.worldidDirPathString().hasSuffix("/worldid")) + XCTAssertTrue(paths.vaultDbPathString().hasSuffix("/worldid/account.vault.sqlite")) + XCTAssertTrue(paths.cacheDbPathString().hasSuffix("/worldid/account.cache.sqlite")) + XCTAssertTrue(paths.lockPathString().hasSuffix("/worldid/lock")) + } + + func testMerkleCachePutRefreshesExistingEntry() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + try store.merkleCachePut(proofBytes: Data([1, 2, 3]), now: 100, ttlSeconds: 10) + try store.merkleCachePut(proofBytes: Data([4, 5, 6]), now: 101, ttlSeconds: 60) + + let cached = try store.merkleCacheGet(validUntil: 120) + XCTAssertEqual(cached, Data([4, 5, 6])) + } + + func testReopenPersistsVaultAndCache() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let firstStore = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + try firstStore.`init`(leafIndex: 42, now: 100) + let credentialId = try firstStore.storeCredential( + credential: sampleCredential(), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 100 + ) + let proofBytes = Data([9, 9, 9]) + try firstStore.merkleCachePut(proofBytes: proofBytes, now: 100, ttlSeconds: 60) + + let reopenedStore = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + try reopenedStore.`init`(leafIndex: 42, now: 101) + + let records = try reopenedStore.listCredentials( + issuerSchemaId: Optional.none, + now: 102 + ) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0].credentialId, credentialId) + XCTAssertEqual(try reopenedStore.merkleCacheGet(validUntil: 120), proofBytes) + } +} diff --git a/swift/tests/WalletKitTests/DeviceKeystoreTests.swift b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift new file mode 100644 index 000000000..ad110842b --- /dev/null +++ b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift @@ -0,0 +1,70 @@ +import CryptoKit +import Foundation +import Security +import XCTest +@testable import WalletKit + +final class DeviceKeystoreTests: XCTestCase { + private let account = "test-account" + + func testSealAndOpenRoundTrip() throws { + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = TestIOSDeviceKeystore(service: service, account: account) + let associatedData = Data("ad".utf8) + let plaintext = Data("hello".utf8) + + let ciphertext = try keystore.seal( + associatedData: associatedData, + plaintext: plaintext + ) + let opened = try keystore.openSealed( + associatedData: associatedData, + ciphertext: ciphertext + ) + + XCTAssertEqual(opened, plaintext) + } + + func testAssociatedDataMismatchFails() throws { + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = TestIOSDeviceKeystore(service: service, account: account) + let plaintext = Data("secret".utf8) + + let ciphertext = try keystore.seal( + associatedData: Data("ad-1".utf8), + plaintext: plaintext + ) + + XCTAssertThrowsError( + try keystore.openSealed( + associatedData: Data("ad-2".utf8), + ciphertext: ciphertext + ) + ) + } + + func testReopenWithSameIdentityCanOpenCiphertext() throws { + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let firstKeystore = TestIOSDeviceKeystore(service: service, account: account) + let secondKeystore = TestIOSDeviceKeystore(service: service, account: account) + let associatedData = Data("ad".utf8) + let plaintext = Data("hello".utf8) + + let ciphertext = try firstKeystore.seal( + associatedData: associatedData, + plaintext: plaintext + ) + let opened = try secondKeystore.openSealed( + associatedData: associatedData, + ciphertext: ciphertext + ) + + XCTAssertEqual(opened, plaintext) + } +} diff --git a/swift/tests/WalletKitTests/SimpleTest.swift b/swift/tests/WalletKitTests/SimpleTest.swift index a246fccb9..7da1dce8a 100644 --- a/swift/tests/WalletKitTests/SimpleTest.swift +++ b/swift/tests/WalletKitTests/SimpleTest.swift @@ -24,9 +24,7 @@ final class SimpleTest: XCTestCase { WalletKit.initLogging(logger: logger, level: .info) WalletKit.emitLog(level: .info, message: "bridge test") - // Log delivery happens on a dedicated background thread, so give it - // a moment to flush through the channel. - Thread.sleep(forTimeInterval: 0.001) + Thread.sleep(forTimeInterval: 0.05) let entries = logger.snapshot() XCTAssertFalse(entries.isEmpty, "expected at least one bridged log entry") diff --git a/swift/tests/WalletKitTests/TestHelpers.swift b/swift/tests/WalletKitTests/TestHelpers.swift new file mode 100644 index 000000000..69a359fc3 --- /dev/null +++ b/swift/tests/WalletKitTests/TestHelpers.swift @@ -0,0 +1,40 @@ +import Foundation +import Security +@testable import WalletKit + +func makeTempDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "walletkit-tests-\(UUID().uuidString)", + isDirectory: true + ) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +func uniqueKeystoreService() -> String { + "walletkit.devicekeystore.test.\(UUID().uuidString)" +} + +func deleteKeychainItem(service: String, account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) +} + +func sampleCredential( + issuerSchemaId: UInt64 = 7, + expiresAt: UInt64 = 1_800_000_000 +) throws -> Credential { + let sampleCredentialJSON = """ + {"id":13758530325042616850,"version":"V1","issuer_schema_id":\(issuerSchemaId),"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":\(expiresAt),"claims":["0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000"],"associated_data_hash":"0x0000000000000000000000000000000000000000000000000000000000000000","signature":null,"issuer":"0100000000000000000000000000000000000000000000000000000000000000"} + """ + let bytes = Data(sampleCredentialJSON.utf8) + return try Credential.fromBytes(bytes: bytes) +} + +func sampleBlindingFactor() -> FieldElement { + FieldElement.fromU64(value: 17) +} diff --git a/swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift b/swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift new file mode 100644 index 000000000..e196af900 --- /dev/null +++ b/swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift @@ -0,0 +1,49 @@ +import Foundation +@testable import WalletKit + +final class TestIOSAtomicBlobStore: AtomicBlobStore { + private let baseURL: URL + private let fileManager = FileManager.default + + init(baseURL: URL) { + self.baseURL = baseURL + } + + func read(path: String) throws -> Data? { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + return nil + } + do { + return try Data(contentsOf: url) + } catch { + throw StorageError.BlobStore("read failed: \(error)") + } + } + + func writeAtomic(path: String, bytes: Data) throws { + let url = baseURL.appendingPathComponent(path) + let parent = url.deletingLastPathComponent() + do { + try fileManager.createDirectory( + at: parent, + withIntermediateDirectories: true + ) + try bytes.write(to: url, options: .atomic) + } catch { + throw StorageError.BlobStore("write failed: \(error)") + } + } + + func delete(path: String) throws { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + throw StorageError.BlobStore("delete failed: file not found") + } + do { + try fileManager.removeItem(at: url) + } catch { + throw StorageError.BlobStore("delete failed: \(error)") + } + } +} diff --git a/swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift b/swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift new file mode 100644 index 000000000..279242842 --- /dev/null +++ b/swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift @@ -0,0 +1,132 @@ +import CryptoKit +import Foundation +import Security +@testable import WalletKit + +final class TestIOSDeviceKeystore: DeviceKeystore { + private let service: String + private let account: String + private let lock = NSLock() + private static let fallbackLock = NSLock() + private static var fallbackKeys: [String: Data] = [:] + + init( + service: String = "walletkit.devicekeystore", + account: String = "default" + ) { + self.service = service + self.account = account + } + + func seal(associatedData: Data, plaintext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.seal( + plaintext, + using: key, + authenticating: associatedData + ) + guard let combined = sealedBox.combined else { + throw StorageError.Keystore("missing AES-GCM combined payload") + } + return combined + } + + func openSealed(associatedData: Data, ciphertext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) + return try AES.GCM.open( + sealedBox, + using: key, + authenticating: associatedData + ) + } + + private func loadOrCreateKey() throws -> SymmetricKey { + lock.lock() + defer { lock.unlock() } + + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StorageError.Keystore("random key generation failed: \(status)") + } + let keyData = Data(bytes) + + let addStatus = SecItemAdd( + keychainAddQuery(keyData: keyData) as CFDictionary, + nil + ) + if addStatus == errSecDuplicateItem { + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + throw StorageError.Keystore("keychain item duplicated but unreadable") + } + if addStatus == errSecMissingEntitlement { + Self.setFallbackKey(id: fallbackKeyId(), data: keyData) + return SymmetricKey(data: keyData) + } + guard addStatus == errSecSuccess else { + throw StorageError.Keystore("keychain add failed: \(addStatus)") + } + + return SymmetricKey(data: keyData) + } + + private func loadKeyData() throws -> Data? { + var query = keychainBaseQuery() + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + if status == errSecMissingEntitlement { + return Self.fallbackKey(id: fallbackKeyId()) + } + guard status == errSecSuccess else { + throw StorageError.Keystore("keychain read failed: \(status)") + } + guard let data = item as? Data else { + throw StorageError.Keystore("keychain read returned non-data") + } + return data + } + + private func keychainBaseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + } + + private func keychainAddQuery(keyData: Data) -> [String: Any] { + var query = keychainBaseQuery() + query[kSecValueData as String] = keyData + return query + } + + private func fallbackKeyId() -> String { + "\(service)::\(account)" + } + + private static func fallbackKey(id: String) -> Data? { + fallbackLock.lock() + defer { fallbackLock.unlock() } + return fallbackKeys[id] + } + + private static func setFallbackKey(id: String, data: Data) { + fallbackLock.lock() + defer { fallbackLock.unlock() } + fallbackKeys[id] = data + } +} From e6cd1044e7f7017157062b478dad9dcae9ca82ab Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 11:58:01 -0800 Subject: [PATCH 02/12] reorg --- kotlin/test_kotlin.sh | 13 +- .../world/walletkit/AtomicBlobStoreTests.kt | 25 -- .../world/walletkit/CredentialStoreTests.kt | 288 ------------ .../world/walletkit/DeviceKeystoreTests.kt | 44 -- .../LoggingTests.kt} | 2 +- .../StorageTests.kt} | 166 +++++++ .../WalletKitTests/AtomicBlobStoreTests.swift | 23 - .../WalletKitTests/CredentialStoreTests.swift | 339 -------------- .../WalletKitTests/DeviceKeystoreTests.swift | 70 --- .../LoggingTests.swift} | 2 +- .../WalletKitTests/Storage/StorageTests.swift | 425 ++++++++++++++++++ swift/tests/WalletKitTests/TestHelpers.swift | 40 -- .../TestIOSAtomicBlobStore.swift | 49 -- .../TestIOSDeviceKeystore.swift | 132 ------ 14 files changed, 601 insertions(+), 1017 deletions(-) delete mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt delete mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt delete mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt rename kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/{SimpleTest.kt => logging/LoggingTests.kt} (98%) rename kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/{TestHelpers.kt => storage/StorageTests.kt} (52%) delete mode 100644 swift/tests/WalletKitTests/AtomicBlobStoreTests.swift delete mode 100644 swift/tests/WalletKitTests/CredentialStoreTests.swift delete mode 100644 swift/tests/WalletKitTests/DeviceKeystoreTests.swift rename swift/tests/WalletKitTests/{SimpleTest.swift => Logging/LoggingTests.swift} (96%) create mode 100644 swift/tests/WalletKitTests/Storage/StorageTests.swift delete mode 100644 swift/tests/WalletKitTests/TestHelpers.swift delete mode 100644 swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift delete mode 100644 swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift diff --git a/kotlin/test_kotlin.sh b/kotlin/test_kotlin.sh index cfa043579..3f46a7d53 100755 --- a/kotlin/test_kotlin.sh +++ b/kotlin/test_kotlin.sh @@ -13,9 +13,7 @@ YELLOW='\033[0;33m' NC='\033[0m' # No Color ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -TEST_RESULTS_DIR="$ROOT_DIR/kotlin/walletkit-tests/build/test-results/test" -rm -rf "$TEST_RESULTS_DIR" +KOTLIN_DIR="$ROOT_DIR/kotlin" cd "$ROOT_DIR" @@ -45,7 +43,10 @@ echo -e "${BLUE}๐Ÿ”จ Step 1: Building Kotlin bindings with build_kotlin.sh${NC}" echo -e "${GREEN}โœ… Kotlin bindings built${NC}" echo -e "${BLUE}๐Ÿ“ฆ Step 2: Setting up Gradle test environment${NC}" -cd "$ROOT_DIR/kotlin" +cd "$KOTLIN_DIR" + +TEST_RESULTS_DIR="$(pwd -P)/walletkit-tests/build/test-results/test" +rm -rf "$TEST_RESULTS_DIR" # Generate Gradle wrapper if missing if [ ! -f "gradlew" ]; then @@ -76,7 +77,9 @@ echo "" echo -e "${BLUE}๐Ÿงช Step 3: Running Kotlin tests with verbose output...${NC}" echo "" -./gradlew --no-daemon walletkit-tests:test --info --continue +# Avoid reusing configuration cache entries from a different checkout, which can +# redirect build outputs away from the current workspace and hide successful runs. +./gradlew --no-daemon --no-configuration-cache walletkit-tests:test --info --continue echo "" echo "๐Ÿ“Š Test Results Summary:" diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt deleted file mode 100644 index 30fb959b1..000000000 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.world.walletkit - -import java.io.File -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class AtomicBlobStoreTests { - @Test - fun writeReadDelete() { - val root = tempDirectory() - val store = FileBlobStore(root) - val path = "account_keys.bin" - val payload = byteArrayOf(1, 2, 3, 4) - - store.writeAtomic(path, payload) - val readBack = store.read(path) - assertEquals(payload.toList(), readBack?.toList()) - - store.delete(path) - assertNull(store.read(path)) - - root.deleteRecursively() - } -} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt deleted file mode 100644 index 3b3b6da31..000000000 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt +++ /dev/null @@ -1,288 +0,0 @@ -package org.world.walletkit - -import uniffi.walletkit_core.CredentialStore -import uniffi.walletkit_core.StorageException -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNotEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class CredentialStoreTests { - @Test - fun methodsRequireInit() { - val root = tempDirectory() - val provider = InMemoryStorageProvider(root) - val store = CredentialStore.fromProviderArc(provider) - - assertFailsWith { - store.listCredentials(issuerSchemaId = null, now = 100UL) - } - assertFailsWith { - store.merkleCacheGet(validUntil = 100UL) - } - - root.deleteRecursively() - } - - @Test - fun initRejectsLeafIndexMismatch() { - val root = tempDirectory() - val provider = InMemoryStorageProvider(root) - val store = CredentialStore.fromProviderArc(provider) - - store.`init`(leafIndex = 42UL, now = 100UL) - val error = - assertFailsWith { - store.`init`(leafIndex = 43UL, now = 101UL) - } - assertEquals(42UL, error.`expected`) - assertEquals(43UL, error.`provided`) - - root.deleteRecursively() - } - - @Test - fun initIsIdempotentForSameLeafIndex() { - val root = tempDirectory() - val provider = InMemoryStorageProvider(root) - val store = CredentialStore.fromProviderArc(provider) - - store.`init`(leafIndex = 42UL, now = 100UL) - val credentialId = - store.storeCredential( - credential = sampleCredential(), - blindingFactor = sampleBlindingFactor(), - expiresAt = 1_800_000_000UL, - associatedData = null, - now = 100UL, - ) - - store.`init`(leafIndex = 42UL, now = 101UL) - - val records = store.listCredentials(issuerSchemaId = null, now = 102UL) - assertEquals(1, records.size) - assertEquals(credentialId, records.single().credentialId) - - root.deleteRecursively() - } - - @Test - fun storeAndCacheFlows() { - val root = tempDirectory() - val provider = InMemoryStorageProvider(root) - val store = CredentialStore.fromProviderArc(provider) - - store.`init`(leafIndex = 42UL, now = 100UL) - assertNull(store.merkleCacheGet(validUntil = 100UL)) - - val credentialId = - store.storeCredential( - credential = sampleCredential(), - blindingFactor = sampleBlindingFactor(), - expiresAt = 1_800_000_000UL, - associatedData = byteArrayOf(4, 5, 6), - now = 100UL, - ) - - val records = store.listCredentials(issuerSchemaId = null, now = 101UL) - assertEquals(1, records.size) - val record = records[0] - assertEquals(credentialId, record.credentialId) - assertEquals(7UL, record.issuerSchemaId) - assertEquals(1_800_000_000UL, record.expiresAt) - - val proofBytes = byteArrayOf(9, 9, 9) - store.merkleCachePut( - proofBytes = proofBytes, - now = 100UL, - ttlSeconds = 60UL, - ) - val cached = - store.merkleCacheGet( - validUntil = 110UL, - ) - assertEquals(proofBytes.toList(), cached?.toList()) - val expired = store.merkleCacheGet(validUntil = 161UL) - assertNull(expired) - - root.deleteRecursively() - } - - @Test - fun storeCredentialReturnsStableDistinctIds() { - val root = tempDirectory() - val provider = InMemoryStorageProvider(root) - val store = CredentialStore.fromProviderArc(provider) - - store.`init`(leafIndex = 42UL, now = 100UL) - val firstCredentialId = - store.storeCredential( - credential = sampleCredential(issuerSchemaId = 7UL, expiresAt = 1_800_000_000UL), - blindingFactor = sampleBlindingFactor(), - expiresAt = 1_800_000_000UL, - associatedData = null, - now = 100UL, - ) - val secondCredentialId = - store.storeCredential( - credential = sampleCredential(issuerSchemaId = 8UL, expiresAt = 1_900_000_000UL), - blindingFactor = sampleBlindingFactor(), - expiresAt = 1_900_000_000UL, - associatedData = null, - now = 101UL, - ) - - assertNotEquals(firstCredentialId, secondCredentialId) - - val records = store.listCredentials(issuerSchemaId = null, now = 102UL) - assertEquals(2, records.size) - assertEquals( - setOf(firstCredentialId, secondCredentialId), - records.map { it.credentialId }.toSet(), - ) - - root.deleteRecursively() - } - - @Test - fun listCredentialsFiltersByIssuerSchemaId() { - val root = tempDirectory() - val provider = InMemoryStorageProvider(root) - val store = CredentialStore.fromProviderArc(provider) - - store.`init`(leafIndex = 42UL, now = 100UL) - store.storeCredential( - credential = sampleCredential(issuerSchemaId = 7UL), - blindingFactor = sampleBlindingFactor(), - expiresAt = 1_800_000_000UL, - associatedData = null, - now = 100UL, - ) - store.storeCredential( - credential = sampleCredential(issuerSchemaId = 8UL, expiresAt = 1_900_000_000UL), - blindingFactor = sampleBlindingFactor(), - expiresAt = 1_900_000_000UL, - associatedData = null, - now = 101UL, - ) - - val filtered = store.listCredentials(issuerSchemaId = 7UL, now = 102UL) - assertEquals(1, filtered.size) - assertEquals(7UL, filtered.single().issuerSchemaId) - - root.deleteRecursively() - } - - @Test - fun expiredCredentialsAreFilteredOut() { - val root = tempDirectory() - val provider = InMemoryStorageProvider(root) - val store = CredentialStore.fromProviderArc(provider) - - store.`init`(leafIndex = 42UL, now = 100UL) - store.storeCredential( - credential = sampleCredential(issuerSchemaId = 7UL, expiresAt = 120UL), - blindingFactor = sampleBlindingFactor(), - expiresAt = 120UL, - associatedData = null, - now = 100UL, - ) - store.storeCredential( - credential = sampleCredential(issuerSchemaId = 8UL, expiresAt = 1_800_000_000UL), - blindingFactor = sampleBlindingFactor(), - expiresAt = 1_800_000_000UL, - associatedData = null, - now = 101UL, - ) - - val records = store.listCredentials(issuerSchemaId = null, now = 121UL) - assertEquals(1, records.size) - assertEquals(8UL, records.single().issuerSchemaId) - - root.deleteRecursively() - } - - @Test - fun storagePathsMatchWorldIdLayout() { - val root = tempDirectory() - val provider = InMemoryStorageProvider(root) - val store = CredentialStore.fromProviderArc(provider) - - val paths = store.storagePaths() - assertEquals(root.absolutePath, paths.rootPathString()) - assertTrue(paths.worldidDirPathString().endsWith("/worldid")) - assertTrue(paths.vaultDbPathString().endsWith("/worldid/account.vault.sqlite")) - assertTrue(paths.cacheDbPathString().endsWith("/worldid/account.cache.sqlite")) - assertTrue(paths.lockPathString().endsWith("/worldid/lock")) - - root.deleteRecursively() - } - - @Test - fun merkleCachePutRefreshesExistingEntry() { - val root = tempDirectory() - val provider = InMemoryStorageProvider(root) - val store = CredentialStore.fromProviderArc(provider) - - store.`init`(leafIndex = 42UL, now = 100UL) - val firstProof = byteArrayOf(1, 2, 3) - val refreshedProof = byteArrayOf(4, 5, 6) - store.merkleCachePut( - proofBytes = firstProof, - now = 100UL, - ttlSeconds = 10UL, - ) - store.merkleCachePut( - proofBytes = refreshedProof, - now = 101UL, - ttlSeconds = 60UL, - ) - - val cached = store.merkleCacheGet(validUntil = 120UL) - assertContentEquals(refreshedProof, cached) - - root.deleteRecursively() - } - - @Test - fun reopenPersistsVaultAndCache() { - val root = tempDirectory() - val keyBytes = randomKeystoreKeyBytes() - val firstStore = - CredentialStore.fromProviderArc( - InMemoryStorageProvider(root, InMemoryDeviceKeystore(keyBytes)), - ) - - firstStore.`init`(leafIndex = 42UL, now = 100UL) - val credentialId = - firstStore.storeCredential( - credential = sampleCredential(), - blindingFactor = sampleBlindingFactor(), - expiresAt = 1_800_000_000UL, - associatedData = null, - now = 100UL, - ) - val proofBytes = byteArrayOf(9, 9, 9) - firstStore.merkleCachePut( - proofBytes = proofBytes, - now = 100UL, - ttlSeconds = 60UL, - ) - - val reopenedStore = - CredentialStore.fromProviderArc( - InMemoryStorageProvider(root, InMemoryDeviceKeystore(keyBytes)), - ) - reopenedStore.`init`(leafIndex = 42UL, now = 101UL) - - val records = reopenedStore.listCredentials(issuerSchemaId = null, now = 102UL) - assertEquals(1, records.size) - assertEquals(credentialId, records.single().credentialId) - assertContentEquals(proofBytes, reopenedStore.merkleCacheGet(validUntil = 120UL)) - - root.deleteRecursively() - } -} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt deleted file mode 100644 index ac19ea5a4..000000000 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.world.walletkit - -import kotlin.test.Test -import kotlin.test.assertFails -import kotlin.test.assertTrue - -class DeviceKeystoreTests { - @Test - fun sealAndOpenRoundTrip() { - val keystore = InMemoryDeviceKeystore() - val associatedData = "ad".encodeToByteArray() - val plaintext = "hello".encodeToByteArray() - - val ciphertext = keystore.seal(associatedData, plaintext) - val opened = keystore.openSealed(associatedData, ciphertext) - - assertTrue(opened.contentEquals(plaintext)) - } - - @Test - fun associatedDataMismatchFails() { - val keystore = InMemoryDeviceKeystore() - val plaintext = "secret".encodeToByteArray() - val ciphertext = keystore.seal("ad-1".encodeToByteArray(), plaintext) - - assertFails { - keystore.openSealed("ad-2".encodeToByteArray(), ciphertext) - } - } - - @Test - fun reopenWithSameKeyMaterialCanOpenCiphertext() { - val keyBytes = randomKeystoreKeyBytes() - val firstKeystore = InMemoryDeviceKeystore(keyBytes) - val secondKeystore = InMemoryDeviceKeystore(keyBytes) - val associatedData = "ad".encodeToByteArray() - val plaintext = "hello".encodeToByteArray() - - val ciphertext = firstKeystore.seal(associatedData, plaintext) - val opened = secondKeystore.openSealed(associatedData, ciphertext) - - assertTrue(opened.contentEquals(plaintext)) - } -} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/logging/LoggingTests.kt similarity index 98% rename from kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt rename to kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/logging/LoggingTests.kt index 91270e6cb..bc9094d17 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/logging/LoggingTests.kt @@ -26,7 +26,7 @@ private class CapturingLogger : Logger { } } -class SimpleTest { +class LoggingTests { @Test fun initLoggingForwardsLevelAndMessage() { val logger = CapturingLogger() diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/storage/StorageTests.kt similarity index 52% rename from kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt rename to kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/storage/StorageTests.kt index fc22656b5..357bf18ec 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/storage/StorageTests.kt @@ -6,14 +6,180 @@ import java.util.UUID import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue import uniffi.walletkit_core.AtomicBlobStore import uniffi.walletkit_core.Credential +import uniffi.walletkit_core.CredentialStore import uniffi.walletkit_core.DeviceKeystore import uniffi.walletkit_core.FieldElement import uniffi.walletkit_core.StorageException import uniffi.walletkit_core.StoragePaths import uniffi.walletkit_core.StorageProvider +class AtomicBlobStoreTests { + @Test + fun writeReadDelete() { + val root = tempDirectory() + val store = FileBlobStore(root) + val path = "account_keys.bin" + val payload = byteArrayOf(1, 2, 3, 4) + + store.writeAtomic(path, payload) + val readBack = store.read(path) + assertEquals(payload.toList(), readBack?.toList()) + + store.delete(path) + assertNull(store.read(path)) + + root.deleteRecursively() + } +} + +class CredentialStoreTests { + @Test + fun methodsRequireInit() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + assertFailsWith { + store.listCredentials(issuerSchemaId = null, now = 100UL) + } + assertFailsWith { + store.merkleCacheGet(validUntil = 100UL) + } + + root.deleteRecursively() + } + + @Test + fun storeAndCacheFlows() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + assertNull(store.merkleCacheGet(validUntil = 100UL)) + + val credentialId = + store.storeCredential( + credential = sampleCredential(), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = byteArrayOf(4, 5, 6), + now = 100UL, + ) + + val records = store.listCredentials(issuerSchemaId = null, now = 101UL) + assertEquals(1, records.size) + val record = records[0] + assertEquals(credentialId, record.credentialId) + assertEquals(7UL, record.issuerSchemaId) + assertEquals(1_800_000_000UL, record.expiresAt) + + val proofBytes = byteArrayOf(9, 9, 9) + store.merkleCachePut( + proofBytes = proofBytes, + now = 100UL, + ttlSeconds = 60UL, + ) + val cached = + store.merkleCacheGet( + validUntil = 110UL, + ) + assertContentEquals(proofBytes, assertNotNull(cached)) + val expired = store.merkleCacheGet(validUntil = 161UL) + assertNull(expired) + + root.deleteRecursively() + } + + @Test + fun reopenPersistsVaultAndCache() { + val root = tempDirectory() + val keyBytes = randomKeystoreKeyBytes() + val firstStore = + CredentialStore.fromProviderArc( + InMemoryStorageProvider(root, InMemoryDeviceKeystore(keyBytes)), + ) + + firstStore.`init`(leafIndex = 42UL, now = 100UL) + val credentialId = + firstStore.storeCredential( + credential = sampleCredential(), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 100UL, + ) + val proofBytes = byteArrayOf(9, 9, 9) + firstStore.merkleCachePut( + proofBytes = proofBytes, + now = 100UL, + ttlSeconds = 60UL, + ) + + val reopenedStore = + CredentialStore.fromProviderArc( + InMemoryStorageProvider(root, InMemoryDeviceKeystore(keyBytes)), + ) + reopenedStore.`init`(leafIndex = 42UL, now = 101UL) + + val records = reopenedStore.listCredentials(issuerSchemaId = null, now = 102UL) + assertEquals(1, records.size) + assertEquals(credentialId, records.single().credentialId) + assertContentEquals(proofBytes, assertNotNull(reopenedStore.merkleCacheGet(validUntil = 120UL))) + + root.deleteRecursively() + } +} + +class DeviceKeystoreTests { + @Test + fun sealAndOpenRoundTrip() { + val keystore = InMemoryDeviceKeystore() + val associatedData = "ad".encodeToByteArray() + val plaintext = "hello".encodeToByteArray() + + val ciphertext = keystore.seal(associatedData, plaintext) + val opened = keystore.openSealed(associatedData, ciphertext) + + assertTrue(opened.contentEquals(plaintext)) + } + + @Test + fun associatedDataMismatchFails() { + val keystore = InMemoryDeviceKeystore() + val plaintext = "secret".encodeToByteArray() + val ciphertext = keystore.seal("ad-1".encodeToByteArray(), plaintext) + + assertFails { + keystore.openSealed("ad-2".encodeToByteArray(), ciphertext) + } + } + + @Test + fun reopenWithSameKeyMaterialCanOpenCiphertext() { + val keyBytes = randomKeystoreKeyBytes() + val firstKeystore = InMemoryDeviceKeystore(keyBytes) + val secondKeystore = InMemoryDeviceKeystore(keyBytes) + val associatedData = "ad".encodeToByteArray() + val plaintext = "hello".encodeToByteArray() + + val ciphertext = firstKeystore.seal(associatedData, plaintext) + val opened = secondKeystore.openSealed(associatedData, ciphertext) + + assertTrue(opened.contentEquals(plaintext)) + } +} + fun tempDirectory(): File { val dir = File(System.getProperty("java.io.tmpdir"), "walletkit-tests-${UUID.randomUUID()}") dir.mkdirs() diff --git a/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift b/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift deleted file mode 100644 index ff9f285df..000000000 --- a/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import XCTest -@testable import WalletKit - -final class AtomicBlobStoreTests: XCTestCase { - func testWriteReadDelete() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let store = TestIOSAtomicBlobStore(baseURL: root) - let path = "account_keys.bin" - let payload = Data([1, 2, 3, 4]) - - try store.writeAtomic(path: path, bytes: payload) - let readBack = try store.read(path: path) - - XCTAssertEqual(readBack, payload) - - try store.delete(path: path) - let afterDelete = try store.read(path: path) - XCTAssertNil(afterDelete) - } -} diff --git a/swift/tests/WalletKitTests/CredentialStoreTests.swift b/swift/tests/WalletKitTests/CredentialStoreTests.swift deleted file mode 100644 index 800d25be3..000000000 --- a/swift/tests/WalletKitTests/CredentialStoreTests.swift +++ /dev/null @@ -1,339 +0,0 @@ -import Foundation -import XCTest -@testable import WalletKit - -final class CredentialStoreTests: XCTestCase { - private let account = "test-account" - - func testMethodsRequireInit() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let store = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - - XCTAssertThrowsError(try store.listCredentials( - issuerSchemaId: Optional.none, - now: 100 - )) { error in - XCTAssertEqual(error as? StorageError, .NotInitialized) - } - XCTAssertThrowsError(try store.merkleCacheGet(validUntil: 100)) { error in - XCTAssertEqual(error as? StorageError, .NotInitialized) - } - } - - func testInitRejectsLeafIndexMismatch() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let store = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - - try store.`init`(leafIndex: 42, now: 100) - - XCTAssertThrowsError(try store.`init`(leafIndex: 43, now: 101)) { error in - guard case let .InvalidLeafIndex(expected, provided) = error as? StorageError else { - return XCTFail("Expected InvalidLeafIndex, got \(error)") - } - XCTAssertEqual(expected, 42) - XCTAssertEqual(provided, 43) - } - } - - func testInitIsIdempotentForSameLeafIndex() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let store = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - - try store.`init`(leafIndex: 42, now: 100) - let credentialId = try store.storeCredential( - credential: sampleCredential(), - blindingFactor: sampleBlindingFactor(), - expiresAt: 1_800_000_000, - associatedData: nil, - now: 100 - ) - - try store.`init`(leafIndex: 42, now: 101) - - let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 102) - XCTAssertEqual(records.count, 1) - XCTAssertEqual(records[0].credentialId, credentialId) - } - - func testStoreAndCacheFlows() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let keystore = TestIOSDeviceKeystore(service: service, account: account) - let worldidDir = root.appendingPathComponent("worldid", isDirectory: true) - let blobStore = TestIOSAtomicBlobStore(baseURL: worldidDir) - let paths = StoragePaths.fromRoot(root: root.path) - - let store = try CredentialStore.newWithComponents( - paths: paths, - keystore: keystore, - blobStore: blobStore - ) - - try store.`init`(leafIndex: 42, now: 100) - XCTAssertNil(try store.merkleCacheGet(validUntil: 100)) - - let credentialId = try store.storeCredential( - credential: sampleCredential(), - blindingFactor: sampleBlindingFactor(), - expiresAt: 1_800_000_000, - associatedData: Data([4, 5, 6]), - now: 100 - ) - - let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 101) - XCTAssertEqual(records.count, 1) - let record = records[0] - XCTAssertEqual(record.credentialId, credentialId) - XCTAssertEqual(record.issuerSchemaId, 7) - XCTAssertEqual(record.expiresAt, 1_800_000_000) - - let proofBytes = Data([9, 9, 9]) - try store.merkleCachePut( - proofBytes: proofBytes, - now: 100, - ttlSeconds: 60 - ) - let cached = try store.merkleCacheGet( - validUntil: 110 - ) - XCTAssertEqual(cached, proofBytes) - let expired = try store.merkleCacheGet(validUntil: 161) - XCTAssertNil(expired) - } - - func testStoreCredentialReturnsStableDistinctIds() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let store = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - - try store.`init`(leafIndex: 42, now: 100) - let firstCredentialId = try store.storeCredential( - credential: sampleCredential(issuerSchemaId: 7, expiresAt: 1_800_000_000), - blindingFactor: sampleBlindingFactor(), - expiresAt: 1_800_000_000, - associatedData: nil, - now: 100 - ) - let secondCredentialId = try store.storeCredential( - credential: sampleCredential(issuerSchemaId: 8, expiresAt: 1_900_000_000), - blindingFactor: sampleBlindingFactor(), - expiresAt: 1_900_000_000, - associatedData: nil, - now: 101 - ) - - XCTAssertNotEqual(firstCredentialId, secondCredentialId) - - let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 102) - XCTAssertEqual(records.count, 2) - XCTAssertEqual(Set(records.map(\.credentialId)), Set([firstCredentialId, secondCredentialId])) - } - - func testListCredentialsFiltersByIssuerSchemaId() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let store = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - - try store.`init`(leafIndex: 42, now: 100) - _ = try store.storeCredential( - credential: sampleCredential(issuerSchemaId: 7, expiresAt: 1_800_000_000), - blindingFactor: sampleBlindingFactor(), - expiresAt: 1_800_000_000, - associatedData: nil, - now: 100 - ) - _ = try store.storeCredential( - credential: sampleCredential(issuerSchemaId: 8, expiresAt: 1_900_000_000), - blindingFactor: sampleBlindingFactor(), - expiresAt: 1_900_000_000, - associatedData: nil, - now: 101 - ) - - let filtered = try store.listCredentials(issuerSchemaId: 7, now: 102) - XCTAssertEqual(filtered.count, 1) - XCTAssertEqual(filtered[0].issuerSchemaId, 7) - } - - func testExpiredCredentialsAreFilteredOut() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let store = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - - try store.`init`(leafIndex: 42, now: 100) - _ = try store.storeCredential( - credential: sampleCredential(issuerSchemaId: 7, expiresAt: 120), - blindingFactor: sampleBlindingFactor(), - expiresAt: 120, - associatedData: nil, - now: 100 - ) - _ = try store.storeCredential( - credential: sampleCredential(issuerSchemaId: 8, expiresAt: 1_800_000_000), - blindingFactor: sampleBlindingFactor(), - expiresAt: 1_800_000_000, - associatedData: nil, - now: 101 - ) - - let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 121) - XCTAssertEqual(records.count, 1) - XCTAssertEqual(records[0].issuerSchemaId, 8) - } - - func testStoragePathsMatchWorldIdLayout() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let store = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - - let paths = try store.storagePaths() - XCTAssertEqual(paths.rootPathString(), root.path) - XCTAssertTrue(paths.worldidDirPathString().hasSuffix("/worldid")) - XCTAssertTrue(paths.vaultDbPathString().hasSuffix("/worldid/account.vault.sqlite")) - XCTAssertTrue(paths.cacheDbPathString().hasSuffix("/worldid/account.cache.sqlite")) - XCTAssertTrue(paths.lockPathString().hasSuffix("/worldid/lock")) - } - - func testMerkleCachePutRefreshesExistingEntry() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let store = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - - try store.`init`(leafIndex: 42, now: 100) - try store.merkleCachePut(proofBytes: Data([1, 2, 3]), now: 100, ttlSeconds: 10) - try store.merkleCachePut(proofBytes: Data([4, 5, 6]), now: 101, ttlSeconds: 60) - - let cached = try store.merkleCacheGet(validUntil: 120) - XCTAssertEqual(cached, Data([4, 5, 6])) - } - - func testReopenPersistsVaultAndCache() throws { - let root = makeTempDirectory() - defer { try? FileManager.default.removeItem(at: root) } - - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let firstStore = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - try firstStore.`init`(leafIndex: 42, now: 100) - let credentialId = try firstStore.storeCredential( - credential: sampleCredential(), - blindingFactor: sampleBlindingFactor(), - expiresAt: 1_800_000_000, - associatedData: nil, - now: 100 - ) - let proofBytes = Data([9, 9, 9]) - try firstStore.merkleCachePut(proofBytes: proofBytes, now: 100, ttlSeconds: 60) - - let reopenedStore = try CredentialStore.newWithComponents( - paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( - baseURL: root.appendingPathComponent("worldid", isDirectory: true) - ) - ) - try reopenedStore.`init`(leafIndex: 42, now: 101) - - let records = try reopenedStore.listCredentials( - issuerSchemaId: Optional.none, - now: 102 - ) - XCTAssertEqual(records.count, 1) - XCTAssertEqual(records[0].credentialId, credentialId) - XCTAssertEqual(try reopenedStore.merkleCacheGet(validUntil: 120), proofBytes) - } -} diff --git a/swift/tests/WalletKitTests/DeviceKeystoreTests.swift b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift deleted file mode 100644 index ad110842b..000000000 --- a/swift/tests/WalletKitTests/DeviceKeystoreTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -import CryptoKit -import Foundation -import Security -import XCTest -@testable import WalletKit - -final class DeviceKeystoreTests: XCTestCase { - private let account = "test-account" - - func testSealAndOpenRoundTrip() throws { - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let keystore = TestIOSDeviceKeystore(service: service, account: account) - let associatedData = Data("ad".utf8) - let plaintext = Data("hello".utf8) - - let ciphertext = try keystore.seal( - associatedData: associatedData, - plaintext: plaintext - ) - let opened = try keystore.openSealed( - associatedData: associatedData, - ciphertext: ciphertext - ) - - XCTAssertEqual(opened, plaintext) - } - - func testAssociatedDataMismatchFails() throws { - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let keystore = TestIOSDeviceKeystore(service: service, account: account) - let plaintext = Data("secret".utf8) - - let ciphertext = try keystore.seal( - associatedData: Data("ad-1".utf8), - plaintext: plaintext - ) - - XCTAssertThrowsError( - try keystore.openSealed( - associatedData: Data("ad-2".utf8), - ciphertext: ciphertext - ) - ) - } - - func testReopenWithSameIdentityCanOpenCiphertext() throws { - let service = uniqueKeystoreService() - defer { deleteKeychainItem(service: service, account: account) } - - let firstKeystore = TestIOSDeviceKeystore(service: service, account: account) - let secondKeystore = TestIOSDeviceKeystore(service: service, account: account) - let associatedData = Data("ad".utf8) - let plaintext = Data("hello".utf8) - - let ciphertext = try firstKeystore.seal( - associatedData: associatedData, - plaintext: plaintext - ) - let opened = try secondKeystore.openSealed( - associatedData: associatedData, - ciphertext: ciphertext - ) - - XCTAssertEqual(opened, plaintext) - } -} diff --git a/swift/tests/WalletKitTests/SimpleTest.swift b/swift/tests/WalletKitTests/Logging/LoggingTests.swift similarity index 96% rename from swift/tests/WalletKitTests/SimpleTest.swift rename to swift/tests/WalletKitTests/Logging/LoggingTests.swift index 7da1dce8a..c661ba2c6 100644 --- a/swift/tests/WalletKitTests/SimpleTest.swift +++ b/swift/tests/WalletKitTests/Logging/LoggingTests.swift @@ -18,7 +18,7 @@ private final class CapturingLogger: WalletKit.Logger { } } -final class SimpleTest: XCTestCase { +final class LoggingTests: XCTestCase { func testInitLoggingForwardsLevelAndMessage() { let logger = CapturingLogger() WalletKit.initLogging(logger: logger, level: .info) diff --git a/swift/tests/WalletKitTests/Storage/StorageTests.swift b/swift/tests/WalletKitTests/Storage/StorageTests.swift new file mode 100644 index 000000000..748ec05f0 --- /dev/null +++ b/swift/tests/WalletKitTests/Storage/StorageTests.swift @@ -0,0 +1,425 @@ +import CryptoKit +import Foundation +import Security +import XCTest +@testable import WalletKit + +final class AtomicBlobStoreTests: XCTestCase { + func testWriteReadDelete() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let store = TestIOSAtomicBlobStore(baseURL: root) + let path = "account_keys.bin" + let payload = Data([1, 2, 3, 4]) + + try store.writeAtomic(path: path, bytes: payload) + let readBack = try store.read(path: path) + + XCTAssertEqual(readBack, payload) + + try store.delete(path: path) + let afterDelete = try store.read(path: path) + XCTAssertNil(afterDelete) + } +} + +final class CredentialStoreTests: XCTestCase { + private let account = "test-account" + + func testMethodsRequireInit() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + XCTAssertThrowsError(try store.listCredentials( + issuerSchemaId: Optional.none, + now: 100 + )) { error in + XCTAssertEqual(error as? StorageError, .NotInitialized) + } + XCTAssertThrowsError(try store.merkleCacheGet(validUntil: 100)) { error in + XCTAssertEqual(error as? StorageError, .NotInitialized) + } + } + + func testStoreAndCacheFlows() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = TestIOSDeviceKeystore(service: service, account: account) + let worldidDir = root.appendingPathComponent("worldid", isDirectory: true) + let blobStore = TestIOSAtomicBlobStore(baseURL: worldidDir) + let paths = StoragePaths.fromRoot(root: root.path) + + let store = try CredentialStore.newWithComponents( + paths: paths, + keystore: keystore, + blobStore: blobStore + ) + + try store.`init`(leafIndex: 42, now: 100) + XCTAssertNil(try store.merkleCacheGet(validUntil: 100)) + + let credentialId = try store.storeCredential( + credential: sampleCredential(), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: Data([4, 5, 6]), + now: 100 + ) + + let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 101) + XCTAssertEqual(records.count, 1) + let record = records[0] + XCTAssertEqual(record.credentialId, credentialId) + XCTAssertEqual(record.issuerSchemaId, 7) + XCTAssertEqual(record.expiresAt, 1_800_000_000) + + let proofBytes = Data([9, 9, 9]) + try store.merkleCachePut( + proofBytes: proofBytes, + now: 100, + ttlSeconds: 60 + ) + let cached = try store.merkleCacheGet( + validUntil: 110 + ) + XCTAssertEqual(cached, proofBytes) + let expired = try store.merkleCacheGet(validUntil: 161) + XCTAssertNil(expired) + } + + func testReopenPersistsVaultAndCache() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let firstStore = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + try firstStore.`init`(leafIndex: 42, now: 100) + let credentialId = try firstStore.storeCredential( + credential: sampleCredential(), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 100 + ) + let proofBytes = Data([9, 9, 9]) + try firstStore.merkleCachePut(proofBytes: proofBytes, now: 100, ttlSeconds: 60) + + let reopenedStore = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + try reopenedStore.`init`(leafIndex: 42, now: 101) + + let records = try reopenedStore.listCredentials( + issuerSchemaId: Optional.none, + now: 102 + ) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0].credentialId, credentialId) + XCTAssertEqual(try reopenedStore.merkleCacheGet(validUntil: 120), proofBytes) + } +} + +final class DeviceKeystoreTests: XCTestCase { + private let account = "test-account" + + func testSealAndOpenRoundTrip() throws { + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = TestIOSDeviceKeystore(service: service, account: account) + let associatedData = Data("ad".utf8) + let plaintext = Data("hello".utf8) + + let ciphertext = try keystore.seal( + associatedData: associatedData, + plaintext: plaintext + ) + let opened = try keystore.openSealed( + associatedData: associatedData, + ciphertext: ciphertext + ) + + XCTAssertEqual(opened, plaintext) + } + + func testAssociatedDataMismatchFails() throws { + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = TestIOSDeviceKeystore(service: service, account: account) + let plaintext = Data("secret".utf8) + + let ciphertext = try keystore.seal( + associatedData: Data("ad-1".utf8), + plaintext: plaintext + ) + + XCTAssertThrowsError( + try keystore.openSealed( + associatedData: Data("ad-2".utf8), + ciphertext: ciphertext + ) + ) + } + + func testReopenWithSameIdentityCanOpenCiphertext() throws { + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let firstKeystore = TestIOSDeviceKeystore(service: service, account: account) + let secondKeystore = TestIOSDeviceKeystore(service: service, account: account) + let associatedData = Data("ad".utf8) + let plaintext = Data("hello".utf8) + + let ciphertext = try firstKeystore.seal( + associatedData: associatedData, + plaintext: plaintext + ) + let opened = try secondKeystore.openSealed( + associatedData: associatedData, + ciphertext: ciphertext + ) + + XCTAssertEqual(opened, plaintext) + } +} + +func makeTempDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "walletkit-tests-\(UUID().uuidString)", + isDirectory: true + ) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +func uniqueKeystoreService() -> String { + "walletkit.devicekeystore.test.\(UUID().uuidString)" +} + +func deleteKeychainItem(service: String, account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) +} + +func sampleCredential( + issuerSchemaId: UInt64 = 7, + expiresAt: UInt64 = 1_800_000_000 +) throws -> Credential { + let sampleCredentialJSON = """ + {"id":13758530325042616850,"version":"V1","issuer_schema_id":\(issuerSchemaId),"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":\(expiresAt),"claims":["0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000"],"associated_data_hash":"0x0000000000000000000000000000000000000000000000000000000000000000","signature":null,"issuer":"0100000000000000000000000000000000000000000000000000000000000000"} + """ + let bytes = Data(sampleCredentialJSON.utf8) + return try Credential.fromBytes(bytes: bytes) +} + +func sampleBlindingFactor() -> FieldElement { + FieldElement.fromU64(value: 17) +} + +final class TestIOSAtomicBlobStore: AtomicBlobStore { + private let baseURL: URL + private let fileManager = FileManager.default + + init(baseURL: URL) { + self.baseURL = baseURL + } + + func read(path: String) throws -> Data? { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + return nil + } + do { + return try Data(contentsOf: url) + } catch { + throw StorageError.BlobStore("read failed: \(error)") + } + } + + func writeAtomic(path: String, bytes: Data) throws { + let url = baseURL.appendingPathComponent(path) + let parent = url.deletingLastPathComponent() + do { + try fileManager.createDirectory( + at: parent, + withIntermediateDirectories: true + ) + try bytes.write(to: url, options: .atomic) + } catch { + throw StorageError.BlobStore("write failed: \(error)") + } + } + + func delete(path: String) throws { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + throw StorageError.BlobStore("delete failed: file not found") + } + do { + try fileManager.removeItem(at: url) + } catch { + throw StorageError.BlobStore("delete failed: \(error)") + } + } +} + +final class TestIOSDeviceKeystore: DeviceKeystore { + private let service: String + private let account: String + private let lock = NSLock() + private static let fallbackLock = NSLock() + private static var fallbackKeys: [String: Data] = [:] + + init( + service: String = "walletkit.devicekeystore", + account: String = "default" + ) { + self.service = service + self.account = account + } + + func seal(associatedData: Data, plaintext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.seal( + plaintext, + using: key, + authenticating: associatedData + ) + guard let combined = sealedBox.combined else { + throw StorageError.Keystore("missing AES-GCM combined payload") + } + return combined + } + + func openSealed(associatedData: Data, ciphertext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) + return try AES.GCM.open( + sealedBox, + using: key, + authenticating: associatedData + ) + } + + private func loadOrCreateKey() throws -> SymmetricKey { + lock.lock() + defer { lock.unlock() } + + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StorageError.Keystore("random key generation failed: \(status)") + } + let keyData = Data(bytes) + + let addStatus = SecItemAdd( + keychainAddQuery(keyData: keyData) as CFDictionary, + nil + ) + if addStatus == errSecDuplicateItem { + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + throw StorageError.Keystore("keychain item duplicated but unreadable") + } + if addStatus == errSecMissingEntitlement { + Self.setFallbackKey(id: fallbackKeyId(), data: keyData) + return SymmetricKey(data: keyData) + } + guard addStatus == errSecSuccess else { + throw StorageError.Keystore("keychain add failed: \(addStatus)") + } + + return SymmetricKey(data: keyData) + } + + private func loadKeyData() throws -> Data? { + var query = keychainBaseQuery() + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + if status == errSecMissingEntitlement { + return Self.fallbackKey(id: fallbackKeyId()) + } + guard status == errSecSuccess else { + throw StorageError.Keystore("keychain read failed: \(status)") + } + guard let data = item as? Data else { + throw StorageError.Keystore("keychain read returned non-data") + } + return data + } + + private func keychainBaseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + } + + private func keychainAddQuery(keyData: Data) -> [String: Any] { + var query = keychainBaseQuery() + query[kSecValueData as String] = keyData + return query + } + + private func fallbackKeyId() -> String { + "\(service)::\(account)" + } + + private static func fallbackKey(id: String) -> Data? { + fallbackLock.lock() + defer { fallbackLock.unlock() } + return fallbackKeys[id] + } + + private static func setFallbackKey(id: String, data: Data) { + fallbackLock.lock() + defer { fallbackLock.unlock() } + fallbackKeys[id] = data + } +} diff --git a/swift/tests/WalletKitTests/TestHelpers.swift b/swift/tests/WalletKitTests/TestHelpers.swift deleted file mode 100644 index 69a359fc3..000000000 --- a/swift/tests/WalletKitTests/TestHelpers.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import Security -@testable import WalletKit - -func makeTempDirectory() -> URL { - let url = FileManager.default.temporaryDirectory.appendingPathComponent( - "walletkit-tests-\(UUID().uuidString)", - isDirectory: true - ) - try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url -} - -func uniqueKeystoreService() -> String { - "walletkit.devicekeystore.test.\(UUID().uuidString)" -} - -func deleteKeychainItem(service: String, account: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - SecItemDelete(query as CFDictionary) -} - -func sampleCredential( - issuerSchemaId: UInt64 = 7, - expiresAt: UInt64 = 1_800_000_000 -) throws -> Credential { - let sampleCredentialJSON = """ - {"id":13758530325042616850,"version":"V1","issuer_schema_id":\(issuerSchemaId),"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":\(expiresAt),"claims":["0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000"],"associated_data_hash":"0x0000000000000000000000000000000000000000000000000000000000000000","signature":null,"issuer":"0100000000000000000000000000000000000000000000000000000000000000"} - """ - let bytes = Data(sampleCredentialJSON.utf8) - return try Credential.fromBytes(bytes: bytes) -} - -func sampleBlindingFactor() -> FieldElement { - FieldElement.fromU64(value: 17) -} diff --git a/swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift b/swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift deleted file mode 100644 index e196af900..000000000 --- a/swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -@testable import WalletKit - -final class TestIOSAtomicBlobStore: AtomicBlobStore { - private let baseURL: URL - private let fileManager = FileManager.default - - init(baseURL: URL) { - self.baseURL = baseURL - } - - func read(path: String) throws -> Data? { - let url = baseURL.appendingPathComponent(path) - guard fileManager.fileExists(atPath: url.path) else { - return nil - } - do { - return try Data(contentsOf: url) - } catch { - throw StorageError.BlobStore("read failed: \(error)") - } - } - - func writeAtomic(path: String, bytes: Data) throws { - let url = baseURL.appendingPathComponent(path) - let parent = url.deletingLastPathComponent() - do { - try fileManager.createDirectory( - at: parent, - withIntermediateDirectories: true - ) - try bytes.write(to: url, options: .atomic) - } catch { - throw StorageError.BlobStore("write failed: \(error)") - } - } - - func delete(path: String) throws { - let url = baseURL.appendingPathComponent(path) - guard fileManager.fileExists(atPath: url.path) else { - throw StorageError.BlobStore("delete failed: file not found") - } - do { - try fileManager.removeItem(at: url) - } catch { - throw StorageError.BlobStore("delete failed: \(error)") - } - } -} diff --git a/swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift b/swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift deleted file mode 100644 index 279242842..000000000 --- a/swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift +++ /dev/null @@ -1,132 +0,0 @@ -import CryptoKit -import Foundation -import Security -@testable import WalletKit - -final class TestIOSDeviceKeystore: DeviceKeystore { - private let service: String - private let account: String - private let lock = NSLock() - private static let fallbackLock = NSLock() - private static var fallbackKeys: [String: Data] = [:] - - init( - service: String = "walletkit.devicekeystore", - account: String = "default" - ) { - self.service = service - self.account = account - } - - func seal(associatedData: Data, plaintext: Data) throws -> Data { - let key = try loadOrCreateKey() - let sealedBox = try AES.GCM.seal( - plaintext, - using: key, - authenticating: associatedData - ) - guard let combined = sealedBox.combined else { - throw StorageError.Keystore("missing AES-GCM combined payload") - } - return combined - } - - func openSealed(associatedData: Data, ciphertext: Data) throws -> Data { - let key = try loadOrCreateKey() - let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) - return try AES.GCM.open( - sealedBox, - using: key, - authenticating: associatedData - ) - } - - private func loadOrCreateKey() throws -> SymmetricKey { - lock.lock() - defer { lock.unlock() } - - if let data = try loadKeyData() { - return SymmetricKey(data: data) - } - - var bytes = [UInt8](repeating: 0, count: 32) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw StorageError.Keystore("random key generation failed: \(status)") - } - let keyData = Data(bytes) - - let addStatus = SecItemAdd( - keychainAddQuery(keyData: keyData) as CFDictionary, - nil - ) - if addStatus == errSecDuplicateItem { - if let data = try loadKeyData() { - return SymmetricKey(data: data) - } - throw StorageError.Keystore("keychain item duplicated but unreadable") - } - if addStatus == errSecMissingEntitlement { - Self.setFallbackKey(id: fallbackKeyId(), data: keyData) - return SymmetricKey(data: keyData) - } - guard addStatus == errSecSuccess else { - throw StorageError.Keystore("keychain add failed: \(addStatus)") - } - - return SymmetricKey(data: keyData) - } - - private func loadKeyData() throws -> Data? { - var query = keychainBaseQuery() - query[kSecReturnData as String] = kCFBooleanTrue - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - if status == errSecItemNotFound { - return nil - } - if status == errSecMissingEntitlement { - return Self.fallbackKey(id: fallbackKeyId()) - } - guard status == errSecSuccess else { - throw StorageError.Keystore("keychain read failed: \(status)") - } - guard let data = item as? Data else { - throw StorageError.Keystore("keychain read returned non-data") - } - return data - } - - private func keychainBaseQuery() -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - ] - } - - private func keychainAddQuery(keyData: Data) -> [String: Any] { - var query = keychainBaseQuery() - query[kSecValueData as String] = keyData - return query - } - - private func fallbackKeyId() -> String { - "\(service)::\(account)" - } - - private static func fallbackKey(id: String) -> Data? { - fallbackLock.lock() - defer { fallbackLock.unlock() } - return fallbackKeys[id] - } - - private static func setFallbackKey(id: String, data: Data) { - fallbackLock.lock() - defer { fallbackLock.unlock() } - fallbackKeys[id] = data - } -} From 7b5a2124179701acffa5cd483a213e78739a7cfb Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 12:01:53 -0800 Subject: [PATCH 03/12] reorg --- .../test/kotlin/org/world/walletkit/{logging => }/LoggingTests.kt | 0 .../test/kotlin/org/world/walletkit/{storage => }/StorageTests.kt | 0 swift/tests/WalletKitTests/{Logging => }/LoggingTests.swift | 0 swift/tests/WalletKitTests/{Storage => }/StorageTests.swift | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/{logging => }/LoggingTests.kt (100%) rename kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/{storage => }/StorageTests.kt (100%) rename swift/tests/WalletKitTests/{Logging => }/LoggingTests.swift (100%) rename swift/tests/WalletKitTests/{Storage => }/StorageTests.swift (100%) diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/logging/LoggingTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt similarity index 100% rename from kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/logging/LoggingTests.kt rename to kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/storage/StorageTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/StorageTests.kt similarity index 100% rename from kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/storage/StorageTests.kt rename to kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/StorageTests.kt diff --git a/swift/tests/WalletKitTests/Logging/LoggingTests.swift b/swift/tests/WalletKitTests/LoggingTests.swift similarity index 100% rename from swift/tests/WalletKitTests/Logging/LoggingTests.swift rename to swift/tests/WalletKitTests/LoggingTests.swift diff --git a/swift/tests/WalletKitTests/Storage/StorageTests.swift b/swift/tests/WalletKitTests/StorageTests.swift similarity index 100% rename from swift/tests/WalletKitTests/Storage/StorageTests.swift rename to swift/tests/WalletKitTests/StorageTests.swift From de25fe6fe94f78126d03c282f8af6aafd07c1be7 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 12:14:40 -0800 Subject: [PATCH 04/12] add testDeleteMissingFileIsNoOp --- swift/support/IOSAtomicBlobStore.swift | 2 +- swift/tests/WalletKitTests/StorageTests.swift | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/swift/support/IOSAtomicBlobStore.swift b/swift/support/IOSAtomicBlobStore.swift index b7a350015..71b635c8f 100644 --- a/swift/support/IOSAtomicBlobStore.swift +++ b/swift/support/IOSAtomicBlobStore.swift @@ -37,7 +37,7 @@ public final class IOSAtomicBlobStore: AtomicBlobStore { public func delete(path: String) throws { let url = baseURL.appendingPathComponent(path) guard fileManager.fileExists(atPath: url.path) else { - throw StorageError.BlobStore("delete failed: file not found") + return } do { try fileManager.removeItem(at: url) diff --git a/swift/tests/WalletKitTests/StorageTests.swift b/swift/tests/WalletKitTests/StorageTests.swift index 748ec05f0..5988c1ea5 100644 --- a/swift/tests/WalletKitTests/StorageTests.swift +++ b/swift/tests/WalletKitTests/StorageTests.swift @@ -22,6 +22,15 @@ final class AtomicBlobStoreTests: XCTestCase { let afterDelete = try store.read(path: path) XCTAssertNil(afterDelete) } + + func testDeleteMissingFileIsNoOp() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let store = TestIOSAtomicBlobStore(baseURL: root) + + XCTAssertNoThrow(try store.delete(path: "missing.bin")) + } } final class CredentialStoreTests: XCTestCase { @@ -286,7 +295,7 @@ final class TestIOSAtomicBlobStore: AtomicBlobStore { func delete(path: String) throws { let url = baseURL.appendingPathComponent(path) guard fileManager.fileExists(atPath: url.path) else { - throw StorageError.BlobStore("delete failed: file not found") + return } do { try fileManager.removeItem(at: url) From e5a79ee18d71df9cfef38097bac7e7fd8951f0e5 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 12:15:23 -0800 Subject: [PATCH 05/12] fix ci --- .../kotlin/org/world/walletkit/LoggingTests.kt | 4 ++-- .../kotlin/org/world/walletkit/StorageTests.kt | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt index bc9094d17..b254d4ff1 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt @@ -1,11 +1,11 @@ package org.world.walletkit -import kotlin.test.Test -import kotlin.test.assertTrue import uniffi.walletkit_core.LogLevel import uniffi.walletkit_core.Logger import uniffi.walletkit_core.emitLog import uniffi.walletkit_core.initLogging +import kotlin.test.Test +import kotlin.test.assertTrue private class CapturingLogger : Logger { private val lock = Any() diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/StorageTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/StorageTests.kt index 357bf18ec..3e0c73e6b 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/StorageTests.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/StorageTests.kt @@ -1,5 +1,13 @@ package org.world.walletkit +import uniffi.walletkit_core.AtomicBlobStore +import uniffi.walletkit_core.Credential +import uniffi.walletkit_core.CredentialStore +import uniffi.walletkit_core.DeviceKeystore +import uniffi.walletkit_core.FieldElement +import uniffi.walletkit_core.StorageException +import uniffi.walletkit_core.StoragePaths +import uniffi.walletkit_core.StorageProvider import java.io.File import java.security.SecureRandom import java.util.UUID @@ -14,14 +22,6 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import uniffi.walletkit_core.AtomicBlobStore -import uniffi.walletkit_core.Credential -import uniffi.walletkit_core.CredentialStore -import uniffi.walletkit_core.DeviceKeystore -import uniffi.walletkit_core.FieldElement -import uniffi.walletkit_core.StorageException -import uniffi.walletkit_core.StoragePaths -import uniffi.walletkit_core.StorageProvider class AtomicBlobStoreTests { @Test From 61f79e5d4abe2247ba5cbc6193ac10eed06dd9e9 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 12:44:14 -0800 Subject: [PATCH 06/12] use IOSAtomicBlobStore --- swift/tests/WalletKitTests/StorageTests.swift | 203 ++---------------- 1 file changed, 14 insertions(+), 189 deletions(-) diff --git a/swift/tests/WalletKitTests/StorageTests.swift b/swift/tests/WalletKitTests/StorageTests.swift index 5988c1ea5..27e2a75ab 100644 --- a/swift/tests/WalletKitTests/StorageTests.swift +++ b/swift/tests/WalletKitTests/StorageTests.swift @@ -9,7 +9,7 @@ final class AtomicBlobStoreTests: XCTestCase { let root = makeTempDirectory() defer { try? FileManager.default.removeItem(at: root) } - let store = TestIOSAtomicBlobStore(baseURL: root) + let store = IOSAtomicBlobStore(baseURL: root) let path = "account_keys.bin" let payload = Data([1, 2, 3, 4]) @@ -27,7 +27,7 @@ final class AtomicBlobStoreTests: XCTestCase { let root = makeTempDirectory() defer { try? FileManager.default.removeItem(at: root) } - let store = TestIOSAtomicBlobStore(baseURL: root) + let store = IOSAtomicBlobStore(baseURL: root) XCTAssertNoThrow(try store.delete(path: "missing.bin")) } @@ -45,8 +45,8 @@ final class CredentialStoreTests: XCTestCase { let store = try CredentialStore.newWithComponents( paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( + keystore: IOSDeviceKeystore(service: service, account: account), + blobStore: IOSAtomicBlobStore( baseURL: root.appendingPathComponent("worldid", isDirectory: true) ) ) @@ -69,9 +69,9 @@ final class CredentialStoreTests: XCTestCase { let service = uniqueKeystoreService() defer { deleteKeychainItem(service: service, account: account) } - let keystore = TestIOSDeviceKeystore(service: service, account: account) + let keystore = IOSDeviceKeystore(service: service, account: account) let worldidDir = root.appendingPathComponent("worldid", isDirectory: true) - let blobStore = TestIOSAtomicBlobStore(baseURL: worldidDir) + let blobStore = IOSAtomicBlobStore(baseURL: worldidDir) let paths = StoragePaths.fromRoot(root: root.path) let store = try CredentialStore.newWithComponents( @@ -121,8 +121,8 @@ final class CredentialStoreTests: XCTestCase { let firstStore = try CredentialStore.newWithComponents( paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( + keystore: IOSDeviceKeystore(service: service, account: account), + blobStore: IOSAtomicBlobStore( baseURL: root.appendingPathComponent("worldid", isDirectory: true) ) ) @@ -139,8 +139,8 @@ final class CredentialStoreTests: XCTestCase { let reopenedStore = try CredentialStore.newWithComponents( paths: StoragePaths.fromRoot(root: root.path), - keystore: TestIOSDeviceKeystore(service: service, account: account), - blobStore: TestIOSAtomicBlobStore( + keystore: IOSDeviceKeystore(service: service, account: account), + blobStore: IOSAtomicBlobStore( baseURL: root.appendingPathComponent("worldid", isDirectory: true) ) ) @@ -163,7 +163,7 @@ final class DeviceKeystoreTests: XCTestCase { let service = uniqueKeystoreService() defer { deleteKeychainItem(service: service, account: account) } - let keystore = TestIOSDeviceKeystore(service: service, account: account) + let keystore = IOSDeviceKeystore(service: service, account: account) let associatedData = Data("ad".utf8) let plaintext = Data("hello".utf8) @@ -183,7 +183,7 @@ final class DeviceKeystoreTests: XCTestCase { let service = uniqueKeystoreService() defer { deleteKeychainItem(service: service, account: account) } - let keystore = TestIOSDeviceKeystore(service: service, account: account) + let keystore = IOSDeviceKeystore(service: service, account: account) let plaintext = Data("secret".utf8) let ciphertext = try keystore.seal( @@ -203,8 +203,8 @@ final class DeviceKeystoreTests: XCTestCase { let service = uniqueKeystoreService() defer { deleteKeychainItem(service: service, account: account) } - let firstKeystore = TestIOSDeviceKeystore(service: service, account: account) - let secondKeystore = TestIOSDeviceKeystore(service: service, account: account) + let firstKeystore = IOSDeviceKeystore(service: service, account: account) + let secondKeystore = IOSDeviceKeystore(service: service, account: account) let associatedData = Data("ad".utf8) let plaintext = Data("hello".utf8) @@ -257,178 +257,3 @@ func sampleCredential( func sampleBlindingFactor() -> FieldElement { FieldElement.fromU64(value: 17) } - -final class TestIOSAtomicBlobStore: AtomicBlobStore { - private let baseURL: URL - private let fileManager = FileManager.default - - init(baseURL: URL) { - self.baseURL = baseURL - } - - func read(path: String) throws -> Data? { - let url = baseURL.appendingPathComponent(path) - guard fileManager.fileExists(atPath: url.path) else { - return nil - } - do { - return try Data(contentsOf: url) - } catch { - throw StorageError.BlobStore("read failed: \(error)") - } - } - - func writeAtomic(path: String, bytes: Data) throws { - let url = baseURL.appendingPathComponent(path) - let parent = url.deletingLastPathComponent() - do { - try fileManager.createDirectory( - at: parent, - withIntermediateDirectories: true - ) - try bytes.write(to: url, options: .atomic) - } catch { - throw StorageError.BlobStore("write failed: \(error)") - } - } - - func delete(path: String) throws { - let url = baseURL.appendingPathComponent(path) - guard fileManager.fileExists(atPath: url.path) else { - return - } - do { - try fileManager.removeItem(at: url) - } catch { - throw StorageError.BlobStore("delete failed: \(error)") - } - } -} - -final class TestIOSDeviceKeystore: DeviceKeystore { - private let service: String - private let account: String - private let lock = NSLock() - private static let fallbackLock = NSLock() - private static var fallbackKeys: [String: Data] = [:] - - init( - service: String = "walletkit.devicekeystore", - account: String = "default" - ) { - self.service = service - self.account = account - } - - func seal(associatedData: Data, plaintext: Data) throws -> Data { - let key = try loadOrCreateKey() - let sealedBox = try AES.GCM.seal( - plaintext, - using: key, - authenticating: associatedData - ) - guard let combined = sealedBox.combined else { - throw StorageError.Keystore("missing AES-GCM combined payload") - } - return combined - } - - func openSealed(associatedData: Data, ciphertext: Data) throws -> Data { - let key = try loadOrCreateKey() - let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) - return try AES.GCM.open( - sealedBox, - using: key, - authenticating: associatedData - ) - } - - private func loadOrCreateKey() throws -> SymmetricKey { - lock.lock() - defer { lock.unlock() } - - if let data = try loadKeyData() { - return SymmetricKey(data: data) - } - - var bytes = [UInt8](repeating: 0, count: 32) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw StorageError.Keystore("random key generation failed: \(status)") - } - let keyData = Data(bytes) - - let addStatus = SecItemAdd( - keychainAddQuery(keyData: keyData) as CFDictionary, - nil - ) - if addStatus == errSecDuplicateItem { - if let data = try loadKeyData() { - return SymmetricKey(data: data) - } - throw StorageError.Keystore("keychain item duplicated but unreadable") - } - if addStatus == errSecMissingEntitlement { - Self.setFallbackKey(id: fallbackKeyId(), data: keyData) - return SymmetricKey(data: keyData) - } - guard addStatus == errSecSuccess else { - throw StorageError.Keystore("keychain add failed: \(addStatus)") - } - - return SymmetricKey(data: keyData) - } - - private func loadKeyData() throws -> Data? { - var query = keychainBaseQuery() - query[kSecReturnData as String] = kCFBooleanTrue - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - if status == errSecItemNotFound { - return nil - } - if status == errSecMissingEntitlement { - return Self.fallbackKey(id: fallbackKeyId()) - } - guard status == errSecSuccess else { - throw StorageError.Keystore("keychain read failed: \(status)") - } - guard let data = item as? Data else { - throw StorageError.Keystore("keychain read returned non-data") - } - return data - } - - private func keychainBaseQuery() -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - ] - } - - private func keychainAddQuery(keyData: Data) -> [String: Any] { - var query = keychainBaseQuery() - query[kSecValueData as String] = keyData - return query - } - - private func fallbackKeyId() -> String { - "\(service)::\(account)" - } - - private static func fallbackKey(id: String) -> Data? { - fallbackLock.lock() - defer { fallbackLock.unlock() } - return fallbackKeys[id] - } - - private static func setFallbackKey(id: String, data: Data) { - fallbackLock.lock() - defer { fallbackLock.unlock() } - fallbackKeys[id] = data - } -} From 22bfecd2acf549660279debe165f992f59e9ab2e Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 12:44:57 -0800 Subject: [PATCH 07/12] swiftlint --- swift/tests/WalletKitTests/StorageTests.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/swift/tests/WalletKitTests/StorageTests.swift b/swift/tests/WalletKitTests/StorageTests.swift index 27e2a75ab..4757f295a 100644 --- a/swift/tests/WalletKitTests/StorageTests.swift +++ b/swift/tests/WalletKitTests/StorageTests.swift @@ -247,8 +247,23 @@ func sampleCredential( issuerSchemaId: UInt64 = 7, expiresAt: UInt64 = 1_800_000_000 ) throws -> Credential { + let zeroClaim = "\"0x0000000000000000000000000000000000000000000000000000000000000000\"" + let claims = Array(repeating: zeroClaim, count: 16).joined(separator: ",\n ") let sampleCredentialJSON = """ - {"id":13758530325042616850,"version":"V1","issuer_schema_id":\(issuerSchemaId),"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":\(expiresAt),"claims":["0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000"],"associated_data_hash":"0x0000000000000000000000000000000000000000000000000000000000000000","signature":null,"issuer":"0100000000000000000000000000000000000000000000000000000000000000"} + { + "id": 13758530325042616850, + "version": "V1", + "issuer_schema_id": \(issuerSchemaId), + "sub": "0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b", + "genesis_issued_at": 1700000000, + "expires_at": \(expiresAt), + "claims": [ + \(claims) + ], + "associated_data_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "signature": null, + "issuer": "0100000000000000000000000000000000000000000000000000000000000000" + } """ let bytes = Data(sampleCredentialJSON.utf8) return try Credential.fromBytes(bytes: bytes) From daccaa91d72c5a2b8d95471f62969b996af9ffe1 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 12:53:45 -0800 Subject: [PATCH 08/12] fix StorageException handle --- .../walletkit/storage/AndroidAtomicBlobStore.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt index 2b3a30405..aae2559d8 100644 --- a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt @@ -24,8 +24,8 @@ class AndroidAtomicBlobStore( override fun writeAtomic(path: String, bytes: ByteArray) { val file = File(baseDir, path) val parent = file.parentFile - if (parent != null && !parent.exists()) { - parent.mkdirs() + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw StorageException.BlobStore("failed to create parent directory") } val temp = File( parent ?: baseDir, @@ -40,7 +40,11 @@ class AndroidAtomicBlobStore( temp.copyTo(file, overwrite = true) temp.delete() } + } catch (error: StorageException) { + cleanupTempFile(temp) + throw error } catch (error: Exception) { + cleanupTempFile(temp) throw StorageException.BlobStore("write failed: ${error.message}") } } @@ -54,4 +58,10 @@ class AndroidAtomicBlobStore( throw StorageException.BlobStore("delete failed") } } + + private fun cleanupTempFile(temp: File) { + if (temp.exists()) { + temp.delete() + } + } } From bb13a23e66264d69be512726d5e7bc80232e1af8 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 13:48:44 -0800 Subject: [PATCH 09/12] use support ios files --- .../walletkit/storage/AndroidAtomicBlobStore.kt | 6 +----- swift/test_swift.sh | 13 +++++++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt index aae2559d8..259715e48 100644 --- a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt @@ -33,12 +33,8 @@ class AndroidAtomicBlobStore( ) try { temp.writeBytes(bytes) - if (file.exists() && !file.delete()) { - throw StorageException.BlobStore("failed to remove existing file") - } if (!temp.renameTo(file)) { - temp.copyTo(file, overwrite = true) - temp.delete() + throw StorageException.BlobStore("failed to atomically replace existing file") } } catch (error: StorageException) { cleanupTempFile(temp) diff --git a/swift/test_swift.sh b/swift/test_swift.sh index 22b964137..0d88f55a0 100755 --- a/swift/test_swift.sh +++ b/swift/test_swift.sh @@ -24,6 +24,7 @@ fi BASE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TESTS_PATH="$BASE_PATH/tests" SOURCES_PATH_NAME="/Sources/WalletKit/" +SUPPORT_PATH="$BASE_PATH/support" echo -e "${BLUE}๐Ÿ”จ Step 1: Building Swift bindings${NC}" # Run the build_swift.sh script from parent directory @@ -41,6 +42,10 @@ echo -e "${BLUE}๐Ÿ“ฆ Step 2: Copying generated Swift files to test package${NC}" # Ensure the destination directory exists mkdir -p "$TESTS_PATH/$SOURCES_PATH_NAME" +# Clear previously staged Swift sources so the test package always mirrors +# the current generated bindings plus local support helpers. +rm -f "$TESTS_PATH/$SOURCES_PATH_NAME"/*.swift + # Copy the generated Swift files to the test package if [ -f "$BASE_PATH/Sources/WalletKit/walletkit.swift" ]; then cp "$BASE_PATH/Sources/WalletKit/walletkit.swift" "$TESTS_PATH/$SOURCES_PATH_NAME" @@ -50,6 +55,14 @@ else exit 1 fi +if compgen -G "$SUPPORT_PATH/*.swift" > /dev/null; then + cp "$SUPPORT_PATH"/*.swift "$TESTS_PATH/$SOURCES_PATH_NAME" + echo -e "${GREEN}โœ… Swift support files copied to test package${NC}" +else + echo -e "${RED}โœ— Could not find Swift support files in: $SUPPORT_PATH${NC}" + exit 1 +fi + echo "" echo -e "${BLUE}๐Ÿงช Running Swift tests with verbose output...${NC}" echo "" From 089d20b07d043b4bee5502a4dcf4e426a5f76126 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 14:01:08 -0800 Subject: [PATCH 10/12] add comment, revert test_kotlin --- kotlin/test_kotlin.sh | 13 +++++-------- .../test/kotlin/org/world/walletkit/LoggingTests.kt | 2 ++ swift/tests/WalletKitTests/LoggingTests.swift | 2 ++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/kotlin/test_kotlin.sh b/kotlin/test_kotlin.sh index 3f46a7d53..cfa043579 100755 --- a/kotlin/test_kotlin.sh +++ b/kotlin/test_kotlin.sh @@ -13,7 +13,9 @@ YELLOW='\033[0;33m' NC='\033[0m' # No Color ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -KOTLIN_DIR="$ROOT_DIR/kotlin" + +TEST_RESULTS_DIR="$ROOT_DIR/kotlin/walletkit-tests/build/test-results/test" +rm -rf "$TEST_RESULTS_DIR" cd "$ROOT_DIR" @@ -43,10 +45,7 @@ echo -e "${BLUE}๐Ÿ”จ Step 1: Building Kotlin bindings with build_kotlin.sh${NC}" echo -e "${GREEN}โœ… Kotlin bindings built${NC}" echo -e "${BLUE}๐Ÿ“ฆ Step 2: Setting up Gradle test environment${NC}" -cd "$KOTLIN_DIR" - -TEST_RESULTS_DIR="$(pwd -P)/walletkit-tests/build/test-results/test" -rm -rf "$TEST_RESULTS_DIR" +cd "$ROOT_DIR/kotlin" # Generate Gradle wrapper if missing if [ ! -f "gradlew" ]; then @@ -77,9 +76,7 @@ echo "" echo -e "${BLUE}๐Ÿงช Step 3: Running Kotlin tests with verbose output...${NC}" echo "" -# Avoid reusing configuration cache entries from a different checkout, which can -# redirect build outputs away from the current workspace and hide successful runs. -./gradlew --no-daemon --no-configuration-cache walletkit-tests:test --info --continue +./gradlew --no-daemon walletkit-tests:test --info --continue echo "" echo "๐Ÿ“Š Test Results Summary:" diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt index b254d4ff1..6a1408b5a 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/LoggingTests.kt @@ -33,6 +33,8 @@ class LoggingTests { initLogging(logger, LogLevel.INFO) emitLog(LogLevel.INFO, "bridge test") + // Log delivery happens on a dedicated background thread, so give it + // a moment to flush through the channel. Thread.sleep(50) val entries = logger.snapshot() diff --git a/swift/tests/WalletKitTests/LoggingTests.swift b/swift/tests/WalletKitTests/LoggingTests.swift index c661ba2c6..a2c20dcf9 100644 --- a/swift/tests/WalletKitTests/LoggingTests.swift +++ b/swift/tests/WalletKitTests/LoggingTests.swift @@ -24,6 +24,8 @@ final class LoggingTests: XCTestCase { WalletKit.initLogging(logger: logger, level: .info) WalletKit.emitLog(level: .info, message: "bridge test") + // Log delivery happens on a dedicated background thread, so give it + // a moment to flush through the channel. Thread.sleep(forTimeInterval: 0.05) let entries = logger.snapshot() From d7484c545b6242b87df5fae668dad96138a2dd60 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Fri, 20 Mar 2026 16:19:16 -0700 Subject: [PATCH 11/12] wrap Swift CryptoKit errors in StorageError Made-with: Cursor --- swift/support/IOSDeviceKeystore.swift | 44 +++++++++++++++++---------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/swift/support/IOSDeviceKeystore.swift b/swift/support/IOSDeviceKeystore.swift index d94753e14..d3885b114 100644 --- a/swift/support/IOSDeviceKeystore.swift +++ b/swift/support/IOSDeviceKeystore.swift @@ -18,26 +18,38 @@ public final class IOSDeviceKeystore: DeviceKeystore { } public func seal(associatedData: Data, plaintext: Data) throws -> Data { - let key = try loadOrCreateKey() - let sealedBox = try AES.GCM.seal( - plaintext, - using: key, - authenticating: associatedData - ) - guard let combined = sealedBox.combined else { - throw StorageError.Keystore("missing AES-GCM combined payload") + do { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.seal( + plaintext, + using: key, + authenticating: associatedData + ) + guard let combined = sealedBox.combined else { + throw StorageError.Keystore("missing AES-GCM combined payload") + } + return combined + } catch let error as StorageError { + throw error + } catch { + throw StorageError.Keystore("seal failed: \(error)") } - return combined } public func openSealed(associatedData: Data, ciphertext: Data) throws -> Data { - let key = try loadOrCreateKey() - let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) - return try AES.GCM.open( - sealedBox, - using: key, - authenticating: associatedData - ) + do { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) + return try AES.GCM.open( + sealedBox, + using: key, + authenticating: associatedData + ) + } catch let error as StorageError { + throw error + } catch { + throw StorageError.Keystore("open failed: \(error)") + } } private func loadOrCreateKey() throws -> SymmetricKey { From 8ca16274c38c62e76a893341cfcb8f1e85477c79 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Fri, 20 Mar 2026 16:42:25 -0700 Subject: [PATCH 12/12] fix StorageException double-wrapping in AndroidDeviceKeystore Made-with: Cursor --- .../org/world/walletkit/storage/AndroidDeviceKeystore.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt index 98f6a8e8b..a992712b9 100644 --- a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt @@ -28,6 +28,8 @@ class AndroidDeviceKeystore( System.arraycopy(iv, 0, output, 1, iv.size) System.arraycopy(ciphertext, 0, output, 1 + iv.size, ciphertext.size) return output + } catch (error: StorageException) { + throw error } catch (error: Exception) { throw StorageException.Keystore("keystore seal failed: ${error.message}") } @@ -53,6 +55,8 @@ class AndroidDeviceKeystore( cipher.init(Cipher.DECRYPT_MODE, key, spec) cipher.updateAAD(associatedData) return cipher.doFinal(payload) + } catch (error: StorageException) { + throw error } catch (error: Exception) { throw StorageException.Keystore("keystore open failed: ${error.message}") }