Encryption
The algorithms and parameters MimiKey uses, the key hierarchy, envelope and sealed-box formats, every AAD domain string, and reproducible test vectors.
Overview
This page is the reference for MimiKey's cryptographic design, written for security researchers and for developers building a compatible client. For goals and the threat model, see Security model.
Every binary value in JSON — keys, nonces, ciphertext, signatures — is encoded as base64url without padding (RFC 4648 §5).
Algorithms and parameters
| Purpose | Algorithm | Parameters |
|---|---|---|
| Two-secret key derivation (2SKD) | Argon2id, version 0x13 | 65536 KiB memory (64 MiB), 3 iterations, parallelism 1, 32-byte output, 16-byte salt |
| Subkey derivation | HKDF-SHA256 | 32-byte output; salt and info listed under domain strings |
| Symmetric encryption | XChaCha20-Poly1305 | 256-bit key, 192-bit random nonce, 128-bit tag; at most 256 KiB of plaintext per envelope |
| Key agreement | X25519 | Account identity keys, shared-vault wraps, third-party result and patch sealed boxes |
| Signatures | Ed25519 | Per-grant approval keys for third-party authorization |
| Account sign-in | SRP-6a | RFC 5054 2048-bit group, SHA-256, normalized email as identity |
| Fingerprints | SHA-256 | First 8 bytes, lowercase hex (16 characters) |
| Code binding | PKCE S256 | base64url(SHA-256(code_verifier)), verifier of 43–128 characters |
The Secret Key is 16 random bytes (128 bits), displayed as PM- followed by 26 Crockford Base32 characters. Master passwords must be 12–1024 bytes of UTF-8.
Key hierarchy
master password ─┐
Secret Key ──────┼─ Argon2id(64 MiB, t=3, p=1, 16-byte account salt) ──┬─ login material ── SRP verifier (stored on server)
account salt ────┘ (three domains, mutually independent) │
├─ personal vault key ── HKDF("item/{item_id}") ── item subkey ── item envelope
│
└─ identity material ── seals ── account identity private key (X25519)
│
┌────────────────────────────────────────────────────────┤
│ │
opens: shared-vault key wraps opens: third-party update patches
│
shared vault key (random 32 bytes) ── HKDF("item/{item_id}") ── item envelope
Third-party SDK (per installation, per grant):
seal key pair (X25519) ── opens: results sealed to it by the user's device
approval key pair (Ed25519) ── signs: every data call
| Key | Origin | Where it lives |
|---|---|---|
| Login material | 2SKD, login domain | Memory only; the server stores only the SRP verifier |
| Personal vault key | 2SKD, vault domain | Memory only; never transmitted or wrapped |
| Identity material | 2SKD, identity domain | Memory only |
| Account identity private key | Random X25519 | Sealed with identity material and stored on the server |
| Shared vault key | Random 32 bytes | Wrapped per member to their identity public key, stored on the server |
| Item subkey | HKDF(vault key, item/{item_id}) | Derived for each encryption or decryption |
| Third-party seal and approval private keys | Generated on-device by the integrator's SDK | The integrating app's system keychain, this device only |
2SKD input
All three values use the same Argon2id parameters and differ only in their domain prefix. The Argon2id password input is the byte string:
domain || 0x00 || u32_be(len(master_password_utf8)) || master_password_utf8 || secret_key (16 bytes)
| Value | domain |
|---|---|
| Login material | password-manager/account-login/v1 |
| Personal vault key | password-manager/account-vault/v1 |
| Identity material | password-manager/account-identity/v1 |
The salt is the account's 16-byte SRP salt. Changing your master password or recovering your account replaces the salt with a fresh random one, so all three values change: personal-vault items are re-encrypted one by one on the client and the identity private key is re-sealed under the new material.
Envelope format
Every symmetric encryption produces the same JSON envelope:
{
"format": 2,
"nonce": "8Ovm4dzX0s3Iw765tK-qpaCblpGMh4J9",
"ciphertext": "9eSfVW9LOkKE0BLuJRYysVYZYiJuhwLy…"
}
| Field | Description |
|---|---|
format | Always 2 |
nonce | 24-byte random nonce |
ciphertext | Ciphertext followed by the 16-byte Poly1305 tag |
XChaCha20-Poly1305 follows the standard construction: HChaCha20 derives a subkey from the first 16 bytes of the nonce, then IETF ChaCha20-Poly1305 encrypts with a 12-byte nonce made of 4 zero bytes followed by the last 8 bytes of the 24-byte nonce. The AAD is authenticated, not encrypted.
Items
The item subkey is HKDF-SHA256(ikm = vault key, salt = "password-manager/v2", info = "item/{item_id}"), and the AAD is:
password-manager/item/v2/{vault_id}/{item_id}/{revision}
The AAD binds the vault, the item and the revision: a ciphertext cannot be moved to another item or vault, or replayed at a different revision. Item plaintext is this JSON, UTF-8 encoded:
{
"title": "Example Bank",
"username": "me@example.com",
"password": "correct horse battery staple",
"url": "https://bank.example.com/login",
"notes": "",
"kind": "login",
"extra": {},
"updated_at": "2026-10-23T00:00:00.000Z"
}
kind is one of login, credit_card, bank_card, api_key, server, secure_note, defaulting to login. extra holds type-specific fields such as a card number or expiry date.
Sealed box format
When data must be readable only by the holder of a given public key — shared-vault key wraps, third-party results and third-party update patches — MimiKey uses a sealed box:
1. Generate an ephemeral X25519 key pair (esk, epk)
2. shared = X25519(esk, recipient public key); reject an all-zero result
3. key = HKDF-SHA256(ikm = shared, salt = domain string, info = AAD), 32 bytes
4. envelope = XChaCha20-Poly1305(key, random 24-byte nonce, AAD, plaintext)
{
"format": 1,
"epk": "PHld72bSpQfUtjDl4WAtULZ7pgXRKD8lMW0U1d-OABo",
"envelope": {
"format": 2,
"nonce": "8Ovm4dzX0s3Iw765tK-qpaCblpGMh4J9",
"ciphertext": "9eSfVW9LOkKE0BLuJRYysVYZYiJuhwLy…"
}
}
| Field | Description |
|---|---|
format | Always 1 |
epk | 32-byte ephemeral public key |
envelope | An envelope as above, with format 2 |
In the third-party authorization API, sealed boxes travel as strings: the values of result_enc and patch_enc are the serialized JSON text above.
AAD and domain strings
Every purpose has its own domain and AAD, so a ciphertext produced for one purpose cannot be opened under another.
| Purpose | Key derivation | AAD |
|---|---|---|
| Items | HKDF salt password-manager/v2, info item/{item_id} | password-manager/item/v2/{vault_id}/{item_id}/{revision} |
| Account identity private key | Key = identity material | password-manager/identity-key/v1 |
| Shared-vault key wrap | Sealed box, salt password-manager/vault-key-wrap/v1 | password-manager/vault-key-wrap/v1/{vault_id}/{recipient_account_id} |
| Third-party result | Sealed box, salt password-manager/oauth-seal/v1 | password-manager/oauth-result/v1/{grant_id}/{request_id} |
| Third-party update patch | Sealed box, salt password-manager/oauth-seal/v1 | password-manager/oauth-patch/v1/{grant_id}/{request_id} |
| Biometric unlock bundle (Mac) | Wrapping key protected by the Secure Enclave | password-manager/device-unlock/v1, a newline, then binding data |
| Backup manifest | HKDF salt password-manager/v2, info backup/manifest/v1/{backup_id} | password-manager/backup/manifest/v1/{vault_id}/{backup_id} |
| Legacy mode 2 vault-key wrap (retired) | Sealed box, salt password-manager/oauth-seal/v1 | password-manager/oauth-vault-key/v1/{client_id}/{account_id}/{vault_id} |
Third-party results and patches
Result (result_enc) | Patch (patch_enc) | |
|---|---|---|
| Sealed by | The MimiKey device that executes the request | The integrator's SDK |
| Recipient | The grant's seal public key | The account identity public key (identity_public_key from GET /api/oauth/account) |
| Opened by | The integrator's SDK | Your unlocked MimiKey device |
| Plaintext | {"item":{"id","revision","record"}}; for list_items, {"v":2,"items":[…]} | {"patch":{"field":"new value"}} |
The patch AAD needs the request_id, so update_item takes two steps: create a draft to obtain the request_id, then upload the patch. See the API reference.
Third-party request signatures
Every data call is signed with the grant's Ed25519 approval private key over this canonical string (UTF-8, \n-separated, no trailing newline):
{METHOD}\n{PATH}\n{TIMESTAMP}\n{NONCE}\n{SHA256_HEX(BODY)}
PATHexcludes the query string.BODYis the raw request body; the SHA-256 of an empty body ise3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.- The 64-byte signature is base64url-encoded into
x-pm-signature. - The approval-key fingerprint is the first 8 bytes of
SHA-256(32-byte public key)in hex, shown on the consent card for comparison.
For the full header conventions, see the API reference.
Test vectors
These vectors are generated by the Rust reference implementation from fixed seeds and checked byte for byte by the Swift SDK's unit tests. They use public test keys that belong to no real account. The complete file ships with the Swift SDK as Tests/MimiKeySDKTests/Vectors/oauth_vectors.json.
Approval signatures
| Field | Value |
|---|---|
| Seed (Ed25519 private key) | 3-bt9PsCCRAXHiUsMzpBSE9WXWRrcnmAh46VnKOqsbg |
| Public key | lAfBINSvrkhw1qvNeOF5ug8mgbUoRkv3Fjn79UVUfIk |
| Fingerprint | d1984a0a3442c41c |
Empty body:
GET
/api/oauth/account
1761177600
n-0123456789abcdef
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Signature: KngAgL1nChfn4NugEnlNlesKA8EtmrhpsL1GU9pU_Z0AcwkQRzP9iasdaFfe3nEVcvCGUT92eWHzzZCEX849DQ
With a body ({"op":"pick_item","vault_id":null,"hint":{"url":"https://bank.example.com"},"reason":"登录示例银行完成对账"}):
POST
/api/oauth/requests
1761177600
n-0123456789abcdef
a60bf0d8213f0871138d7ed45ba0cfdbd8aa81238a24262b2e5ce9ab985c8a07
Signature: -PzhO-9DzOmyskR2C-Sq85OOWMiGgCs_bRMy1vI3_T_PN2RAJrWcysXhn_7A1GBo5_6RaVqlD_axrK2gncNkCQ
Sealed boxes
The result and patch sections of the vector file give a fixed recipient key, ephemeral private key, nonce, AAD, plaintext and the expected sealed-box JSON, so you can verify both sealing and opening:
| Field | Value |
|---|---|
| Recipient public key | 9Xwg4YSHxLN9dr2JSRYL_wfRh_0iafGiNzQtT5blNys |
| Recipient fingerprint | 8d9b63107169f06e |
grant_id | 6f1d2c3b-4a59-4e68-8b7c-9d0e1f2a3b4c |
request_id | a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d |
| Patch plaintext | {"patch":{"password":"new-password-✓"}} |
PKCE
The server self-tests with the RFC 7636 Appendix B vector: verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk maps to challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM.
Backup manifest digest
The backup manifest authenticates compact JSON encoded in a fixed field order (this is not RFC 8785):
{"backup_format":1,"format":2,"vault_id":"00000000-0000-4000-8000-000000000001","backup_id":"00000000-0000-4000-8000-000000000002","items":[{"id":"00000000-0000-4000-8000-000000000003","revision":1,"envelope":{"format":2,"nonce":"AA","ciphertext":"BB"}}]}
SHA-256: 31406c915e36b94924581c9d2a7a01f716fe1d2d36833ac54fe7c38a734d34fb
Important Envelope nonces are random, so apart from the fixed-seed vectors above, encrypting the same plaintext twice yields different ciphertext. Test interoperability by whether you can open each other's output, not by comparing ciphertext.