Contact Voucher Protocol Narration
Introduction
The voucher spec was very difficult for me to read and it overloads variable names and doesn’t make certain facts explicit enough. I wrote this spec for my own understanding of the design as an implementation guide for myself.
Streams
There is no separate reply stream. Alice replies on the very same stream Bob used to mint the voucher.
- MessageStream: Bob writes; the group reads. This is the stream whose live messages the protocol must protect.
- VoucherStream: the rendezvous, derived entirely from the
Voucher. Box 0 holds Bob’sVoucherPayload; box 1 holds Alice’sVoucherReply. - the group’s other streams: each existing member has their own MessageStream, just like Bob’s.
Stream Objects
- MessageStream:
.WriteCap(Bob keeps),.ReadCap(travels inVoucherPayload, insideSignedPleaseAdd). - VoucherStream: read and write caps derivable by anyone holding the
Voucher. - VoucherKeypair:
VoucherSecretKey(Bob keeps),VoucherPublicKey(travels inVoucherPayload). This is the MKEM keypair under which Alice seals the reply to Bob.
Messages
SignedPleaseAddis Bob’s signed self-introduction: a serializedPleaseAdd(hisDisplayNameandMessageStream.ReadCap) plus a signature over it produced byMessageStream.WriteCap.
// PleaseAdd is a member's request to join, carrying their display name
// and the read cap that lets others read their messages.
type PleaseAdd struct {
// DisplayName is the party's name to be displayed in chat clients.
DisplayName string
// UniversalReadCap is the BACAP read cap for this member's
// MessageStream, letting others read all messages posted by them.
UniversalReadCap *bacap.UniversalReadCap
}
// SignedPleaseAdd binds a PleaseAdd to a signature made by the member's
// write cap, so any party can verify the name-and-cap binding.
type SignedPleaseAdd struct {
// PleaseAdd contains the CBOR serialized PleaseAdd struct.
PleaseAdd []byte
// Signature contains the cryptographic signature over the PleaseAdd field.
Signature []byte
}
WhoReply:= the existing members’ MessageStream read caps (one or more), each unchanged and paired with a start index, so Bob can read everyone already in the group. Each index is the box after Alice’s last published acknowledgement of that stream; Alice’s own starts at herIntroductionof Bob.VoucherReply:=WhoReply || VoucherSalt, MKEM-sealed toVoucherPublicKeyand written to VoucherStream box 1. Box 1 is an ordinary BACAP box, so the payload is BACAP-encrypted as a stream box and then additionally MKEM-encrypted to Bob’sVoucherPublicKey; only Bob, holdingVoucherSecretKey, can open it.Introduction:= Bob’s display name, his MessageStream read cap unchanged, and his salted start index, published to the existing group so the members can read Bob. The group receives the salted index directly and never the salt.
VoucherPayload := SignedPleaseAdd || VoucherPublicKey
Voucher := Hash(VoucherPayload)
The VoucherSalt: re-seeding the MessageStream
VoucherSalt is the heart of the protocol. It is not a nonce and it does not modify BACAP. It is a value that replaces the KDF state inside a MessageStream’s MessageBoxIndex, which re-seeds the index chain and so moves the stream to a fresh, unpredictable sequence of box IDs. Only two parties ever touch it. Bob mutates his MessageStream.WriteCap by the salt and writes his real messages at the mutated position. Alice applies the same salt once to the index she pairs with Bob’s MessageStream.ReadCap, leaving the read cap itself unchanged, so writer and readers meet at the mutated position. The group never learns the salt; it only ever receives the already-salted index.
That is why the spec says derive Bob’s read capability from VoucherPayload + VoucherSalt: the read cap published in box 0 is only half of it. That derivation is Alice’s alone. The read cap in box 0 names Bob’s original stream; the salted index says where Bob’s real messages actually go. She performs the derivation once and hands the result to the group.
The salt is the one secret a voucher snoop never receives. It is born at induction, not at mint: Alice mints it and seals it inside the MKEM-encrypted VoucherReply. It is never written to box 0, so Bob’s PleaseAdd carries only his original read cap, never the salt. An interceptor of the out-of-band Voucher can derive the VoucherStream, read box 0, and check that the voucher was spent, but he holds only the read cap and its unsalted index. He cannot compute the salted box IDs, so he cannot read a single one of Bob’s actual messages. That is exactly the spec’s stated confidentiality goal.
Because the salt re-seeds rather than decorates the stream, it is a per-member fact known only to that member and to whoever inducted them. A mature group mixes voucher-joined members (each on their own salt-mutated index) with seed members (on the unmutated index), and no member needs to carry any salt: every index that circulates beside a read cap is already salted. WhoReply hands Bob the existing members’ read caps with their start indexes, and the Introduction hands the group Bob’s read cap with the index Alice salted.
Steps
- Bob mints. He generates his MessageStream (keeping
MessageStream.WriteCap) and aVoucherKeypair(keepingVoucherSecretKey). He formsSignedPleaseAdd = {DisplayName, MessageStream.ReadCap}signed byMessageStream.WriteCap; setsVoucherPayload := SignedPleaseAdd || VoucherPublicKey;Voucher := Hash(VoucherPayload). - Bob publishes. The
Voucherderives the VoucherStream; Bob writesVoucherPayloadto box 0. - Bob → Alice (OOB). He hands over only the
Voucher. - Alice reads and verifies. From
Vouchershe derives the VoucherStream, reads box 0, checksHash(VoucherPayload) == Voucher, and verifies theSignedPleaseAddsignature against its read cap’s rootPK. - Alice replies. She mints
VoucherSalt, assemblesWhoReplyfrom the start positions above, formsVoucherReply := WhoReply || VoucherSalt, and MKEM-seals it toVoucherPublicKey. She publishes no acknowledgements between this step and the commit. - Alice commits (all-or-nothing COPY). She first derives Bob’s start index by applying the
VoucherSaltto his read cap’s index, leaving the read cap unchanged. Then, in one operation: write the sealedVoucherReplyto VoucherStream box 1; publish theIntroduction(Bob’s display name, his read cap, and his salted start index) to her group, which never sees the salt itself; tombstone box 0 against reuse. Because no acknowledgement separates the start positions from this commit, there is no race: every member keeps each box Alice had not acknowledged at theIntroductionuntil Bob acknowledges it (see Joining in the group chat spec). She rewrites box 1 each replica epoch until Bob’s first message appears, for at most the Sent-box retention window; a Bob later than that needs a new voucher. - Bob finishes. He polls VoucherStream box 1, MKEM-opens the
VoucherReplywithVoucherSecretKey, and recoversWhoReplyandVoucherSalt. He mutates hisMessageStream.WriteCapby the salt and begins writing real messages there. The group already holds his read cap and salted start index, so they read him without ever learning the salt. Both sides now share the live streams.