Flows
This page captures the core DeRec sub-protocol flows: Pairing, Sharing, Verification, Discovery, Recovery, Unpair, and UpdateChannelInfo.
Pairing
Pairing starts with an out-of-band transfer of a ContactMessage (channel id, transport URI, nonce). The initiator then sends PairRequestMessage and receives PairResponseMessage; after this, future messages for that secretId can be both decrypted and signature-verified upon receipt.
Contact-delivery modes
DeRec pairing supports two contact-delivery modes, both arriving via the same out-of-band channel and selected by the contactMode field on ContactMessage:
InlineKeys(default) — theContactMessagecarries the initiator's ML-KEM-768 encapsulation key and ECIES public key directly in the contact payload. Single round-trip:ContactMessage(out-of-band) →PairRequestMessage→PairResponseMessage. Use when the out-of-band channel can carry ~1.2 KB (NFC, deep links, direct messaging).HashedKeys— theContactMessagecarries only a 48-byte SHA-384 commitment (contactBindingHash) to the keys instead of the keys themselves. The scanner fetches the real keys via a plaintextPrePairRequestMessage/PrePairResponseMessageround-trip before sending thePairRequestMessage, then verifies the published keys against the contact's binding hash. Use when the out-of-band channel is size-constrained (QR codes).
After either sub-flow completes the cryptographic handshake, an Owner ↔ Helper channel is immediately ready for protocol traffic (stored as ChannelStatus::Paired). A replica channel (ReplicaSource / ReplicaDestination), on the other hand, is stored as ChannelStatus::Pending and cannot carry protocol traffic until the fingerprint cross-confirmation step below succeeds.
InlineKeys sub-flow
As an example of how an Out-of-band ContactMessage transfer could be achieved, after creating the ContactMessage, it can be base64 encoded and use that output to create a QR code that is scanned by counter-party's app.
use prost::Message;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use derec_library::pairing::*;
use derec_library::types::ChannelId;
let channel_id = ChannelId(42);
let transport_uri = "https://relay.example/v1/ws/abc";
let CreateContactMessageResult {
wire_bytes,
secret_key: _,
} = create_contact_message(channel_id, transport_uri)
.expect("failed to create contact message");
// Encode for transport (QR-friendly)
let qr_payload = URL_SAFE_NO_PAD.encode(wire_bytes);
println!("QR payload:\n{}", qr_payload);The secret_key returned by create_contact_message and produce_pairing_request_message must never leave Initiator's and Responder's devices and must kept until pairing completes. After that they can be safely discarded.
HashedKeys sub-flow
In HashedKeys mode the ContactMessage is small enough to fit in a QR code: it carries a 48-byte SHA-384 commitment to the keys instead of the keys themselves. The scanner retrieves the real public keys with a plaintext PrePairRequest / PrePairResponse exchange, verifies them against the commitment, and only then proceeds with the normal PairRequest / PairResponse round-trip.
Steps:
Initiator calls
create_contactwithcontact_mode = HashedKeys. The library generates the ML-KEM-768 + ECIES key pair, computesand emits a
ContactMessagecarrying onlycontactBindingHash(nomlkemEncapsulationKey, noeciesPublicKey).The
ContactMessageis delivered out-of-band as inInlineKeysmode (e.g. QR code).The scanner decodes the
ContactMessage, seescontactMode == HASHED_KEYS, and issues a plaintextPrePairRequestMessageover the transport endpoint advertised in the contact. The PrePair leg is plaintext because no shared key exists yet, and the keys it asks for cannot themselves be used for encryption.The initiator receives the
PrePairRequestMessage, validates the echoed nonce against the original contact, and replies with aPrePairResponseMessagecarrying the actualmlkemEncapsulationKeyandeciesPublicKey.The scanner recomputes the SHA-384 binding hash over the keys it just received (using the
nonceandchannelIdfrom the originalContactMessage) and compares againstcontactBindingHash. On mismatch, the scanner aborts and the library surfacesError::Pairing(PairingError::PrePairHashMismatch). On match, the scanner now holds the same keys it would have received underInlineKeys.The scanner continues with the normal
PairRequestMessage/PairResponseMessageround-trip exactly as in theInlineKeyssub-flow.
PrePairRequestMessage and PrePairResponseMessage travel as plaintext inside the DeRecMessage envelope — no shared key exists yet when they are exchanged. The transport endpoint advertised in a HashedKeys ContactMessage MUST therefore be ephemeral: used only for this one PrePair exchange and then retired (via UpdateChannelInfoRequestMessage) once pairing completes. Reusing the endpoint for later traffic lets a passive observer correlate the now-published public keys with a long-lived peer endpoint.
The binding hash is what stops a man-in-the-middle from substituting different keys during the PrePair leg. Without the hash check, HashedKeys would be insecure; with it, the out-of-band channel only needs to carry the 48-byte commitment integrity-protected.
Fingerprint cross-confirmation
Fingerprint cross-confirmation applies to replica pairings only (ReplicaSource ↔ ReplicaDestination). The orchestrator does not gate Owner ↔ Helper channels on it and offers no built-in mechanism for the application to enforce it on those channels either — see the note at the end of this section for the rationale.
The cryptographic handshake produces a SharedKey but does not by itself authenticate the peer against any out-of-band identity. On a replica channel, the risk is severe: a successfully-handshaken peer receives the full Secret on the first sync round, so accepting a man-in-the-middle at that point hands the entire Secret to an unknown party. The orchestrator closes this gap by holding replica channels in ChannelStatus::Pending (silently dropping protocol traffic on them) until both sides confirm out-of-band that they share the same channel key.
After the handshake, replica channels are stored with
ChannelStatus::Pending. The orchestrator silently drops protocol traffic onPendingchannels.Each side independently computes a deterministic short fingerprint over the channel's shared key. Both sides will arrive at the same fingerprint string (e.g.
"1234-5678-9012-3456").The application surfaces its locally-computed fingerprint to the user via
protocol.get_fingerprint(channel_id)and presents it for human comparison against the peer's fingerprint — side-by-side on two devices, or relayed via voice / video / in-person.After the user confirms a match, each side calls
protocol.verify_fingerprint(channel_id, peer_fingerprint)with the fingerprint string it observed on the peer.The orchestrator recomputes the local fingerprint and constant-time-compares it against the supplied value, returning
Ok(true)on match andOk(false)on mismatch. On mismatch the channel staysPendingand the application may reject the pairing.On match, the orchestrator transitions the channel
Pending→Pairedand, forReplicaSourcechannels, auto-publishes the current Secret snapshot to the newly-confirmed peer.
For replica pairings, fingerprint cross-confirmation is the only defence against an active man-in-the-middle during the cryptographic handshake. Skipping it means trusting whatever peer completed the handshake — including a MITM who may have intercepted both sides of the contact exchange and run two separate handshakes against the two endpoints. Because a replica receives the full Secret on the first sync, accepting an unverified peer is equivalent to handing the entire Secret to that party. The Pending gate is what makes this safe: a MITM-stalled channel never leaves Pending and therefore never receives a Secret sync.
Why Owner ↔ Helper channels skip this gate. On the Helper side, the threshold-of-shares assumption already bounds the damage a single rogue Helper (or a MITM impersonating a Helper) can do — no fewer than t colluding peers can reconstruct anything. The protocol does not enforce fingerprint verification on those channels and does not provide a mechanism for the application to gate protocol traffic on it — the orchestrator processes Owner ↔ Helper messages as soon as the channel is Paired, which happens immediately at handshake completion. Applications that want out-of-band identity confirmation for a Helper have to do it before or around the pairing at the application layer; the protocol does not expose a hook for it.
Sharing
Once Owner and Helpers are paired, the Owner splits the current Secret into shares using a threshold secret-sharing scheme and distributes one committed share to each Helper. The same flow also fans out the full Secret to every paired Replica so the Owner identity stays synchronized across all its devices.
Each receiver (Helper or Replica) MUST verify the validity of the sender's message with the per-channel Shared Key established at pairing.
The two paths use the same wire envelope (StoreShareRequestMessage) but a different shareAlgorithm discriminator:
shareAlgorithm = 0→ thesharefield carries one committed share of the Secret for a Helper.shareAlgorithm = 1→ thesharefield carries the encoded full Secret for a Replica.
See the StoreShareRequestMessage section in Messages for the field details and the Secret payload for the structure delivered to Replicas.
Verification
Verification is a challenge-response protocol used by the Owner to check that a Helper retains the correct share. The Owner sends a random nonce; the Helper responds with a hash computed over the share contents and the nonce.
If verification fails, the Owner MUST initiate the Verification Remediation Flow (see subsection below), which resends the correct share up to N times and re-verifies after each resend.
Binding to the outstanding request
The challenge-response hash check above is not, on its own, enough to defeat replay: a VerifyShareResponseMessage is internally self-consistent — its nonce and hash are wire fields a captured envelope already carries — so a structurally well-formed response can be replayed against the Owner and still pass an isolated hash check. The library closes that gap by binding every inbound response back to the specific outstanding VerifyShareRequestMessage the Owner sent.
The Owner-side nonce is the binding anchor. When the Owner produces a
VerifyShareRequestMessageviaverification::request::produce, the library mints a freshu64nonce, embeds it in the encrypted request, and surfaces it back throughProduceResult.nonce. The Owner retains(secret_id, version, nonce)alongside the outbound request so the nonce can be re-presented when the matching response arrives.The Helper's response carries the same triple. A well-formed
VerifyShareResponseMessageechoessecret_id,version, andnoncefrom the request, plus the SHA-384 proofhash = SHA-384(stored_share || nonce_be).verification::response::processenforces the binding before the hash check. The library validates a response by callingverification::response::process(request, response, share_content). Before any cryptographic work happens,processchecks each ofresponse.nonce,response.secret_id, andresponse.versionagainst the owner-retainedrequestand returnsError::Verification(VerificationError::ResponseBindingMismatch { field, expected, got })on the first disagreement —fieldis one of"nonce","secret_id","version". Only after the binding check passes does the SHA-384 computation run, and the digest is recomputed using the owner's nonce (request.nonce), not the helper-suppliedresponse.nonce.The orchestrator owns the retention.
DeRecProtocolkeeps apending_verification: HashMap<ChannelId, VerifyShareRequestMessage>map keyed bychannel_id.start(VerifyShares)inserts the outstanding challenge before sending the request envelope;on_responseremoves it and feeds the recoveredVerifyShareRequestMessagetoverification::response::process. Application code driving the orchestrator never touches this map.Replay arrives as
NoOp. A response with no matching outstanding entry — because the entry was already consumed by an earlier response, or because no request was ever sent on this channel — is dropped silently asDeRecEvent::NoOprather than processed. Re-issuingstart(VerifyShares)for the same channel overwrites any in-flight challenge; the newer nonce wins, and a response carrying the older nonce is rejected by the binding check on arrival.Primitive-direct callers. Applications that drive
verification::request::produceandverification::response::processthemselves (e.g. SDK parity tests, custom integrations that bypass the orchestrator) MUST retain the originalVerifyShareRequestMessageand pass it back toprocesswhen the matching response arrives. The cryptographic contract is identical to the orchestrator path; only the retention machinery differs. SeeProduceResult.noncefor the retention story.
The hash is recomputed against the owner's nonce (request.nonce), not the helper-supplied response.nonce. The binding gate guarantees the two are equal at the point the hash runs, but the digest is read from request explicitly — that is what prevents replay of a captured response. A helper (or an attacker holding a captured envelope) that crafts a self-consistent (nonce, hash) pair from a different share_content cannot satisfy a challenge it didn't actually receive, because the Owner will hash against a nonce the attacker never observed.
Discovery
Discovery is owner-initiated: the Owner asks one or more paired Helpers to enumerate the (secretId, version, replicaId) tuples they currently hold for this Owner. The Helper replies with the catalog grouped by secretId, and each version is paired with the human-readable versionDescription and the replicaId of the writer that produced it.
Preconditions: a confirmed Owner ↔ Helper channel must already exist. Discovery does not require recovery mode — it works against any paired Helper. The Owner role initiates; the Helper role accepts and responds. Reject mode is supported: the Helper application may refuse to disclose its catalog, in which case it replies with a non-Ok response and no secretList content.
Discovery is most commonly the precursor an Owner uses to drive recovery — after re-pairing in recovery mode the Owner has no local record of what each Helper holds and must ask. But the flow is general-purpose: any time the Owner wants an inventory of a Helper's stored shares (routine audit, post-replica reconciliation, drift detection) it can run Discovery against an already-paired channel.
Why per-version replicaId
replicaIdUnder the replica model multiple ReplicaSource instances can independently bump the same secretId.version — each replica observes the current latest version and increments it locally. The Helper persists each (secretId, channelId, version, replicaId) tuple side-by-side rather than collapsing them. Discovery surfaces these as distinct entries in the catalog: two entries with the same version but different replicaId for the same secretId are the conflict-visibility surface the recovering Owner / application uses to detect concurrent writes from multiple replicas before driving recovery. Resolution of duplicates is the application's responsibility — typically: pick one, then re-run ProtectSecret with the chosen value to produce a fresh, uncontested version.
Wire messages
Discovery uses two messages, both fully specified in the Messages section:
GetSecretIdsVersionsRequestMessage— essentially a ping carryingtimestampplus optionalreplyTofor one-shot endpoint override.GetSecretIdsVersionsResponseMessage— carriesresult,secretList(a list ofVersionListentries, each with asecretIdand a list ofVersionEntryvalues pairingversion,versionDescription, and optionalreplicaId), andtimestamp.
See the field tables under GetSecretIdsVersionsRequestMessage / GetSecretIdsVersionsResponseMessage in Messages for the exact field shapes; they are not duplicated here.
Event surface
On the Helper side, the application sees an ActionRequired event carrying PendingAction::Discovery. Calling accept builds the response from the Helper's ShareStore (grouping the live (secretId, version, replicaId) tuples across all channels linked to this Owner) and sends it back. Calling reject sends a non-Ok response and discloses no catalog content.
On the Owner side, a successful response surfaces as DeRecEvent::SecretsDiscovered { channel_id, secrets } — one SecretVersionEntry per secretId, each containing the (version, description, replicaId) tuples reported by that Helper.
Recovery
During recovery, the Owner pairs with Helpers again in “recovery mode” (pairing is the same mechanism; software may treat it differently). Once paired in recovery mode, the Owner can request the list of secret_ids and versions held by the Helper for that Owner, then request specific shares.
Unpair
Unpair is a request to end the pairing relationship and delete relationship data subject to external constraints (regulation/service agreement).
UpdateChannelInfo
UpdateChannelInfo is a maintenance flow that lets either party of an already-paired channel broadcast updated communicationInfo and/or transportProtocol to one or more paired peers, without re-pairing. The most common use case is rotating to a new transport endpoint while keeping the channel — and its established shared key — intact.
Either field on UpdateChannelInfoRequestMessage may be omitted; at least one MUST be present. Presence of communicationInfo — even with an empty entries map — instructs the peer to replace its stored map for this channel with the supplied one (so updates, additions, and deletions are all expressed by the difference between the receiver's current map and the supplied one). Presence of transportProtocol instructs the peer to use the new endpoint for the response and all subsequent messages on this channel.
Symmetry
This is the only DeRec flow where either party may initiate. All pairing kinds — Owner ↔ Helper, Owner ↔ Replica, and Replica ↔ Replica — may use it, and the protocol does not enforce a role gate. Every other flow (Sharing, Verification, Discovery, Recovery, Unpair) is initiated by the Owner identity; UpdateChannelInfo is the maintenance side-channel that breaks that rule because either side's contact metadata or transport endpoint may legitimately change.
Local-state update precondition (initiator)
The initiator MUST update its own local state before broadcasting the change, otherwise the local node and its peers will disagree about what communicationInfo / transportProtocol describe this node:
Call
protocol.set_communication_info(new_map)and/orprotocol.set_own_transport(new_uri)to mutate the local node's state.Then call
protocol.start(DeRecFlow::UpdateChannelInfo { target, communication_info, transport_protocol })to announce the change to the targeted peers.
Skipping step 1 leaves the local state inconsistent with what the peers are told.
Receiver-side accept does the mutation
accept does the mutationWhen the application calls protocol.accept(action) for a PendingAction::UpdateChannelInfo, the orchestrator writes the new fields onto the receiver's stored Channel record automatically. When transportProtocol is part of the update, the orchestrator routes the Ok response to the new endpoint, so subsequent outbound traffic on this channel already targets the new address. Calling protocol.reject(action) instead sends a non-Ok response and leaves the receiver's stored channel state unchanged.
Setter usage is asymmetric. The initiator calls set_communication_info / set_own_transport before broadcasting; the receiver does NOT call any setter — accept performs the mutation on the stored channel. The local-node setters exist for the initiating side only.
Endpoint-changeover discipline
When broadcasting a transportProtocol update, each receiving peer routes its response (and all subsequent messages) to the NEW endpoint. The initiator MUST therefore:
Bring up the new endpoint and start listening on it before calling
start(UpdateChannelInfo).Keep the old endpoint operational during the changeover. Peers that have not yet processed the update still route to the old address; in-flight messages may also be targeted there.
Retire the old endpoint only once every targeted peer has surfaced
DeRecEvent::ChannelInfoUpdated(orDeRecEvent::ChannelInfoUpdateRejected), plus a grace window for in-flight traffic.
Failing to keep both endpoints reachable through the changeover window will cause messages to be lost. Peers that have not yet processed the UpdateChannelInfo request still route to the old address, and in-flight messages from any peer may also be targeted there. The new endpoint MUST be listening before the broadcast, and the old endpoint MUST remain live until every targeted peer has surfaced ChannelInfoUpdated / ChannelInfoUpdateRejected plus a grace window. This is the highest-risk failure mode of this flow.
Wire messages
UpdateChannelInfo uses two messages, both fully specified in the Messages section:
UpdateChannelInfoRequestMessage— carries optionalcommunicationInfoand optionaltransportProtocol(at least one MUST be present), plustimestamp.UpdateChannelInfoResponseMessage— carriesresultandtimestamp.
See the field tables under UpdateChannelInfoRequestMessage / UpdateChannelInfoResponseMessage in Messages for the exact field shapes; they are not duplicated here.
Event surface
On the receiver side, the application sees DeRecEvent::ActionRequired { action: PendingAction::UpdateChannelInfo { channel_id, request, shared_key, trace_id } }. Calling accept persists the new fields onto the stored channel and replies Ok (routed to the new endpoint when transportProtocol was part of the update); calling reject replies non-Ok and leaves stored state unchanged.
On the initiator side, the peer's Ok response surfaces as DeRecEvent::ChannelInfoUpdated { channel_id }. A non-Ok response surfaces as DeRecEvent::ChannelInfoUpdateRejected { channel_id, status, memo }, where status is the peer's StatusEnum and memo is the peer's human-readable reason. ChannelInfoUpdated also fires on the receiver side once accept has persisted the update — so applications observing either end can treat the event as "this channel's stored metadata is now current."
Last updated