com.mervey.p2p

Cryptographic Protocol Specification

Полное описание криптографических конструкций P2PChat — иерархия ключей, параметры деривации, метки доменного разделения, протокол согласования ключей для звонков и все форматы контейнеров. Все примитивы являются опубликованными международными стандартами; проприетарные алгоритмы не используются.

Версия 1.0Опубликовано: 7 August 2026P2PChat mobile application (iOS, Android)
Издатель
Private Company «Mervey Ltd.», Astana International Financial Centre, Astana, Kazakhstan

Спецификация является нормативной и публикуется на английском языке. Изменения выпускаются новой версией документа, а не правкой текущей.

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.

PrimitiveStandard
AES-256-GCMNIST SP 800-38D
PBKDF2-HMAC-SHA256RFC 8018
HKDF-SHA256RFC 5869
HMACRFC 2104
SHA-256, SHA-512FIPS 180-4
SHA-3 / Keccak-256FIPS 202
Diffie-Hellman, MODP group 14 (2048-bit)RFC 3526
Ed25519RFC 8032
ECDSA over secp256k1SEC 2, ANSI X9.62
BIP-39 / BIP-32 / BIP-44Bitcoin Improvement Proposals
DTLS-SRTPRFC 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:

LabelPurpose
mervey.wallet.seed-kek.v1key-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:

FieldValue
version1
cipher"aes-256-gcm"
kdf"platform-keystore"
nonce12 bytes
ciphertextvariable
tag16 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 p

The 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 reveal

On 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 bytes

5.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 ‖ tag

The 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 client

push_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.

😀0
😃1
😄2
😁3
😆4
😅5
🤣6
😂7
🙂8
🙃9
😉10
😊11
😇12
🥰13
😍14
🤩15
😘16
😗17
😚18
😙19
🥲20
😋21
😛22
😜23
🤪24
😝25
🤑26
🤗27
🤭28
🤫29
🤔30
🤐31
🤨32
😐33
😑34
😶35
😏36
😒37
🙄38
😬39
🤥40
😌41
😔42
😪43
🤤44
😴45
😷46
🤒47
🤕48
🤢49
🤮50
🤧51
🥵52
🥶53
🥴54
😵55
🤯56
🤠57
🥳58
🥸59
😎60
🤓61
🧐62
😕63
😟64
🙁65
😮66
😯67
😲68
😳69
🥺70
😦71
😧72
😨73
😰74
😥75
😢76
😭77
😱78
😖79
😣80
😞81
😓82
😩83
😫84
🥱85
😤86
😡87
😠88
🤬89
😈90
👿91
💀92
💩93
🤡94
👹95
👺96
👻97
👽98
👾99
🤖100
🎃101
😺102
😸103
😹104
😻105
😼106
😽107
🙀108
😿109
😾110
🐶111
🐱112
🐭113
🐹114
🐰115
🦊116
🐻117
🐼118
🐨119
🐯120
🦁121
🐮122
🐷123
🐸124
🐵125
🐔126
🐧127
🐦128
🐤129
🦆130
🦅131
🦉132
🦇133
🐺134
🐗135
🐴136
🦄137
🐝138
🐛139
🦋140
🐌141
🐞142
🐜143
🪰144
🪲145
🕷146
🦂147
🐢148
🐍149
🦎150
🦖151
🦕152
🐙153
🦑154
🦐155
🦞156
🦀157
🐡158
🐠159
🐟160
🐬161
🐳162
🐋163
🦈164
🐊165
🐅166
🐆167
🦓168
🦍169
🦧170
🐘171
🦛172
🦏173
🐪174
🐫175
🦒176
🦘177
🐃178
🐂179
🐄180
🐎181
🐖182
🐏183
🐑184
🦙185
🐐186
🦌187
🐕188
🐩189
🦮190
🐈191
🐓192
🦃193
🦚194
🦜195
🦢196
🦩197
🕊198
🐇199
🦝200
🦨201
🦡202
🦦203
🦥204
🐁205
🐀206
🐿207
🦔208
🌵209
🎄210
🌲211
🌳212
🌴213
🌱214
🌿215
🍀216
🎍217
🎋218
🍃219
🍂220
🍁221
🍄222
🐚223
🌾224
💐225
🌷226
🌹227
🥀228
🌺229
🌸230
🌼231
🌻232
🌞233
🌝234
🌛235
🌜236
🌚237
🌕238
🌖239
🌗240
🌘241
🌑242
🌒243
🌓244
🌔245
🌙246
🌎247
🌍248
🌏249
🪐250
💫251
252
🌟253
254
255
VersionDateChange
1.07 August 2026Initial publication

Private Company «Mervey Ltd.», Astana International Financial Centre, 010000, Astana, Esil district, 29 Alikhan Bokeikhan, office 42.

Спецификация криптографического протокола | P2PChat