Skip to content

Vero Session Protocol Specification

Version: 1.0.0 Status: Normative Scope: All platforms (iOS, Android, Web)

This document defines the session lifecycle, state machine, WebSocket message schemas, and key transfer formats for all Vero session channels. Two independent implementations following this specification MUST be able to establish sessions and exchange messages interoperably.


1. Overview

A Vero session coordinates the exchange of cryptographic material, blink parameters, and entangled vectors between a verifier and a presenter. Sessions may be established through multiple channels (peer-to-peer WebSocket, deep link, push notification, or QR code) but all converge on the same state machine and message formats.


2. Session State Machine

2.1 States

State Code Description
CREATED 0 Session initialized by verifier; waiting for presenter to join
WAITING 1 Invite sent; waiting for presenter to acknowledge
CONNECTED 2 Both parties online; keys exchanged; ready to authenticate
AUTHENTICATING 3 Blink sequence active; scan in progress
COMPLETED 4 Authentication decision rendered (accept, review, or reject)
EXPIRED 5 Session timed out without completing authentication
CANCELLED 6 Session explicitly cancelled by either party
ERROR 7 Unrecoverable error occurred

2.2 State Transitions

CREATED ---------> WAITING         (verifier sends invite)
CREATED ---------> CANCELLED       (verifier cancels)
CREATED ---------> EXPIRED         (timeout: 24 hours)

WAITING ---------> CONNECTED       (presenter acknowledges / joins)
WAITING ---------> CANCELLED       (either party cancels)
WAITING ---------> EXPIRED         (timeout: 1 hour after invite sent)

CONNECTED -------> AUTHENTICATING  (verifier sends OTC / session key)
CONNECTED -------> CANCELLED       (either party cancels)
CONNECTED -------> EXPIRED         (timeout: 15 minutes)

AUTHENTICATING --> COMPLETED       (authentication decision rendered)
AUTHENTICATING --> CANCELLED       (either party cancels)
AUTHENTICATING --> EXPIRED         (timeout: 5 minutes)
AUTHENTICATING --> ERROR           (unrecoverable failure)

COMPLETED -------> (terminal)      // No further transitions
EXPIRED ---------> (terminal)
CANCELLED -------> (terminal)
ERROR -----------> (terminal)

2.3 Timeouts

Transition Context Timeout Description
CREATED 24 hours Invite must be sent within this window
WAITING 1 hour Presenter must join within this window
CONNECTED 15 minutes Authentication must start within this window
AUTHENTICATING 5 minutes Scan and comparison must complete

All timeouts are measured from the moment the session enters the respective state. On timeout, the session transitions to EXPIRED and all associated key material MUST be zeroized.


3. Session Object

3.1 Schema

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["sessionId", "state", "createdAt", "verifierId"],
  "properties": {
    "sessionId": {
      "type": "string",
      "format": "uuid",
      "description": "Unique session identifier (UUIDv4)"
    },
    "state": {
      "type": "integer",
      "enum": [0, 1, 2, 3, 4, 5, 6, 7],
      "description": "Current session state code"
    },
    "createdAt": {
      "type": "integer",
      "description": "Unix epoch milliseconds when session was created"
    },
    "updatedAt": {
      "type": "integer",
      "description": "Unix epoch milliseconds of last state change"
    },
    "expiresAt": {
      "type": "integer",
      "description": "Unix epoch milliseconds when current state times out"
    },
    "verifierId": {
      "type": "string",
      "description": "Verifier's Vero ID or ephemeral identifier"
    },
    "presenterId": {
      "type": ["string", "null"],
      "description": "Presenter's Vero ID or ephemeral identifier (null until CONNECTED)"
    },
    "verifierPublicKey": {
      "type": "string",
      "description": "Verifier's ECDH public key, base64url-encoded uncompressed point (65 bytes)"
    },
    "presenterPublicKey": {
      "type": ["string", "null"],
      "description": "Presenter's ECDH public key (null until CONNECTED)"
    },
    "blinkParams": {
      "type": ["object", "null"],
      "description": "Blink sequence parameters (set when OTC is sent)",
      "properties": {
        "sessionKeyLength": { "type": "integer", "minimum": 2, "maximum": 16 },
        "blinkRate": { "type": "integer", "minimum": 50, "maximum": 1000 },
        "sessionKeyType": { "type": "string", "enum": ["s", "m"] },
        "bitMode": { "type": "integer", "enum": [1, 2, 3] }
      }
    },
    "tipId": {
      "type": ["string", "null"],
      "description": "Reference to the Temporal Identity Proof (if used)"
    },
    "result": {
      "type": ["object", "null"],
      "description": "Authentication result (set in COMPLETED state)",
      "properties": {
        "decision": { "type": "string", "enum": ["ACCEPT", "REVIEW", "REJECT"] },
        "score": { "type": "number", "minimum": 0, "maximum": 1 },
        "faceSimilarity": { "type": "number" },
        "sequenceFidelity": { "type": "number" },
        "confidence3D": { "type": "number" },
        "rejectReason": { "type": ["string", "null"] }
      }
    }
  }
}

