White paper · v1.0

A local-first password manager for Android.

A technical and strategic white paper for privacy-conscious users and security-minded reviewers. Deliberately blunt about trade-offs.

v1.1 · June 2026 · 11 sections · ~45 min read

01Executive summary

1Key is an Android password manager that stores nothing in the cloud and requires no account. The vault lives on a single device, encrypted with modern primitives - Argon2id for password-derived keys, AES-256-GCM for authenticated encryption - and bound to that device's hardware-backed Android Keystore. There is no sign-up, no telemetry, no recovery server, and no INTERNET permission in the manifest.

This paper is written for two audiences: privacy-conscious individuals deciding whether 1Key fits their threat model, and security-minded reviewers who want to understand exactly how the cryptographic stack is assembled. It is deliberately blunt about trade-offs. 1Key does not synchronise across devices over the network. It does not offer team sharing. It does not support recovery if the master password is forgotten. These are not omissions - they are direct consequences of the design decision that the vault should never leave the device unless the user explicitly exports it. The optional Sync feature writes an encrypted backup file to a folder the user picks (local storage, USB drive, or a folder another app uploads to cloud storage); moving that file between devices is the user's responsibility.

Within those constraints, 1Key matches the modern cryptographic baseline used by the leading cloud-vault products and adds local-only hardening tailored to the Android threat model: a leaked database file alone cannot be brute-forced offline, because the password verifier is held in Keystore-bound EncryptedSharedPreferences rather than alongside the encrypted vault. The product is free, has no premium tier, and is released as open source under the GNU General Public License v3.0. The "1Key" name and icon are reserved as trademarks of the author; forks must rebrand before redistribution.

Headline takeaways
  • The Android manifest declares no INTERNET permission. The OS sandbox enforces this - even if a future bug introduced a network call, it would be denied at the system layer.
  • The master-password verifier sits in EncryptedSharedPreferences, encrypted under an Android Keystore-bound master key. Offline brute-force against an exfiltrated database file is structurally closed on devices with a working Keystore.
  • The cryptographic primitives match the cloud-vault category (Argon2id, AES-256-GCM, HKDF-SHA256). 1Key's distinction is operational - no server, no account - not cryptographic.

02The problem: why cloud vaults are now the default

Every mainstream password manager is built around a cloud-hosted vault tied to a vendor account. This architecture is not accidental. It solves three real product problems:

  1. Cross-device sync. A user with a phone, laptop, and tablet wants the same vault on all three.
  2. Recovery. A forgotten master password is the leading reason users abandon a password manager entirely. Cloud vendors layer recovery codes, secret keys, or social recovery on top of the encrypted blob.
  3. Onboarding. Account creation, even when E2EE (end-to-end encrypted), gives the vendor a stable identity to attach billing, support, and feature flags to.

The cost of this architecture is rarely surfaced clearly. Even with E2EE, the vendor holds the encrypted vault blob and an authentication ciphertext that gates access to it. This is a real attack surface: documented incidents in recent years - including the LastPass 2022 breach, in which encrypted vault backups were exfiltrated alongside customer URL data, and the May 2026 Dashlane device-registration brute-force that exposed encrypted vaults for fewer than 20 personal-plan accounts - have involved exfiltration of an encrypted vault blob followed by offline brute-forcing against weak master passwords. The cryptographic guarantees are not wrong; the threat model assumes a sufficiently strong master password, which a non-trivial fraction of users do not have.

For users who do not need sync - who carry one phone, who would rather export a backup file once a quarter than trust a server - the cloud auth blob is attack surface they did not ask for. 1Key exists for this user. This is a deliberately narrow audience; users who need cross-device sync, sharing, or recovery should choose a hosted product.

The secondary problem is commercial. The features that distinguish a basic password manager from a useful one - built-in TOTP generation, secure export, custom fields, biometric unlock - are paywalled by most vendors. The result is a population of users who either pay £25-£100 per year for a category of software that should be commoditised, or who keep using the browser-native manager their laptop's vendor happens to ship.

03The 1Key approach: local-first, account-free

1Key takes three positions, in descending order of strength.

3.1 The vault never leaves the device

There is no server. There is no vendor backend. There is no peer-to-peer sync protocol. The encrypted vault lives in a Room/SQLite database in the application's private storage, and data leaves the device only through (a) an explicit user-initiated export, or (b) the optional Sync feature, which writes an encrypted backup file to a user-chosen folder on every master-password unlock - never over a network from the app itself.

Confirmed in app/src/main/AndroidManifest.xml: the manifest declares <uses-permission android:name="android.permission.INTERNET" tools:node="remove" /> and the same line for ACCESS_NETWORK_STATE, so the merged manifest carries neither permission. Both are pulled in transitively by ML Kit's Firelog subgraph (com.google.android.datatransport); Gradle-level exclusion is unsafe because BarcodeScanning.getClient() has a static class-init reference to CCTDestination (see the build comment in app/build.gradle.kts next to the ML Kit dependencies). The classes stay in the APK so ML Kit loads, but the OS refuses every socket attempt because the permission is gone from the merged manifest. On-device inference (OCR, barcode scanning) does not need network access.

A note on temporal honesty: the no-INTERNET property is true of current builds. Earlier APKs inherited INTERNET and ACCESS_NETWORK_STATE transitively from the same Firelog subgraph. The runtime never actually opened a socket - there was no code path that called the transport - but the permission was declared in the merged manifest. Users who want full assurance should run a build dated May 2026 or later.

3.2 No account exists to compromise

Onboarding is a single screen that asks the user to choose a master password. There is no email address, no recovery question, no telemetry opt-in, no terms-of-service signature. The application has no notion of "user" beyond "the person who knows the master password for this device's vault."

This is not a UX refinement; it is a security property. A vendor that holds no account metadata cannot be compelled to surrender it, cannot leak it, and cannot use it to reset access against the user's wishes.

3.3 Free, GPL-3.0, single-maintainer

1Key is distributed without cost and with no in-app purchases. The full source is published at github.com/roufsyed/1Key under the GNU General Public License v3.0, with a separate TRADEMARKS.md reserving the name and icon. The build script is the entire setup procedure: clone, run ./gradlew assembleDebug, install the resulting APK.

1Key is currently maintained by a single developer. This is an honest constraint: there is no on-call rotation, no enterprise SLA, and no commitment to indefinite future development. Because the app is local-only and the user holds the only copy of the encrypted vault, the user's data is not at risk if development pauses - there is no server to shut down and no account to lose. Users can export to standard CSV or JSON at any time and migrate to any other manager.

04Cryptographic architecture

This section walks through the cryptographic stack from the moment the user types their master password to the moment a credential field is decrypted. Every parameter cited here is verifiable in the source files listed in the Editor's Notes.

4.1 Acronyms used in this section

KDF
Key Derivation Function. Turns a low-entropy password into a high-entropy key.
AEAD
Authenticated Encryption with Associated Data. An encryption mode that detects tampering.
AES
Advanced Encryption Standard. The block cipher used here at a 256-bit key size.
GCM
Galois/Counter Mode. The AEAD mode of operation used with AES.
HKDF
HMAC-based Extract-and-Expand Key Derivation Function (RFC 5869).
AAD
Additional Authenticated Data. Bytes that are not encrypted but are bound to the auth tag.
Argon2id
A memory-hard password-hashing function; winner of the 2015 Password Hashing Competition.
JNI
Java Native Interface. The bridge between Kotlin/Java code and native C libraries.
TEE
Trusted Execution Environment. A hardware-isolated execution context on the device's main SoC.

4.2 Master-password key derivation

When the user sets or enters a master password, 1Key derives a key using Argon2id, the memory-hard winner of the 2015 Password Hashing Competition. The default parameter set is m = 65,536 KiB (64 MiB), t = 3 iterations, p = 1 lane, output = 32 bytes - the OWASP 2023 recommendation for interactive authentication. The derivation entry point is CryptoManager.kt :: deriveKeyFromPasswordArgon2id; the OWASP defaults are surfaced through the STANDARD entry of KdfPreset.kt :: KdfPreset.

Argon2id is memory-hard: each guess in a brute-force attack must allocate 64 MiB of RAM, which collapses the speedup an attacker can extract from GPUs or ASICs. By contrast, a PBKDF2 attack at 600,000 iterations parallelises essentially linearly across thousands of cores. The implementation uses the open-source lambdapioneer/argon2kt JNI wrapper, which ships prebuilt .so libraries for every Android ABI.

The character array carrying the password is converted to UTF-8 bytes via CharBuffer.encode rather than String.toByteArray(), so the password material is never interned in the JVM string pool. The byte buffer is zeroed after use.

Default memory
64 MiB
Default iterations
3 passes
Output size
32 bytes
Parallelism
1 (locked)

User-selectable presets. The 64 MiB / t=3 / p=1 tuple is the default, not the only option. Users who want to trade unlock latency for resistance to offline brute force can pick a stronger preset, or supply their own (memory, iterations) inside guard rails. The five presets are declared in KdfPreset.kt :: KdfPreset; p is locked to 1 on every preset (including Custom) because on commodity Android hardware Argon2id parallelism greater than 1 reduces attacker cost more than defender cost, contradicting OWASP guidance.

PresetMemory (m)Iterations (t)Parallelism (p)Device RAM gate
Standard (default)64 MiB31none
Standard-plus64 MiB81none
Hardened128 MiB41~4 GB total
Maximum128 MiB81~6 GB total
Custom32 MiB to device cap2 to 161 (fixed)none

Custom guard rails. The Custom dialog enforces a memory floor of CUSTOM_M_MIN = 32 MiB (the OWASP 2023 memory-constrained floor; below this Argon2id collapses into something easily parallelised on GPUs) and an iteration range of CUSTOM_T_MIN = 2 to CUSTOM_T_MAX = 16. The upper memory bound is computed per device by DeviceCapacityDetector.kt :: maxCustomMemoryMb, so devices with less headroom cannot be configured into swap-thrashing or OOM-killer territory. Parallelism is shown as the literal label "Parallelism: 1 (fixed)" and cannot be moved. Constants live in KdfCustomDialog.kt :: CUSTOM_M_MIN, CUSTOM_T_MIN, CUSTOM_T_MAX.

Settings path. The customization screen is reached from the in-app settings: Settings > Security > Encryption strength. The destination is the SettingsKdfStrength route (NavGraph.kt :: Screen.SettingsKdfStrength, route literal settings/security/kdf_strength). From there, the Custom card opens KdfCustomDialog.kt :: KdfCustomDialog. Once the user commits a change, KdfMigrator.kt :: KdfMigrator re-derives under the new preset and persists the integer version code under SharedPreferences key kdf_version (codes: 30 Standard, 31 Standard-plus, 32 Hardened, 33 Maximum, 34 Custom).

When the Secret Key is enabled

When the Secret Key (default for new vaults) is enabled, the Argon2id input is the byte concatenation MP_utf8 || SK_raw16 rather than the master password alone, mixed through CryptoManager.kt :: deriveKeyFromPasswordWithSecretKeyArgon2id. The KDF parameters above are unchanged. Full detail in section 4.9.

4.3 Vault-key wrapping and hardware isolation

The Argon2id-derived key is not the key that encrypts the database. Instead, 1Key generates a 256-bit AES vault key uniformly at random and wraps it with a non-exportable key stored in the Android Keystore. The only way to use that wrapping key is to ask the Keystore to perform an operation on the application's behalf; the raw key bytes never enter the app process.

From commit 1def89f onward, 1Key actively prefers the device's StrongBox secure element when one is present, and falls back to the Trusted Execution Environment (TEE) when it is not. The same StrongBox-prefer-with-TEE-fallback pattern is used by three Keystore aliases across two code paths (the legacy and upgraded vault-wrap aliases share getOrCreateKeystoreKey; the versioned Secret Key alias prefix uses an inline copy of the same logic), so every long-lived wrapping key in the app is offered the strongest available isolation:

API gate · on Build.VERSION.SDK_INT >= P (API 28+), getOrCreateKeystoreKey calls setIsStrongBoxBacked(true) on the KeyGenParameterSpec builder. Below API 28, the StrongBox block is skipped entirely.
Try StrongBox · KeyGenerator.generateKey() is invoked once with StrongBox requested. On success, the key lives inside a dedicated tamper-resistant chip, physically separate from the main CPU.
Catch StrongBoxUnavailableException · thrown by devices that do not ship a StrongBox chip. The catch block rebuilds the spec without setIsStrongBoxBacked and retries against the TEE.
Catch ProviderException · some Pixel firmware surfaces this in place of StrongBoxUnavailableException under load. It is treated identically: silent TEE fallback, no user-visible error.
Unlocked-device binding · on API 28+ the spec sets setUnlockedDeviceRequired(true), so the wrapping key is unusable while the device is locked, regardless of which tier it lands in.

Three Keystore aliases go through this path: the legacy vault wrapping key onekey_master (KEYSTORE_ALIAS_V1), the upgraded vault wrapping key onekey_master_v2 (KEYSTORE_ALIAS_V2), and each Secret Key wrapping alias under the prefix 1key_secret_key_v (versioned generations _v1, _v2, ...). The vault aliases live in CryptoManager.kt (getOrCreateKeystoreKey); the Secret Key aliases inline the same pattern in SecretKeyKeystoreWrapper.kt (getOrCreateWrappingKey) so that alias rotation via deleteAlias on the Secret Key side never touches the vault key's lifecycle.

What ended up where on a given device is surfaced to the user via the HardwareKeyIsolationTier enum, with three values and a strict ordering of STRONGBOX > TEE > SOFTWARE:

TierWhat it meansSettings indicator
STRONGBOXDedicated tamper-resistant chip, physically separate from the main CPU. Strongest isolation Android offers.Green check
TEETrusted Execution Environment - a separate secure-only mode of the main CPU. Full-strength hardware isolation, just not on a discrete chip.Green check
SOFTWAREWrapping key is not in secure hardware. Older or rooted handsets without a working Keystore.Warning

Both STRONGBOX and TEE are treated as success states in the UI - the green check is the same on both - because TEE is full-strength hardware isolation for everyday threat models. Only SOFTWARE raises a warning, because that tier collapses the offline brute-force defence described in section 4.4 back to software-key strength.

On API 31+, the tier is read directly from KeyInfo.securityLevel. On API 28-30, where securityLevel does not exist, a process-lifetime @Volatile flag CryptoManager.lastKeyCreatedWithStrongBox records the outcome of the most recent generate call; HardwareKeyIsolationProbe uses it to disambiguate STRONGBOX from TEE when KeyInfo.isInsideSecureHardware is true. Below API 28 the flag is irrelevant - StrongBox is unreachable - and the probe maps isInsideSecureHardware directly to TEE or SOFTWARE.

4.4 The verifier, and why it lives in EncryptedSharedPreferences

When the user types their master password, 1Key needs to check whether it is correct before unwrapping the vault key. A naive implementation would store an Argon2id hash and compare. 1Key does something stronger: it stores a small ciphertext, the bytes "VALID" encrypted under an Argon2id-derived key, inside EncryptedSharedPreferences. The verifier-key derivation is owned by AuthRepositoryImpl.kt :: deriveVerifierKey, which routes to the SK-aware path described below when its secretKey parameter is non-null.

EncryptedSharedPreferences is part of AndroidX security-crypto 1.1.0. The file on disk is itself encrypted with a master key held in the Android Keystore. Reading the verifier therefore requires live Keystore access on the device that wrote it.

The consequence: a leaked database file alone cannot be brute-forced offline. An attacker with the SQLite database but no Keystore access has no oracle to test password guesses against. They cannot extract the verifier from the encrypted preferences file without first compromising the Keystore-backed master key, and the Keystore master key is bound to the device's hardware. This closes the offline brute-force surface that cloud vaults inherently expose by storing an authentication ciphertext alongside (or in lieu of) the vault blob.

Secret Key layered on top of the KDF. The pre-SK verifier was K_verifier = Argon2id(password, salt, params) for every vault. When the Secret Key (SK) feature is enabled (the default for new vaults), the KDF input changes to the byte concatenation MP_utf8 || SK_raw16, fed into the same Argon2id parameter set. The SP_SECRET_KEY_ENABLED flag is read in AuthRepositoryImpl.kt :: verifyMasterPassword; when true, that method calls secretKeyWrapper.unwrap() for a fresh 16-byte copy of the SK and passes it as the secretKey parameter to deriveVerifierKey. deriveVerifierKey then delegates to CryptoManager.kt :: deriveKeyFromPasswordWithSecretKeyArgon2id, which builds the combined buffer, runs Argon2id, and zeros that buffer in a finally block. The caller zeros its skBytes copy in its own finally. Full SK coverage (default-on policy, counter-alias scheme, Emergency Kit, memory hygiene, onboarding ceremony) lives in section 4.9.

ModeKDF inputSymbol that builds it
SK off (legacy vault)MP_utf8CryptoManager.kt :: deriveKeyFromPasswordArgon2id
SK on (default for new vaults)MP_utf8 || SK_raw16CryptoManager.kt :: deriveKeyFromPasswordWithSecretKeyArgon2id
User types master password into the unlock surface
Read SP_SECRET_KEY_ENABLED and conditionally unwrap SK inside AuthRepositoryImpl.kt :: verifyMasterPassword
Delegate to deriveVerifierKey with the SK bytes (or null)
SK off · K_verifier = Argon2id(MP_utf8, salt, params) via deriveKeyFromPasswordArgon2id
SK on · K_verifier = Argon2id(MP_utf8 || SK_raw16, salt, params) via deriveKeyFromPasswordWithSecretKeyArgon2id; combined buffer zeroed in finally
Verifier ciphertext stored in EncryptedSharedPreferences, file encrypted under Keystore master key

Why concatenation, not XOR. XOR was considered and rejected. XOR would only pre-combine 16 bytes at the head of the password buffer, which on short master passwords offers an algebraic shortcut: an attacker who guesses the password can XOR the SK back out of the input. Byte concatenation forces every Argon2id memory pass to absorb the full MP_utf8 || SK_raw16 buffer, so the SK contributes its 128 bits of entropy at every iteration of the avalanche rather than collapsing into a one-time prefix XOR. The order is MP first, then SK, and is enforced at the construction site inside CryptoManager.kt :: deriveKeyFromPasswordWithSecretKeyArgon2id.

This is layered defence on top of EncryptedSharedPreferences. Even an attacker who somehow reads the verifier ciphertext still faces a 128-bit Secret Key in the KDF input, on top of the Argon2id memory cost. Mixing the SK into a legacy PBKDF2 verifier is explicitly rejected; legacy vaults must complete the silent Argon2id migration before the SK toggle is offered. See section 4.9 for how the SK itself is generated, wrapped, rotated, and surfaced through the Emergency Kit.

4.5 Per-field encryption with AAD binding

Every credential field - title, username, password, URL, notes, TOTP secret, custom fields - is encrypted independently with AES-256-GCM. The IV is 12 bytes (96 bits, the AES-GCM standard) and the auth tag is 128 bits (CryptoManager.kt line 32).

Each encryption call passes AAD that ties the ciphertext to its row and column. The format is 1k:v1|<credentialId>|<fieldName> for credential fields and 1k:v2|<credentialId>|title for titles, built by CredentialDecryptor.kt :: fieldAad and CredentialDecryptor.kt :: titleAad and consumed at encrypt-time in CredentialRepositoryImpl.kt. If an attacker with database write access tries to swap the encrypted password from row A into the password column of row B, decryption fails - the AAD seen at decrypt time will not match the AAD baked into the auth tag at encrypt time.

This is defence-in-depth, not a unique competitive feature. Cloud-vault products typically encrypt the whole credential record as a single blob, which already prevents within-record field swapping via the GCM/HMAC tag. Per-field AAD additionally blocks cross-record swap attacks against the local database - a narrow gain against an attacker who already has database write access (and therefore likely has process memory anyway). It is documented here because the implementation is open to inspection, not because it represents a category of protection competitors lack.

4.6 HKDF subkey separation

Using one master key for multiple purposes is a known anti-pattern. 1Key derives purpose-specific subkeys from the vault master key using HKDF-SHA256 (CryptoManager.kt: deriveSubkey).

Two labels are currently in use, both versioned so a future cipher rotation can occur without label collision:

HKDF info labelSubkey purpose
1key-field-enc-v1Encrypts non-title credential fields
1key-title-enc-v1Encrypts titles

Because the vault key is already a uniformly random 256-bit AES key, the implementation skips the HKDF-Extract step (RFC 5869 section 3.3 explicitly permits this when the input is uniform) and emits a single 32-byte HKDF-Expand block: T(1) = HMAC-SHA256(masterKey, info || 0x01).

4.7 Encrypted backup envelope (V5)

Manual user-initiated exports write a custom binary envelope, version 5 (BackupEncryption.kt :: encrypt, version constant MANUAL_EXPORT_VERSION = VERSION_V5). V5 supersedes V4 as the on-disk format for new exports because it adds two things the older layout cannot represent: the Argon2id parameters that produced the wrapping key, and a FLAGS byte advertising whether the Secret Key (SK) is required on restore. The byte-exact header layout is:

MAGIC       8 B   offset  0   "1KEYBKP\n"
VERSION     1 B   offset  8   0x05
FORMAT      1 B   offset  9   0x00=JSON  0x01=CSV
FLAGS       1 B   offset 10   bit 0 = FLAG_REQUIRES_SECRET_KEY (0x01)
                              bits 1-7 reserved, MUST be 0
KDF_M_KIB   4 B   offset 11   uint32 BE, Argon2id memory in KiB
KDF_T       4 B   offset 15   uint32 BE, Argon2id iterations
KDF_P       1 B   offset 19   uint8 parallelism (locked to 1 today)
TIMESTAMP   8 B   offset 20   int64 BE, export time epoch-ms
VAULT_VER   4 B   offset 28   int32 BE, vault version counter
SALT       32 B   offset 32   per-export random Argon2id salt
IV         12 B   offset 64   AES-GCM 96-bit nonce
BODY        N B   offset 76   AES-256-GCM ciphertext + 16-byte auth tag

Total fixed header is 76 bytes before BODY (the constant V5_HEADER_LEN in BackupEncryption.kt). The 32-byte AAD bound into the GCM auth tag is MAGIC || VERSION || FORMAT || FLAGS || KDF_M_KIB || KDF_T || KDF_P || TIMESTAMP || VAULT_VER - every header field except SALT and IV, built by BackupEncryption.kt :: buildHeaderAadV5. SALT is authenticated indirectly through the derived key (any change produces the wrong key, which fails GCM verification); IV is authenticated intrinsically by GCM. Tampering with FLAGS or the KDF triple invalidates the tag, so an attacker cannot downgrade an SK-protected backup, push the victim onto cheaper Argon2id parameters, or rewrite the timestamp to make a stale backup look fresh.

FLAGS byte semantics. Bit 0 (FLAG_REQUIRES_SECRET_KEY = 0x01) is set when the export was produced from a vault that has the Secret Key enabled, in which case the KDF input is the byte concatenation MP_utf8 || SK_raw16 rather than the password alone (see section 4.9). Bits 1-7 are reserved; BackupEncryption.kt :: decrypt rejects any envelope with reserved bits set via the mask FLAGS_RESERVED_MASK = 0xFE before any other processing, so a malformed FLAGS byte cannot leak metadata via differential error paths.

SecretKeyRequiredException pivot. When V5 is read with bit 0 set and no Secret Key has been supplied to decrypt, the function throws SecretKeyRequiredException(createdAtMs, vaultVersion) before any Argon2id work, so a hostile or misconfigured restore cannot burn CPU on a doomed derivation. The throw site carries the header's timestamp and vault version so the restore UI can prompt the user with which backup needs the Secret Key. Test suite BackupEncryptionV5Test locks this ordering.

Backward compatibility. V1 (plain), V2 (PBKDF2 + header AAD), V3 (Argon2id + header AAD), and V4 (Argon2id + extended header AAD) continue to decrypt forever on restore. BackupEncryption.kt :: decrypt accepts VERSION in {0x01, 0x02, 0x03, 0x04, 0x05} and routes each to the appropriate AAD shape and KDF. The Sync engine still writes V4 in the SK-disabled case (byte-identical to pre-SK sync output) and V5 in the SK-enabled case (see section 7.4); only the manual-export path is V5-only.

The encrypt path is a five-step pipeline:

Derive K via Argon2id over MP_utf8 || SK_raw16 (or MP_utf8 alone when SK is disabled) with caller-supplied kdfParams (defaults to KdfPreset.STANDARD.toKdfParams()); the combined input buffer is zeroed in a finally block
Build AAD = first 32 bytes of the header = MAGIC || VERSION(0x05) || FORMAT || FLAGS || KDF_M_KIB || KDF_T || KDF_P || TIMESTAMP || VAULT_VER via buildHeaderAadV5
GCM-encrypt BODY = AES-256-GCM(K, IV, plaintext, AAD) producing ciphertext + 16-byte auth tag; the IV is the fresh 12-byte nonce returned by CryptoManager :: encrypt
Concatenate header (76 B: MAGIC || VERSION || FORMAT || FLAGS || KDF_M_KIB || KDF_T || KDF_P || TIMESTAMP || VAULT_VER || SALT || IV) || BODY into a single byte stream
Hand off bytes to the SAF writer; the caller picks the destination, 1Key never opens a network socket (see section 7.4)

The decrypt-side decision tree (version dispatch, FLAGS sanity, SK guard, KDF dispatch, AAD reconstruction, GCM verify) lives in section 7.4 alongside the user-facing restore flow.

4.8 End-to-end flow

An unlock walks the master password from a UTF-8 byte buffer to a rendered plaintext credential without ever interning the password in the JVM String pool. The exact path depends on whether the Secret Key (SK) feature is enabled on the device: with SK off the verifier KDF input is the master password alone, with SK on the input is the concatenation MP_utf8 || SK_raw16 so Argon2id's avalanche absorbs the SK across every memory pass (XOR was considered and rejected). The vault key wrap is unchanged either way - SK only mixes into the verifier, not into the vault-key derivation.

The diagram below traces the SK-enabled path end to end. When SK is disabled, skip the SK-unwrap step and feed the master password directly into Argon2id (deriveKeyFromPasswordArgon2id in CryptoManager.kt); every other step is identical.

User types master password into the unlock surface
Keystore unwraps the Secret Key when SP_SECRET_KEY_ENABLED is set - SecretKeyKeystoreWrapper.unwrap AES-256-GCM-decrypts the base64 blob at SharedPreferences key secret_key_wrapped under the active alias 1key_secret_key_v{N}, producing a 16-byte local buffer. The wrapping key lives in StrongBox where available (API 28+) and falls back to TEE via the StrongBoxUnavailableException and ProviderException catch paths in getOrCreateWrappingKey.
Argon2id KDF over MP_utf8 || SK_raw16 with the active preset (Standard default: m=64 MiB, t=3, p=1) via deriveKeyFromPasswordWithSecretKeyArgon2id. The combined buffer is zeroed in a finally block. With SK off, the input is the password alone.
Verify the password verifier stored at SP_PASSWORD_VERIFIER in EncryptedSharedPreferences. A failure increments the attempt counter (tiered cooldowns at 3, 5, 10 wrong attempts); a success proceeds.
Load wrapped vault key from EncryptedSharedPreferences - separate from the database file so a leaked Room snapshot has no verifier to attack offline.
Android Keystore unwraps the vault key inside StrongBox or TEE under alias onekey_master_v2 (or legacy onekey_master on older installs), resolved by CryptoManager.getOrCreateKeystoreKey. The unwrapping key sets setUnlockedDeviceRequired(true) on API 28+ so a locked screen blocks decryption.
Install the SK into SecretKeyHolder via loadSecretKeyIntoHolderIfEnabled (calls setBytes, which copies the input and zeroes the previous buffer in place). The local unwrap buffer is then zeroed in finally. No-op when SK is disabled.
HKDF-SHA256 derives the field subkey and title subkey from the unwrapped vault key.
Read encrypted Room row and run per-field AES-256-GCM decrypt with AAD 1k:v1|id|field. Tampering with a row ID or column name invalidates the auth tag.
Plaintext credential rendered in UI; FLAG_SECURE keeps screenshots and Recent Apps previews blank.

The Keystore unwrap step (both the vault-key alias and any active SK alias) resolves to StrongBox when the device exposes a discrete tamper-resistant element and falls back to TEE otherwise. Classification surfaces in Settings via HardwareKeyIsolationTier as STRONGBOX, TEE, or SOFTWARE; only SOFTWARE warns. On API 28-30 the classifier reads the same-process CryptoManager.lastKeyCreatedWithStrongBox flag to disambiguate the two hardware tiers, because KeyInfo.securityLevel is API 31+ only. The SK alias scheme is versioned (1key_secret_key_v{N}) so a Rotate mints generation N+1 before the active N is destroyed; a crash mid-rotate leaves the still-existing previous alias decryptable, preventing a bricked vault.

Where each secret lives during an unlock

MaterialAt restIn memory after unlockZeroing
Master passwordNever persistedUTF-8 byte buffer inside the KDF callfinally block in deriveKeyFromPasswordArgon2id; the caller-owned CharArray is not zeroed by the KDF
Secret Key (raw 16 bytes)AES-256-GCM-wrapped blob at secret_key_wrapped in EncryptedSharedPreferences, wrapping key in StrongBox or TEE under alias 1key_secret_key_v{N}SecretKeyHolder (AtomicReference<ByteArray?>) - defensive copy, zeroed by withBytes and clear()Prior buffer zeroed in place on setBytes; cleared with VaultKeyHolder.lock
Verifier key (Argon2id output)Verifier ciphertext at SP_PASSWORD_VERIFIERTransient - used to AES-GCM-verify the marker, not keptIntermediate SecretKeySpec buffer zeroed after wrap
Vault keyWrapped blob in EncryptedSharedPreferences, wrapping key in StrongBox or TEE under onekey_master_v2 (or legacy onekey_master)VaultKeyHolder until inactivity auto-lock firesCleared on lock alongside SecretKeyHolder
Field / title subkeysNever persisted (HKDF-derived on demand)Transient per decryptionOut of scope when the vault key is cleared
Why concatenation, not XOR

With Secret Key on, the verifier is derived as K_verifier = Argon2id(MP_utf8 || SK_raw16, salt, params). XOR would only pre-combine the first 16 bytes of the input and offer an algebraic shortcut on short master passwords. Concatenation lets Argon2id's memory-hard avalanche absorb the SK across every pass, so even a leaked verifier salt and ciphertext does not reduce the work factor below the Argon2id parameter set.

4.9 Secret Key (SK)

The Secret Key is a raw 16-byte (128-bit) value generated and held only on the user's device, mixed into the master-password KDF input to harden the verifier against offline brute force. The model mirrors 1Password's 2SKD construction: even if an attacker recovers the encrypted verifier blob, they cannot guess only a human-typable password to forge a key. The single source of truth for the byte length is SECRET_KEY_RAW_LENGTH = 16 in SecretKeyHolder.kt.

The verifier KDF input is the byte concatenation K_verifier = Argon2id(MP_utf8 || SK_raw16, salt, params), NOT an XOR pre-combination. Concatenation is the deliberate choice: Argon2id's avalanche absorbs the SK across every memory pass of every iteration, so an attacker cannot factor SK out algebraically. XOR would only pre-combine 16 bytes at the head of the password buffer and would offer a shortcut on short master passwords. The concatenation order and the length check are enforced in CryptoManager.kt by deriveKeyFromPasswordWithSecretKeyArgon2id, which require()s secretKey.size == SECRET_KEY_RAW_LENGTH and zeros the combined buffer in finally.

SK length
16 bytes
KDF mix
MP || SK
Kit format
A3-26 chars
QR version
5 (V5 envelope)

