Skip to content

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, chainId 1, non-zero prevrandao), and the record's beacon field MUST carry the block used, or null with §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 sessionKeyLength characters (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

  1. Private keys MUST be generated from a CSPRNG with at least 128 bits of entropy
  2. Public keys MUST be validated before use (on-curve check, not point at infinity, correct order)
  3. HKDF salt and info strings MUST be the exact byte sequences specified (UTF-8 encoded, no null terminator)
  4. Session key derivation with the same inputs (shared secret, nonce, timestamp) MUST produce identical output across all platforms
  5. 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
  6. TIP encryption MUST use AES-256-GCM with 12-byte random nonce and 16-byte authentication tag
  7. 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.