4. WebSocket Protocol

4.1 Connection

Endpoint: wss://api.verifywithvero.com/ws/session/{sessionId}

Authentication: Bearer token in the Authorization header or as query parameter ?token=<jwt>.

Subprotocol: vero-session-v1

4.2 Message Envelope

All WebSocket messages are JSON-encoded text frames with this envelope:

{
  "type": "<message_type>",
  "sessionId": "<uuid>",
  "senderId": "<sender_identifier>",
  "timestamp": <unix_epoch_ms>,
  "payload": { ... }
}
Field Type Required Description
type string Yes Message type identifier (see Section 4.3)
sessionId string Yes Session UUID
senderId string Yes Sender's Vero ID or ephemeral ID
timestamp integer Yes Unix epoch milliseconds when message was sent
payload object Yes Type-specific payload (may be empty {})

4.3 Message Types

4.3.1 session.create

Sent by: Verifier Transition: (none) -> CREATED

{
  "type": "session.create",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "verifier-001",
  "timestamp": 1700000000000,
  "payload": {
    "verifierPublicKey": "<base64url-encoded uncompressed ECDH P-256 public key>",
    "requestedAnchors": ["github", "linkedin"],
    "presenterEmail": "[email protected]",
    "expiresAt": 1700086400000
  }
}

4.3.2 session.invite

Sent by: Verifier (or system on behalf of verifier) Transition: CREATED -> WAITING

{
  "type": "session.invite",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "verifier-001",
  "timestamp": 1700000100000,
  "payload": {
    "channel": "email",
    "recipientAddress": "[email protected]",
    "inviteUrl": "https://vero.technology/join?s=550e8400&pk=<base64url_pubkey>",
    "message": "Please verify your identity for our upcoming meeting."
  }
}

4.3.3 session.join

Sent by: Presenter Transition: WAITING -> CONNECTED

{
  "type": "session.join",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "presenter-002",
  "timestamp": 1700000200000,
  "payload": {
    "presenterPublicKey": "<base64url-encoded uncompressed ECDH P-256 public key>",
    "tipCiphertext": "<base64url-encoded encrypted TIP, or null>",
    "tipNonce": "<base64url-encoded 12-byte AES-GCM nonce, or null>"
  }
}

4.3.4 session.otc

Sent by: Verifier Transition: CONNECTED -> AUTHENTICATING

{
  "type": "session.otc",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "verifier-001",
  "timestamp": 1700000300000,
  "payload": {
    "otc": "7f3a2",
    "blinkParams": {
      "sessionKeyLength": 5,
      "blinkRate": 200,
      "sessionKeyType": "s",
      "bitMode": 1
    },
    "derivationTimestamp": 1700000300
  }
}

Note: When a shared secret exists (TIP was exchanged), the otc is a nonce used with HKDF to derive the session key (see crypto-spec.md Section 4.2). When no shared secret exists, the otc IS the session key.

4.3.5 session.scan_start

Sent by: Presenter Transition: (within AUTHENTICATING)

{
  "type": "session.scan_start",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "presenter-002",
  "timestamp": 1700000310000,
  "payload": {
    "scanMode": "3d",
    "deviceModel": "iPhone 15 Pro",
    "cameraType": "front_truedepth"
  }
}

4.3.6 session.scan_complete

Sent by: Presenter Transition: (within AUTHENTICATING)

{
  "type": "session.scan_complete",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "presenter-002",
  "timestamp": 1700000320000,
  "payload": {
    "vectorBase64": "<base64url-encoded 924-byte entangled vector>",
    "vectorHash": "<SHA-256 hex digest of raw vector bytes>",
    "scanDurationMs": 8400,
    "loopsCompleted": 3
  }
}

Vector encoding: The 231 x float32 vector (924 bytes, little-endian) is base64url-encoded without padding.

4.3.7 session.verify_start

Sent by: Verifier Transition: (within AUTHENTICATING)

{
  "type": "session.verify_start",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "verifier-001",
  "timestamp": 1700000311000,
  "payload": {
    "scanMode": "2d",
    "source": "video_feed"
  }
}

4.3.8 session.result

Sent by: Verifier (who performs the comparison) Transition: AUTHENTICATING -> COMPLETED

{
  "type": "session.result",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "verifier-001",
  "timestamp": 1700000330000,
  "payload": {
    "decision": "ACCEPT",
    "score": 0.925,
    "faceSimilarity": 0.85,
    "sequenceFidelity": 1.00,
    "confidence3D": 0.90,
    "rejectReason": null
  }
}

4.3.9 session.cancel

Sent by: Either party Transition: (any non-terminal) -> CANCELLED

{
  "type": "session.cancel",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "verifier-001",
  "timestamp": 1700000400000,
  "payload": {
    "reason": "user_cancelled"
  }
}

4.3.10 session.error

Sent by: Either party or system Transition: AUTHENTICATING -> ERROR

{
  "type": "session.error",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "system",
  "timestamp": 1700000500000,
  "payload": {
    "code": "VECTOR_DECODE_FAILED",
    "message": "Failed to decode presenter vector",
    "recoverable": false
  }
}

4.3.11 session.ping / session.pong

Keepalive mechanism. Sent every 30 seconds by either party.

{
  "type": "session.ping",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "verifier-001",
  "timestamp": 1700000600000,
  "payload": {}
}

If no pong is received within 10 seconds, the connection is considered lost.

4.3.12 session.sequence_attempt

Sent by: Verifier (informational, during AUTHENTICATING) Reports real-time blink detection progress.

{
  "type": "session.sequence_attempt",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "senderId": "verifier-001",
  "timestamp": 1700000315000,
  "payload": {
    "detectedSequence": "10100",
    "expectedLength": 20,
    "matchPercentage": 25,
    "attemptNumber": 1
  }
}

5. Key Transfer Channels

The session key (OTC) and associated parameters can be transferred through multiple channels. All channels MUST deliver the same logical payload.

5.1 Peer-to-Peer (WebSocket)

Format: session.otc message as defined in Section 4.3.4.

This is the primary real-time channel when both parties are simultaneously connected.

URL format:

https://vero.technology/join?s={sessionId}&jt={joinToken}&r={blinkRate}&t={sessionKeyType}&b={bitMode}&l={sessionKeyLength}[&k={otc}][&ts={derivationTimestamp}][&pk={verifierPublicKeyBase64url}]

Parsers MUST also accept the legacy /verify path (the pre-T02 VerifyWithVero.com form) for invites printed before 2026-08-07; emitters MUST use /join.

Key-less by default (2026-08-17). Emitters MUST omit k: invites are displayed on meeting-visible surfaces (screen share, chat, video tiles), and the optical challenge must not ride along — an embedded key gives an adversarial presenter unbounded precompute head start on the flash sequence (see docs/research/qr-in-meeting-options.md §2). The session key is late-bound instead: after the presenter joins, the verifier delivers it over the relay's session_key message — the same path the pairing-code flow (§5.5) has always used. Parsers MUST accept both forms; k, when present, is the legacy embedded-key form.

Parameter Description Required
s Session ID (UUID, no dashes) Yes
jt One-time join token (base64url, minted at create_session; consumed on first join_session) Yes**
r Blink rate in milliseconds Yes
t Session key type: s or m Yes
b Bit mode: 1, 2, or 3 Yes
l Session key length (hex chars) Yes
k OTC / session key (hex string) — legacy embedded-key form only; emitters MUST omit Legacy
ts Derivation timestamp (Unix epoch seconds) No*
pk Verifier public key (base64url, uncompressed) No*

