Client protocol specification
This page is the normative specification for any Vouch client. A client is any program that holds a user’s plaintext phone book, performs the device-side half of the PSI protocol, and talks to the Vouch API.
It exists so that every client behaves identically. The protocol is distributed,
stateful and cryptographic: two clients that poll on different schedules, order
their sets differently, or handle a 409 differently produce different — and
separately debuggable — failure modes. Divergence here is the most expensive
kind of bug in this system, because a broken client looks exactly like “no
matches”, which is also the correct output most of the time.
The keywords MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119. Every constant in this page is part of the protocol: do not tune it per client. Changing one means changing this page first.
- For why the cryptography works, see The matching protocol.
- For the wire shapes of each endpoint, see the generated API Reference.
- The reference implementation is
client/src/(crypto.ts,store.ts,sync.ts).
1. Protocol constants
Section titled “1. Protocol constants”| Constant | Value | Defined by |
|---|---|---|
| Curve / group | ristretto255 | protocol.py, crypto.ts |
| Point encoding length | 32 bytes | models.POINT_LEN |
| Hash-to-point domain string | vouch/v1 (8 ASCII bytes, no separator) |
protocol.DOMAIN |
| Digest for hash-to-point | SHA-512 (64 bytes) | protocol.hash_to_point |
| Wire encoding of points | RFC 4648 standard base64, padded, no line breaks | schemas.py / contacts.decode_point |
| E.164 accept pattern | ^\+[1-9][0-9]{6,14}$ |
protocol._E164_RE |
| Characters stripped before validation | space, -, (, ), . |
protocol._STRIP_RE |
| Max elements per contact set | 5000 | schemas.MAX_ELEMENTS |
| Auth scheme | Authorization: Bearer <JWT> (access token) |
auth.py |
| Access token lifetime | 900 s (VOUCH_ACCESS_TOKEN_TTL_SECONDS); claims sub, sid, admin |
auth.make_access_token |
| Refresh token | httpOnly cookie vouch_refresh, path /auth/refresh, 30 days, rotated on every use |
auth.py |
| DLEQ domain string | vouch/dleq/v1 |
protocol.DLEQ_DOMAIN |
| Server matcher pass interval | 60 s (VOUCH_MATCHER_INTERVAL_SECONDS) |
config.py |
Base64 details matter: the server calls base64.b64decode(v, validate=True),
which rejects the URL-safe alphabet, rejects unpadded input, and rejects
whitespace. Emit +// with = padding and nothing else.
2. Cryptographic operations
Section titled “2. Cryptographic operations”A client MUST implement exactly these four operations, with exactly these libsodium calls. Any deviation (a different domain string, a different digest, a different normalisation) yields points that are individually valid and never match anything — a silent, total failure.
| Operation | Definition | libsodium |
|---|---|---|
normalize(raw) |
strip the separator characters, trim, then assert the E.164 pattern | — |
hash_to_point(e164) |
H = SHA-512("vouch/v1" ‖ e164); P = from_hash(H) |
crypto_hash_sha512, crypto_core_ristretto255_from_hash |
generate_key() |
uniform random ristretto255 scalar | crypto_core_ristretto255_scalar_random |
apply_key(P, k) |
P·k |
crypto_scalarmult_ristretto255(k, P) |
Rules:
normalizeMUST be applied to every number before hashing — the user’s own number and every contact. The two devices in a pair hash independent strings; they only agree if both normalise to byte-identical E.164.- A client MUST NOT invent its own key derivation.
kMUST come from the CSPRNG above. Derivingkfrom a password or device ID would let anyone who can guess that input decrypt the published set by brute-forcing phone numbers. - A client MUST NOT publish the identity point (all-zero encoding). The server
rejects it (
422), becauseidentity·k == identityfor everyk, which would match every user universally. - Implementations MUST be validated against
vectors/protocol_vectors.jsonbefore being used against a live server. Those vectors are the only thing guaranteeing cross-language byte-compatibility; a new client in a new language MUST add its own runner over that file.
3. Client state
Section titled “3. Client state”This is the complete set of data a client is responsible for. Nothing here is recoverable from the server.
| # | Field | Type | Lifetime | Why it matters |
|---|---|---|---|---|
| 1 | device_key |
32-byte scalar | Permanent, per account | The single secret. Loss ⇒ every published element is dead weight and every in-flight pair silently mismatches. Compromise ⇒ an attacker can test any phone number against the user’s published set. |
| 2 | key_fingerprint |
first 8 bytes of SHA-256(device_key), hex |
Derived | Cheap comparable identity for the key, so state can be tagged with which key produced it without storing the key twice. |
| 3 | own_e164 |
string | Until the user changes it | Feeds own_token; drives direct-acquaintance suppression on the other side. |
| 4 | contacts |
list of {display_name, e164} |
User-managed | Plaintext phone book. MUST NOT leave the device. |
| 5 | published_index |
ordered list of e164 |
Replaced on every successful publish | The pairing. Index i of this list is the plaintext behind index i of the published elements, and is what my_matched_indices refers to. Lose it and matches become unnameable. |
| 6 | published_version |
integer | With #5 | The version the server returned. Lets the client detect that its view is stale. |
| 7 | published_digest |
32-byte hash (§5.3) | With #5 | Decides whether a republish is needed at all. Without it clients either republish too often (destroying pairs) or never (going stale). |
| 8 | published_key_fingerprint |
hex | With #5 | Binds the published set to the key that produced it. The invariant in §7.1 is checked against this. |
| 9 | matches |
list of MatchOut + resolved names |
Cache | Presentation only; the server is authoritative. Each entry MUST be tagged with the published_version under which it was observed. |
| 10 | last_sync_at, sync_backoff |
timestamps / integer | Runtime | Enforce the schedule in §6. |
| 11 | session |
access token (memory only) + GET /me view |
≤ 900 s; renewed via the refresh cookie | Bearer credential. The refresh token is an httpOnly cookie the client never reads. GET /me also returns the server-bound own_token, which a publish must reproduce exactly (§5.1). |
3.1 Storage requirements
Section titled “3.1 Storage requirements”device_keyMUST be stored in the most protected store the platform offers (iOS Keychain / Android Keystore / OS credential store;localStorageis acceptable only for the browser reference client). It MUST NOT be written to logs, crash reports, analytics, or any cloud/device backup that leaves the device.contactsandpublished_indexcontain plaintext phone numbers. They MUST be stored in the app’s private storage and MUST NOT be transmitted anywhere.- Fields 5–8 form a single record and MUST be written atomically, after the
PUT /me/contact-setcall returns200. Writing them before the response (or writing #5 without #7) leaves the client believing it published something it did not, which desynchronises every subsequent index lookup. - A client MUST clear all of fields 1–10 on logout or account deletion.
3.2 Suggested schema
Section titled “3.2 Suggested schema”Any storage engine is fine. This shape is sufficient and is what the reference client persists:
{ "device_key": "<32 bytes, hex, in the secure store>", "own_e164": "+32470123456", "contacts": [{ "display_name": "Ada", "e164": "+32470111111" }], "published": { "version": 7, "key_fingerprint": "9f2c41ab0d5e7761", "digest": "<sha256 hex>", "index": ["+32470111111", "+32470222222"], "at": "2026-07-20T09:12:44Z" }, "matches": [ { "pair_id": "01a0999a-793f-7e42-8649-f2bc76feb0e6", "seen_at_version": 7, "...": "" } ]}3.3 One key per account
Section titled “3.3 One key per account”An account has exactly one active device_key. This is a hard constraint of
the current server design: the server keeps one contact set per user, and
round-2 responses are only meaningful when computed with the same k that
produced that set.
Consequently:
- A second device MUST NOT simply generate its own key and publish. Doing so makes the two devices overwrite each other’s set; whichever device did not publish last will answer work items with the wrong key and will match nobody, with no error anywhere.
- Multi-device support MUST be implemented as either (a) out-of-band transfer of
device_keyto the new device, or (b) explicit takeover: generate a new key, immediately republish the full set, and treat the other device as logged out. - Key rotation is only complete once a successful publish under the new key has returned. See the invariant in §7.1.
4. Set construction
Section titled “4. Set construction”4.1 Canonical order
Section titled “4.1 Canonical order”The published set MUST be built as follows, in this order:
normalizeevery contact number; drop entries that fail validation and surface them to the user (a silently dropped contact is a silently missed match).- Remove
own_e164if present. It is carried separately asown_token; including it inflatesmutual_countand muddies direct-acquaintance semantics. - De-duplicate by normalised value. The server rejects duplicate points with
422, because duplicates makemutual_countgameable and desynchronise the two sides’ index lists. - Sort ascending by the byte value of the E.164 ASCII string.
- If more than 5000 entries remain, keep the first 5000 in that order, record that truncation happened, and tell the user. Truncation MUST be deterministic so two clients for the same user publish the same set.
Sorting is not required by the server, but it is required by this spec: it makes the published order a pure function of the contact set, so the digest in §5.3 is comparable across clients and platforms, and so a re-implementation can be diffed against another client’s output.
4.2 Elements
Section titled “4.2 Elements”For the resulting list C[0..n-1]:
elements[i] = base64( apply_key( hash_to_point(C[i]), device_key ) )own_token = base64( apply_key( hash_to_point(own_e164), device_key ) )published_index = C # persist alongside version/digest/fingerprintown_e164 MUST be set before publishing; own_token has no valid “absent”
encoding, and a placeholder point would produce false direct-acquaintance hits.
4.3 Set-size padding
Section titled “4.3 Set-size padding”The server learns the size of every published set. To stop it from counting a user’s contacts, a client SHOULD pad the element list to a fixed bucket before publishing:
bucket = 64target = min(5000, ceil(max(n, 1) / bucket) * bucket)elements = elements ++ [ base64(random_ristretto255_point()) for _ in range(target - n) ]Padding points MUST be fresh uniformly random group elements
(crypto_core_ristretto255_random), MUST come after the real elements so
published_index stays aligned, and MUST NOT be persisted. A random point is
not the image of any phone number under hash_to_point, so a padding element
can never intersect anything: mutual_count, my_matched_indices and the
direct check are unaffected, and an index ≥ n never appears in a match. The
reference client pads with bucket = 64; a client MAY choose a different
bucket, but all of a user’s publishes SHOULD use the same one.
5. Operations
Section titled “5. Operations”5.1 Authentication and phone verification
Section titled “5.1 Authentication and phone verification”Login. Either POST /auth/register / POST /auth/login (username +
password) or a top-level navigation to GET /auth/google/start (Google OIDC,
authorization-code + PKCE; the server never exposes Google tokens to the
client). Both end with a vouch_refresh httpOnly cookie set and, for the
password endpoints, { "token": "<JWT>" } in the body. After the Google
redirect lands back on the app, and on every app start, call
POST /auth/refresh (cookie-authenticated) to obtain an access token. Send it
as Authorization: Bearer <token> on every other call; on 401 refresh once
and retry (§8). POST /auth/logout revokes the session server-side;
GET /me/sessions / DELETE /me/sessions/{id} manage other devices.
Phone verification. Every PSI endpoint (PUT /me/contact-set, GET /sync,
POST /pairs/*) answers 403 phone_not_verified until the account has proved
possession of a phone number and bound its own_token to it:
POST /me/phone/start { "e164": own_e164 } → 202, SMS sentPOST /me/phone/verify { "code": "<6 digits>", "own_token": base64( k·H(own_e164) ), "own_pubkey": base64( k·G ), "proof": { "c": base64(c), "s": base64(s) } → 200 { verified_at, republish_required }}(c, s) is a Chaum–Pedersen (DLEQ) proof that the same k (the device_key)
is behind own_pubkey and own_token. With P = H(own_e164), G the
ristretto255 basepoint, L the group order:
K = k·G, T = k·Pr ← random scalarA1 = r·G, A2 = r·Pc = scalar_reduce( SHA-512( "vouch/dleq/v1" ‖ P ‖ K ‖ T ‖ A1 ‖ A2 ) )s = r + c·k (mod L)The server recomputes A1' = s·G − c·K, A2' = s·P − c·T and checks the hash.
It then discards P, keeping only a keyed hash of the number (one account per
number) and T = own_token. vectors/protocol_vectors.json carries a fixed
(k, r) vector; a client MUST reproduce it byte for byte.
Consequences a client MUST respect:
own_tokeninPUT /me/contact-setMUST equal the verified token (422 own_token_mismatchotherwise). If thedevice_keychanges, the user MUST re-verify before publishing.- Verifying a different number, or the same number with a new key, deletes
every published set and pair (
republish_required: true); treat it like a key rotation in §5.3. - Codes expire after 10 minutes, allow 5 attempts, and at most 3 sends per
hour per account and per number (
410,429). - Rate:
POST /auth/*is limited per source IP at the edge.
5.2 Profile
Section titled “5.2 Profile”PUT /me/profile with gender, birthdate, seeking, age_min, age_max.
The server deletes all of the user’s pairs on every profile write and
re-runs the matcher, so a client MUST NOT write the profile unless a field
actually changed — a no-op write still destroys in-flight pairings and cached
matches.
5.3 Deciding whether to publish
Section titled “5.3 Deciding whether to publish”Republishing is destructive: PUT /me/contact-set deletes every pair the user
is in, cascading their responses and matches, and the pipeline restarts from
zero. A client therefore MUST NOT publish on a fixed schedule, on app launch, or
“just in case”. It publishes only when the set actually changed.
Compute, over the canonical list from §4.1:
digest = SHA-256( own_e164 ‖ "\n" ‖ C[0] ‖ "\n" ‖ C[1] ‖ … ‖ "\n" ‖ C[n-1] )Publish if and only if at least one holds:
- there is no stored
publishedrecord (first run, or after logout); digest != published.digest;key_fingerprint != published.key_fingerprint(the key was rotated);- the last publish attempt failed and has not since succeeded.
Additional timing rules:
- Debounce: after a contact-book change, wait
PUBLISH_DEBOUNCE = 60 sof quiet before publishing, so a user editing several contacts causes one republish, not five. - Floor: a client MUST NOT issue more than one successful
PUT /me/contact-setperPUBLISH_MIN_INTERVAL = 300 s, except for the first publish after login or key rotation. - Rescan: the client SHOULD re-read the OS contact book on app foreground and on any OS change notification, and MUST re-read it at least every 24 h when no change notification is available. Re-reading is free; publishing is what costs.
On 200, atomically store version, index, digest, key_fingerprint, and
discard the local matches cache — the server has just deleted those matches.
5.4 Sync: the single polling operation
Section titled “5.4 Sync: the single polling operation”GET /sync returns everything the client needs:
{ "work": [{ "pair_id": "<uuid>", "elements": ["<b64>", "..."], "own_token": "<b64>" }], "matches": [{ "pair_id": "<uuid>", "other": {...}, "my_matched_indices": [3], "mutual_count": 1, "matched_at": "..." }]}Identifiers are UUID strings (time-ordered UUIDv7, generated by the database).
A pair has at most one match, so pair_id identifies the match as well; there
is no separate match_id.
The sync algorithm is fixed:
sync(): if a sync is already running: return # single-flight, §6.3 assert published.key_fingerprint == key_fingerprint # §7.1 payload = GET /sync for item in payload.work: # sequential, in the given order elements = [ base64(apply_key(unbase64(e), device_key)) for e in item.elements ] # order preserved, 1:1 direct_check = base64(apply_key(unbase64(item.own_token), device_key)) POST /pairs/{item.pair_id}/response { elements, direct_check } if payload.work was non-empty: payload = GET /sync # exactly one extra fetch store payload.matches (tagged with published.version)Non-negotiable details:
- Order preservation.
elements[i]of the response MUST be the re-encryption ofelements[i]of the work item, and the lists MUST have the same length. The server enforces the length (422otherwise) but cannot detect reordering; a reordered response produces wrongmy_matched_indicesfor the counterparty — they would see a match against the wrong contact. Never sort, filter, or de-duplicate a work item. direct_checkcomes from the work item. It isapply_key(item.own_token, device_key)— the other user’s own-token re-encrypted with this device’s key. Computing it from any local value breaks direct-acquaintance suppression, which is the mechanism that stops the app from introducing people who already know each other.- Exactly one follow-up fetch. When this client posts the second response
for a pair, the server compares inline, so the match exists immediately; one
extra
GET /syncsurfaces it in the same cycle. A client MUST NOT loop further — remaining matches arrive on the next scheduled sync. - A client MUST NOT cache or reuse a work item across syncs. Pairs are deleted
whenever either side republishes; a stale
pair_idyields404.
5.5 Resolving matches
Section titled “5.5 Resolving matches”my_matched_indices indexes into this user’s own published set, in the
order that was published. Resolve with the stored index:
names = [ published_index[i] for i in match.my_matched_indices ]A client MUST discard (and not display) any cached match whose seen_at_version
differs from the current published_version, and MUST NOT resolve indices
against a published_index from a different version. Indices from an older set
point at different contacts — the failure mode is showing the user the wrong
mutual friend, which is a privacy incident, not a cosmetic bug.
The server only returns matches with mutual_count >= 1 and direct == false,
so a client does not filter; it displays what it receives.
5.6 Declarations and consent
Section titled “5.6 Declarations and consent”Before phone verification, profile or contacts, the account must carry an
age declaration and an acceptance of the current privacy notice
(GET /me → declarations_complete, notice_version,
current_notice_version). Password registration takes age_declared and
terms_accepted; Google sign-ups and re-acceptance after a notice change
use POST /me/declarations. Until then the gated endpoints answer
403 declarations_required.
The profile carries orientation data, so the first PUT /me/profile MUST
send "consent": true (explicit consent); without it the server answers
403 profile_consent_required. Consent stands until DELETE /me/profile,
which deletes the profile and every pair and clears profile_consent_at.
PUT /me/contact-set requires a profile (403 profile_required) and
DELETE /me/contact-set unpublishes: every version and every pair is
removed and the user is not matched until they publish again. Only the
version being served is stored server-side; superseded versions are deleted
on publish.
5.7 Reporting a match
Section titled “5.7 Reporting a match”POST /me/reports { "pair_id": "<uuid>", "reason": "…" } (authenticated, not
gated on phone verification) files a report against the other side of one of
the caller’s pairs. One report per (reporter, reported) pair of users: a second
attempt answers 409. GET /me/reports lists the caller’s reports with their
resolution. Reports are reviewed by admins; a banned account answers
403 account_banned on every authenticated call (§8).
5.8 Data export and deletion
Section titled “5.8 Data export and deletion”GET /me/export returns every record the server holds about the caller:
account fields (hashes and tokens base64-encoded), login identities (provider
and a hint, never a Google sub or a password hash), sessions, profile,
contact-set versions (element counts, not elements), pairs with match results,
reports made, and reports received (with the reporter and their text
withheld). Both work for a banned account too. DELETE /me removes the
account and cascades to all of it.
Both are the technical side of the access, portability and erasure rights
described in docs/privacy.md.
6. Timing
Section titled “6. Timing”These values are protocol constants. They are chosen together, and they are the main reason this document exists — clients that poll on different schedules produce different end-to-end latencies and different reproduction steps for the same bug.
| Constant | Value | Rationale |
|---|---|---|
SYNC_INTERVAL |
300 s (5 min) while the app is in the foreground | Bounds end-to-end match latency at ~2 intervals (§6.1) while keeping the request rate at ~12/h per client. |
SYNC_MIN_INTERVAL |
30 s | Floor across all triggers. Coalesce anything that would fire sooner. |
SYNC_BACKGROUND_INTERVAL |
≥ 900 s (15 min) | Mobile background/scheduled work. Never poll harder in the background than in the foreground. |
SYNC_BACKOFF_BASE / FACTOR / CAP |
5 s / ×2 / 300 s | Recovery from server errors without a thundering herd. |
SYNC_BACKOFF_JITTER |
±20 % | Clients are woken by the same events (network regained, push); jitter de-synchronises them. |
PUBLISH_DEBOUNCE |
60 s | One republish per editing session. |
PUBLISH_MIN_INTERVAL |
300 s | Republish destroys all pairs; this bounds the damage from a pathological client. |
CONTACT_RESCAN_MAX_AGE |
24 h | Upper bound on staleness when the platform gives no change events. |
A sync MUST be triggered, subject to SYNC_MIN_INTERVAL, on each of:
- successful login or session restore at app start;
- app entering the foreground;
- immediately after a successful
PUT /me/contact-set; - immediately after a successful
PUT /me/profile; - every
SYNC_INTERVALwhile in the foreground; - explicit user action (“sync now”), which SHOULD bypass the interval but MUST NOT bypass single-flight;
- network connectivity regained after a failure.
Triggers 3 and 4 matter because both endpoints run the matcher inline: pairs exist the instant they return, so syncing immediately turns a 5-minute wait into a sub-second one.
6.1 Why 5 minutes
Section titled “6.1 Why 5 minutes”The pipeline needs two round-2 responses before a match materialises:
publish (pairs created inline) → device A syncs, posts response ≤ SYNC_INTERVAL → device B syncs, posts response ≤ SYNC_INTERVAL (compare runs inline here) → B sees the match immediately (follow-up fetch) → A sees the match on its next sync ≤ SYNC_INTERVALWorst case for the slower side is therefore ~2 × SYNC_INTERVAL ≈ 10 minutes
from publish to visible match, and the median is well under that. Polling also
cannot be replaced by event-driven syncing alone: the server’s background
matcher creates pairs every 60 s in response to other users’ activity, so work
appears with no local trigger at all.
6.2 Backoff
Section titled “6.2 Backoff”On a transport error or 5xx, the next sync is delayed by
min(CAP, BASE × FACTOR^failures) with ±JITTER, and the failure counter
resets on the first success. Backoff replaces the regular interval; it does not
add to it.
6.3 Single-flight
Section titled “6.3 Single-flight”A client MUST hold a lock (or equivalent) for the duration of sync() and drop
any sync requested while it is held. Two concurrent syncs fetch the same work
item and race to POST /pairs/{id}/response; the loser gets
409 already responded, which is harmless but indistinguishable in logs from a
genuine protocol bug.
7. Invariants
Section titled “7. Invariants”Each of these has a silent failure mode, which is why they are stated as invariants rather than left to implementation taste.
- Key–set binding. The key used to answer a work item MUST be the key that
produced the currently published set. Before processing work, a client MUST
check
published.key_fingerprint == key_fingerprint; on mismatch it MUST republish first (§5.3) and skip this sync’s work. Violation: every comparison fails; the user simply never matches, with no error client- or server-side. - Index–plaintext binding.
published_index[i]is the plaintext of published elementi, for the lifetime ofpublished_version. Violation: matches are attributed to the wrong contact. - Order preservation in round 2. Response element
iis the re-encryption of work elementi. Violation: the counterparty is told the wrong contact matched. - No duplicates, own number excluded. Violation:
422on publish, or an inflatedmutual_count. - Canonical E.164 everywhere. Hash only the output of
normalize. Violation: the same person hashes to two different points and never matches. - Publish only on change. Violation: pairs are destroyed faster than they can complete; the user never matches even though everything “works”.
- Plaintext never leaves the device. Numbers,
published_index, and the device key are local-only, including in logs and telemetry. Violation: the entire privacy claim of the system.
8. Error handling
Section titled “8. Error handling”| Status | Endpoint | Meaning | Required client behaviour |
|---|---|---|---|
401 |
any except /auth/* |
Access token expired (15 min), session revoked, or invalid | POST /auth/refresh once; on success retry once with the new token, on 401 drop the session and show login. Do not loop. |
401 |
POST /auth/refresh |
Refresh token unknown, expired, revoked, or replayed | The session is gone (a replayed old token revokes it). Show login. |
403 |
/admin/* |
Not an admin | Do not retry. |
403 |
phone, profile, contacts | declarations_required |
Show the declarations screen (§5.6), then retry. |
403 |
PUT /me/profile |
profile_consent_required |
Ask for explicit consent and resend with consent: true. |
403 |
PUT /me/contact-set |
profile_required |
Route the user to the profile step. |
403 |
PSI endpoints | phone_not_verified |
Route the user to phone verification (§5.1). |
403 |
any authenticated | account_banned |
Show the user they are locked out; offer logout. Do not retry. |
429 |
PUT /me/contact-set |
too_many_publishes: more than VOUCH_PUBLISH_PER_HOUR versions this hour |
Honour the Retry-After header (seconds). Keep the local state; publish again after it elapses. |
409 |
POST /me/reports |
already reported |
Treat as success. |
409/410/429 |
/me/phone/* |
Number in use / no pending code / too many attempts or sends | Surface to the user; 410 means start again. |
422 |
POST /me/phone/verify |
invalid_proof: the DLEQ did not verify against the number just texted |
A client bug (wrong P, wrong key). Do not retry with the same payload. |
422 |
POST /me/phone/verify |
number_mismatch: e164 differs from the number the code was sent to |
Resend the number that was started; the server keeps no copy of it. |
409 |
POST /me/phone/verify |
phone_in_use: another account verified this number |
Surface to the user. POST /me/phone/start deliberately never reveals this. |
422 |
PUT /me/contact-set |
own_token_mismatch: token differs from the one bound at verification |
The key changed since verification. Re-verify (§5.1), then republish. |
404 |
POST /pairs/{id}/response |
The pair no longer exists — the counterparty (or this user) republished | Expected. Drop the work item, continue with the rest, no error to the user. |
409 |
POST /pairs/{id}/response |
already responded or pair already compared |
Treat as success. It means a previous attempt landed. Do not retry. |
409 |
POST /auth/register |
Username taken | Surface to the user. |
422 |
PUT /me/contact-set |
Invalid base64, non-canonical/identity point, or duplicate points | A client bug. Do not retry with the same payload; log and surface. |
422 |
POST /pairs/{id}/response |
Element count differs from the other side’s set | A client bug (the work item was filtered or reordered) or a lost race. Drop the item and re-sync. |
5xx, transport |
any | Server or network fault | Back off per §6.2. Never drop local state in response. |
Responses are safe to retry only where stated: PUT endpoints are idempotent by
design (a repeated contact-set publish creates a new version, so retry only on
transport failure, never speculatively), and POST /pairs/{id}/response is made
idempotent by the 409 rule above.
9. Sequence
Section titled “9. Sequence” Device A Server Device B | | | |-- POST /auth/register ---->| | |<-- access JWT + refresh ---| | | cookie | | |-- POST /me/phone/start --->| SMS code; keeps H(ownA) 10m | | T = H(ownA)·kA, K = kA·G | | | DLEQ proof (c, s) | | |-- POST /me/phone/verify -->| check code + proof; store | |<-- {verified_at} ----------| hash(ownA), T, K; drop H | |-- PUT /me/profile -------->| (drops A's pairs, runs matcher) | | | | build canonical set C | | | e[i] = H(C[i])·kA | | | own = T (must match) | | |-- PUT /me/contact-set ---->| store v1; drop A's pairs; | |<-- {version, count} -------| run matcher | | persist index+digest+fp | | | |<---- PUT /me/contact-set ---| | | matcher: A & B mutually | | | compatible -> Pair(id=P, | | | state=awaiting_both) | | | | |-- GET /sync -------------->| | |<-- work[{P, B's elements, | | | B's own_token}] --| | | r[i] = elements[i]·kA | | | dc = own_token·kA | | |-- POST /pairs/P/response ->| store; state=awaiting_one | |<-- 204 --------------------| | |-- GET /sync -------------->| (follow-up; no match yet) | | | | | |<---------- GET /sync -------| | |--- work[{P, A's elements, | | | A's own_token}] -->| | |<-- POST /pairs/P/response --| | | compare_pair inline: | | | equal 32-byte values | | | -> mutual_count, indices | | | direct_check hit? | | | -> suppress | | | state=compared | | |----------- 204 ------------>| | |<---------- GET /sync -------| | |-- matches[{my_matched_...}] >| |-- GET /sync (next tick) -->| | |<-- matches[{P, indices}] --| | | names = index[i] for i | |10. Conformance checklist
Section titled “10. Conformance checklist”A new client is conformant when all of the following hold:
- Runs
vectors/protocol_vectors.jsonin its own test suite, green. - Emits padded standard base64; rejects nothing the server accepts.
- Builds the published set per §4.1 (normalise, drop own, de-duplicate, sort, cap at 5000) and pads it per §4.3 with padding after the real elements.
- Persists
index,version,digest,key_fingerprintatomically after a successful publish, and only then. - Publishes only when the digest or key fingerprint changed, with the debounce and floor from §5.3.
- Refuses to process work when the key fingerprint does not match the published set (§7.1).
- Preserves work-item order exactly in the round-2 response.
- Derives
direct_checkfrom the work item’sown_token. - Performs exactly one follow-up
GET /syncafter posting responses. - Implements the trigger list and constants in §6, including single-flight and jittered backoff.
- Treats
409on a pair response as success and404as an expected drop. - Discards cached matches when
published_versionchanges. - Keeps the device key in platform-secure storage and out of every log, backup and telemetry sink.
Related
Section titled “Related”- The matching protocol — why the cryptography works.
- See the matching algorithm work — the same protocol run step by step on live bytes.
- API Reference — exact request and response schemas.
- Configuration — the server-side knobs referenced
here (
VOUCH_MATCHER_INTERVAL_SECONDS).