Default-on policy. The Secret Key is presented as the recommended default for every new vault during onboarding step 3 (OnboardingScreen.kt, function SecretKeyCeremonyPage). Opting out is intentionally costly: tapping "Continue without Secret Key" opens SecretKeySkipOnboardingDialog, a destructive second-confirm dialog that requires ticking an "I understand the risks" checkbox before the "Skip Secret Key" button enables; the cancel button is labelled "Keep Secret Key". Opt-out is persisted as a metadata-only boolean under sk_opted_out in onekey_auth EncryptedSharedPreferences via AuthRepositoryImpl.setupMasterPasswordOptingOutOfSecretKey; the flag drives a Settings subtitle but does not alter the verifier KDF, and any successful Enable clears it.

Generation, storage, isolation. Fresh SK bytes are sourced from java.security.SecureRandom().nextBytes(ByteArray(16)) inside SecretKeyEnableUseCase and SecretKeyRotateUseCase; the use case (not the migrator) owns generation so plaintext bytes never reach the generic crypto layer. On-device persistence is owned by SecretKeyKeystoreWrapper, which AES-256-GCM-wraps the 16 raw bytes under an Android Keystore key. The wrapped blob is base64 of IV(12 B) || ciphertext(16 B) || GCM tag(16 B) and lives under key secret_key_wrapped in the onekey_auth EncryptedSharedPreferences file. The wrapping key prefers StrongBox via setIsStrongBoxBacked(true), falls back silently to TEE on StrongBoxUnavailableException or ProviderException, and pins decryption to a user-unlocked device via setUnlockedDeviceRequired(true) on API 28+.

Counter-alias scheme. The Keystore alias prefix is the literal 1key_secret_key_v; generation N uses alias 1key_secret_key_v + N. Generation 0 is a sentinel meaning "no SK installed" and no _v0 alias is ever created. The active generation is held in SP_SK_ACTIVE_ALIAS_VERSION. Rotation is crash-safe by construction: Phase 1e of KdfMigrator.runSecretKeyTransition calls wrapper.wrap(skRaw, oldActiveVersion + 1) to mint 1key_secret_key_v{N+1} WITHOUT touching the active alias, Phase 2 commits the new wrapped blob plus new alias version atomically in a single edit().commit(), and the post-commit GC then calls deleteAliasVersion(oldActiveVersion). A crash between Phase 1e and Phase 2 leaves the old alias intact and the vault decryptable; a crash between Phase 2 and the GC is recovered by the cold-start sweep SecretKeyKeystoreWrapper.sweepOrphanAliases, invoked from KdfMigrator.resumePendingInternal, which enumerates every alias under the prefix and deletes any not in the keep-set.

Rotate phaseActive aliasActive SP versionCrash-safe outcome
Phase 1e: wrap to N+1v{N} (untouched)NOld blob still decrypts under v{N}; orphan v{N+1} swept on next start
Phase 2: atomic SP commitv{N+1}N+1Single commit() flips blob + version together; never half-applied
Post-commit GC: delete v{N}v{N+1}N+1If process dies first, sweep deletes orphan v{N} on next start

Emergency Kit and QR. The printed form is A3-XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX: a fixed prefix A3- followed by five dashed groups of sizes 5+5+5+5+6 totalling 26 characters from the Crockford base32 alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ (no I, L, O, U). The "A" tags the auth/alias family and the "3" is a third-generation format stamp reserved for a future bump to A4. 16 raw bytes is exactly 128 bits, which packs into 26 Crockford base32 characters plus 2 trailing zero padding bits that the decoder requires to be zero, rejecting bit-flipped scans. The constants live in SecretKeyQrParser.kt as SECRET_KEY_HUMAN_PREFIX, SECRET_KEY_GROUP_SIZES, SECRET_KEY_CANONICAL_LENGTH, and the formatter is formatCanonicalSkForPrint. The QR carries the bare 26-character canonical form under a custom URI 1key-emergency:?sk=<26-char-canonical>&ver=5, parsed by the strictly anchored regex EMERGENCY_KIT_URI_REGEX. SECRET_KEY_QR_VERSION = 5 is pinned to the V5 backup envelope so a kit scanned on a new device matches the backup it must decrypt.

Memory hygiene. The in-memory holder SecretKeyHolder uses an AtomicReference<ByteArray?>. setBytes defensively copies its input and zeros the previously held buffer in place before publishing the new reference; withBytes hands a defensive copy to a lambda and zeros that copy in finally; clear() zeros the held bytes in place and drops the reference. The holder is wired into the same lock pipeline as VaultKeyHolder.lock, so a locked vault carries no SK bytes in process memory.

Onboarding ceremony. Step 3 of onboarding generates the SK, displays the Emergency Kit, demands user confirmation that it has been written down, and only then atomically commits the vault via AuthRepository.setupMasterPasswordWithSecretKey (a single authPrefs.edit().commit() that writes SETUP_COMPLETE, vault salt, wrapped vault key, password verifier, KDF version, SP_SECRET_KEY_ENABLED, the wrapped SK blob, and active alias version 1).

Master password set (onboarding step 2)
Generate SK via SecureRandom().nextBytes(ByteArray(16)) in SecretKeyEnableUseCase
Display Emergency Kit A3-XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX + QR 1key-emergency:?sk=...&ver=5
User confirms write-down (or opens SecretKeySkipOnboardingDialog with destructive second-confirm)
AES-256-GCM-wrap SK under Keystore alias 1key_secret_key_v1 (StrongBox preferred, TEE fallback)
Atomic commit verifier + wrapped SK + SP_SECRET_KEY_ENABLED + SP_SK_ACTIVE_ALIAS_VERSION = 1 in one edit().commit()
SK installed in SecretKeyHolder via setBytes; vault is open

05Security model and threat analysis

5.1 What 1Key defends against

ThreatDefence
Lost or stolen device, screen lockedKeystore wrap key requires UNLOCKED_DEVICE on API 28+. Vault is unreadable while the device is locked.
Lost or stolen device, screen unlocked, user idleConfigurable inactivity auto-lock (default 5 min). Tiered cooldowns - 30 s, 5 min, 1 hour at 3, 5, 10 wrong attempts.
Database file extraction (ADB backup, root, forensic image)Verifier sits in EncryptedSharedPreferences, not next to the database. Without live Keystore access, the attacker has no oracle. The wrapping key under alias onekey_master_v2 is classified at unlock time by HardwareKeyIsolationProbe into one of three tiers (STRONGBOX > TEE > SOFTWARE) exposed via HardwareKeyIsolationTier (see section 4.3); on STRONGBOX or TEE the key is non-exportable hardware-isolated, so offline brute-force is closed. SOFTWARE tier (older or rooted handsets without a working hardware Keystore) collapses back to software-key strength and raises a warning in Settings.
Database tampering at the row levelPer-field AAD binds each ciphertext to its row ID and column name. Swapping ciphertexts invalidates the auth tag.
Backup file leakedManual exports write a V5 envelope (AES-256-GCM under an Argon2id-derived key), authored by BackupEncryption.kt :: encrypt. The 32-byte GCM AAD binds MAGIC || VERSION || FORMAT || FLAGS || KDF_M_KIB || KDF_T || KDF_P || TIMESTAMP || VAULT_VER, so timestamp tampering, replay, and KDF-parameter downgrade all invalidate the tag. When Secret Key is enabled, the envelope sets FLAGS bit 0 (FLAG_REQUIRES_SECRET_KEY = 0x01) and the GCM key is derived via deriveKeyFromPasswordWithSecretKeyArgon2id(MP, SK, salt, embedded params) - so an attacker who steals the file must present BOTH the master password AND the 128-bit Secret Key (the value printed on the Emergency Kit, see section 4.9) to even run Argon2id. The full V5 layout and restore decision tree are in section 7.4.
Screenshot / Recent Apps previewFLAG_SECURE set by default on the host activity, blocking screenshots and rendering the Recent Apps card blank.
Network exfiltrationCurrent builds declare no INTERNET permission. The OS will not let the process open a socket.

5.2 What 1Key does not defend against

ThreatStatus
Compromised OS / rooted device with active malicious codeOut of scope. An attacker with root and process-attach privileges can read the vault key from process memory after unlock. True of every password manager.
Malicious app with accessibility privilegesOut of scope. An accessibility-service attacker can read text on screen.
Forgotten master passwordNo recovery. There is no server-side reset, no escrow, and no backdoor.
Cross-device syncNot provided. See section 9.
Physical hardware attacks on the TEE / StrongBoxOut of scope. Platform-level guarantees from the device manufacturer.
Shoulder-surfing the master passwordMitigations: password-input dots and FLAG_SECURE. Hostile premises → leave FLAG_SECURE on, use PIN fallback after first unlock.
Devices without a working hardware KeystoreThe Keystore-bound verifier defence degrades to software-key strength on older or rooted handsets.

5.3 Trust model

1Key is a single-developer project. The user trusts:

  1. The author of 1Key (the Kotlin code).
  2. The Argon2 reference implementation, via the lambdapioneer/argon2kt JNI wrapper.
  3. AndroidX security-crypto 1.1.0 (EncryptedSharedPreferences, JetPack Tink).
  4. The Android platform: Keystore, TEE, and the OS sandbox.
Known gap

A team-of-thousands cloud vendor can offer audit reports, SOC 2 certifications, and bug-bounty payouts. 1Key cannot. No third-party security audit (e.g. Cure53, Recurity Labs) has been commissioned. Treat this as a known gap relative to the cloud-vault category.

What 1Key offers in exchange is a much smaller attack surface to audit. The full cryptographic core is roughly 230 lines of Kotlin in CryptoManager.kt. The auth state machine is one file. There is no server.

06Use cases

The personas below are illustrative composites drawn from the user research that informed 1Key's design.

6.1 The privacy-focused individual

A single-device user who treats her phone as her primary computer and refuses any cloud password manager because she does not want her authentication state in a vendor database. Before: plaintext passwords in a notes app, reused across thirty-plus accounts, no TOTP because of authenticator-app friction. After: all credentials in an Argon2id-protected vault, TOTP codes in the same record as the password they protect, no subscription. Outcome: eliminated reuse across the audited account set; adopted 2FA on twelve accounts that previously used SMS-only or no second factor.

6.2 The journalist on a hardened device

An investigative reporter working with sources whose safety depends on his operational security. Has been advised that any cloud-resident auth blob is attack surface - a state-level adversary can subpoena a vendor or compromise a server. Before: paper notebook in a safe plus a cloud manager for low-sensitivity accounts; the bifurcation itself was a leak. After: a single vault on the hardened device, with no INTERNET permission so the OS itself enforces that no telemetry can reach a server, and no account for an adversary to subpoena. Outcome: auth metadata (which accounts exist, when they were created, which device unlocked them) never leaves the device.

6.3 The developer tired of subscriptions

A senior engineer who has cycled through three cloud password managers in five years as pricing and acquisitions reshape the market, tired of paying £35-£60/year for autofill. Before: a premium subscription primarily to unlock TOTP and secure export. After: a self-built APK; imported the existing vault via CSV; verified the absent INTERNET permission via tools:node="remove" in AndroidManifest.xml herself. Outcome: zero recurring cost, auditable cryptographic stack of roughly 230 lines of Kotlin, CSV importer auto-detected the source format with no manual mapping.

07Installation and migration

This section replaces the SaaS-style "Implementation & Integration" template. There are no APIs, SSO connectors, or IdP integrations to configure. There is an APK, a master password, and an importer.

7.1 Installing the APK

1Key is distributed as a free Android APK. The current channel is GitHub Releases; F-Droid distribution is planned but not yet active. Release builds carry the author's signing certificate. Users uncomfortable with sideloading can build from source - clone the repository, run ./gradlew assembleDebug, install the resulting app-debug.apk.

Minimum supported Android version is API 26 (Android 8.0 Oreo). Compile and target SDK is 36; see the android { defaultConfig { ... } } block in app/build.gradle.kts.

7.2 First-run setup

On first launch the user is asked to choose a master password. There is no email, no recovery question, no telemetry consent screen. The password is the primary secret; the optional Secret Key (default-on, see section 4.9) is a co-secret that is co-located on this device but never leaves it except via the printed Emergency Kit or its QR.

User chooses a master password
App generates random 256-bit vault key + random 32-byte salts
Secret Key ceremony · 16-byte SK generated and shown on the “Your Secret Key” page in printed form A3-XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX · user reviews and taps Save & Continue (default-on) or opts out via Continue without Secret Key, which raises the destructive second-confirm dialog “Are you sure?” gated by the “I understand the risks” checkbox before Skip Secret Key becomes enabled
Wrap SK with Keystore alias 1key_secret_key_v1 (StrongBox preferred, TEE fallback) · skipped if the user opted out
Argon2id derives the verifier key from password + salt (and SK mixed in when enabled)
Keystore creates a non-exportable wrap key inside the TEE
Vault key wrapped by Keystore key · 'VALID' encrypted under verifier key
Persist wrapped vault key + verifier ciphertext + salts to EncryptedSharedPreferences
Zero the password CharArray and any in-memory SK byte buffer

The ceremony page itself sets expectations: “1Key just generated a 128-bit Secret Key on this device. We will mix it into your master password to derive the vault key, so a stolen backup file cannot be brute-forced from the password alone.” The footnote under the printed key reminds users to capture the Emergency Kit later: “After you continue, save this value as an Emergency Kit from Settings > Security > Secret Key. Without it, V5 encrypted backups taken from this device cannot be restored.” UI strings live in OnboardingScreen.kt :: SecretKeyCeremonyPage and SecretKeySkipOnboardingDialog.kt :: SecretKeySkipOnboardingDialog.

7.3 Importing from an incumbent password manager

1Key includes an import flow that auto-detects format and column headers - no manual mapping is required. Confirmed support: Google Passwords, LastPass, KeePass, Safari / iCloud Keychain, 1Password, Dashlane, NordPass (all CSV).

Duplicate credentials are detected by exact match of title plus username and skipped. The CSV file is read once into memory, parsed, and discarded; nothing transits the network.

7.4 Encrypted backups

A manual export now writes a .1key V5 envelope (the prior V4 layout is still accepted on restore, forever). V5 binds the Argon2id parameters and a FLAGS byte into the GCM AAD, so a tampered header invalidates the auth tag and the restore aborts. The same envelope can also carry the Secret Key requirement, in which case the restorer must present BOTH the master password AND the Secret Key.

V5 header layout (76 bytes before BODY), authored by encrypt in BackupEncryption.kt:

OffsetBytesFieldNotes
08MAGICliteral 1KEYBKP\n
81VERSION0x05 for V5
91FORMAT0x00 JSON, 0x01 CSV
101FLAGSbit 0 = FLAG_REQUIRES_SECRET_KEY (0x01); bits 1-7 reserved, MUST be 0
114KDF_M_KIBuint32 big-endian, Argon2id memory in KiB
154KDF_Tuint32 big-endian, Argon2id iterations
191KDF_Puint8 parallelism, always 1 today
208TIMESTAMPint64 big-endian, export epoch-ms
284VAULT_VERint32 big-endian, vault version counter
3232SALTper-export random Argon2id salt
6412IVAES-GCM 96-bit nonce
76+NBODYAES-256-GCM ciphertext + 16-byte auth tag

AAD bound into the GCM tag is exactly the first 32 bytes: MAGIC || VERSION || FORMAT || FLAGS || KDF_M_KIB || KDF_T || KDF_P || TIMESTAMP || VAULT_VER. SALT and IV are written verbatim but not appended to the AAD (GCM authenticates the IV intrinsically, and SALT is authenticated indirectly through the derived key). Tampering with FLAGS or the KDF triple invalidates the tag, so an attacker cannot downgrade a Secret-Key-protected backup or push the victim onto cheaper Argon2id parameters.

Restore decision tree in decrypt:

Parse header · accept VERSION in {0x01, 0x02, 0x03, 0x04, 0x05}, else reject with "Unsupported backup version"
FLAGS sanity · enforce FLAGS_RESERVED_MASK = 0xFE; any reserved bit set rejects BEFORE the SK guard so a malformed FLAGS byte cannot leak header metadata via SecretKeyRequiredException
SK-required guard · if VERSION == V5 AND FLAGS & 0x01 AND no Secret Key supplied, throw SecretKeyRequiredException(createdAtMs, vaultVersion) BEFORE any Argon2id work - this ordering is locked by the BackupEncryptionV5Test suite
KDF dispatch · V5+SK → deriveKeyFromPasswordWithSecretKeyArgon2id(MP, SK, salt, embedded params); V5 without SK → deriveKeyFromPasswordArgon2id(MP, salt, embedded params); V3/V4 → Argon2id at Standard params; V1/V2 → PBKDF2
AAD reconstruction · V5 rebuilds the 32-byte AAD from the header; V4 uses the shorter (MAGIC || VERSION || FORMAT || TIMESTAMP || VAULT_VER); V2/V3 use 10-byte AAD; V1 has no AAD
AES-256-GCM decrypt · any byte changed in header, ciphertext, or tag fails verification

Secret Key path. When the vault has Secret Key enabled, the V5 export sets FLAGS bit 0 and the KDF input is the concatenation MP_utf8 || SK_raw16 (NOT XOR - concatenation lets Argon2id's avalanche absorb the SK across every memory pass). The 16-byte SK is the same value printed on the Emergency Kit as A3-XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX (Crockford base32, 26 characters excluding the A3- stamp), and encoded into the QR as 1key-emergency:?sk=<26-char>&ver=5. The QR's ver=5 is locked to the V5 envelope so a kit scanned on a new device matches the backup it must decrypt.

SK raw length
16 bytes / 128 bits
Emergency Kit groups
5+5+5+5+6 chars
QR scheme version
ver=5 (matches V5)
Cost of wrong/missing SK
no Argon2id work
No recovery path

Losing the device plus the backup password is unrecoverable. With Secret Key enabled, the same is true if you lose the Secret Key: the restorer needs MP AND SK, and the SK never leaves the device except via the printed Emergency Kit or its QR. There is no vendor reset, no email recovery, no support channel.

Recommended migration discipline. Export once after initial setup, store the encrypted .1key off-device on physical media (USB stick, external SSD, or encrypted cloud storage of your choice), AND keep the printed Emergency Kit physically separate from that backup. Refresh on a personal cadence. The two artefacts together let a future-you restore on a fresh device; either alone is useless.

08Comparison framework

8.1 1Key vs cloud-vault category

PropertyCloud vault (typical)1Key
Account requiredYes, with emailNo
INTERNET permissionYesNo (current builds)
Vault stored on vendor serversYes (E2EE)No - local only
Account metadata held off-deviceEmail, billing, devicesNone
Telemetry / analyticsOpt-out (some opt-in)None
Cross-device syncYesNo
Recovery if password forgottenYesNo
Built-in TOTPSometimes premium-tierYes, free
Secure exportSometimes premium-tierYes, free
Cost$2-$10/month typicalFree
Offline brute-force surfaceCloud auth blobNone - verifier in Keystore-bound storage
Secret Key construction (off-device factor mixed into KDF)Some (e.g. 1Password)Yes (default-on, optional opt-out)
Independent security auditYes (Cure53, Recurity Labs)None

The trade is honest: cloud vaults give up the local-only property in exchange for sync and recovery. 1Key gives up sync and recovery in exchange for the local-only property.

8.2 1Key vs browser-native managers

Browser-native password managers are zero-friction and free, but tie the vault to a browser identity (Google account, Apple ID, Firefox account) and sync through the browser vendor's cloud. They generally do not offer TOTP, secure notes, custom fields, OCR capture, or encrypted export. 1Key is a strict superset on features and a strict subset on integration surface.

8.3 1Key vs hardware tokens

Hardware tokens and password managers are not substitutes - they are complements. A hardware token holds a small number of WebAuthn credentials and TOTP secrets in tamper-resistant hardware. A password manager stores arbitrarily many username/password pairs plus other structured data. The right architecture for a security-conscious user is hardware tokens for high-value accounts (email, banking) plus a password manager for the long tail.

09Roadmap and honest limitations

9.1 Permanently closed

Closed Background-daemon auto-backup (still closed)

The original objection stands for any flavour of auto-backup that runs without the user present: a daemon, a JobScheduler task, a system-broadcast listener, or a periodic WorkManager job would all need to persist the master password (or a key derived from it) somewhere the OS can reach without an unlock. Any such persistence undermines the central property that the master password exists only in memory between unlock and lock.

  • What is still refused. No background service, no periodic worker, no boot-completed listener, no "silent" sync. The app must not be able to write a backup while the vault is locked.
  • What did ship instead. An opt-in, master-password-unlock-only sync feature: a fresh defensive copy of the password (and, when Secret Key is enabled, the unwrapped SK) is forwarded from AuthRepositoryImpl :: unlockWithPassword into SyncEngineImpl :: maybeTriggerSync, which encrypts a backup to the user-chosen SAF location and zeroes both buffers in its own finally. Biometric and PIN unlocks do not trigger sync (see AuthRepositoryImpl :: unlockWithBiometric and AuthRepositoryImpl :: unlockWithPin). No key material is persisted; the password material exists only for the duration of that one unlock.
Refused · background/daemon path · would need a persisted key
Allowed · MP-unlock-only path · key lives only across one unlockWithPassword call
Output · atomic write to vault-backup.1key via .part rename (see SyncEngineImpl :: FINAL_FILENAME, PART_FILENAME, runSync)

In short: the daemon-style auto-backup the original card refused is still refused. The narrower, unlock-gated variant that does not require key persistence is the shipped sync feature.

Closed Cloud sync via vendor backend

Out of scope. 1Key is local-first by design.

9.2 Parked and shipped-since-v1.0

Items previously listed here as "designed, not built" have split into two outcomes: one still parked, one delivered. The Secret Key feature documented in section 4.9 shipped along the same calendar.

Parked LAN sync

A peer-to-peer protocol over local Wi-Fi using the master password and a 4-digit short authentication string (SAS) for pairing. Designed; awaiting build-or-skip decision.

A LAN sync feature would require re-introducing some form of local-network permission. Confirm whether the no-INTERNET property survives the design before the feature ships.

Shipped since v1.0. The following items moved out of the parked list and into the app:

  • Autofill (May 2026). Native-app autofill via Android's AutofillService ships along the "fill from explicit user-picked credential" path. The service is registered in AndroidManifest.xml as OneKeyAutofillService and matches on exact host. Locked-vault matching is deferred to post-unlock with a generic chip, because matching across the vault while locked would require either a plaintext index or a partial unlock, both of which weaken the threat model. Inline IME suggestions and passkeys remain deferred.
  • Secret Key (June 2026). The 128-bit second factor and V5 backup envelope; see section 4.9 and section 7.4.
  • Markdown notes editing. Live preview and a markdown helper bar in the notes editor, with edit-mode showing markers dimmed grey rather than hidden so the soft keyboard cannot compose over a hidden range.

9.3 Open / under consideration

Open Third-party security audit

Not yet performed. Roadmap commitment pending.

Open F-Droid distribution

Planned, not yet submitted.

Open Reproducible builds

Status to be determined.

Open Localisation

Currently English only. Localisation plan to be determined.

Open OCR for non-Latin scripts

Currently Latin script only via ML Kit.

9.4 Honest limits the user must accept

  • One device. Lose the device without a current backup, lose the vault.
  • One password. Forget it without a current backup, lose the vault.
  • One developer. No on-call rotation, no enterprise SLA, no audit log.

These limits are not bugs to be fixed. They are the price of the architecture.

10Frequently asked questions

A short version. The full FAQ lives at FAQ.html.

Is this stronger crypto than the leading cloud password managers?

The cryptographic primitives are at parity: Argon2id, AES-256-GCM, HKDF. The historical advantage of the largest cloud vendor was its Secret Key construction (the so-called 2SKD or two-secret key derivation model), which mixes a high-entropy device-resident value into the password KDF and so resists offline brute force in a way no KDF tuning can match alone. 1Key now ships the same construction.

The Secret Key is a raw 16-byte (128-bit) value generated on-device via SecureRandom, default-on for every new vault (see SecretKeyEnableUseCase and the onboarding ceremony in OnboardingScreen.kt's SecretKeyCeremonyPage). The verifier KDF input is byte-concatenation rather than XOR:

Secret Key off · K_verifier = Argon2id(MP_utf8, salt, params)
vs
Secret Key on · K_verifier = Argon2id(MP_utf8 || SK_raw16, salt, params) · default-on

XOR was considered and rejected: concatenation lets Argon2id's avalanche absorb the 128-bit Secret Key across every memory pass of the m=64 MiB / t=3 STANDARD preset, while XOR would only pre-combine sixteen bytes at the front of the password and offer an algebraic shortcut on short master passwords. The branch lives in CryptoManager.deriveKeyFromPasswordWithSecretKeyArgon2id and is gated by SP_SECRET_KEY_ENABLED inside both AuthRepositoryImpl.deriveVerifierKey and KdfMigrator.deriveActiveVerifierKey.

Because the Secret Key never leaves the device by network, it must leave the device as paper. At setup the user is presented an Emergency Kit they are expected to print or write down:

Canonical form
A3-XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX · Crockford base32, 26 characters in five dashed groups (5+5+5+5+6); prefix A3- marks the auth/alias family at format generation 3 (see SecretKeyQrParser.kt · SECRET_KEY_HUMAN_PREFIX, SECRET_KEY_GROUP_SIZES, formatCanonicalSkForPrint).
QR form
1key-emergency:?sk=<26-char-canonical>&ver=5 · custom URI scheme, anchored regex, version locked at 5 to match the V5 backup envelope (SecretKeyQrParser.SECRET_KEY_QR_VERSION).
If lost
Unrecoverable. There is no escrow and no reset link. The kit is the only off-device copy.

The comparison is no longer "cloud has Secret Key, 1Key has a smaller perimeter." It is both at once. An attacker who somehow obtains a 1Key backup file (the V5 envelope sets FLAG_REQUIRES_SECRET_KEY = 0x01 in its FLAGS byte, bound into the GCM auth tag) still needs both the master password and the 128-bit Secret Key from the user's paper kit before they can begin Argon2id work. The SK-required guard in BackupEncryption.decrypt fires before any KDF is run.

Nothing leaves the device by network; there is no off-device auth blob attached to an email address sitting on a vendor server. 1Key is now arguably the broader defence: no off-device blob, plus an off-device second factor required for any backup-restore path.

How do I sync across devices?

1Key ships sync today, but it is intra-device: there is no server, no cloud account, and no network call. On every successful master-password unlock, the app writes an encrypted file named vault-backup.1key into a folder you pick once (Settings > Sync). Moving that file to another device, by whatever offline channel you trust (USB cable, a self-hosted file share, a removable card), is your responsibility. The other device restores it from Restore from backup using the same master password and, if applicable, the same Secret Key.

Triggered only by master-password unlock.

Biometric and PIN unlocks do not run sync. The trigger lives at the tail of AuthRepositoryImpl.kt :: unlockWithPassword; unlockWithBiometric and unlockWithPin install the vault key (and Secret Key, when enabled) but never call maybeTriggerSync. The contract is documented in SyncEngine.kt's KDoc: "Called from AuthRepositoryImpl at the moment of a successful master-password unlock."

The file is written atomically: bytes go to vault-backup.1key.part first, then an atomic rename promotes it to vault-backup.1key, replacing the prior copy. Stale .part files from a crashed run are swept at the start of the next sync. Constants live in SyncEngineImpl.kt :: FINAL_FILENAME and PART_FILENAME; the sweep is SyncEngineImpl.kt :: sweepStaleParts.

Step 1. User unlocks vault with master password on device A.
Step 2. AuthRepositoryImpl.unlockWithPassword derives the vault key and, if Secret Key is enabled, unwraps a fresh defensive copy of the SK bytes from the Android Keystore.
Step 3. SyncEngineImpl.maybeTriggerSync is called with the password and (optional) SK; if no folder URI is configured or another sync is in flight, it exits.
Step 4. BackupEncryption.encryptWithKey writes a V5 envelope with FLAGS = 0x01 (FLAG_REQUIRES_SECRET_KEY) when SK is on, or a V4 envelope when SK is off, embedding the KdfPreset.STANDARD parameters in the header.
Step 5. Bytes land in vault-backup.1key.part, then an atomic rename promotes them to vault-backup.1key in the user-picked folder.
Step 6. User physically moves the file to device B (USB, SD card, self-hosted share). Device B restores it via Restore from backup.

Two envelope versions can come out of this pipeline. Which one depends solely on whether Secret Key is enabled on the sending device:

Secret Key on this deviceEnvelope writtenFLAGS byteHeader layout
Enabled (default for new vaults)V50x01 (FLAG_REQUIRES_SECRET_KEY)MAGIC, VERSION=0x05, FORMAT, FLAGS, KDF_M, KDF_T, KDF_P, TIMESTAMP, VAULT_VER, SALT, IV, ciphertext
Disabled (opted out)V4(no FLAGS field)MAGIC, VERSION=0x04, FORMAT, TIMESTAMP, VAULT_VER, SALT, IV, ciphertext

The V5 branch lives in BackupEncryption.kt :: encryptWithKey (the if (requiresSecretKey) branch) and the V4 branch is the else branch; the flag constant is BackupEncryption.kt :: FLAG_REQUIRES_SECRET_KEY. Sync hardcodes KdfPreset.STANDARD for its KDF block so the restoring device can re-derive with the same parameters regardless of its own current preset. See section 4.7 for the envelope formats and section 7.4 for the restore decision tree.

1Key does not move the file for you.

There is no network code in the sync engine and no INTERNET permission on the app. If you want the backup on another device, you copy it yourself. If you would rather not, the file is still useful on this device as a same-device restore source after an uninstall or factory reset.

If you want server-mediated cross-device sync with conflict resolution and push notifications, choose a cloud manager. They exist for good reasons. 1Key is for users who want the encrypted backup, on disk, on their own terms.

How do I recover if I forget my master password?

You cannot, unless you have an encrypted backup whose password you remember. There is no server, no escrow, no "reset my password" link. If both the master password and any backup passwords are lost, the vault is unrecoverable. This is by design.

Is the source code really audited?

Self-audit only. No third-party audit has been commissioned. The cryptographic core is small enough (~230 lines in CryptoManager.kt) that a competent reviewer can read it end-to-end in an evening.

Can I trust that there is really no telemetry?

The strongest evidence is structural. Current builds declare no INTERNET permission in app/src/main/AndroidManifest.xml. The OS sandbox enforces this - even if a future bug introduced a network call, it would be denied at the system layer. The Gradle build also explicitly excludes the Firelog telemetry subgraph that ML Kit pulls in transitively, so even the local telemetry-queueing code is absent from the binary.

What happens to my data if the project is abandoned?

You keep using the version you have. There is no server to shut down. You can also export your vault to plain CSV or JSON at any time and migrate to any other manager.

Why no fingerprint-only mode?

Biometric unlock is supported, but the master password is always the primary fallback. The app receives only a yes/no result from the OS BiometricPrompt API. An occasional master-password recheck (configurable) prevents biometric drift from making the password effectively unrecoverable through disuse.

Why an Android-only product?

The platform-specific guarantees 1Key's threat model relies on - Keystore-backed EncryptedSharedPreferences, FLAG_SECURE, the INTERNET permission model, setUnlockedDeviceRequired - are Android primitives. A faithful port elsewhere would require finding equivalents on each platform.

11Editor's notes - verified facts

Every claim below is anchored to a stable symbol (class, function, or named constant) rather than a line number, so the references survive code drift. Verified against the source tree as of June 2026.

Cryptographic primitives

Hardware-bound key storage

Secret Key feature

Backup envelope

KDF presets and customization

Networking and build

Project metadata

- End of paper -