Required when shared secret derivation is used. *Parsers tolerate its absence (older invites), but the server rejects the resulting join with JOIN_TOKEN_REQUIRED.

Example (key-less default):

https://vero.technology/join?s=550e8400e29b41d4a716446655440000&jt=a1b2c3d4e5f60718&r=200&t=s&b=1&l=5

Example (legacy embedded-key form, parse-only):

https://vero.technology/join?s=550e8400e29b41d4a716446655440000&k=7f3a2&r=200&t=s&b=1&l=5

Maximum URL length: 2048 characters (to ensure compatibility with all platforms and email clients).

5.3 Push Notification

Payload (FCM / APNs):

{
  "notification": {
    "title": "Vero Identity Verification",
    "body": "A verification request is ready. Tap to begin."
  },
  "data": {
    "type": "vero_otc",
    "sessionId": "550e8400-e29b-41d4-a716-446655440000",
    "otc": "7f3a2",
    "blinkRate": 200,
    "sessionKeyType": "s",
    "bitMode": 1,
    "sessionKeyLength": 5,
    "derivationTimestamp": 1700000300,
    "verifierPublicKey": "<base64url>"
  }
}

The data payload MUST contain all fields necessary to derive the session key and configure the blink sequence without requiring an additional network request.

5.4 QR Code

Data format: The deep link URL (Section 5.2) is encoded as a QR code.

QR parameters: - Error correction level: M (15% recovery) - Minimum module size: 3px at display resolution - Encoding: UTF-8

For compressed format (when URL exceeds QR capacity; predates the key-less default — a compressed emit would likewise omit k):

{
  "v": 1,
  "s": "<sessionId>",
  "k": "<otc>",
  "r": 200,
  "t": "s",
  "b": 1,
  "l": 5,
  "pk": "<compressed public key, base64url, 33 bytes>"
}

This JSON is then encoded as the QR payload. Note the use of the compressed public key format (33 bytes vs 65 bytes) to reduce QR code density.

5.5 Pairing Word

The spoken fallback for when the QR is awkward to scan (a projected screen, a camera that will not focus). It is a discovery channel, not a key channel: it carries no key material and grants no authority. It resolves to {sessionId, joinToken}, and the join token still gates the join.

Format. One common English word, 5 or 6 letters, lowercase a–z. No digits, no separators, no grouping — what the verifier shows is exactly what the presenter types.

This replaced a 6-glyph code (23456789ABCDEFGHJKMNPQRSTUVWXYZ, displayed ABC-DEF). The old alphabet, its OQ / IJ folding, and the display hyphen are all removed, not deprecated. An implementation that still applies them turns apple into APPJE and fails to resolve with no visible cause.

The vocabulary is server-side and normative to the server alone. The reference list is server/src/pairing-words.ts. Clients MUST NOT ship a copy and MUST NOT validate membership against one: a client one release behind would reject a perfectly good word before the server ever saw it. Clients perform a shape check only (5–6 letters, a–z after normalization).

Server requirements.

  1. Uniqueness across live sessions. No two sessions that are open at the same time may hold the same word. The mint re-draws on collision. This is a promise the verifier UI makes to the operator in so many words, so it is a correctness requirement, not a probabilistic one.
  2. Release on session end. The word returns to the vocabulary when the session is removed or expires on its TTL. A word retired by expiry is a slow leak out of a finite vocabulary.
  3. Refuse, never reissue. If every word is held by a live session, session creation MUST fail rather than issue a duplicate.
  4. Single-use resolution. The index entry is consumed on the first successful resolve. A consumed, expired, or never-existent word MUST answer identically (PAIRING_CODE_NOT_FOUND), so a probe learns nothing.
  5. Rate-limit resolution on the same budget as join_session. A few hundred words is ample against a human and trivial for a loop — the limiter, not the vocabulary size, is what makes guessing pointless.
  6. Uniform draw. Select with a CSPRNG and reject modulo bias; a skew would concentrate live sessions onto a handful of words.

Normalization (both ends). Lowercase, then strip everything that is not a–z, then truncate to 6. That tolerates a trailing space, an autocapitalised first letter, and a pasted quotation mark. It cannot mangle a word, because a word is letters. Matching is therefore case-insensitive: Apple, APPLE, and apple are the same request.

Wire messages (unchanged in shape; code now carries the word):

{ "type": "resolve_pairing_code", "code": "apple", "ts": 1754246400000 }
{ "type": "pairing_code_resolved", "sessionId": "…", "joinToken": "…" }

The presenter then sends the ordinary join_session on the same connection.


6. Vector Transfer

6.1 Vector Payload Format

The entangled vector is transferred as a base64url-encoded binary blob:

vectorBytes = float32[231] in little-endian byte order    // 924 bytes
vectorBase64 = base64urlEncode(vectorBytes)               // ~1232 chars, no padding
vectorHash = SHA256(vectorBytes).hexDigest()               // 64 hex chars

6.2 Transfer Channels

Channel Method Max Size
WebSocket session.scan_complete message N/A
Deep Link URL Query parameter &v=<base64url> 2048 ch
Push Data data.vectorBase64 field 4 KB
REST API POST /api/v1/sessions/{id}/vector body 10 KB

For channels with size constraints, the vector (924 bytes -> ~1232 base64url chars) fits comfortably within limits.

6.3 Integrity Verification

The receiver MUST verify the SHA-256 hash of the decoded vector bytes against the provided vectorHash before processing. Mismatched hashes MUST cause the session to transition to ERROR.


7. Session Security

7.1 Transport Security

  • All WebSocket connections MUST use TLS 1.2 or higher
  • Certificate pinning is RECOMMENDED for native apps
  • The wss:// scheme is REQUIRED (plain ws:// MUST be rejected)

7.2 Message Authentication

Each WebSocket message SHOULD be signed by the sender using ECDSA with their session key pair:

{
  "type": "...",
  "sessionId": "...",
  "senderId": "...",
  "timestamp": 1700000000000,
  "payload": { ... },
  "signature": "<base64url-encoded ECDSA signature over canonical JSON of all other fields>"
}

Signature input: The canonical JSON serialization (keys sorted alphabetically, no whitespace) of all fields except signature.

Algorithm: ECDSA with SHA-256 on P-256 (same curve as ECDH keys).

7.3 Replay Protection

  • Each message MUST include a monotonically increasing timestamp
  • Receivers MUST reject messages with timestamps more than 30 seconds in the past
  • Receivers MUST reject messages with timestamps in the future (with 5-second tolerance for clock skew)
  • Session IDs MUST NOT be reused

7.4 Key Material Cleanup

On session completion (COMPLETED, EXPIRED, CANCELLED, or ERROR): - All private keys specific to this session MUST be zeroized - The shared secret MUST be zeroized - The session key MUST be zeroized - Vector data MUST be zeroized - LocalStorage/Keychain entries for the session MUST be removed


8. Conformance Requirements

  1. Implementations MUST support all session states and enforce the defined transition rules
  2. Implementations MUST support at least the WebSocket and deep link transfer channels
  3. All message types MUST conform to their JSON schemas
  4. Timeout enforcement is REQUIRED (implementations MUST NOT allow sessions to persist indefinitely)
  5. Vector transfer MUST include SHA-256 integrity verification
  6. TLS 1.2+ is REQUIRED for all network communication
  7. Key material cleanup MUST occur on session termination regardless of the terminal state

9. Error Codes

Code Description Recoverable
INVALID_STATE Message received in wrong session state No
KEY_VALIDATION_FAILED Public key failed on-curve validation No
TIP_DECRYPT_FAILED Could not decrypt the Temporal Identity Proof No
VECTOR_DECODE_FAILED Could not decode or parse the entangled vector No
VECTOR_HASH_MISMATCH Vector SHA-256 hash does not match No
TIMEOUT Session state timed out No
CONNECTION_LOST WebSocket connection dropped Yes
SCAN_FAILED Camera or CNN processing failure Yes
SEQUENCE_UNDETECTED Blink sequence could not be detected Yes
PAIRING_CODE_NOT_FOUND Pairing word unknown, already consumed, or expired (§5.5 — the three are deliberately indistinguishable) Yes