DeRec Rust SDK
Rust implementation of the DeRec protocol — split a secret into shares, distribute them across independent helpers, verify possession over time, and reconstruct the secret from a threshold of shares when needed. The library is transport- and storage-agnostic, supports both native Rust and WebAssembly targets, and is the reference implementation maintained by the DeRec Alliance.
[!WARNING] This is a pre-release version. APIs may change until 0.1.0.
Contents
- What is DeRec?
- Installation
- Cargo features
- Two API layers
- Quick start (Protocol layer)
- Builder configuration
- Event-driven model
- Protocol flows
- Replica flows
- Storage and transport traits
- Errors
- Async and executors
- WebAssembly support
- Transport layer
- Observability
- Primitives (advanced)
- Protocol specification
- Security considerations
- License
- Contributing
- DeRec Alliance
What is DeRec?
DeRec is a threshold secret-sharing protocol for decentralized recovery. The Owner splits a secret using Verifiable Secret Sharing, distributes the shares across trusted Helpers, and can later reconstruct the secret from any threshold-sized subset of shares.
Three roles participate:
- Owner — the party that wants to protect a secret. Splits the secret, distributes shares, verifies that helpers still hold them, and drives recovery when needed.
- Helper — a trusted entity that stores one share. Responds to verification challenges, returns the share during recovery, and acknowledges unpair requests.
- Replica — a second device belonging to the same Owner that mirrors
the Source's secret. Replica pairings are unidirectional, just like
Owner↔Helper: one side scans as
SenderKind::ReplicaSource(writes the secret), the other asSenderKind::ReplicaDestination(receives it). Pairing is confirmed out of band with a human-readable fingerprint (DeRecProtocol::get_fingerprint/verify_fingerprint), thenProtectSecretdistributes the full secret (helpers + secrets + replicas +owner_replica_id) to every Destination via aReplicaSecretPayload. Destinations surface the typedDeRecEvent::ReplicaSecretReceived. Configure viaDeRecProtocolBuilder::with_replica_id.
The protocol is transport-agnostic and produces wire-compatible protobuf messages; the library never assumes a specific delivery channel.
Typical applications: cryptocurrency wallets, digital identity, secure backup, key management.
Installation
The badge above shows the latest published version.
Cargo features
Every feature is off by default — a default build compiles no serde, no FFI layer, and no logging, so a pure-Rust consumer pays for none of them.
| Feature | Enables |
|---|---|
serde |
serde::Serialize / Deserialize on the public store and channel types (SecretValue, PairingKeyMaterial, Channel, ChannelStatus, TransportProtocol), so a store implementation can persist them with any serde format. Without it, use the byte-level accessors (e.g. PairingKeyMaterial::as_bytes / from_bytes) or your own codec. Also pulls the matching derec-proto/serde. The serde wire format is not part of the public API and may change. |
logging |
tracing spans and events across the protocol and primitives layers. Adds no overhead when no subscriber is installed. |
ffi |
The native C-ABI bridge for host languages that link the shared library. Implies serde + serde_json. Pure-Rust consumers do not need this. |
unsafe-http |
Development only. Lets TransportProtocol::validate accept plaintext http:// endpoints. Production builds MUST leave this off — enabling it also lets a peer-supplied replyTo downgrade the reply path to plaintext. |
[]
= { = "*", = ["serde"] }
The wasm32 target pulls serde in automatically, so WebAssembly builds need
no extra feature flags.
Two API layers
The library exposes two surfaces:
-
Protocol layer (
derec_library::protocol) — start here. A stateful orchestrator (DeRecProtocol) that owns your storage/transport implementations, manages session state, and drives every flow through a singlestart/process/accept/rejectsurface. ReturnsDeRecEventvalues the application reacts to. -
Primitives layer (
derec_library::primitives) — the lower-level surface: one module per flow exposingproduce/extract/processfunctions that build and decode individual protocol messages. Use this only when the protocol layer cannot fit (e.g. you are implementing your own orchestrator, integrating into a system whose event loop you don't control, or need to manipulate messages directly).
Most consumers should only use the protocol layer. The primitives section at the bottom of this README is provided for the cases that need it.
Quick start (Protocol layer)
Three steps: implement the storage and transport traits for your environment,
build a DeRecProtocol, drive it with start / process.
use ;
use ;
// 1. Implement the four storage/transport traits for your environment.
// See the trait docs:
// - DeRecChannelStore — paired channels
// - DeRecShareStore — secret shares
// - DeRecSecretStore — per-channel key material (sensitive)
// - DeRecTransport — outbound message delivery
let my_channel_store = /* ... */;
let my_share_store = /* ... */;
let my_secret_store = /* ... */;
let my_transport = /* ... */;
// 2. Build a protocol instance.
let mut protocol = new
.with_channel_store
.with_share_store
.with_secret_store
.with_transport
.with_own_transport
// Optional setters — see "Builder configuration" below.
.build?;
// 3. Drive flows.
//
// `start` initiates an outbound flow (pairing, sharing, recovery, …).
// `process` feeds incoming wire bytes and returns events.
// `accept` / `reject` resolve `ActionRequired` events the app must confirm.
// Initiate a pairing flow from a contact message received out-of-band.
// `start` returns per-target `*Started` / `*Failed` events describing
// what was dispatched — for pairing that's one `PairingStarted` with
// the fresh `channel_id`. Fan-out flows (ProtectSecret, VerifyShares,
// RecoverSecret, Discovery, UpdateChannelInfo) return one event per
// targeted channel. Follow-up events (`PairingCompleted`,
// `ShareConfirmed`, `SecretRecovered`, …) still surface from
// `process()` on the peer's response.
for event in protocol
.start
.await?
// Inbound message processing loop:
loop
A complete working example lives at bindings/rust/src/protocol.rs in the
repository.
Builder configuration
DeRecProtocolBuilder enforces required-slot completion at compile time
(missing a required setter is a type error, not a runtime panic). Runtime
invariants — currently threshold >= 2 — are checked inside
build(),
which returns Result<DeRecProtocol, crate::Error> so invalid
configurations surface as Error::InvalidInput instead of panicking.
Optional setters have defaults:
| Setter | Default | Purpose |
|---|---|---|
with_threshold(n) |
3 |
Minimum shares required to reconstruct the secret. |
with_keep_versions_count(n) |
3 |
Number of recent versions each helper must retain. |
with_timeout(duration) |
5 minutes |
Staleness boundary for inbound envelopes and pending state. One-second granularity. |
with_communication_info(map) |
empty | Key-value identity metadata embedded in pairing messages. |
with_auto_respond_on_failure(bool) |
false |
If true, the protocol replies to the peer on inbound processing failures; if false, errors only surface as events. |
with_unpair_ack(ack) |
UnpairAck::Required |
Whether the unpair initiator waits for the peer's Ok before dropping local state. |
with_auto_reply_to(bool) |
false |
If true, every outbound request stamps replyTo = own_transport so the responder routes the reply back to this node — even if the channel's stored peer endpoint points elsewhere. Useful for replica scenarios. See Correlation and routing on the wire. |
See the builder rustdoc for the full per-setter contract.
Event-driven model
Both start and process return Vec<DeRecEvent>. The application
reacts to events; the protocol owns the state.
start emits per-target dispatch events describing what was just
sent — PairingStarted, DiscoveryStarted, ProtectSecretStarted,
VerifySharesStarted, RecoverSecretStarted, UnpairStarted,
UpdateChannelInfoStarted. Fan-out flows also surface per-target
failures with the matching *Failed { channel_id, error } variant when
a single target's send hits a transport / store error — those failures
don't short-circuit the rest of the fan-out. Single-channel flows
(Pairing, Unpair) don't have *Failed variants; a dispatch error
returns Err from start instead. Programmer errors (invalid input,
missing preconditions, role mismatch) always return Err up-front,
before any target-level events are emitted.
process emits the settlement events driven by peer responses:
ActionRequired { channel_id, action }— an incoming request needs application confirmation. The app callsprotocol.accept(action)orprotocol.reject(action, status, memo)to complete the flow.PairingCompleted { channel_id, kind, peer_communication_info }ShareStored { channel_id, version }/ShareConfirmed { … }/ShareRejected { … }/SharingComplete { … }ShareVerified { channel_id, version }SecretsDiscovered { channel_id, secrets }RecoveryShareReceived { … }/SecretRecovered { secret: Secret }(typed;secret.secretsis the list ofUserSecretthe owner protected, alongside the captured helper/replica roster) /RecoveryShareError { … }Unpaired { channel_id }/UnpairRejected { channel_id, status, memo }ChannelInfoUpdated { channel_id }/ChannelInfoUpdateRejected { channel_id, status, memo }PrePairRejected { channel_id, status, memo }— the contact creator answered ourPrePairRequestwith a non-Okstatus (scanner side,HashedKeysflow). Distinct from a cryptographic binding-hash mismatch, which surfaces asError::Pairing(PairingError::PrePairHashMismatch)fromprocess()rather than as an event. See Pairing modes.NoOp— emitted when an inbound message was processed but had no application-visible consequence.
See DeRecEvent
for the complete enum and per-variant docs.
Channel roles
Each paired channel carries the local node's role — SenderKind::Owner,
SenderKind::Helper, SenderKind::ReplicaSource, or
SenderKind::ReplicaDestination — fixed at pairing time and stored on
Channel.role.
The orchestrator enforces flow directionality against this value:
- Outbound:
ProtectSecret,VerifyShares,Discovery,RecoverSecret, andUnpairrequire the local role to beOwner(orReplicaSourceforProtectSecret) on every targeted channel. - Inbound: a
StoreShareRequest/VerifyShareRequest/GetSecretIdsVersionsRequest/GetShareRequest/UnpairRequestis only honored on a channel where the local role isHelper; the corresponding responses requireOwner. AStoreShareRequeston aReplicaDestinationchannel is decoded as aReplicaSecretPayloadand surfaces asDeRecEvent::ReplicaSecretReceived. UpdateChannelInfois symmetric — either side may initiate it, and the role is not consulted.
A mismatch surfaces as Error::RoleMismatch { channel_id, expected, actual }.
Protocol flows
| Flow | Purpose |
|---|---|
| Pairing | Establish a secure channel between any two SenderKinds (Owner↔Helper or ReplicaSource↔ReplicaDestination). Two contact modes — see Pairing modes. |
| Share Distribution | Split a secret and distribute the shares to helpers; replica destinations receive the full secret as a ReplicaSecretPayload. |
| Verification | Challenge helpers to prove they still hold their shares. |
| Discovery | Ask helpers which secrets and versions they store. |
| Recovery | Re-pair, collect shares, reconstruct the secret. |
| Restore | Commit a recovered Secret into an empty protocol — reseats canonical helper / replica channels at the recovered version and wipes the throwaway recovery-mode channels. Called once, after Recovery, via DeRecProtocol::restore. |
| Unpairing | Tear down a paired channel and drop local state. |
| Update channel info | Propagate post-pairing changes to communication info and/or transport endpoint. |
Pairing modes
DeRecProtocol::create_contact takes a ContactMode argument; the choice
travels in the ContactMessage and tells the scanner which handshake to run.
ContactMode |
Contact carries | When to use |
|---|---|---|
InlineKeys |
Full ML-KEM encapsulation key + ECIES public key | Out-of-band channel can carry ~1.2 KB (NFC, deep link, direct messaging). |
HashedKeys |
Only a 48-byte SHA-384 commitment to the keys | Out-of-band channel is size-constrained (QR codes). The scanner fetches the real keys over the wire via a plaintext PrePair round-trip and verifies them against the commitment. |
The library's primitives section below documents the InlineKeys
and HashedKeys message-level handshakes. At
the orchestrator layer, the choice is invisible to most application code
after create_contact — the difference surfaces only in the events the
two sides observe during the HashedKeys handshake:
- Contact creator (the side that called
create_contact): receives anActionRequired { action_kind: "PrePair" }event when the scanner'sPrePairRequestarrives. Callprotocol.accept(action)to publish the keys (the orchestrator builds the response and routes it back to the scanner's transport endpoint), orprotocol.reject(action, status, memo)to refuse. After accepting, the next inbound message is a regularPairRequest— itsActionRequiredevent is identical to theInlineKeyspath. - Scanner (the side that called
start(Pairing { contact, .. })):PrePairis silent on success. The library validates the published keys againstcontact.contact_binding_hash, synthesizes anInlineKeys-shaped contact, and auto-sends the regularPairRequest. The application seesPairingCompletedonly after thePairResponseround-trip lands. Failure modes:- Contact creator rejected →
DeRecEvent::PrePairRejected { channel_id, status, memo }. - Cryptographic binding-hash mismatch →
process()returnsErr(Error::Pairing(PairingError::PrePairHashMismatch)). This is a security-relevant signal — the keys published by the peer do not match the commitment the scanner originally accepted, so the contact may have been swapped or tampered between creation and scan.
- Contact creator rejected →
Security caveat — the PrePair envelopes cross the wire plaintext.
The TransportProtocol passed to create_contact (HashedKeys) MUST be
an ephemeral endpoint that an observer cannot link to a long-lived
identity. After pairing completes, propagate a long-term endpoint via
DeRecFlow::UpdateChannelInfo; retire the ephemeral one.
End-to-end smoke tests covering both happy path and tampered-hash:
- Rust orchestrator level —
bindings/rust/src/protocol.rs::run_hashed_keys_pairing_flow. - nodejs / web orchestrator level —
bindings/nodejs/protocol.ts::runHashedKeysPairingFlowandbindings/web/src/protocol.ts::runHashedKeysPairingFlow. - Primitives level —
bindings/{rust,dotnet,nodejs,web}/{primitives.*,Program.cs}cover the same flow without an orchestrator (the .NET binding is primitives-only).
Replica flows
Replicas mirror the Owner's secret onto a second device so the same secrets remain accessible after device loss without going through full helper-based recovery. The model is unidirectional, exactly like Owner↔Helper:
| SenderKind | Role on the pair |
|---|---|
ReplicaSource |
Owns the secret. Drives ProtectSecret and pushes updates. |
ReplicaDestination |
Receives the secret. Stores Secret + share map. |
Configuring replica identity
Replica pairings carry a stable per-device u64 id under the reserved
derec.replica_id key in CommunicationInfo. Pass it once at builder
time and the orchestrator handles injection / validation automatically:
let mut protocol = new
.with_replica_id
// ... other slots ...
.build?;
Attempting any replica-mode flow on a protocol built without
with_replica_id returns
Error::ReplicaIdNotConfigured.
The reserved derec.* CommunicationInfo namespace — including
derec.replica_id and its wire encoding — is documented in
protocol::reserved_keys.
Apps should not write to this namespace; the orchestrator strips and
re-injects entries at the protocol boundary.
Fingerprint confirmation
Replica channels start in ChannelStatus::Pending after the handshake
and are not eligible as ProtectSecret targets until both sides confirm
the pair out of band. The protocol derives a deterministic
human-readable fingerprint from the shared key — same on both
devices — that users compare visually before each side calls
verify_fingerprint:
let local = source.get_fingerprint.await?;
let peer = destination.get_fingerprint.await?; // out of band
source.verify_fingerprint.await?; // → true
destination.verify_fingerprint.await?; // → true
verify_fingerprint returns true on match and transitions the channel
to Paired. A mismatch returns false and leaves the channel Pending
so the app can retry.
Secret distribution
Once the destination is paired (status Paired), the source includes it
as a ProtectSecret target alongside helpers. The orchestrator routes
two payload shapes from the same start call:
- Helpers receive the usual VSS share via
StoreShareRequest. - Destinations receive a typed
ReplicaSecretPayload{ secret: Secret, shares: Vec<ChannelShare> }— the full secret plus every helper's share keyed bychannel_id. This is what lets a destination act in the source's place during recovery without re-running the share collection.
On the destination, the inbound envelope decodes into
DeRecEvent::ReplicaSecretReceived
with secret: Secret and shares: Vec<ChannelShare> already
parsed — the app just installs the secrets.
Correlation and routing on the wire
Two metadata fields cut across every channel-mode exchange:
traceId — request/response correlation
DeRecMessage.traceId is an opaque uint64 correlation token on the
plaintext outer envelope. The requester sets it; the responder echoes it
verbatim. The orchestrator (DeRecProtocol) handles both sides
automatically — every outbound request gets a fresh random token via
fresh_trace_id;
every response is stamped with the inbound trace id via
apply_trace_id.
Consumers driving the protocol through primitives (not the
orchestrator) get trace_id = 0 (the protobuf default) by default and can
opt in manually using the envelope helpers:
use ;
let outbound = apply_trace_id?;
// …send outbound…
let inbound_token = read_trace_id?;
// match `inbound_token` against `my_token` to correlate
The same helpers are surfaced as envelope.apply_trace_id /
envelope.read_trace_id on the nodejs/web packages and as
apply_trace_id_to_envelope / read_trace_id_from_envelope on the FFI
(plus a Envelope.ApplyTraceId / Envelope.ReadTraceId static class on
the dotnet package). The encrypted inner message is never touched, so
re-stamping has no crypto cost.
replyTo — ephemeral response routing
replyTo is an optional TransportProtocol on request bodies
(StoreShareRequest, VerifyShareRequest, GetSecretIdsVersionsRequest,
GetShareRequest, UnpairRequest). It tells the responder to route this
exchange's response to an alternate endpoint — without persisting it (use
UpdateChannelInfo for a permanent change).
The motivating case is replicas: when Replica A sends a request on a
channel the helper paired with sibling Replica B, the helper's stored peer
endpoint points at B. replyTo lets A say "send the response back to me,"
without rewriting channel state.
Two ways to set it:
- Per call (primitives): every
*::request::producetakes a trailingreply_to: Option<TransportProtocol>parameter. - Protocol-wide:
DeRecProtocolBuilder::with_auto_reply_to(true)makes the orchestrator stampreplyTo = own_transporton every outbound request automatically.
The responder honours an inbound replyTo regardless of how it was set —
the flag only governs outbound population. PairRequest,
PrePairRequest, and UpdateChannelInfoRequest are intentionally
excluded: each already carries its own transportProtocol field serving
the same role.
Storage and transport traits
The library does not ship a default backend for storage or transport.
Consumers implement four traits — the protocol holds them by &mut self, so
implementations need no internal synchronization:
| Trait | Stores |
|---|---|
DeRecChannelStore |
Paired channel records and the channel-link graph. |
DeRecShareStore |
Encoded share entries keyed by (channel_id, secret_id, version). |
DeRecSecretStore |
Per-channel cryptographic material (shared keys, pairing secrets, pairing contacts). |
DeRecTransport |
Outbound envelope delivery to peers. |
Each trait's rustdoc states its contract, idempotency expectations, and the
security classification of the data it holds (DeRecSecretStore content is
keychain-grade; the others need durable storage only).
Errors
Public errors are structured and typed:
Error— the library-level error type returned by most calls.ProcessError— wrapsErrorwith thechannel_idan inbound message was processed against (if known).ChannelStoreError,ShareStoreError,SecretStoreError— surfaced by storage trait implementations.
The protocol never panics on malformed input. Inbound parsing or decryption
failures surface as events (or as a typed Error::ProtobufDecode /
Error::DecryptionFailed etc.); see with_auto_respond_on_failure for
controlling whether such failures are replied to.
Async and executors
Storage and transport methods return type-erased futures
(SecretStoreFuture
and friends). No specific executor is prescribed:
- Native targets — futures are
Send, so they can be spawned on multi-threaded executors such astokio::spawn. ffifeature orwasm32target — theSendbound is dropped because the host runs single-threaded or callbacks cross an FFI boundary.
Sync backends can implement the trait methods with
Box::pin(std::future::ready(...)) at zero cost; async backends use
Box::pin(async move { ... }).
WebAssembly support
The same protocol layer is exposed to JavaScript/TypeScript via
wasm-bindgen. Packages:
import { primitives } from "@derec-alliance/nodejs"; // or @derec-alliance/web
const result = primitives.verification.request.produce(
channelId, secretId, version, sharedKey,
);
// result carries the encoded DeRecMessage envelope, ready to send over transport
The TypeScript bindings expose both the protocol layer (via the
DeRecProtocol class) and the primitives surface. See the package READMEs
for full TypeScript examples.
Transport layer
The protocol is transport-agnostic. The TransportProtocol value carried in
contact and pairing messages identifies the peer endpoint; the application's
DeRecTransport
implementation decides how to deliver bytes (HTTPS, WebSocket, message queue,
custom relay, …).
[!NOTE] The on-the-wire
TransportProtocol.protocolenum currently definesHttpsas the only supported value. New transports can be added by extending the protobuf enum.
Updating channel info post-pairing
A peer's communication_info and transport endpoint are exchanged at pairing
time. To propagate later changes, mutate local state with
DeRecProtocol::set_communication_info / set_own_transport and then run
start(DeRecFlow::UpdateChannelInfo { ... }) against the target channels.
Per-field semantics:
communication_info: Option<HashMap<String, String>>—Noneleaves the peer's stored map untouched.Some(_)replaces it; an empty map clears it.transport_protocol: Option<TransportProtocol>—Noneleaves it untouched.Some(_)updates both URI and protocol.
The flow is symmetric — either Owner or Helper may initiate it — and
auto-applies on the receiver via the standard ActionRequired → accept
path. Outcome surfaces as DeRecEvent::ChannelInfoUpdated (or
ChannelInfoUpdateRejected if the peer refused).
[!WARNING] Endpoint changeover discipline. When
transport_protocolis updated, the receiving peer sends its response to the new endpoint. The application MUST bring up the new endpoint and start listening on it before initiating the flow, and MUST keep the old endpoint operational until every targeted peer has emittedChannelInfoUpdated/ChannelInfoUpdateRejected(plus a grace window for in-flight messages from peers not yet aware of the update). Failing to keep both endpoints reachable during this window will cause messages to be lost. See the rustdoc onset_own_transportfor details.
Observability
The library emits structured tracing spans and
events for every protocol step. Instrumentation is off by default and
opt-in via the logging feature flag — enabling it adds no overhead when no
subscriber is active.
Enabling
[]
= { = "*", = ["logging"] }
= { = "0.3", = ["env-filter"] }
Wiring up a subscriber
fmt
.with_env_filter
.init;
Controlling the log level at runtime
# Protocol milestones only (contact created, pairing complete, share stored, …)
DEREC_LOG=info
# Intermediate state — sizes, versions, channel IDs
DEREC_LOG=debug
# Full detail including byte lengths from the cryptography layer
DEREC_LOG=trace
# Only DeRec events, silence everything else
DEREC_LOG=derec_library=debug,derec_cryptography=debug
What is logged
| Level | Content |
|---|---|
info |
Protocol milestones — contact created, pairing complete, share split, share stored, verification result, secret reconstructed. |
debug |
Intermediate state — channel IDs, versions, thresholds, response counts. |
trace |
Low-level byte lengths from the cryptography layer. |
Security guarantee: secret bytes, symmetric keys, and share content are never emitted. Only non-sensitive metadata (lengths, identifiers, roles) appears in events.
Primitives (advanced)
Use the protocol layer unless you have a specific reason not to. The primitives surface is documented here for embedders who need to construct/parse individual messages directly or are integrating into an orchestrator they already own.
Each flow has a primitives::<flow> module with request and response
submodules. The general pattern is:
produce(...)— construct an outbound envelope, returning encoded wire bytes.extract(envelope_bytes, ...)— decrypt and decode an inbound envelope into the typed inner message.process(...)— interpret the decoded inner message (when there's logic beyond decoding).
Pairing
The contact message is exchanged out-of-band (QR codes, existing messaging channels, etc.). Two modes select how the initiator's public encryption material is delivered:
ContactMode |
What's in the contact | When to use |
|---|---|---|
InlineKeys (default) |
Full ML-KEM encapsulation key + ECIES public key | Out-of-band channel can carry the keys (e.g. NFC, direct messaging). |
HashedKeys |
Only a SHA-384 commitment to the keys (contact_binding_hash) |
The out-of-band channel is size-constrained (QR codes). The scanner fetches the actual keys over the wire via a PrePair round-trip, then verifies them against the hash. |
After the handshake completes, both modes rekey the channel id. The
responder includes
channel_id = u64::from_be_bytes( SHA-384(u64_be(originalId) || sharedKey)[..8] )
in the encrypted PairResponseMessage; both sides switch their local channel
record to that value and route all future traffic on it. Because the new id
travels encrypted, a passive observer who only saw the pre-rekey traffic
cannot link the long-running channel back to its pairing-time id.
InlineKeys flow
use ;
use ChannelId;
use ;
let channel_id = ChannelId;
// Step 1 — Initiator creates the contact message (sent out-of-band).
let CreateContactResult = create_contact.unwrap;
// Step 2 — Responder decodes the contact and produces the request envelope.
let ProduceResult = produce.unwrap;
// Step 3 — Initiator extracts the request and produces the response.
let ExtractResult =
extract.unwrap;
let ProduceResult = produce.unwrap;
// Step 4 — Responder extracts the response and finalizes pairing.
let ExtractResult =
extract.unwrap;
let ProcessResult = process.unwrap;
// Both sides now hold the same shared key and the same rekeyed channel id;
// rename local channel state from `channel_id` to `rekeyed_channel_id`
// before sending any further traffic.
assert_eq!;
assert_eq!;
assert_ne!;
To reject the request, build a PairResponseMessage with a non-Ok
StatusEnum and encrypt it with DeRecMessageBuilder::pairing() against the
peer's request.ecies_public_key. The protocol-layer reject method does
this for you. Rejected responses do not carry a meaningful channel_id —
the rekey only takes effect on Ok responses.
HashedKeys flow (PrePair)
HashedKeys adds one plaintext round-trip before the regular InlineKeys
handshake. The scanner fetches the keys via PrePair, verifies them against
contact.contact_binding_hash, and then runs the normal pairing flow on a
synthesized ContactMessage with the keys filled in.
use ;
use ChannelId;
use ;
let channel_id = ChannelId;
// Initiator: HASHED_KEYS contact (no inline keys, only the binding hash).
// The transport URI MUST be ephemeral — PrePair envelopes are plaintext.
let CreateContactResult = create_contact.unwrap;
// Scanner: fetch keys via PrePair.
let ProducePrePairResult =
produce_pre_pair_request.unwrap;
let PrePairExtractResult =
extract_pre_pair.unwrap;
let ProducePrePairResult =
produce_pre_pair.unwrap;
let PrePairExtractResult =
extract_pre_pair.unwrap;
// Scanner validates the published keys against `contact.contact_binding_hash`;
// returns the keys + nonce on match, errors with
// `PairingError::PrePairHashMismatch` on mismatch.
let validated = process_pre_pair.unwrap;
// Synthesize a "filled-in" contact and run the regular pairing flow. The
// mode flip is required — `request::produce` enforces `InlineKeys` and
// rejects a contact that still advertises `HashedKeys`.
let filled_in_contact = ContactMessage ;
// ... continue with request::produce / extract / response::produce / process
// against `filled_in_contact` exactly as in the InlineKeys example.
After the PrePair exchange the application must swap the transport
endpoint to a long-term one via UpdateChannelInfo. The ephemeral endpoint
advertised in the HashedKeys contact is intended to be retired immediately
after pairing.
Share Distribution
use request;
use ChannelId;
let secret_id: u64 = 42;
let secret_data = b"super_secret_value";
let channels = ;
let threshold = 2; // 2 <= threshold <= channels.len()
let version: u32 = 1;
let SplitResult = split.unwrap;
// shares: HashMap<ChannelId, CommittedDeRecShare>
// Pass each share to request::produce() to create encrypted delivery envelopes.
Verification
use ;
use ChannelId;
let channel_id = ChannelId;
let secret_id: u64 = 42;
let version: u32 = 7;
let shared_key = ; // established during pairing
// Owner side: produce and send the verification request.
let result = produce.unwrap;
let request_wire_bytes = result.envelope;
// Helper side: decrypt and extract the inner request.
let ExtractResult =
extract.unwrap;
// Helper side: produce the response, proving possession of the share.
let resp_result =
produce.unwrap;
let response_wire_bytes = resp_result.envelope;
// Owner side: decrypt and verify the proof.
let ExtractResult =
extract.unwrap;
let ok = process.unwrap;
assert!;
Discovery
use ;
use ChannelId;
let channel_id = ChannelId;
let shared_key = ; // established during pairing
// Owner side: produce the discovery request.
let ProduceResult =
produce.unwrap;
// Helper side: extract the request, enumerate stored secrets, produce the response.
let _req = extract.unwrap;
let stored: = vec!;
let ProduceResult =
produce.unwrap;
// Owner side: extract and process the response to get the secret list.
let ExtractResult =
extract.unwrap;
let ProcessResult =
process.unwrap;
for entry in &secret_list
Recovery
Recovery is a three-step process: pairing (re-establish a channel with
each helper in recovery mode), discovery (ask each helper which secrets it
holds), and share collection (reconstruct the secret). The end-to-end
flow is orchestrated by the protocol layer; see bindings/rust/src/protocol.rs
in the repository for the full driver loop.
Helper-side primitive surface:
use ;
use ChannelId;
let channel_id = ChannelId;
let shared_key = ; // established during pairing
// request_envelope: outer DeRecMessage bytes carrying a GetShareRequest
// stored_share_request: StoreShareRequestMessage the helper persisted at sharing time
let ExtractResult =
extract.unwrap;
let ProduceResult = produce.unwrap;
Owner side — decrypt each helper response, reconstruct the secret once enough have arrived:
use response;
let secret_id: u64 = 42;
let version: u32 = 1;
// responses: Vec<GetShareResponseMessage> collected from `response::extract` per helper.
let inputs: = responses.iter.collect;
let recovered = recover.unwrap;
// recovered.secret_data contains the reconstructed payload.
Unpairing
Either party may end a channel by initiating an unpair flow. The recipient
drops its state (shared key, channel record, stored shares) and acknowledges
with an UnpairResponseMessage. Through the protocol layer this is one call;
the primitive surface is below.
use ;
use ChannelId;
let channel_id = ChannelId;
let shared_key = ; // established during pairing
// Initiator side: produce and send the unpair request.
let req_result = produce.unwrap;
// Responder side: extract the request, drop local state, send Ok response.
let _extracted = extract.unwrap;
let resp_result = produce.unwrap;
// Initiator side: extract and validate the response.
let ExtractResult =
extract.unwrap;
let outcome = process.unwrap;
assert!;
Through the protocol layer:
use ;
use Target;
let mut protocol = new
.with_unpair_ack
// … other setters …
.build?;
protocol.start.await?;
UnpairAck::Required (default) keeps local state until the peer replies
Ok, or until the configured timeout elapses; the Unpaired event surfaces
in either case. UnpairAck::NotRequired drops local state immediately on
start(Unpair) and ignores any later response.
When the peer's UnpairRequest arrives on the responder side, the
orchestrator emits DeRecEvent::ActionRequired { channel_id, action } with
action set to PendingAction::Unpair { .. }. The application calls
protocol.accept(action) to drop local state and reply Ok, or
protocol.reject(action, status, memo) to keep local state and reply with a
non-Ok status.
Protocol specification
Full protocol documentation: https://derec-alliance.gitbook.io/docs/protocol-specification/messages
Security considerations
Applications using this SDK should ensure:
- Secure storage of secret material (
DeRecSecretStorecontent is keychain-grade — see the trait doc). - Proper authentication of helpers.
- Safe transport channels.
- Protection against replay attacks beyond the protocol's own timestamp-based staleness check.
The DeRec protocol assumes helpers are independent and trusted entities.
Replica destinations inherit Source trust
A ReplicaDestination receives the full
Secret,
which embeds every helper's channel_id and shared_key under
HelperInfo.
Anyone with the secret can therefore authenticate as the Source toward
every helper. This is intentional — it is what makes Destination-driven
recovery work — but it means a compromised Destination can impersonate
the Source against every helper that was paired at the time the secret
was sent. Pick Destinations with at least the trust level of the Source
device itself; do not treat them as opaque backups.
The replica group also shares a single group channel key: every
(secret_id, channel_id) entry in
DeRecSecretStore
for a paired replica channel holds the same 32-byte key, established at
the first replica pair and handed over to every subsequent joiner via
ReplicaSecretPayload.shared_key
on its first sync round. Compromise of any one Destination therefore
exposes that single key to the attacker; the protocol does not provide
per-pair forward secrecy across replicas.
HashedKeys requires an ephemeral transport URI
ContactMode::HashedKeys ships only a SHA-384 binding hash in the
contact and serves the actual public keys through a plaintext PrePair
round-trip on the contact creator's own_transport. Any party that can
reach that URI before the legitimate scanner gets the keys. Use
HashedKeys only with a transport endpoint that is freshly minted for
this pairing and that you can retire as soon as the PrePair leg
completes. ContactMode::InlineKeys has no such constraint because the
keys are embedded directly in the (already binding-hash-validated)
contact bytes.
The recommended pattern is: pair on the ephemeral URI, then — as soon
as the pairing completes on the contact creator side — call
set_own_transport with the permanent endpoint and start an
UpdateChannelInfo flow against the peer to announce the swap. Once
the peer acknowledges, retire the ephemeral URI. This keeps the
plaintext PrePair window tight while letting subsequent traffic ride
on the long-lived endpoint.
Replica fingerprint verification is mandatory
Replica channels are created in ChannelStatus::Pending and remain
there until both sides call verify_fingerprint with the value the
peer derived from the shared key — confirmed out of band, the same way
helper pairings are. The orchestrator enforces this:
start(ProtectSecret) returns
Error::InvalidInput
when a target is still Pending. Treat verification as a required step
in the pairing UX, not an optional confirmation — a scanner that
auto-pairs without it accepts a MITM-vulnerable replica.
The derec.* namespace in CommunicationInfo is library-owned
CommunicationInfo is otherwise an opaque app-defined map, but every
key under the derec. prefix is reserved for the protocol. Today the
library owns derec.replica_id; future protocol additions will use the
same namespace. Application code must not write any derec.* entry —
the orchestrator silently overwrites or strips library-owned keys at
the protocol boundary, and app-set values are lost without warning.
License
Licensed under the Apache License, Version 2.0. See the LICENSE file for
details.
Contributing
Contributions are welcome. Repository: https://github.com/derecalliance/lib-derec. Please open issues or pull requests to discuss improvements.
DeRec Alliance
The DeRec Alliance is an open initiative focused on standards for decentralized secret recovery. https://derec.org