Skip to content

feat: Swift and Kotlin credential storage tests#271

Open
lukejmann wants to merge 17 commits into
mainfrom
swift-and-kotlin-foreign-tests-v2
Open

feat: Swift and Kotlin credential storage tests#271
lukejmann wants to merge 17 commits into
mainfrom
swift-and-kotlin-foreign-tests-v2

Conversation

@lukejmann
Copy link
Copy Markdown
Contributor

@lukejmann lukejmann commented Mar 5, 2026

Note

Medium Risk
Introduces new platform keystore and blob-store implementations (AES-GCM + AndroidKeyStore/iOS Keychain) and exercises persistence in tests, which can affect confidentiality and data durability if incorrect.

Overview
Adds end-to-end Swift and Kotlin tests for credential storage, covering CredentialStore init requirements, credential persistence across reopen, and Merkle proof cache TTL behavior, plus basic atomic blob store read/write/delete semantics.

Introduces platform storage implementations: Android AndroidDeviceKeystore, AndroidAtomicBlobStore, and AndroidStorageProvider (with WalletKitStorage.defaultProvider), and iOS IOSDeviceKeystore, IOSAtomicBlobStore, and IOSStorageProvider (with WalletKitStorage.makeDefaultProvider). Also updates Swift test harness (test_swift.sh) to stage local support Swift sources and renames logging test classes to LoggingTests.

Reviewed by Cursor Bugbot for commit 54a1188. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread swift/support/IOSAtomicBlobStore.swift
Comment thread swift/tests/WalletKitTests/StorageTests.swift Outdated
Comment thread swift/support/IOSDeviceKeystore.swift
…ign-tests-v2

Made-with: Cursor

# Conflicts:
#	swift/tests/WalletKitTests/LoggingTests.swift
@paolodamico
Copy link
Copy Markdown
Contributor

@codex review

}
}

