Documentation / Security

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

PurposeAlgorithmParameters
Two-secret key derivation (2SKD)Argon2id, version 0x1365536 KiB memory (64 MiB), 3 iterations, parallelism 1, 32-byte output, 16-byte salt
Subkey derivationHKDF-SHA25632-byte output; salt and info listed under domain strings
Symmetric encryptionXChaCha20-Poly1305256-bit key, 192-bit random nonce, 128-bit tag; at most 256 KiB of plaintext per envelope
Key agreementX25519Account identity keys, shared-vault wraps, third-party result and patch sealed boxes
SignaturesEd25519Per-grant approval keys for third-party authorization
Account sign-inSRP-6aRFC 5054 2048-bit group, SHA-256, normalized email as identity
FingerprintsSHA-256First 8 bytes, lowercase hex (16 characters)
Code bindingPKCE S256base64url(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
KeyOriginWhere it lives
Login material2SKD, login domainMemory only; the server stores only the SRP verifier
Personal vault key2SKD, vault domainMemory only; never transmitted or wrapped
Identity material2SKD, identity domainMemory only
Account identity private keyRandom X25519Sealed with identity material and stored on the server
Shared vault keyRandom 32 bytesWrapped per member to their identity public key, stored on the server
Item subkeyHKDF(vault key, item/{item_id})Derived for each encryption or decryption
Third-party seal and approval private keysGenerated on-device by the integrator's SDKThe 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)
Valuedomain
Login materialpassword-manager/account-login/v1
Personal vault keypassword-manager/account-vault/v1
Identity materialpassword-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…"
}
FieldDescription
formatAlways 2
nonce24-byte random nonce
ciphertextCiphertext 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…"
  }
}
FieldDescription
formatAlways 1
epk32-byte ephemeral public key
envelopeAn 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.

PurposeKey derivationAAD
ItemsHKDF salt password-manager/v2, info item/{item_id}password-manager/item/v2/{vault_id}/{item_id}/{revision}
Account identity private keyKey = identity materialpassword-manager/identity-key/v1
Shared-vault key wrapSealed box, salt password-manager/vault-key-wrap/v1password-manager/vault-key-wrap/v1/{vault_id}/{recipient_account_id}
Third-party resultSealed box, salt password-manager/oauth-seal/v1password-manager/oauth-result/v1/{grant_id}/{request_id}
Third-party update patchSealed box, salt password-manager/oauth-seal/v1password-manager/oauth-patch/v1/{grant_id}/{request_id}
Biometric unlock bundle (Mac)Wrapping key protected by the Secure Enclavepassword-manager/device-unlock/v1, a newline, then binding data
Backup manifestHKDF 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/v1password-manager/oauth-vault-key/v1/{client_id}/{account_id}/{vault_id}

Third-party results and patches

Result (result_enc)Patch (patch_enc)
Sealed byThe MimiKey device that executes the requestThe integrator's SDK
RecipientThe grant's seal public keyThe account identity public key (identity_public_key from GET /api/oauth/account)
Opened byThe integrator's SDKYour 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)}
  • PATH excludes the query string. BODY is the raw request body; the SHA-256 of an empty body is e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  • 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

FieldValue
Seed (Ed25519 private key)3-bt9PsCCRAXHiUsMzpBSE9WXWRrcnmAh46VnKOqsbg
Public keylAfBINSvrkhw1qvNeOF5ug8mgbUoRkv3Fjn79UVUfIk
Fingerprintd1984a0a3442c41c

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:

FieldValue
Recipient public key9Xwg4YSHxLN9dr2JSRYL_wfRh_0iafGiNzQtT5blNys
Recipient fingerprint8d9b63107169f06e
grant_id6f1d2c3b-4a59-4e68-8b7c-9d0e1f2a3b4c
request_ida1b2c3d4-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.

Last updated: 2026-10-23