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.
- The Android manifest declares no
INTERNETpermission. 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:
- Cross-device sync. A user with a phone, laptop, and tablet wants the same vault on all three.
- 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.
- 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.
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.
| Preset | Memory (m) | Iterations (t) | Parallelism (p) | Device RAM gate |
|---|---|---|---|---|
Standard (default) | 64 MiB | 3 | 1 | none |
Standard-plus | 64 MiB | 8 | 1 | none |
Hardened | 128 MiB | 4 | 1 | ~4 GB total |
Maximum | 128 MiB | 8 | 1 | ~6 GB total |
Custom | 32 MiB to device cap | 2 to 16 | 1 (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 (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:
Build.VERSION.SDK_INT >= P (API 28+), getOrCreateKeystoreKey calls setIsStrongBoxBacked(true) on the KeyGenParameterSpec builder. Below API 28, the StrongBox block is skipped entirely.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.setIsStrongBoxBacked and retries against the TEE.StrongBoxUnavailableException under load. It is treated identically: silent TEE fallback, no user-visible error.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:
| Tier | What it means | Settings indicator |
|---|---|---|
STRONGBOX | Dedicated tamper-resistant chip, physically separate from the main CPU. Strongest isolation Android offers. | Green check |
TEE | Trusted Execution Environment - a separate secure-only mode of the main CPU. Full-strength hardware isolation, just not on a discrete chip. | Green check |
SOFTWARE | Wrapping 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.
| Mode | KDF input | Symbol that builds it |
|---|---|---|
| SK off (legacy vault) | MP_utf8 | CryptoManager.kt :: deriveKeyFromPasswordArgon2id |
| SK on (default for new vaults) | MP_utf8 || SK_raw16 | CryptoManager.kt :: deriveKeyFromPasswordWithSecretKeyArgon2id |
SP_SECRET_KEY_ENABLED and conditionally unwrap SK inside AuthRepositoryImpl.kt :: verifyMasterPasswordderiveVerifierKey with the SK bytes (or null)K_verifier = Argon2id(MP_utf8, salt, params) via deriveKeyFromPasswordArgon2idK_verifier = Argon2id(MP_utf8 || SK_raw16, salt, params) via deriveKeyFromPasswordWithSecretKeyArgon2id; combined buffer zeroed in finallyEncryptedSharedPreferences, file encrypted under Keystore master keyWhy 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 label | Subkey purpose |
|---|---|
1key-field-enc-v1 | Encrypts non-title credential fields |
1key-title-enc-v1 | Encrypts 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:
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 blockMAGIC || VERSION(0x05) || FORMAT || FLAGS || KDF_M_KIB || KDF_T || KDF_P || TIMESTAMP || VAULT_VER via buildHeaderAadV5CryptoManager :: encryptMAGIC || VERSION || FORMAT || FLAGS || KDF_M_KIB || KDF_T || KDF_P || TIMESTAMP || VAULT_VER || SALT || IV) || BODY into a single byte streamThe 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.
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.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.SP_PASSWORD_VERIFIER in EncryptedSharedPreferences. A failure increments the attempt counter (tiered cooldowns at 3, 5, 10 wrong attempts); a success proceeds.EncryptedSharedPreferences - separate from the database file so a leaked Room snapshot has no verifier to attack offline.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.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.1k:v1|id|field. Tampering with a row ID or column name invalidates the auth tag.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
| Material | At rest | In memory after unlock | Zeroing |
|---|---|---|---|
| Master password | Never persisted | UTF-8 byte buffer inside the KDF call | finally 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_VERIFIER | Transient - used to AES-GCM-verify the marker, not kept | Intermediate SecretKeySpec buffer zeroed after wrap |
| Vault key | Wrapped blob in EncryptedSharedPreferences, wrapping key in StrongBox or TEE under onekey_master_v2 (or legacy onekey_master) | VaultKeyHolder until inactivity auto-lock fires | Cleared on lock alongside SecretKeyHolder |
| Field / title subkeys | Never persisted (HKDF-derived on demand) | Transient per decryption | Out of scope when the vault key is cleared |
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.
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 phase | Active alias | Active SP version | Crash-safe outcome |
|---|---|---|---|
| Phase 1e: wrap to N+1 | v{N} (untouched) | N | Old blob still decrypts under v{N}; orphan v{N+1} swept on next start |
| Phase 2: atomic SP commit | v{N+1} | N+1 | Single commit() flips blob + version together; never half-applied |
| Post-commit GC: delete v{N} | v{N+1} | N+1 | If 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).
SecureRandom().nextBytes(ByteArray(16)) in SecretKeyEnableUseCaseA3-XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX + QR 1key-emergency:?sk=...&ver=5SecretKeySkipOnboardingDialog with destructive second-confirm)1key_secret_key_v1 (StrongBox preferred, TEE fallback)SP_SECRET_KEY_ENABLED + SP_SK_ACTIVE_ALIAS_VERSION = 1 in one edit().commit()SecretKeyHolder via setBytes; vault is open05Security model and threat analysis
5.1 What 1Key defends against
| Threat | Defence |
|---|---|
| Lost or stolen device, screen locked | Keystore wrap key requires UNLOCKED_DEVICE on API 28+. Vault is unreadable while the device is locked. |
| Lost or stolen device, screen unlocked, user idle | Configurable 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 level | Per-field AAD binds each ciphertext to its row ID and column name. Swapping ciphertexts invalidates the auth tag. |
| Backup file leaked | Manual 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 preview | FLAG_SECURE set by default on the host activity, blocking screenshots and rendering the Recent Apps card blank. |
| Network exfiltration | Current builds declare no INTERNET permission. The OS will not let the process open a socket. |
5.2 What 1Key does not defend against
| Threat | Status |
|---|---|
| Compromised OS / rooted device with active malicious code | Out 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 privileges | Out of scope. An accessibility-service attacker can read text on screen. |
| Forgotten master password | No recovery. There is no server-side reset, no escrow, and no backdoor. |
| Cross-device sync | Not provided. See section 9. |
| Physical hardware attacks on the TEE / StrongBox | Out of scope. Platform-level guarantees from the device manufacturer. |
| Shoulder-surfing the master password | Mitigations: password-input dots and FLAG_SECURE. Hostile premises → leave FLAG_SECURE on, use PIN fallback after first unlock. |
| Devices without a working hardware Keystore | The 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:
- The author of 1Key (the Kotlin code).
- The Argon2 reference implementation, via the
lambdapioneer/argon2ktJNI wrapper. - AndroidX security-crypto 1.1.0 (
EncryptedSharedPreferences, JetPack Tink). - The Android platform: Keystore, TEE, and the OS sandbox.
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.
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 enabled1key_secret_key_v1 (StrongBox preferred, TEE fallback) · skipped if the user opted out'VALID' encrypted under verifier keyEncryptedSharedPreferencesCharArray and any in-memory SK byte bufferThe 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:
| Offset | Bytes | Field | Notes |
|---|---|---|---|
| 0 | 8 | MAGIC | literal 1KEYBKP\n |
| 8 | 1 | VERSION | 0x05 for V5 |
| 9 | 1 | FORMAT | 0x00 JSON, 0x01 CSV |
| 10 | 1 | FLAGS | bit 0 = FLAG_REQUIRES_SECRET_KEY (0x01); bits 1-7 reserved, MUST be 0 |
| 11 | 4 | KDF_M_KIB | uint32 big-endian, Argon2id memory in KiB |
| 15 | 4 | KDF_T | uint32 big-endian, Argon2id iterations |
| 19 | 1 | KDF_P | uint8 parallelism, always 1 today |
| 20 | 8 | TIMESTAMP | int64 big-endian, export epoch-ms |
| 28 | 4 | VAULT_VER | int32 big-endian, vault version counter |
| 32 | 32 | SALT | per-export random Argon2id salt |
| 64 | 12 | IV | AES-GCM 96-bit nonce |
| 76+ | N | BODY | AES-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:
{0x01, 0x02, 0x03, 0x04, 0x05}, else reject with "Unsupported backup version"FLAGS_RESERVED_MASK = 0xFE; any reserved bit set rejects BEFORE the SK guard so a malformed FLAGS byte cannot leak header metadata via SecretKeyRequiredExceptionVERSION == V5 AND FLAGS & 0x01 AND no Secret Key supplied, throw SecretKeyRequiredException(createdAtMs, vaultVersion) BEFORE any Argon2id work - this ordering is locked by the BackupEncryptionV5Test suitederiveKeyFromPasswordWithSecretKeyArgon2id(MP, SK, salt, embedded params); V5 without SK → deriveKeyFromPasswordArgon2id(MP, salt, embedded params); V3/V4 → Argon2id at Standard params; V1/V2 → PBKDF2(MAGIC || VERSION || FORMAT || TIMESTAMP || VAULT_VER); V2/V3 use 10-byte AAD; V1 has no AADSecret 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.
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
| Property | Cloud vault (typical) | 1Key |
|---|---|---|
| Account required | Yes, with email | No |
INTERNET permission | Yes | No (current builds) |
| Vault stored on vendor servers | Yes (E2EE) | No - local only |
| Account metadata held off-device | Email, billing, devices | None |
| Telemetry / analytics | Opt-out (some opt-in) | None |
| Cross-device sync | Yes | No |
| Recovery if password forgotten | Yes | No |
| Built-in TOTP | Sometimes premium-tier | Yes, free |
| Secure export | Sometimes premium-tier | Yes, free |
| Cost | $2-$10/month typical | Free |
| Offline brute-force surface | Cloud auth blob | None - 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 audit | Yes (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::unlockWithPasswordintoSyncEngineImpl::maybeTriggerSync, which encrypts a backup to the user-chosen SAF location and zeroes both buffers in its ownfinally. Biometric and PIN unlocks do not trigger sync (seeAuthRepositoryImpl::unlockWithBiometricandAuthRepositoryImpl::unlockWithPin). No key material is persisted; the password material exists only for the duration of that one unlock.
unlockWithPassword callvault-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
AutofillServiceships along the "fill from explicit user-picked credential" path. The service is registered inAndroidManifest.xmlasOneKeyAutofillServiceand 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:
K_verifier = Argon2id(MP_utf8, salt, params)K_verifier = Argon2id(MP_utf8 || SK_raw16, salt, params) · default-onXOR 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); prefixA3-marks the auth/alias family at format generation 3 (seeSecretKeyQrParser.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.
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.
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.SyncEngineImpl.maybeTriggerSync is called with the password and (optional) SK; if no folder URI is configured or another sync is in flight, it exits.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.vault-backup.1key.part, then an atomic rename promotes them to vault-backup.1key in the user-picked folder.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 device | Envelope written | FLAGS byte | Header layout |
|---|---|---|---|
| Enabled (default for new vaults) | V5 | 0x01 (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.
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
- Argon2id default parameters m=64 MiB, t=3, p=1, output 32 bytes - constants
ARGON2_M_COST,ARGON2_T_COST,ARGON2_PARALLELISM,ARGON2_HASH_LENGTHinCryptoManager.kt; surfaced asKdfPreset.STANDARDinKdfPreset.kt. Argon2id entry points:CryptoManager.kt::deriveKeyFromPasswordArgon2id(password only) andCryptoManager.kt::deriveKeyFromPasswordWithSecretKeyArgon2id(password concatenated with the raw 16-byte Secret Key). - AES-256-GCM cipher mode and 128-bit auth tag - constants
AES_GCMandGCM_TAG_LENGTHinCryptoManager.kt. AES-GCM IV is 12 bytes (96 bits, the GCM standard) and is generated per-encryption. - HKDF-SHA256 subkey labels
1key-field-enc-v1and1key-title-enc-v1- constantsHKDF_FIELD_KEY_INFOandHKDF_TITLE_KEY_INFOinCryptoManager.kt. RFC 5869 Extract step is skipped because the vault key is already a uniformly random 256-bit AES key. - AAD format
1k:v1|<id>|<field>(fields) and1k:v2|<id>|title(titles) - built byCredentialDecryptor.kt::fieldAadandCredentialDecryptor.kt::titleAad, consumed at encrypt-time inCredentialRepositoryImpl.kt.
Hardware-bound key storage
EncryptedSharedPreferencesfor the master-password verifier (SP_PASSWORD_VERIFIER) and the wrapped vault key (SP_WRAPPED_VAULT_KEY) - prefs handle constructed inEncryptedAuthPrefsModule.kt, injected intoAuthRepositoryImplvia Hilt with the qualifier@Named("auth").- Vault wrapping key in the Android Keystore - aliases
onekey_master(legacy v1, kept for reading wrapped blobs on upgraded installs) andonekey_master_v2(upgraded).CryptoManager.kt::createUpgradedKeystoreKeysetssetUnlockedDeviceRequired(true)on API 28+. Silent migration of existing installs runs inAuthRepositoryImpl.kt::migrateKeystoreKeyon the first successful unlock following the upgrade. - StrongBox preference with TEE fallback -
CryptoManager.kt::getOrCreateKeystoreKeycallssetIsStrongBoxBacked(true)on API 28+ and catchesStrongBoxUnavailableExceptionandProviderExceptionto retry against the TEE without StrongBox. The same pattern is used for Secret Key wrapping aliases inSecretKeyKeystoreWrapper.kt::getOrCreateWrappingKey. Tier classification is exposed to the UI viaHardwareKeyIsolationTier(STRONGBOX/TEE/SOFTWARE); onlySOFTWAREraises a warning. StrongBox was introduced for the SK alias in commit1def89f(later commits include32993d5and199a784).
Secret Key feature
- Raw SK length is 128 bits / 16 bytes -
SecretKeyHolder.kt::SECRET_KEY_RAW_LENGTH. KDF input shape when SK is enabled is byte concatenationMP_utf8 || SK_raw16(NOT XOR) - see the rationale comment inCryptoManager.kt::deriveKeyFromPasswordWithSecretKeyArgon2id. - In-memory holder is a singleton with an
AtomicReference<ByteArray?>-SecretKeyHolder.kt::setBytes,withBytes,clear. Defensive copies are made on every read; the prior buffer is zeroed in place before the new reference is installed. - On-disk wrapped SK blob in
EncryptedSharedPreferences- keysSP_SECRET_KEY_WRAPPEDandSP_SK_ACTIVE_ALIAS_VERSIONinSecretKeyKeystoreWrapper.kt. The wrapping Keystore alias is versioned as1key_secret_key_v{N}(constantKEYSTORE_ALIAS_SECRET_KEY_PREFIX) so a rotate mints generationN+1before the activeNis destroyed. - Enable / disable / rotate flow runs the same two-phase commit as KDF preset changes -
KdfMigrator.kt::runSecretKeyTransition. Default-on policy at vault setup is enforced byAuthRepositoryImpl.kt::setupMasterPasswordWithSecretKey; opt-out runs throughAuthRepositoryImpl.kt::setupMasterPasswordOptingOutOfSecretKeyand requires the destructive second-confirm inSecretKeySkipOnboardingDialog.kt. - Emergency Kit canonical printed form
A3-XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX(26-char Crockford base32 with anA3-prefix) - encode/decode helpers inSecretKeyQrParser.kt::bytesToCanonicalSk,canonicalSkToBytes,formatCanonicalSkForPrint. QR scheme version constantSECRET_KEY_QR_VERSION = 5(matches the V5 envelope).
Backup envelope
- V5 envelope is the on-disk format for manual user-initiated exports - constant
MANUAL_EXPORT_VERSION = VERSION_V5inBackupEncryption.kt. V5 bindsMAGIC || VERSION || FORMAT || FLAGS || KDF_M_KIB || KDF_T || KDF_P || TIMESTAMP || VAULT_VER(32 bytes) into the GCM auth tag viaBackupEncryption.kt::buildHeaderAadV5; fixed header is 76 bytes (V5_HEADER_LEN). - FLAGS bit 0 (
FLAG_REQUIRES_SECRET_KEY = 0x01) marks an SK-protected envelope. Bits 1-7 reserved; reader rejects nonzero reserved bits viaFLAGS_RESERVED_MASK = 0xFEbefore any other processing. SK-required pivot isBackupEncryption.kt::SecretKeyRequiredException, thrown before any Argon2id work. - V1 / V2 / V3 / V4 envelopes continue to decrypt forever on restore -
BackupEncryption.kt::decryptdispatches on the version byte. Test suiteBackupEncryptionV5Testlocks the SK-required ordering. - Sync engine writes V5 with
FLAGS = 0x01when SK is enabled and V4 when SK is disabled -SyncEngineImpl.kt::runSynccallsBackupEncryption.kt::encryptWithKeywithrequiresSecretKey = (secretKey != null). SK forwarding happens inAuthRepositoryImpl.kt::unlockWithPassword. Filename isvault-backup.1key; atomic rename via.partstaging.
KDF presets and customization
- Five presets:
STANDARD,STANDARD_PLUS,HARDENED,MAXIMUM,CUSTOM- declared inKdfPreset.kt::KdfPreset. Integer version codesKDF_V3_STANDARD= 30,KDF_V3_STANDARD_PLUS= 31,KDF_V3_HARDENED= 32,KDF_V3_MAXIMUM= 33,KDF_V3_CUSTOM= 34. Parallelism is locked to 1 on every preset. - Custom preset guard rails - constants
CUSTOM_M_MIN,CUSTOM_T_MIN,CUSTOM_T_MAXinKdfCustomDialog.kt; per-device upper memory bound fromDeviceCapacityDetector.kt::maxCustomMemoryMb. Settings route literalsettings/security/kdf_strengthinNavGraph.kt::Screen.SettingsKdfStrength. - Preset switch runs a two-phase commit through
KdfMigrator.kt::migrateTo: stage a pending verifier, round-trip-verify, then atomically swap. Recovery on next launch wipes any half-finished pending state.
Networking and build
- No
INTERNETorACCESS_NETWORK_STATEpermission in merged manifests - both are stripped viatools:node="remove"on the<uses-permission>lines inAndroidManifest.xml. ML Kit's Firelog subgraph is left on the classpath becauseBarcodeScanning.getClient()has a static class-init reference toCCTDestination; the OS blocks every socket attempt because the permission is gone. - minSdk 26, compileSdk 36, targetSdk 36 -
android { defaultConfig { ... } }block inapp/build.gradle.kts. - Tiered lockouts at 3 / 5 / 10 wrong attempts produce 30 s / 5 min / 1 hour cooldowns -
PasswordAttemptTracker.kt/PinAttemptTracker.kt/BiometricAttemptTracker.kt.
Project metadata
- License: GNU General Public License v3.0, plus
TRADEMARKS.mdfor the "1Key" name and icon. - Repository: github.com/roufsyed/1Key.
- Free, no tiers, no in-app purchases. Distribution: GitHub Releases; F-Droid planned, not yet submitted.
- End of paper -