public func openSealed(associatedData: Data, ciphertext: Data) throws -> Data {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@forceunwrap @NnnOooPppEee could you help double check this is how we should do things?

override fun seal(associatedData: ByteArray, plaintext: ByteArray): ByteArray {
try {
val key = getOrCreateKey()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Guardiola31337 @NnnOooPppEee this warrants particular attention

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c1bc22854b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +36 to +37
if (!temp.renameTo(file)) {
throw StorageException.BlobStore("failed to atomically replace existing file")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle overwrite when atomic write target already exists

writeAtomic writes to a temp file and then throws if temp.renameTo(file) returns false, but File.renameTo is 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 though writeAtomic is expected to replace atomically. Use a replace-capable move (REPLACE_EXISTING/ATOMIC_MOVE where available) or an explicit overwrite fallback.

Useful? React with 👍 / 👎.

Comment thread swift/support/IOSDeviceKeystore.swift
Copy link
Copy Markdown
Contributor

@forceunwrap forceunwrap left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed from iOS/swift perspective.


private func keychainBaseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
Copy link
Copy Markdown
Contributor

@forceunwrap forceunwrap Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is different from iOS's current implementation (SecKeyCreateEncryptedData with .eciesEncryptionCofactorVariableIVX963SHA256AESGCM). Are they meant to be compatible? This doesn't use the secure enclave either. It's missing kSecAttrTokenIDSecureEnclave for real devices.

https://github.com/worldcoin/world-app-ios/blob/c8bed84eab94909d42ce0412119bd86a3c0541ae/WorldApp/WorldAppLibrary/Sources/WalletKitWrapper/WalletKitSecureEnclave.swift#L61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/otto please update this test to match the iOS implementation that @forceunwrap pointed out

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've read the current IOSDeviceKeystore implementation and the thread. Before making the change I want to confirm scope, since this touches data compatibility:

What @forceunwrap pointed out (from the thread):

  • Current impl: AES.GCM + symmetric key stored in kSecClassGenericPassword keychain, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  • world-app-ios impl: SecKeyCreateEncryptedData with .eciesEncryptionCofactorVariableIVX963SHA256AESGCM, EC key pair backed by kSecAttrTokenIDSecureEnclave, kSecAttrAccessibleWhenUnlockedThisDeviceOnly

Switching to the SE-based ECIES approach would mean:

  1. Data format changes — blobs sealed with the current AES-GCM approach can't be opened with ECIES, so this is a breaking storage migration if any existing data uses this keystore.
  2. Associated data handlingSecKeyCreateEncryptedData has no AAD parameter; the seal(associatedData:plaintext:) contract would need a custom AAD scheme (e.g. prepend AD to plaintext or HMAC over ciphertext).
  3. Simulator/test environment — SE isn't available in simulators, so the current errSecMissingEntitlement fallback path would need to change.

Questions before I proceed:

  • Is IOSDeviceKeystore in this PR meant to replace WalletKitSecureEnclave.swift in world-app-ios, or is it independent new storage that doesn't need to be compatible?
  • If switching to ECIES/SE: how should associatedData be handled? Should I mirror exactly what WalletKitSecureEnclave.swift does, or use a new scheme?
  • Or is the ask narrower — e.g. just update the keychain accessibility attribute to kSecAttrAccessibleWhenUnlockedThisDeviceOnly to match the existing app?

Happy to make the full change once the intent is clear. cc @forceunwrap

Comment thread swift/support/IOSAtomicBlobStore.swift
public static func makeDefaultProvider(
bundleIdentifier: String? = Bundle.main.bundleIdentifier
) throws -> IOSStorageProvider {
let fileManager = FileManager.default
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, we store in Documents/worldid/ (via StoragePaths.fromRoot) this would move storage to Library/Application Support/

Is that intended?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/otto also update to match

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — pushed in 54a1188.

Changes to makeDefaultProvider:

  • Switched from .applicationSupportDirectory to .documentDirectory, so the resolved blob-store base becomes Documents/worldid/ matching the existing app layout.
  • Removed the bundleIdentifier parameter (not needed — iOS sandboxing already gives each app its own Documents/ folder, so there's no collision to avoid).

Note: on the broader IOSDeviceKeystore question I've asked for clarification in that thread before making the encryption-scheme change — that one has data-compatibility implications worth confirming first.

kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is different from the existing DeviceKeystoreImp use of
kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Do we want to change this to make the keychain accessible when the device is locked?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@forceunwrap is the point that this is different in the iOS implementation? the data should definitely not be accessible if the device is locked

Comment thread swift/support/IOSDeviceKeystore.swift Outdated
import Foundation
import Security

public final class IOSDeviceKeystore: DeviceKeystore {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this and WalletKitFileSystem will will bring in swift 6 concurrency errors. Maybe unchecked Sendable or an actor?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/otto please fix

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 577d51a — added , @unchecked Sendable to all three iOS support classes:

  • IOSDeviceKeystore — already uses NSLock / fallbackLock to protect all mutable state, so the invariant holds
  • IOSAtomicBlobStorebaseURL is immutable after init; FileManager.default isn't Sendable itself but is safe to use concurrently
  • IOSStorageProvider — all fields are set once in init and never mutated

Went with @unchecked Sendable over actor since making the protocol methods async would break the UniFFI-generated call site.

@agentotto
Copy link
Copy Markdown
Contributor

agentotto Bot commented May 8, 2026

Let me look at the PR and the relevant comments to understand the full context.
Now let me look at the actual source files in the PR branch to understand the current implementation and tests.
Let me also look at the reference file directly while the subagent works.
Let me check on the subagent's progress.
The subagent is still working. Let me fetch the reference file from GitHub directly in parallel.
Now let me fetch the reference Secure Enclave implementation from world-app-ios to understand what needs to be aligned.
Now I have the full picture. Let me look at what @forceunwrap specifically pointed to — the key issue is that the current IOSDeviceKeystore uses AES-GCM with a symmetric keychain key, while the existing iOS implementation uses SecKeyCreateEncryptedData with .eciesEncryptionCofactorVariableIVX963SHA256AESGCM and Secure Enclave. Since the reference file is in a private repo and the change involves a fundamentally different encryption scheme with data-compatibility implications, I want to confirm scope before making the change.
I've replied to the review thread rather than making a potentially incorrect large change. Here's my reasoning:

The request to "update this test to match the iOS implementation" sounds straightforward, but the two implementations are fundamentally different:

Current IOSDeviceKeystore world-app-ios WalletKitSecureEnclave
Crypto AES.GCM symmetric key SecKeyCreateEncryptedData ECIES
Key storage kSecClassGenericPassword (symmetric blob) EC key pair in Secure Enclave
Accessibility kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly kSecAttrAccessibleWhenUnlockedThisDeviceOnly

Switching would be a breaking change (old ciphertext can't be opened), and ECIES doesn't natively support the associatedData parameter in the seal/openSealed contract. I've asked @paolodamico and @forceunwrap to clarify:

  1. Whether compatibility with world-app-ios is required
  2. How associatedData should be handled under ECIES
  3. Whether the ask might be narrower (e.g. just fixing the accessibility attribute)

makeDefaultProvider previously resolved to Library/Application Support/<bundleId>/
which differs from the existing iOS app's Documents/worldid/ layout.

Switch to .documentDirectory (no bundle ID subdirectory needed — iOS
sandboxing already isolates each app's Documents folder) so that the
resulting blob store base path is Documents/worldid/, matching the
existing app.

Also drops the unused  parameter.
@agentotto
Copy link
Copy Markdown
Contributor

agentotto Bot commented May 8, 2026

Let me read the full IOSStorageProvider.swift to understand what makeDefaultProvider does.
The issue is clear: makeDefaultProvider currently resolves to Library/Application Support/<bundleId>/ but the existing iOS app stores under Documents/worldid/. The fix is to switch to .documentDirectory and drop the bundle ID subdirectory (iOS sandboxing already isolates the Documents folder per-app).

Let me make that change on the branch.
Good. Now push this to the branch and reply to the comment.
Done. Here's what changed in commit 54a1188:

swift/support/IOSStorageProvider.swiftmakeDefaultProvider:

  • Switched from .applicationSupportDirectory (Library/Application Support/<bundleId>/) → .documentDirectory (Documents/)
  • With IOSStorageProvider's existing worldid subdirectory appended, the blob store now lands at Documents/worldid/ — matching the existing iOS app layout that @forceunwrap called out
  • Dropped the now-unnecessary bundleIdentifier parameter (iOS sandboxing already isolates each app's Documents/ folder)

The IOSDeviceKeystore encryption-scheme question (AES-GCM vs Secure Enclave ECIES) is still pending clarification in the other thread, since it involves data-format compatibility.

IOSDeviceKeystore, IOSAtomicBlobStore, and IOSStorageProvider all use
internal synchronisation (NSLock / FileManager) or are effectively
immutable after init, so @unchecked Sendable is the right approach
rather than converting to actors (which would make every method async
and break the UniFFI-generated protocol surface).
@agentotto
Copy link
Copy Markdown
Contributor

agentotto Bot commented May 8, 2026

All three classes need the fix. IOSDeviceKeystore already guards state with NSLock, so @unchecked Sendable is the right call (over actor, which would require making every method async). Same for IOSAtomicBlobStore (uses FileManager which isn't Sendable) and IOSStorageProvider (holds refs to the above).
Done. Pushed in 577d51a. Added , @unchecked Sendable to all three iOS support classes:

Class Rationale
IOSDeviceKeystore Already guarded by NSLock / fallbackLock
IOSAtomicBlobStore baseURL immutable after init; FileManager.default used safely
IOSStorageProvider All properties set once in init, never mutated

Chose @unchecked Sendable over actor — converting to actors would make every protocol method async, which would break the UniFFI-generated protocol surface.

@agentotto
Copy link
Copy Markdown
Contributor

agentotto Bot commented May 8, 2026

The subagent did the same file-fetching work I'd already done in parallel — no further action needed from it. I'll leave it as complete.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 577d51a. Configure here.

kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keychain accessibility allows access when device is locked

High Severity

The keychain query uses kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, which allows the encryption key to be read even while the device is locked (after the first unlock since boot). The PR discussion explicitly confirms the intent is that "the data should definitely not be accessible if the device is locked," and the existing implementation uses kSecAttrAccessibleWhenUnlockedThisDeviceOnly. This weaker accessibility level exposes credential key material to attack scenarios where the device is locked but powered on.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 577d51a. Configure here.

.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setRandomizedEncryptionRequired(true)
.build()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Android keystore key accessible when device is locked

High Severity

The KeyGenParameterSpec does not call setUnlockedDeviceRequired(true) (available on API 28+), meaning the AES key can be used for encrypt/decrypt operations even when the device is locked. The PR owner confirmed "the data should definitely not be accessible if the device is locked." While minSdk is 23 and this API requires 28, a runtime version check could conditionally enable this protection on supported devices. Currently no lock-screen restriction is applied on any API level.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 577d51a. Configure here.

Copy link
Copy Markdown
Contributor

@Guardiola31337 Guardiola31337 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Android/Kotlin consumer questions from the World App side. I skipped the lock-state and atomic-replace points already covered in existing threads.

try {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val existing = keyStore.getKey(alias, null) as? SecretKey
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: if we tighten this key spec, how should we handle an existing key already stored under this alias?

Right now we silently reuse whatever walletkit_device_key resolves to. Should the alias be versioned, or should we inspect KeyInfo and rotate/delete keys that do not meet the expected lock-state / hardware-backed requirements?

return existing
}

val keyGenerator = KeyGenerator.getInstance(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: is this intended to be a production-grade Android default, or a minimal reference implementation?

As a consumer, I’d expect the default to either attempt/verify StrongBox or TEE backing, or clearly document that apps needing those guarantees should inject their own DeviceKeystore. Otherwise this may look like a safe default while being weaker than existing app integrations. For reference, World App’s current Android wrapper goes through SecureEnvironmentService, which attempts StrongBox/TEE and verifies the resulting key security level.

}

override fun writeAtomic(path: String, bytes: ByteArray) {
val file = File(baseDir, path)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: should this resolve and canonicalize path before using it?

Even if the Rust core currently only passes known filenames like account_keys.bin, this is a public Android blob-store implementation. Without checking that the canonical target stays under baseDir, ../ or absolute paths could escape the storage root.

"${file.name}.tmp-${UUID.randomUUID()}"
)
try {
temp.writeBytes(bytes)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: separate from the existing atomic-replace discussion, do we need to fsync the temp file before renaming it?

Since this can store account_keys.bin, I’m wondering whether a successful writeAtomic should mean the bytes are durable across process/device crash, or only that readers will not observe a partial file.


object WalletKitStorage {
fun defaultProvider(context: Context): AndroidStorageProvider {
val root = File(context.filesDir, "walletkit")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: should this default root and keystore alias be called out as new storage, not a migration path for existing Android consumers?

This uses filesDir/walletkit/worldid plus the walletkit_device_key alias. Existing clients may already use a different root and keystore alias, so switching to WalletKitStorage.defaultProvider(context) could make existing credentials appear missing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants