com.mervey.p2p
Cryptographic Protocol Specification
Полное описание криптографических конструкций P2PChat — иерархия ключей, параметры деривации, метки доменного разделения, протокол согласования ключей для звонков и все форматы контейнеров. Все примитивы являются опубликованными международными стандартами; проприетарные алгоритмы не используются.
Спецификация является нормативной и публикуется на английском языке. Изменения выпускаются новой версией документа, а не правкой текущей.
This document publishes the cryptographic constructions used by the P2PChat mobile application: the key hierarchy, key-derivation parameters, domain-separation labels, the call key-agreement protocol, and every envelope format.
All cryptographic primitives used by the application are published international standards. What is specific to this application is the composition of those primitives — the message sequence of the call key agreement, the domain-separation labels, and the envelope formats. This specification publishes that composition in full so that no part of the application’s cryptographic functionality is unpublished.
Publishing the source code is not required to reproduce these constructions; the parameters below are complete.
This specification describes the client implementation. It is versioned: changes are published as a new version of this document rather than by editing this one in place.
Every primitive is a published standard.
| Primitive | Standard |
|---|---|
| AES-256-GCM | NIST SP 800-38D |
| PBKDF2-HMAC-SHA256 | RFC 8018 |
| HKDF-SHA256 | RFC 5869 |
| HMAC | RFC 2104 |
| SHA-256, SHA-512 | FIPS 180-4 |
| SHA-3 / Keccak-256 | FIPS 202 |
| Diffie-Hellman, MODP group 14 (2048-bit) | RFC 3526 |
| Ed25519 | RFC 8032 |
| ECDSA over secp256k1 | SEC 2, ANSI X9.62 |
| BIP-39 / BIP-32 / BIP-44 | Bitcoin Improvement Proposals |
| DTLS-SRTP | RFC 5763, RFC 5764 |
| TLS 1.2+ | RFC 5246, RFC 8446 — provided by the operating system |
No proprietary or unpublished algorithm is used.
All random values specified below — keys, salts, nonces, and Diffie-Hellman exponents — are drawn from the operating system’s cryptographically secure random number generator.
The application derives every data-protection key from a single root established by the user’s application-lock password. No derived key is stored.
2.1 Root key establishment
At password setup:
password' = trim(user password) see note below
DEK = random 32 bytes (data encryption key)
salt = random 32 bytes
KEK = PBKDF2-HMAC-SHA256(
password = UTF8(password'),
salt = salt,
c = 200000,
dkLen = 32 bytes)
wrappedDEK = AES-256-GCM(plaintext = DEK, key = KEK, nonce = random 12 bytes)Stored: wrappedDEK, salt, the iteration count, and a KDF version integer. The DEK itself is never written to storage.
wrappedDEK is serialised as the ASCII prefix v1: followed by base64url(nonce ‖ ciphertext ‖ tag), where the nonce is 12 bytes and the authentication tag is 16 bytes.
Unlocking re-derives KEK from the entered password and the stored salt and opens wrappedDEK. An incorrect password is detected by GCM tag verification failure; no password or key equality check is performed.
2.2 Biometric unlock
Biometric unlock is an alternative path to the same DEK, not a separate key hierarchy. It can only be enabled from an unlocked state:
bioKEK = random 32 bytes
wrappedDEKbio = AES-256-GCM(plaintext = DEK, key = bioKEK, nonce = random 12 bytes)bioKEK is written to platform secure storage — iOS Keychain with accessibility kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, Android EncryptedSharedPreferences — and wrappedDEKbio is stored alongside the password-wrapped copy. Both unlock paths therefore recover an identical DEK.
The biometric check is performed by the application through the platform authentication API before bioKEK is read. The stored item itself does not carry a biometric access-control list; the ordering is enforced by the application, not by the platform key store.
2.3 Subkey derivation
Purpose-specific keys are derived from the DEK by HKDF-SHA256 with a fixed, non-secret salt. Separation between purposes comes from the info parameter, not from the salt.
subkey = HKDF-SHA256(
IKM = DEK,
salt = UTF8("mervey.app_lock.hkdf.salt.v1"),
info = <purpose label>,
L = 32 bytes)Purpose labels currently defined:
| Label | Purpose |
|---|---|
mervey.wallet.seed-kek.v1 | key-encryption key for the wallet seed vault (§3) |
The wallet seed is BIP-39 seed material derived from a BIP-39 mnemonic. It is encrypted before it reaches any storage.
seedKEK = HKDF-SHA256(IKM = DEK,
salt = UTF8("mervey.app_lock.hkdf.salt.v1"),
info = UTF8("mervey.wallet.seed-kek.v1"),
L = 32 bytes)
nonce = random 12 bytes
AAD = UTF8("wallet_crypto_seed_payload_v1")
ciphertext, tag = AES-256-GCM(plaintext = seed bytes,
key = seedKEK,
nonce = nonce,
aad = AAD)The seed KEK is derived on demand and never stored: it is a deterministic function of the DEK, which the application lock owns.
3.1 Stored payload format
Versioned JSON object with the fields:
| Field | Value |
|---|---|
version | 1 |
cipher | "aes-256-gcm" |
kdf | "platform-keystore" |
nonce | 12 bytes |
ciphertext | variable |
tag | 16 bytes |
aad | "wallet_crypto_seed_payload_v1" |
The payload is written to platform secure storage — iOS Keychain with accessibility kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and synchronizable = false; Android EncryptedSharedPreferences whose master key is held in the Android Keystore. The payload is already ciphertext before it reaches either store; the platform store is a second, independent layer and not the sole protection.
An optional, user-initiated backup encrypts the seed under a password chosen by the user for this purpose. Only ciphertext leaves the device.
salt = random 32 bytes
nonce = random 12 bytes
key = PBKDF2-HMAC-SHA256(password = backup password,
salt = salt,
c = 200000,
dkLen = 32 bytes)
ciphertext, tag = AES-256-GCM(plaintext = seed bytes, key = key, nonce = nonce)4.1 Envelope format
The envelope is self-describing: the KDF parameters travel inside it, so a restore on another device requires only the blob and the password.
{
"v": 1,
"kdf": {
"algo": "pbkdf2-hmac-sha256",
"iters": 200000,
"salt": "<base64>"
},
"nonce": "<base64>",
"ct": "<base64>",
"tag": "<base64>"
}Byte strings in this envelope use standard base64 with padding (RFC 4648 §4). This differs from the push envelope of §6, which uses base64url without padding.
On decryption the iteration count is read from the envelope rather than assumed, so a future change of iters does not invalidate blobs already written. The version field is checked first and a value other than 1 is rejected.
Storage destinations are the user’s own cloud account — iCloud Keychain on iOS as a synchronizable item, Google Drive application data folder on Android. The storage provider receives ciphertext only and cannot derive the key without the user’s backup password.
This is the construction specific to this application. The primitives are standard; the message sequence is composed as described here.
Voice calls are one-to-one. The media stream is protected by DTLS-SRTP as implemented by the WebRTC library. The construction below protects the signaling payloads — SDP and ICE candidates — and produces the short authentication string used for out-of-band verification.
5.1 Group parameters
RFC 3526 MODP group 14: 2048-bit prime modulus p, generator g = 2.
Public values and the shared secret are encoded as fixed-width big-endian octet strings of 256 bytes, zero-padded on the left.
5.2 Key pair generation
Each party samples a private exponent by rejection and computes its public value:
repeat:
r = big-endian integer over 256 random bytes
a = r mod (p − 2)
until a ≥ 2
A = g^a mod pThe sampled exponent therefore lies in [2, p−3]. The modular reduction of a 2048-bit sample by p − 2 introduces a bias that is negligible at this width; it is stated here so that the procedure is reproducible exactly rather than approximated as a uniform draw.
5.3 Message sequence — three messages with commit-reveal
The caller commits to its public value before learning the callee’s, which removes the opportunity for an active attacker to choose its own contribution after seeing the caller’s.
Step 1 caller → callee commit = SHA-256( encode256(A) )
Step 2 callee → caller encode256(B) where B = g^b mod p
Step 3 caller → callee encode256(A) the revealOn receiving step 3 the callee recomputes SHA-256(encode256(A)) and compares it against the commitment from step 1 using a constant-time comparison. A mismatch indicates tampering and the call is aborted.
The values exchanged in steps 1–3 are public and are transmitted without additional encryption.
5.4 Peer value validation
Before use, a received peer public value P is rejected when P ≤ 1 or P ≥ p−1. Such degenerate values would confine the shared secret to a small set. Rejection aborts the call.
5.5 Shared key derivation
S = P^x mod p where x is the local private exponent
K = SHA-256( encode256(S) ) 32 bytes5.6 Signaling payload protection
Each signaling payload is sealed independently under K:
nonce = random 12 bytes fresh for every message
ciphertext, tag = AES-256-GCM(plaintext = payload, key = K, nonce = nonce)
sealed = nonce ‖ ciphertext ‖ tagThe tag is 16 bytes; no additional authenticated data is used. A payload shorter than 12 + 16 bytes is rejected as malformed. Tag verification failure is treated as tampering or a wrong key and aborts processing.
Only SDP and ICE candidate payloads are sealed. The Diffie-Hellman values of steps 1–3 are not.
5.7 Short authentication string
Both parties derive four emoji from the shared key and the caller’s revealed public value, and compare them by voice. Identical sequences on both ends confirm the absence of an active attacker; a mismatch requires aborting the call.
digest = SHA-256( K ‖ encode256(A) ) shared key first, no separator
index_i = uint64_be( digest[8i .. 8i+8] ) mod 256 for i = 0,1,2,3
emoji_i = ALPHABET[ index_i ]ALPHABET is a fixed, ordered list of 256 emoji. The ordering is load-bearing: both endpoints must use the identical list for the comparison to be meaningful. The list is published in Appendix A.
5.8 Private exponent lifetime
The private exponent is discarded after the shared key is derived. The runtime represents integers immutably, so the value cannot be overwritten in place; the implementation drops the reference and relies on the short lifetime of the object.
Notification payloads are encrypted by the server so that their contents do not traverse third-party push infrastructure in cleartext. The client generates the key and decrypts; it does not encrypt in this exchange.
push_key = random 32 bytes generated on the clientpush_key is transmitted once to the application server over TLS during authenticated device registration, held in platform secure storage, and discarded at sign-out.
Decryption:
encKey = HKDF-SHA256(IKM = push_key,
salt = "" (empty),
info = UTF8("p2pchat.push.enc.v1"),
L = 32 bytes)
payload_bytes = base64url_nopad_decode(payload)
nonce = payload_bytes[0 .. 12]
tag = payload_bytes[len−16 .. len]
ciphertext = payload_bytes[12 .. len−16]
plaintext = AES-256-GCM-decrypt(ciphertext, key = encKey, nonce = nonce, tag = tag)No additional authenticated data is used.
6.1 Envelope format
{
"enc_v": "1",
"key_id": "630dcd29",
"payload": "<base64url, no padding>"
}key_id is the first 4 bytes of SHA-256(push_key) rendered as lowercase hexadecimal. It identifies which registration a message was encrypted for so that a rotated key is distinguished from a corrupted payload. It is a non-secret diagnostic value: it does not reveal key material.
All network traffic uses HTTPS and WSS over the TLS stack provided by the operating system. The application implements no transport-layer cryptography of its own.
The wallet’s native module performs BIP-39 mnemonic handling, BIP-32/BIP-44 hierarchical key derivation, ECDSA signatures over secp256k1, Ed25519 signatures, and SHA-2, SHA-3/Keccak-256 and HMAC hashing.
This module contains no confidentiality cipher. Its functionality is limited to digital signature, key derivation and hashing. It is listed here for completeness of the cryptographic inventory.
Ordered list of 256 entries, index 0 first. The order is normative. Some entries intentionally omit the variation selector U+FE0F; the byte sequences below are normative and must not be altered.
| Version | Date | Change |
|---|---|---|
| 1.0 | 7 August 2026 | Initial publication |
Private Company «Mervey Ltd.», Astana International Financial Centre, 010000, Astana, Esil district, 29 Alikhan Bokeikhan, office 42.