Vero Cryptographic Specification¶
Version: 1.0.0 Status: Normative Scope: All platforms (iOS, Android, Web)
This document defines the exact cryptographic primitives, parameters, key formats, and derivation procedures used in the Vero protocol. Two independent implementations following this specification MUST derive identical shared secrets, session keys, and derived keying material given the same inputs.
1. Overview¶
Vero uses Elliptic Curve Diffie-Hellman (ECDH) on the NIST P-256 curve for key agreement, and HKDF-SHA256 for all key derivation. Session keys bind the cryptographic identity of both participants to the specific interaction, ensuring that the entangled latent identity vector is only meaningful within its originating session.
2. Elliptic Curve Parameters (NIST P-256)¶
All implementations MUST use the NIST P-256 curve (also known as secp256r1 or prime256v1).
2.1 Curve Definition¶
| Parameter | Value |
|---|---|
| Curve | NIST P-256 / secp256r1 / prime256v1 |
| Field | Prime field F_p |
| p | 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF |
| a | 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC |
| b | 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B |
| G_x | 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296 |
| G_y | 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5 |
| n (order) | 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 |
| h (cofactor) | 1 |
| Key size | 256 bits (32 bytes) |
2.2 Key Generation¶
Private key: A cryptographically random integer d in the range [1, n-1], generated using the platform's secure random number generator (e.g., SecRandomCopyBytes on iOS, SecureRandom on Android, crypto.getRandomValues on Web).
Public key: The elliptic curve point Q = d * G, where G is the generator point.
2.3 Key Serialization¶
Private key format: Raw 32-byte big-endian unsigned integer.
Public key formats:
| Format | Size | Structure | Usage |
|---|---|---|---|
| Uncompressed | 65 bytes | 0x04 || X (32 bytes) || Y (32 bytes) |
Wire transfer |
| Compressed | 33 bytes | 0x02/0x03 || X (32 bytes) |
Storage, QR |
| JWK | Variable | JSON Web Key with crv: "P-256" |
REST API |
JWK format:
{
"kty": "EC",
"crv": "P-256",
"x": "<base64url-encoded X coordinate, 32 bytes>",
"y": "<base64url-encoded Y coordinate, 32 bytes>"
}
For public key exchange (e.g., in invite links), the uncompressed format is the canonical wire format. Implementations MAY use compressed format for QR codes to reduce data size.
3. ECDH Key Agreement¶
3.1 Shared Secret Computation¶
Given:
- Presenter's key pair: (d_P, Q_P) where Q_P = d_P * G
- Verifier's key pair: (d_V, Q_V) where Q_V = d_V * G
The shared secret point is computed:
S = d_P * Q_V = d_V * Q_P // Both yield the same point
The raw shared secret is the X coordinate of point S, encoded as a 32-byte big-endian unsigned integer:
sharedSecret = S.x // 32 bytes, big-endian
3.2 Validation Requirements¶
Before using a received public key, implementations MUST verify:
1. The point is not the point at infinity
2. The point coordinates satisfy the curve equation: y^2 = x^3 + ax + b (mod p)
3. The point has order n (i.e., n * Q = O, the point at infinity)
Failure to validate public keys enables small-subgroup attacks.
4. HKDF-SHA256 Key Derivation¶
All key derivation in Vero uses HKDF (HMAC-based Key Derivation Function) as specified in RFC 5869, instantiated with SHA-256.
4.1 HKDF Specification¶
HKDF consists of two stages:
Extract:
PRK = HMAC-SHA256(salt, IKM)
Expand:
T(0) = empty string
T(i) = HMAC-SHA256(PRK, T(i-1) || info || i) // i is a single byte 0x01, 0x02, ...
OKM = T(1) || T(2) || ... (truncated to desired length)
4.2 Session Key Derivation (With Shared Secret)¶
When a TIP exchange has established a shared secret, the session key is derived from the shared secret and a nonce (OTC).
sessionKey = HKDF-SHA256(
IKM = sharedSecret, // 32 bytes from ECDH
salt = UTF8("VeroSessionKey-v1"), // 17 bytes
info = UTF8("VeroSession") || nonce || timestamp, // variable length
length = ceil(sessionKeyLength / 2) // bytes; hex-encoded then truncated to sessionKeyLength chars (§4.2.1)
)
| Parameter | Value / Format |
|---|---|
IKM |
32-byte ECDH shared secret (X coordinate) |
salt |
UTF-8 encoded string "VeroSessionKey-v1" (17 bytes) |
info |
Concatenation of context string, nonce, and timestamp |
length |
Number of bytes to produce, based on desired hex key length |
| Context string | UTF-8 "VeroSession" (11 bytes) |
| Nonce | The OTC value as raw bytes |
| Timestamp | Unix epoch seconds as 8-byte big-endian uint64 |
Info field construction (byte-level):
info = "VeroSession" (11 bytes, UTF-8)
|| nonce (variable length, raw bytes)
|| timestamp (8 bytes, big-endian uint64)
The output is then hex-encoded and truncated to produce the session key string:
sessionKeyHex = hexEncode(sessionKeyBytes).slice(0, sessionKeyLength)
// ceil(len/2) bytes hex-encode to an EVEN number of chars; take the
// first sessionKeyLength. e.g. len=5 -> 3 bytes -> "a3f1b0" -> "a3f1b".
4.2.1 Truncation rule (normative)¶
ceil(sessionKeyLength / 2) bytes always hex-encode to 2 * ceil(len/2) characters — an even count that exceeds sessionKeyLength for every odd length (the default 5 is odd). Implementations MUST take the first sessionKeyLength hex characters. There is no zero-padding step. (Prior text referencing "left-pad with zeros" is void — hex of N bytes is always exactly 2N chars.)
4.2.2 Beacon-bound session key (v1.1 OPTIONAL profile)¶
When a deployment enables the Presence Record lower time bound
(presence-record-spec.md §5), the L1 beacon value is bound into the
session key by appending it to the HKDF info field:
info = "VeroSession" (11 bytes, UTF-8)
|| nonce (variable length, raw bytes)
|| timestamp (8 bytes, big-endian uint64)
|| beaconValue (32 bytes — the beacon block's prevrandao, raw)
Everything else in §4.2 is unchanged. Because no valid optical challenge can then exist before the beacon block was published, the session becomes provably no-earlier-than the beacon's block time.
Profile rules:
- This profile is OPTIONAL in v1.1 and negotiated per session; a session without a beacon derives per §4.2 exactly as in v1. The two derivations are deliberately incompatible — a beacon-bound session key cannot be mistaken for an unbound one.
- The beacon MUST satisfy
presence-record-spec.md§5 (L1 finalized block,chainId1, non-zeroprevrandao), and the record'sbeaconfield MUST carry the block used, ornullwith §4.2 derivation. - In no-shared-secret mode (§4.3) the beacon profile does not apply — the OTC is transmitted, not derived, and the lower bound is not supportable.
Test vector (v1.1 profile): with the §9.3 inputs (shared secret 32
bytes of 0x01, salt "VeroSessionKey-v1", nonce "12345", timestamp
1700000000, sessionKeyLength 5) and beacon value
0x9c1e4f2a7b8d3c6e5f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e,
the session key MUST be 56f0c. (The same inputs without the beacon
derive 04bbd under §4.2 — implementations can use the pair to confirm
they are on the intended profile.)
4.3 Session Key Without Shared Secret¶
When no TIP/shared secret exists, the OTC itself serves directly as the session key:
sessionKeyHex = OTC // The OTC IS the session key
In this mode, the OTC MUST be generated as a cryptographically random hex string of length sessionKeyLength.
4.3.1 Single session key (normative)¶
There is exactly one session key per session. Its first sessionKeyLength hex characters ARE the optical challenge (the value blinked by the presenter). Implementations MUST NOT mint a second, independent "challenge" — the challenge is a visible prefix of the one session key. (The 256-bit transport/binding secret is derived from the ECDH shared secret per §4.2 or, absent a TIP, is not established; the short challenge defends against precomputation, not brute force.)
4.3.2 Degenerate-key control (normative)¶
The optical challenge MUST NOT be degenerate. Expanding the challenge to its blinked bit string (4 bits per hex character, per signal-protocol.md), reject and redraw any challenge that is:
- all-zeros or all-ones, or
- a single symbol in ≥ 80% of its bits.
Rationale: these are exactly the challenges the right-aligned matching rule is vulnerable to (an all-ones detected stream right-align-matches any heavily-one key), and the all-zeros end of the dim-blue detection edge. Rejecting them at generation closes the exploit at the source; it composes with — and does not replace — the ordered-acceptance rule in comparison-spec.md §5. Rejection is cheap (~1.2% of 20-bit keys are redrawn).
4.4 Vector Rotation Key Derivation¶
The session key is also used to derive the PRNG seed for vector rotation (see vector-spec.md Section 6):
rotationSeed = HKDF-SHA256(
IKM = UTF8(toLowercase(sessionKeyHex)), // UTF-8 bytes of the lowercase hex STRING — NOT nibble-decoded (keys may be odd-length). Cross-platform rule; matches vector-spec §6.1.
salt = UTF8("vero-vector-rotation-v1"), // 23 bytes
info = UTF8("xoshiro256-seed"), // 15 bytes
length = 32 // 256 bits for PRNG state
)
| Parameter | Value |
|---|---|
IKM |
Session key decoded from hex to raw bytes |
salt |
"vero-vector-rotation-v1" (UTF-8, 26 bytes) |
info |
"xoshiro256-seed" (UTF-8, 15 bytes) |
length |
32 bytes |
4.5 TIP Encryption Key Derivation¶
When encrypting a TIP for transmission:
tipEncryptionKey = HKDF-SHA256(
IKM = sharedSecret, // 32 bytes from ECDH
salt = UTF8("VeroTIPEncryption-v1"), // 20 bytes
info = UTF8("AES-256-GCM") || senderPubKey, // variable
length = 32 // 256 bits for AES-256
)
The TIP is then encrypted with AES-256-GCM: - Key: 32 bytes from HKDF above - Nonce/IV: 12 bytes, cryptographically random, prepended to ciphertext - AAD (Additional Authenticated Data): The sender's public key (65 bytes, uncompressed) - Tag length: 16 bytes (128 bits), appended to ciphertext
TIP ciphertext format:
nonce (12 bytes) || ciphertext (variable) || tag (16 bytes)
5. Session Key Format¶
5.1 Structure¶
The session key is a lowercase hexadecimal string. The length is configurable.
| Property | Value |
|---|---|
| Character set | [0-9a-f] |
| Default length | 5 hex characters (20 bits of entropy) |
| Minimum length | 2 hex characters (8 bits) |
| Maximum length | 16 hex characters (64 bits) |
| Entropy per char | 4 bits |
5.2 Key Types¶
| Type Code | Name | Description |
|---|---|---|
's' |
Session | Standard session key derived from shared secret + OTC |
'm' |
Multiplier | Key used as a multiplier against a base attestation UID |
5.3 Generation Requirements¶
- When generated randomly (no shared secret mode): use platform CSPRNG
- When derived from shared secret: use HKDF as specified in Section 4.2
- MUST NOT contain uppercase hex characters
- MUST be exactly
sessionKeyLengthcharacters (left-pad with zeros if HKDF output has leading zero bytes)
6. Key Derivation Flow¶
6.1 Verifier-Initiated with TIP (Full Flow)¶
Verifier Presenter
-------- ---------
1. Generate key pair (d_V, Q_V)
2. Send Q_V in invite email/link
3. Generate key pair (d_P, Q_P)
4. Create TIP, sign with d_P
5. Encrypt TIP with tipEncryptionKey
6. Send encrypted TIP + Q_P to Verifier
7. Decrypt TIP, verify signature
8. Compute sharedSecret = d_V * Q_P
9. Compute sharedSecret = d_P * Q_V
// Both now have identical sharedSecret
--- At meeting time ---
10. Generate random OTC (nonce)
11. Derive sessionKey from HKDF(sharedSecret, OTC, timestamp)
12. Send OTC + blink params to Presenter
13. Derive sessionKey from HKDF(sharedSecret, OTC, timestamp)
// Both now have identical sessionKey
// without the session key ever crossing the wire
6.2 Verifier-Initiated without TIP (Simple Flow)¶
Verifier Presenter
-------- ---------
1. Generate random sessionKeyHex (CSPRNG)
2. Send sessionKeyHex + blink params
3. Receive sessionKeyHex directly
// Both now have identical sessionKey
// (key was transmitted in plaintext)
6.3 Presenter-Initiated (Public Attestation)¶
Presenter
---------
1. Create public attestation
2. Sign attestation -> UID
3. sessionKeyHex = truncate(hexEncode(UID), sessionKeyLength)
4. Encode sessionKey as blink sequence
5. Perform scan, generate entangled vector
6. Append vector to public attestation
7. Platform Library Requirements¶
| Platform | ECDH Library | HKDF Library |
|---|---|---|
| iOS | Security.framework / CryptoKit | CryptoKit.HKDF |
| Android | java.security / Tink | javax.crypto (HmacSHA256) |
| Web | Web Crypto API (SubtleCrypto) | SubtleCrypto.deriveBits |
All libraries MUST support: - ECDH with P-256 (secp256r1) - HKDF with SHA-256 - AES-256-GCM for TIP encryption
8. Conformance Requirements¶
- Private keys MUST be generated from a CSPRNG with at least 128 bits of entropy
- Public keys MUST be validated before use (on-curve check, not point at infinity, correct order)
- HKDF salt and info strings MUST be the exact byte sequences specified (UTF-8 encoded, no null terminator)
- Session key derivation with the same inputs (shared secret, nonce, timestamp) MUST produce identical output across all platforms
- The raw shared secret MUST be the X coordinate of the ECDH result, 32 bytes, big-endian, zero-padded on the left if shorter than 32 bytes
- TIP encryption MUST use AES-256-GCM with 12-byte random nonce and 16-byte authentication tag
- All sensitive key material (private keys, shared secrets, session keys) MUST be zeroized from memory after use
9. Test Vectors¶
9.1 ECDH Key Agreement¶
Verifier private key (d_V):
0xC9AFA9D845BA75166B5C215767B1D6934E50C3DB36E89B127B8A622B120F6721
Verifier public key (Q_V), uncompressed:
0x0460FED4BA255A9D31C961EB74C6356D68C049B8923B61FA6CE669622E60F29FB67903FE1008B8BC99A41AE9E95628BC64F2F1B20C2D7E9F5177A3C294D4462299
Presenter private key (d_P):
0xA0A4F130B98A5BE145B002E66FC6C4192FC70A73ABABDEBD9B5363C13F1E5EC1
Implementations MUST verify that d_V * Q_P and d_P * Q_V produce the same X coordinate.
9.2 HKDF-SHA256¶
RFC 5869 Test Case 1 (for validation):
- IKM: 0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b (22 bytes)
- Salt: 0x000102030405060708090a0b0c (13 bytes)
- Info: 0xf0f1f2f3f4f5f6f7f8f9 (10 bytes)
- Length: 42
Expected OKM:
0x3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
9.3 Session Key Derivation¶
Given:
- Shared secret: 32 bytes of 0x01
- Salt: "VeroSessionKey-v1"
- Nonce (OTC): "12345"
- Timestamp: 1700000000 (as 8-byte big-endian: 0x00000000654FF1A0)
- sessionKeyLength: 5
Implementations MUST produce identical 5-character hex session keys from these inputs on all platforms.