-
Notifications
You must be signed in to change notification settings - Fork 3
feat: Swift and Kotlin credential storage tests #271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lukejmann
wants to merge
15
commits into
main
Choose a base branch
from
swift-and-kotlin-foreign-tests-v2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
37f0ef5
re-add swift and kotlin credential storage tests
lukejmann e6cd104
reorg
lukejmann 7b5a212
reorg
lukejmann de25fe6
add testDeleteMissingFileIsNoOp
lukejmann e5a79ee
fix ci
lukejmann 61f79e5
use IOSAtomicBlobStore
lukejmann 22bfecd
swiftlint
lukejmann daccaa9
fix StorageException handle
lukejmann bb13a23
use support ios files
lukejmann 2a7f859
Merge branch 'main' into swift-and-kotlin-foreign-tests-v2
lukejmann 089d20b
add comment, revert test_kotlin
lukejmann d7484c5
wrap Swift CryptoKit errors in StorageError
lukejmann d55e161
Merge remote-tracking branch 'origin/main' into swift-and-kotlin-fore…
lukejmann 8ca1627
fix StorageException double-wrapping in AndroidDeviceKeystore
lukejmann c1bc228
Merge branch 'main' into swift-and-kotlin-foreign-tests-v2
lukejmann File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
300 changes: 300 additions & 0 deletions
300
kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/StorageTests.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<StorageException.NotInitialized> { | ||
| store.listCredentials(issuerSchemaId = null, now = 100UL) | ||
| } | ||
| assertFailsWith<StorageException.NotInitialized> { | ||
| 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 | ||
| } |
63 changes: 63 additions & 0 deletions
63
kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}") | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| 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() | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
writeAtomicwrites to a temp file and then throws iftemp.renameTo(file)returns false, butFile.renameTois allowed to fail when the destination already exists. In environments/filesystems where that happens, any rewrite of an existing blob path will fail, so callers cannot safely update previously written data even thoughwriteAtomicis expected to replace atomically. Use a replace-capable move (REPLACE_EXISTING/ATOMIC_MOVEwhere available) or an explicit overwrite fallback.Useful? React with 👍 / 👎.