diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/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/LoggingTests.kt index a8180250f..6a1408b5a 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/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/StorageTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/StorageTests.kt new file mode 100644 index 000000000..3e0c73e6b --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/StorageTests.kt @@ -0,0 +1,300 @@ +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 +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 + +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() + 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..259715e48 --- /dev/null +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt @@ -0,0 +1,63 @@ +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()) { + throw StorageException.BlobStore("failed to create parent directory") + } + val temp = File( + parent ?: baseDir, + "${file.name}.tmp-${UUID.randomUUID()}" + ) + try { + temp.writeBytes(bytes) + if (!temp.renameTo(file)) { + throw StorageException.BlobStore("failed to atomically replace existing file") + } + } catch (error: StorageException) { + cleanupTempFile(temp) + throw error + } catch (error: Exception) { + cleanupTempFile(temp) + 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") + } + } + + private fun cleanupTempFile(temp: File) { + if (temp.exists()) { + temp.delete() + } + } +} 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..a992712b9 --- /dev/null +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt @@ -0,0 +1,95 @@ +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: StorageException) { + throw error + } 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: StorageException) { + throw error + } 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..71b635c8f --- /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 { + return + } + 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..d3885b114 --- /dev/null +++ b/swift/support/IOSDeviceKeystore.swift @@ -0,0 +1,143 @@ +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 { + 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)") + } + } + + public func openSealed(associatedData: Data, ciphertext: Data) throws -> Data { + 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 { + 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/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 "" diff --git a/swift/tests/WalletKitTests/SimpleTest.swift b/swift/tests/WalletKitTests/LoggingTests.swift similarity index 97% rename from swift/tests/WalletKitTests/SimpleTest.swift rename to swift/tests/WalletKitTests/LoggingTests.swift index 41f352fc2..4f24fb1e1 100644 --- a/swift/tests/WalletKitTests/SimpleTest.swift +++ b/swift/tests/WalletKitTests/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) @@ -37,7 +37,6 @@ final class SimpleTest: XCTestCase { Thread.sleep(forTimeInterval: 0.05) } - XCTAssertFalse(entries.isEmpty, "expected at least one bridged log entry") let hasBridgedMessage = entries.contains { level, message in diff --git a/swift/tests/WalletKitTests/StorageTests.swift b/swift/tests/WalletKitTests/StorageTests.swift new file mode 100644 index 000000000..4757f295a --- /dev/null +++ b/swift/tests/WalletKitTests/StorageTests.swift @@ -0,0 +1,274 @@ +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 = IOSAtomicBlobStore(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) + } + + func testDeleteMissingFileIsNoOp() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let store = IOSAtomicBlobStore(baseURL: root) + + XCTAssertNoThrow(try store.delete(path: "missing.bin")) + } +} + +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: IOSDeviceKeystore(service: service, account: account), + blobStore: IOSAtomicBlobStore( + 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 = IOSDeviceKeystore(service: service, account: account) + let worldidDir = root.appendingPathComponent("worldid", isDirectory: true) + let blobStore = IOSAtomicBlobStore(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: IOSDeviceKeystore(service: service, account: account), + blobStore: IOSAtomicBlobStore( + 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: IOSDeviceKeystore(service: service, account: account), + blobStore: IOSAtomicBlobStore( + 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 = IOSDeviceKeystore(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 = IOSDeviceKeystore(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 = IOSDeviceKeystore(service: service, account: account) + let secondKeystore = IOSDeviceKeystore(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 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": [ + \(claims) + ], + "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) +}