For the complete documentation index, see llms.txt. This page is also available as Markdown.

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) — the ContactMessage carries the initiator's ML-KEM-768 encapsulation key and ECIES public key directly in the contact payload. Single round-trip: ContactMessage (out-of-band) → PairRequestMessagePairResponseMessage. Use when the out-of-band channel can carry ~1.2 KB (NFC, deep links, direct messaging).

  • HashedKeys — the ContactMessage carries only a 48-byte SHA-384 commitment (contactBindingHash) to the keys instead of the keys themselves. The scanner fetches the real keys via a plaintext PrePairRequestMessage / PrePairResponseMessage round-trip before sending the PairRequestMessage, 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);

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:

  1. Initiator calls create_contact with contact_mode = HashedKeys. The library generates the ML-KEM-768 + ECIES key pair, computes

    and emits a ContactMessage carrying only contactBindingHash (no mlkemEncapsulationKey, no eciesPublicKey).

  2. The ContactMessage is delivered out-of-band as in InlineKeys mode (e.g. QR code).

  3. The scanner decodes the ContactMessage, sees contactMode == HASHED_KEYS, and issues a plaintext PrePairRequestMessage over 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.

  4. The initiator receives the PrePairRequestMessage, validates the echoed nonce against the original contact, and replies with a PrePairResponseMessage carrying the actual mlkemEncapsulationKey and eciesPublicKey.

  5. The scanner recomputes the SHA-384 binding hash over the keys it just received (using the nonce and channelId from the original ContactMessage) and compares against contactBindingHash. On mismatch, the scanner aborts and the library surfaces Error::Pairing(PairingError::PrePairHashMismatch). On match, the scanner now holds the same keys it would have received under InlineKeys.

  6. The scanner continues with the normal PairRequestMessage / PairResponseMessage round-trip exactly as in the InlineKeys sub-flow.

Fingerprint cross-confirmation

Fingerprint cross-confirmation applies to replica pairings only (ReplicaSourceReplicaDestination). 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 on Pending channels.

  • 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 and Ok(false) on mismatch. On mismatch the channel stays Pending and the application may reject the pairing.

  • On match, the orchestrator transitions the channel PendingPaired and, for ReplicaSource channels, auto-publishes the current Secret snapshot to the newly-confirmed peer.

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.

The two paths use the same wire envelope (StoreShareRequestMessage) but a different shareAlgorithm discriminator:

  • shareAlgorithm = 0 → the share field carries one committed share of the Secret for a Helper.

  • shareAlgorithm = 1 → the share field 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 VerifyShareRequestMessage via verification::request::produce, the library mints a fresh u64 nonce, embeds it in the encrypted request, and surfaces it back through ProduceResult.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 VerifyShareResponseMessage echoes secret_id, version, and nonce from the request, plus the SHA-384 proof hash = SHA-384(stored_share || nonce_be).

  • verification::response::process enforces the binding before the hash check. The library validates a response by calling verification::response::process(request, response, share_content). Before any cryptographic work happens, process checks each of response.nonce, response.secret_id, and response.version against the owner-retained request and returns Error::Verification(VerificationError::ResponseBindingMismatch { field, expected, got }) on the first disagreement — field is 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-supplied response.nonce.

  • The orchestrator owns the retention. DeRecProtocol keeps a pending_verification: HashMap<ChannelId, VerifyShareRequestMessage> map keyed by channel_id. start(VerifyShares) inserts the outstanding challenge before sending the request envelope; on_response removes it and feeds the recovered VerifyShareRequestMessage to verification::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 as DeRecEvent::NoOp rather than processed. Re-issuing start(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::produce and verification::response::process themselves (e.g. SDK parity tests, custom integrations that bypass the orchestrator) MUST retain the original VerifyShareRequestMessage and pass it back to process when the matching response arrives. The cryptographic contract is identical to the orchestrator path; only the retention machinery differs. See ProduceResult.nonce for the retention story.

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

Under 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 carrying timestamp plus optional replyTo for one-shot endpoint override.

  • GetSecretIdsVersionsResponseMessage — carries result, secretList (a list of VersionList entries, each with a secretId and a list of VersionEntry values pairing version, versionDescription, and optional replicaId), and timestamp.

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:

  1. Call protocol.set_communication_info(new_map) and/or protocol.set_own_transport(new_uri) to mutate the local node's state.

  2. 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

When 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:

  1. Bring up the new endpoint and start listening on it before calling start(UpdateChannelInfo).

  2. 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.

  3. Retire the old endpoint only once every targeted peer has surfaced DeRecEvent::ChannelInfoUpdated (or DeRecEvent::ChannelInfoUpdateRejected), plus a grace window for in-flight traffic.

Wire messages

UpdateChannelInfo uses two messages, both fully specified in the Messages section:

  • UpdateChannelInfoRequestMessage — carries optional communicationInfo and optional transportProtocol (at least one MUST be present), plus timestamp.

  • UpdateChannelInfoResponseMessage — carries result and timestamp.

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