Engineering Notes: LockRoot Investigations
This document compiles 10 detailed engineering notes tracking the design, cryptographic profiling, and implementation challenges overcome during the cross-platform development of the LockRoot password manager.
Note 1: Mitigating Offline Brute-Forcing via Memory-Hard KDFs
Observation
Cloud-based password managers rely on server-side rate-limiting to prevent attackers from guessing a user’s master password. Because LockRoot is entirely local, an attacker who extracts the encrypted vault file can immediately subject it to offline parallelized brute-forcing using GPU clusters (e.g., Hashcat on RTX 4090s). Traditional hashing algorithms like PBKDF2 or SHA-256 require negligible memory, allowing GPUs to achieve massive parallelism and calculate millions of hashes per second.
Hypothesis
Implementing Argon2id (RFC 9106), a memory-hard Key Derivation Function (KDF), will bottleneck GPU parallelization. By requiring a large allocation of RAM for every single hash attempt, the attacker’s GPU will exhaust its VRAM long before it utilizes its compute cores, drastically reducing the hash rate.
Experiment
I profiled Argon2id across a range of target mobile hardware (older ARMv8 Android devices) to find a baseline parameter set that was tolerable for a human user (under 1 second execution time) but expensive for an attacker.
Tested parameters:
- Memory: 32 MiB, 64 MiB, 128 MiB
- Iterations: 1, 3, 5
- Lanes: 2
Result
The combination of 64 MiB memory, 3 iterations, and 2 lanes resulted in an average execution time of 600ms on a mid-range Android phone.
Discussion
While 600ms is imperceptible during a manual login event, it creates a devastating bottleneck for an attacker. An RTX 4090 with 24GB of VRAM can theoretically run only 384 concurrent 64-MiB Argon2id threads before exhausting its memory. This limits the theoretical hash rate to roughly several hundred hashes per second, compared to the millions or billions of hashes per second achievable against PBKDF2.
Takeaway
Argon2id (64 MiB, 3 passes) effectively neutralizes parallelized GPU brute-forcing, shifting the security burden to the length of the master password (which LockRoot mandates at a minimum of 12 characters).
Note 2: Defeating the JIT Compiler in Memory Wiping
Observation
The master password must be temporarily held in memory to derive the decryption key. In Kotlin (Android), if this byte array is not explicitly zeroed after use, it remains in RAM until garbage collected, leaving it vulnerable to memory scraping forensics. However, modern JVM Just-In-Time (JIT) compilers perform dead-code elimination. If the compiler sees an Arrays.fill(bytes, 0) instruction but the bytes array is never read again, it often optimizes the instruction away to save CPU cycles.
Hypothesis
Creating a side-effect that forces the JVM to read the array after it has been zeroed will prevent the JIT compiler from stripping the Arrays.fill instruction.
Experiment
I implemented a @Volatile private var wipeSink: Int variable inside CryptoService.kt. After generating random bytes over the array and then explicitly zeroing it with Arrays.fill(bytes, 0.toByte()), I assigned the array’s hashcode to the volatile variable.
secureRandom.nextBytes(bytes)
Arrays.fill(bytes, 0.toByte())
wipeSink = bytes.hashCode()
Result
Memory dumping the Android application via ADB after a successful unlock confirmed that the plaintext password was no longer present in the heap space previously occupied by the CharArray.
Discussion
The @Volatile keyword ensures that the read and write operations to wipeSink are not cached or reordered by the JVM. Because computing bytes.hashCode() requires reading the contents of the array, the JIT compiler cannot eliminate the preceding Arrays.fill instruction; doing so would change the observed behavior of the program.
Takeaway
In managed languages, explicit memory zeroing is unreliable unless mathematically bound to a non-optimizable side effect. The wipeSink pattern successfully guarantees memory erasure on Android.
Note 3: Guaranteeing Wipes on Exception in Swift
Observation
During the iOS implementation, the master password is converted to a Data object to pass into the Argon2id C-bindings. The derivation process can throw exceptions (e.g., if memory allocation fails). If an exception is thrown before the wiping instruction (sodium_memzero) is reached, the Data object falls out of scope and remains in memory containing the plaintext password.
Hypothesis
Wrapping the wiping instruction in a Swift defer block will guarantee execution regardless of how the function exits (return, throw, or panic).
Experiment
In ios/Lockroot/Lockroot/Crypto/CryptoService.swift, I implemented the processPassword logic utilizing defer:
func processPassword(input: inout Data) throws {
defer {
input.withUnsafeMutableBytes { raw in
if let baseAddress = raw.baseAddress {
sodium_memzero(baseAddress, raw.count)
}
}
}
// Simulate error throwing
throw CryptoError.allocationFailed
}
Result
Upon simulating an allocation failure, the defer block executed successfully prior to the error bubbling up the call stack, and sodium_memzero wiped the underlying memory buffer.
Discussion
Swift’s defer statement operates identically to Java’s finally block, pushing the enclosed code onto an execution stack that is unwound as the current scope exits. Combined with Clibsodium’s sodium_memzero (which is compiled at the C level with #pragma directives to prevent LLVM optimization), this provides a foolproof, compiler-safe memory erasure mechanism on iOS and macOS.
Takeaway
Critical cryptographic cleanup routines in Swift must always be housed within defer blocks to prevent memory leaks during unhandled exceptions.
Note 4: Binding Envelope Metadata to AEAD (AAD)
Observation
The LockRoot vault file is a JSON envelope containing public cryptographic parameters (KDF memory size, iterations, cipher nonce) alongside the encrypted payload. An attacker could modify the iterations from 3 to 1 in the JSON file. When the user attempts to unlock the vault, the app would read 1, derive the key in 1/3rd the time, and fail to decrypt the payload. However, this tampering would only be caught when the final AES decryption failed, after the CPU had already wasted cycles on the KDF.
Hypothesis
By passing the entire JSON envelope metadata as Associated Data (AAD) into the AES-256-GCM cipher during encryption, any modification to the metadata will invalidate the AEAD authentication tag.
Experiment
I constructed a pipe-delimited string representing the vault state:
Lockroot_VAULT|2|argon2id|65536|3|2|<salt_b64>|aes-256-gcm|<nonce_b64>
This string is passed as the AAD parameter to the AES-GCM encryption routine. During decryption, the string is reconstructed from the parsed JSON envelope and passed into the decryption routine. I intentionally modified the iterations field in a test vault to 2.
Result
The AES-GCM decryption routine immediately threw an AEADBadTagException (Android) / CryptoKitError.authenticationFailure (iOS).
Discussion
The AES-GCM authentication tag is calculated over both the ciphertext and the AAD. By binding the envelope metadata into the AAD, LockRoot ensures cryptographic integrity over the entire file structure, not just the encrypted payload. If an attacker tampers with the KDF parameters, the AEAD tag mathematically fails to validate.
Takeaway
Metadata governing cryptographic execution paths must be cryptographically authenticated. Using AES-GCM AAD effectively neutralizes envelope tampering attacks.
Note 5: Defeating DoS via Parameter Bounding
Observation
Because the Argon2id parameters are stored in plaintext within the V2 JSON envelope (so the app knows how to derive the key), a malicious actor could send a user a crafted vault file specifying memory: 8,388,608 (8 GiB) and iterations: 1000. When the user attempts to unlock this file, the application would attempt to allocate 8 GiB of RAM, instantly crashing the mobile device via an Out-of-Memory (OOM) killer.
Hypothesis
Implementing strict upper and lower bounds on all KDF parameters during the JSON parsing phase, prior to initiating the cryptographic pipeline, will neutralize this Denial-of-Service (DoS) vector.
Experiment
I added boundary checks to the vault parser:
Memory: 19,456 to 262,144 (256 MiB)Iterations: 2 to 10Parallelism: 1 to 8
I then attempted to load a crafted vault file requesting 512 MiB of memory.
Result
The parser immediately rejected the file, throwing a SecurityException: Parameter kdf.memory out of bounds, halting the unlock flow before the Argon2id bindings were invoked.
Discussion
Allowing untrusted input to dictate memory allocation sizes is a classic vulnerability pattern. By enforcing hard caps that represent the maximum reasonable parameters for mobile and desktop environments (256 MiB memory, 10 iterations), LockRoot protects the host OS from resource exhaustion attacks triggered by malicious vault files.
Takeaway
Cryptographic parameters read from untrusted files must be strictly validated against sane upper limits before being passed into allocation-heavy derivation functions.
Note 6: Cross-Platform Clipboard Eviction Strategies
Observation
Users copy passwords to the system clipboard to paste them into browsers. If left in the clipboard indefinitely, any background application (or subsequent user of the device) can read the plaintext secret.
Hypothesis
Implementing an asynchronous timer to clear the clipboard after 20 seconds will mitigate exposure. However, this must be handled carefully so the app doesn’t accidentally clear a different string that the user manually copied during that 20-second window.
Experiment
On iOS (VaultViewModel.swift), I utilized the UIPasteboard.general.changeCount property. When a secret is copied, the app records the current changeCount. It then launches a Task that sleeps for 20_000_000_000 nanoseconds.
if UIPasteboard.general.changeCount == changeCount {
UIPasteboard.general.items = []
}
Result
If the user copies a password, it is cleared after 20 seconds. If the user copies a password, then 5 seconds later copies a URL from Safari, the changeCount increments. When the 20-second timer fires, it detects the mismatch and aborts the eviction, preserving the URL.
Discussion
Clipboard behavior is highly platform-dependent. Android 13+ introduces a UI toast notification when the clipboard is accessed, which can be annoying if the app clears the clipboard visibly. The changeCount check (or ClipDescription label matching on Android) ensures the app only evicts data it explicitly owns, providing a seamless user experience while minimizing the exposure window of the plaintext secret.
Takeaway
Clipboard eviction must track state (via change counters or primary clip labels) to avoid destructive interference with normal OS workflow.
Note 7: Mitigating Screen Capture and Over-the-Shoulder Sniping
Observation
Malware frequently attempts to capture the screen while a password manager is unlocked. Similarly, screen-sharing software (like Zoom or Teams) can inadvertently broadcast the vault interface to unauthorized viewers.
Hypothesis
Utilizing OS-level secure surface flags will force the desktop compositor to redact the application window from screen captures and video streams.
Experiment
I implemented the following platform-specific APIs:
- Android:
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)insideMainActivity.kt. - Windows: Interop call to
SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE)in the WPF host.
Result
On Android, attempting to take a screenshot results in a system error: “Can’t take screenshot due to security policy.” On Windows, the LockRoot application window appears as a solid black rectangle to OBS Studio and Microsoft Teams screen sharing, while remaining fully visible to the local user.
Discussion
These APIs instruct the OS compositor (SurfaceFlinger on Android, DWM on Windows) to exclude the application’s buffers from any read-back operations. Unfortunately, iOS and Linux (X11) do not provide equivalent granular APIs for third-party developers, meaning LockRoot must rely on aggressive auto-locking timers on those platforms to mitigate visual exposure.
Takeaway
OS-level compositor flags (FLAG_SECURE, WDA_EXCLUDEFROMCAPTURE) are highly effective at defeating software-based screen capture and should be universally applied to security applications where available.
Note 8: The Transition from XChaCha20 to AES-256-GCM
Observation
The V1 format of LockRoot utilized XChaCha20-Poly1305 on mobile devices (Android/iOS) due to its excellent software performance on older ARM chips lacking hardware AES acceleration. However, the desktop applications (Windows/Linux) relied on the .NET framework, which provided native, hardware-accelerated AesGcm but lacked a built-in XChaCha20 implementation. This fragmentation prevented a user from exporting an Android vault and importing it into the Windows app.
Hypothesis
Migrating all platforms to a unified V2 envelope utilizing AES-256-GCM will enable seamless cross-platform synchronization, as all target hardware (mobile and desktop) now natively supports AES-NI hardware acceleration.
Experiment
I implemented a one-way cryptographic migration routine. The mobile applications (which still bundle the lazysodium library) were updated to decrypt the legacy XChaCha20 vaults. Upon a successful unlock, the VaultRepository checks the parsed envelope:
val needsMigration = (envelope.magic != "Lockroot_VAULT") ||
(envelope.version != 2) ||
(envelope.cipher.name != "aes-256-gcm")
If true, the vault is immediately re-encrypted using the active key into the new AES-256-GCM V2 format and saved to disk.
Result
The migration executed transparently. The user entered their password once; the app decrypted the XChaCha20 vault, migrated the payload in memory, and wrote the new AES-GCM vault to disk. Subsequent unlocks utilized the AES-GCM routines natively.
Discussion
This one-way migration strategy resolves the technical debt of maintaining disparate cryptographic pipelines without requiring the user to manually intervene or manage multiple file formats. The desktop platforms do not need the bloated libsodium dependency, as they only encounter V1 desktop vaults (which already used AES-GCM) or V2 universal vaults.
Takeaway
Protocol fragmentation can be resolved seamlessly via transparent, opportunistic migration logic executed immediately following successful cryptographic authentication.
Note 9: Managing Background State Lifecycles (iOS)
Observation
When a user switches away from the LockRoot iOS app to copy a password into Safari, the app enters the background state. If the OS terminates the app while in the background to free up memory, the standard view teardown lifecycle methods (viewWillDisappear) might not execute reliably, potentially leaving decrypted vault state stranded in memory.
Hypothesis
Binding the vault locking logic directly to the SwiftUI scenePhase environment value will guarantee execution the moment the app loses active focus, regardless of subsequent OS termination actions.
Experiment
In LockrootApp.swift, I added an .onScenePhaseChange(scenePhase) listener to the main WindowGroup.
.onChange(of: scenePhase) { newPhase in
if newPhase == .inactive || newPhase == .background {
viewModel.lockForBackground()
}
}
Result
The moment the user triggers the iOS app switcher (entering the .inactive state), the lockForBackground() method fires. This method scrubs the decrypted entries array from the UI state and zeroes the AES key in memory.
Discussion
The .inactive phase is crucial. It occurs before the app goes into the full .background state (e.g., while the app switcher carousel is visible). By locking on .inactive, LockRoot ensures that the OS task-switcher snapshot (which takes a screenshot of the app for the carousel) captures a locked, redacted screen rather than the plaintext vault contents.
Takeaway
SwiftUI’s scenePhase provides deterministic, high-priority state monitoring required for implementing strict auto-lock and memory scrubbing routines on mobile platforms.
Note 10: Enforcing iOS Data Protection (.completeFileProtection)
Observation
Even with AES-GCM encryption, the encrypted vault file sits on the iOS filesystem. If a physical attacker gains possession of a powered-off device, they could theoretically extract the raw storage chip and attempt to extract the file for offline brute-forcing.
Hypothesis
Leveraging the iOS Secure Enclave via the Data Protection API (.completeFileProtection) adds a hardware-backed encryption layer. The OS will encrypt the vault file using a key tied to the user’s device passcode, making it mathematically impossible to read the file from disk while the device is locked.
Experiment
When saving the serialized JSON envelope to the iOS app container, I added the NSFileProtectionComplete attribute to the write operation:
try data.write(to: fileURL, options: .completeFileProtection)
Result
Testing with a jailbroken device confirmed that attempting to read the vault.json file via SSH while the device was at the lock screen resulted in a Permission Denied (I/O error) at the kernel level. The file became readable only after the device was unlocked via FaceID or the passcode.
Discussion
This defense-in-depth strategy ensures that an attacker must bypass the iOS hardware Secure Enclave and the Argon2id/AES-GCM cryptography to access the vault. It effectively neutralizes physical extraction attacks on locked devices, pushing the attacker back to attempting to exploit a live, unlocked system.
Takeaway
Local security applications should always utilize OS-level hardware encryption APIs (.completeFileProtection) to layer hardware boundaries beneath software encryption implementations.
REGAAN R