Protocol Specification v0.6
Message Protocol
Each message has a family discriminator (opens in a new tab) and an opcode. The family selects exactly one body field at tag 15 + family; the opcode selects its positional layout. The seven family numbers are retained from v0.5, while some family meanings and operation payloads have changed.
Family and opcode
The family selects the body field and the opcode the operand words; the reference set separately decides what runs.
- 1
Body field
family3 isLOCK_MANAGEMENT, so the body must be field 18,lock_management. A body at another tag, or a second body, isINVALID_FORMAT(70). - 2
Known opcode
The family has opcodes 1 to 8. The canonical Core decode refuses any other opcode with
INVALID_FORMAT(70). - 3
Reference set
check_operationpasses only the reference set, opcodes 1 to 6 of this family.WriteandTimeoutare defined operations, and it refuses both withUNSUPPORTED_VERSION(71) before the signature check. - 4
Operand words
After signature and admission, every family 3 layout starts with
call_prefix, an optionalcredentialand a countedversion_vector, so its length varies. The opcode fixes the words after them, and words that do not fit areINVALID_FORMAT(70).
- Selected by the family or the opcode
- Path of a family 3 message
- Passes the reference set check
- Refused, with its response code
- Not a body or opcode of family 3
Schematic: blobs are not drawn and cell widths are not sizes. Each opcode word is one fixed64; the three leading groups take a variable number of words.
Families and operations#
NormativeBytes, test vectors and proofs
STATE_SYNC carries settlement execution commands in this edition. It is not the general asset-state message of v0.5. GOVERNANCE carries owner handover and authority epoch (opens in a new tab) installation layouts, not general routing-table or Registry edits.
| Family | Opcode | Operation | Reference delivery |
|---|---|---|---|
1 STATE_SYNC | 1 | Debit | Accepted |
1 STATE_SYNC | 2 | RecordTerminal | Accepted |
1 STATE_SYNC | 3 | DeliverCredit | Accepted |
1 STATE_SYNC | 4 | Refund | Accepted |
1 STATE_SYNC | 5 | PublishPrimary | Accepted |
1 STATE_SYNC | 6 | OrdinarySpend | Accepted |
2 REGULATORY_ACTION | 1 | EmitLegalOrder | Accepted |
2 REGULATORY_ACTION | 2 | CommitLegalOrder | Accepted |
2 REGULATORY_ACTION | 3 | EnforcementSpend | Accepted |
3 LOCK_MANAGEMENT | 1 | Reserve | Accepted |
3 LOCK_MANAGEMENT | 2 | Dispatch | Accepted |
3 LOCK_MANAGEMENT | 3 | Cancel | Accepted |
3 LOCK_MANAGEMENT | 4 | Fence | Accepted |
3 LOCK_MANAGEMENT | 5 | Preempt | Accepted |
3 LOCK_MANAGEMENT | 6 | Reconcile | Accepted |
3 LOCK_MANAGEMENT | 7 | Write | UNSUPPORTED_VERSION (71) |
3 LOCK_MANAGEMENT | 8 | Timeout | UNSUPPORTED_VERSION (71) |
4 QUERY | 1 | CurrentRead | Accepted |
4 QUERY | 2 | HistoricalRead | Accepted |
4 QUERY | 3 | RawRead | Accepted |
4 QUERY | 4 | OperationStatus | Accepted |
4 QUERY | 5 | Journal | Accepted |
4 QUERY | 6 | CaptureCache | Accepted |
4 QUERY | 7 | OwnerRead | Accepted |
4 QUERY | 8 | ExactReceipt | Accepted |
5 ACK | 1 | SettlementAcknowledgement | Accepted |
5 ACK | 2 | CorrelatedResponse | UNSUPPORTED_VERSION (71) |
6 HEARTBEAT | 1 | Capabilities | UNSUPPORTED_VERSION (71) |
6 HEARTBEAT | 2 | Liveness | UNSUPPORTED_VERSION (71) |
7 GOVERNANCE | 1 | OwnerHandover | Accepted |
7 GOVERNANCE | 2 | InstallEpoch | UNSUPPORTED_VERSION (71) |
The reference profile (opens in a new tab)'s acceptance column applies to inbound deliveries. An outbound correlated response can be defined by the protocol and still be unsupported as a request to that profile. Decoding a known operation and accepting it for execution are separate decisions.
Family and opcode dispatch#
The operation table names the body field of each family, and the tag of that field is 15 plus the family code.
"family": { "description": "Core family (tag 12). The Core body field of a family has tag 15 plus the family code.", "values": [ {"code": 1, "name": "STATE_SYNC", "body_field": "state_sync"}, {"code": 2, "name": "REGULATORY_ACTION", "body_field": "regulatory_action"}, {"code": 3, "name": "LOCK_MANAGEMENT", "body_field": "lock_management"}, {"code": 4, "name": "QUERY", "body_field": "query"}, {"code": 5, "name": "ACK", "body_field": "ack"}, {"code": 6, "name": "HEARTBEAT", "body_field": "heartbeat"}, {"code": 7, "name": "GOVERNANCE", "body_field": "governance"} ]},Each family and opcode pair has exactly one layout. Both decoders send the two spend operations to one spend decoder, the other STATE_SYNC (opens in a new tab) operations and every LOCK_MANAGEMENT (opens in a new tab) operation to the ordinary decoder, and each remaining family to its own decoder.
pub fn decode(core: &Core, max_nesting: usize) -> Result<Operands> { if max_nesting == 0 { return Err(BAD); } let (family, op) = body(core)?; match (family, op.opcode) { (1, Some(6)) | (2, Some(3)) => spend(core, family, op), (1 | 3, _) => ordinary(core, family, op), (2, _) => legal(core, op), (4, _) => read(op, max_nesting), (5, _) => ack(core, op, max_nesting), (6, _) => heartbeat(core, op), (7, _) => governance(core, op), _ => Err(BAD), }}func DecodeOperands(core *wirepb.Core, maxNesting int) (Operands, error) { if maxNesting < 1 { return Operands{}, ErrFormat } family, op := Body(core) if op == nil { return Operands{}, ErrFormat } opcode := op.GetOpcode() typed := Operands{Family: family, Opcode: opcode, Words: slices.Clone(op.Words)} for _, blob := range op.Blobs { typed.Blobs = append(typed.Blobs, bytes.Clone(blob)) } var err error switch { case (family == 1 && opcode == 6) || (family == 2 && opcode == 3): typed.Native, err = spend(core, family, op) case family == 1 || family == 3: typed.Native, err = ordinary(core, family, op) case family == 2: typed.Native, err = legal(core, op) case family == 4: typed.Session, typed.Original, err = decodeRead(op,maxNesting,core.GetAuthorityEpoch(),&typed.TypedIR) case family == 5: typed.Original, err = ack(core,op,maxNesting,&typed.TypedIR) case family == 6: err = heartbeat(core,op,&typed.TypedIR) case family == 7: typed.Session, err = governance(core,op,&typed.TypedIR) default: err = ErrFormat } if typed.Native != nil { typed.Session = typed.Native.Session typed.TypedIR = typed.Native.Summary } if err != nil { return Operands{}, err } return typed, nil}The two spend layouts differ. Under REGULATORY_ACTION (opens in a new tab) the spend decoder also reads the enforcement order words, a non-empty third blob with the proof and the appeal deadline (opens in a new tab); under STATE_SYNC it requires exactly two blobs and no deadline.
let (authorization, proof) = if family == 2 { let [_, _, proof] = op.blobs.as_slice() else { return Err(BAD); }; if proof.is_empty() { return Err(BAD); } ( Some(EnforcementOrder { scope, officer: words.word()?, jurisdiction: words.word()?, legal_basis: words.word()?, assets: words.set()?, prior_seizure: words.optional_origin()?, appeal_deadline: deadline(op), }), Some(proof.clone()), )} else { if op.blobs.len() != 2 || op.appeal_deadline.is_some() { return Err(BAD); } (None, None)};if family==2 { if len(op.Blobs)!=3 || len(op.Blobs[2])==0 {return nil,ErrFormat} var officer,jurisdiction,basis uint64 if err:=w.read(&officer,&jurisdiction,&basis);err!=nil {return nil,err} assets,err:=w.valueSet();if err!=nil {return nil,err} prior,err:=w.valueOptional(2);if err!=nil {return nil,err} authorization=valueSome(valueNode(108,valueScalar(officer),valueScalar(jurisdiction),valueScalar(basis),valueScalars(assets),prior,valueDeadline(op))) proof=valueSome(valueBytes(op.Blobs[2]))} else if len(op.Blobs)!=2 || op.AppealDeadline!=nil {return nil,ErrFormat}Both encoders hold the opcode count of each family. Decoding encodes every Core again and compares the bytes, so an opcode outside 1 through that count is refused as INVALID_FORMAT.
fn opcode_allowed(family: u64, opcode: u64) -> bool { [6, 3, 8, 8, 2, 2, 2] .get((family.saturating_sub(1)) as usize) .is_some_and(|last| (1..=*last).contains(&opcode)) && family != 0}primary, scheme, atomicity, priority := core.GetPrimary(), core.GetSignatureScheme(), core.GetAtomicity(), core.GetPriority()last := [...]uint64{6, 3, 8, 8, 2, 2, 2}if primary < 1 || primary > 5 || (scheme != 1 && scheme != 2) || atomicity < 1 || atomicity > 3 || priority > 4 || (priority == 0 && atomicity != 3) || op.GetOpcode() < 1 || op.GetOpcode() > last[family-1] { return nil, ErrFormat}Reference operation set#
Both implementations write the reference set as one opcode range per family, and both ranges give the same 25 operations.
fn supported_frame(core: &Core) -> bool { match core.body.as_ref() { Some(Body::StateSync(op)) => matches!(op.opcode, Some(1..=6)), Some(Body::RegulatoryAction(op)) => matches!(op.opcode, Some(1..=3)), Some(Body::Ack(op)) => op.opcode == Some(1), Some(Body::Query(op)) => matches!(op.opcode, Some(1..=8)), Some(Body::LockManagement(op)) => matches!(op.opcode, Some(1..=6)), Some(Body::Governance(op)) => op.opcode == Some(1), _ => false, }}// ReferenceAccepts reports whether the reference configuration executes the// operation of a decoded Core. It accepts 25 of the 31 operations of version// 6 and refuses Write, Timeout, CorrelatedResponse, Capabilities, Liveness// and InstallEpoch.func ReferenceAccepts(core *wirepb.Core) bool { family, op := Body(core) opcode := op.GetOpcode() switch family { case 1, 3: return opcode >= 1 && opcode <= 6 case 2: return opcode >= 1 && opcode <= 3 case 4: return opcode >= 1 && opcode <= 8 case 5, 7: return opcode == 1 default: return false }}In the Rust authority entry paths, check_operation runs right after canonical decoding and before the signature check, and Go ReceiveAccepting runs its configured check before AdmitPacket. Both refuse an operation outside the set as UNSUPPORTED_VERSION and decode operands only after admission (opens in a new tab).
let frame_limit = product.security.frame_limit(channel);if bytes.len() > frame_limit { return Err(WireError::InvalidFormat);}let input = match channel.profile().version { 6 => { check_declared_version(bytes, frame_limit)?; Input::Modern(Box::new(decode_packet(bytes, frame_limit)?)) } 5 if product.security.legacy_configured(channel) => Input::Legacy { bytes, core: Box::new(legacy::decode_shape(bytes, frame_limit)?), }, _ => return Err(WireError::UnsupportedVersion),};check_operation(product.security.as_ref(), channel, input.core())?;if !product.security.matches_signed_packet(bytes, credentials.signature) { return Err(WireError::InvalidSignature);}let core = input.core();let boundary = boundary_for(product.security.as_ref(), channel, core, deployment, cut)?;if bytes.len() > boundary.max_frame { return Err(WireError::InvalidFormat);}gate_delivery(delivery, boundary.now, ordered.is_none())?;if ordered.is_none() && product.security.delivery_cancelled(core) { return Err(WireError::Cancelled);}let legacy = input.verify(channel, &boundary)?;check_delivery_ttl(product.security.as_ref(), channel, core)?;let operands = decode(core, boundary.max_nesting)?;// ReceiveAccepting is Receive with the operation check that a configuration// runs in front of the admission gate. An operation that accepts refuses// receives ErrVersion before any signature or source check. A nil accepts// admits every operation.func (store *TransportBindings) ReceiveAccepting(raw []byte, profile Profile, boundary Boundary, accepts func(*wirepb.Core) bool, attest func(*wirepb.Core) bool) (Arrival, *CanonicalPacket, error) { if profile.Version != 6 { return First, nil, ErrVersion } if err := CheckDeclaredVersion(raw, boundary.MaxFrame); err != nil { return First, nil, err } packet, err := DecodePacket(raw, boundary.MaxFrame) if err != nil { return First, nil, err } // Decode and bind the signed Core without exposing it to either callback. signedCore := packet.Core() if accepts != nil && !accepts(packet.Core()) { return First, nil, ErrVersion } if err = AdmitPacket(packet, profile, boundary); err != nil { return First, nil, err } if !attest(packet.Core()) { return First, nil, ErrSource } decodedOperands, err := DecodeOperands(signedCore, boundary.MaxNesting) if err != nil { return First, nil, err }A test walks all 31 operations through the reference profile and counts the accepted ones.
#[test]fn reference_ed25519_profile_accepts_only_the_operations_it_executes() { use crate::authority::recovery::wire::vector_tests::{channel, core_with}; let security = Ed25519Security::new(config(), key(6), FixedClock(105)).unwrap(); let channel = channel(0); let refused = BTreeSet::from([(3, 7), (3, 8), (5, 2), (6, 1), (6, 2), (7, 2)]); let mut accepted = 0; for (family, count) in (1..=7).zip([6, 3, 8, 8, 2, 2, 2]) { for opcode in 1..=count { let core = core_with(family, opcode, 0, 1, Vec::new(), Vec::new()); let accepts = security.accepts_operation(&channel, &core); assert_eq!(accepts, !refused.contains(&(family, opcode)), "{family}/{opcode}"); accepted += usize::from(accepts); } } assert_eq!(accepted, 25); assert_eq!(security.max_delivery_ttl(&channel), None); let limited = Ed25519Profile { max_delivery_ttl_seconds: Some(30), ..config() }; let limited = Ed25519Security::new(limited, key(6), FixedClock(105)).unwrap(); assert_eq!(limited.max_delivery_ttl(&channel), Some(30));}Immutable request and response#
NormativeBytes, test vectors and proofs
A Packet authenticates its complete canonical Core. The header's principal, target and epoch supply the caller and current authority; an operand cannot substitute another authenticated identity. Execution credentials come from host adapters and are not extra wire fields.
An exact receipt query carries the immutable original Core as nested canonical data. It does not re-admit that original as a delivery. A response binds the full original request and caller, the result kind and code, and the commit position when the result code requires one. Error Reference states which outcomes may be retried safely.
The exact receipt query has one session word, and its only blob is the original call.
{ "family": 4, "opcode": 8, "name": "ExactReceipt", "title": "exact receipt", "summary": "Receipt for an exact original call.", "reference_accepts": true, "words": [ {"kind": "word", "name": "session", "type": "session_id"} ], "blobs": [ {"name": "original_core", "type": "canonical_core", "description": "Complete immutable original call."} ], "deadline": "absent", "checks": [ {"when": "decode", "rule": "The nesting limit is at least 2, which leaves one level for original_core." , "error": "INVALID_FORMAT"}, {"when": "decode", "rule": "original_core decodes as an ordinary, legal or spend call." , "error": "INVALID_FORMAT"}, {"when": "consumer", "rule": "The original call has the deployment, source domain, target domain, principal and authority epoch of this Core and version 6." , "error": "INVALID_SOURCE"} ], "call": false},The correlated response carries the caller's call identifier (opens in a new tab), the result kind and code, an optional commit position and the original bytes. Each check in the entry names the refusal it returns.
{ "family": 5, "opcode": 2, "name": "CorrelatedResponse", "title": "correlated response", "summary": "Signed response correlated with an original call.", "reference_accepts": false, "words": [ {"kind": "word", "name": "caller_namespace", "type": "principal_id"}, {"kind": "word", "name": "call_sequence", "type": "call_sequence"}, {"kind": "word", "name": "kind", "type": "code", "enum": "response_kind", "accepted": "all"}, {"kind": "word", "name": "code", "type": "code", "enum": "response_code", "accepted": "any", "description": "The response verifier checks the code against the original call."}, {"kind": "optional", "name": "commit", "fields": [ {"kind": "word", "name": "position", "type": "commit_position"} ]} ], "blobs": [ {"name": "original_core", "type": "canonical_core", "description": "Canonical Core of the original call in its original bytes."} ], "deadline": "absent", "checks": [ {"when": "decode", "rule": "The nesting limit is at least 2, which leaves one level for original_core." , "error": "INVALID_FORMAT"}, {"when": "decode", "rule": "A commit is present only when kind is RECORDED.", "error": "INVALID_FORMAT"}, {"when": "decode", "rule": "The original has the version and deployment of this Core, its principal equals caller_namespace, and its source and target domains are this Core's target and source domains." , "error": "INVALID_SOURCE"}, {"when": "decode", "rule": "The original is not a correlated response.", "error": "INVALID_FORMAT"}, {"when": "decode", "rule": "An original call has the call identifier (caller_namespace, call_sequence). When it is a Debit, a present commit position equals its revision." , "error": "INVALID_FORMAT"}, {"when": "decode", "rule": "An original exact receipt query carries a call with the deployment, source domain, target domain, principal and authority epoch of that query and version 6." , "error": "INVALID_SOURCE"}, {"when": "decode", "rule": "An original exact receipt query carries a call with the call identifier (caller_namespace, call_sequence). When that call is a Debit, a present commit position equals its revision." , "error": "INVALID_FORMAT"}, {"when": "consumer", "rule": "original_core equals the caller's retained canonical request and the call identifier equals that request's call identifier.", "error": "REPLAY_CONFLICT"} ], "call": false, "reference_note": "The reference authority signs it after the original call returns. Callers verify it with the reference response verifier. The reference authority does not accept it as a request."},Both decoders accept a response only when the original was sent by the named caller in the opposite direction, with the same version and deployment. They then match the nested call identifier, and a commit position given for a Debit must equal its revision.
if nested.version != core.version || nested.deployment != core.deployment || nested.principal != Some(id.namespace) || nested.source_domain != core.target_domain || nested.target_domain != core.source_domain{ return Err(WireError::InvalidSource);}let matches_debit_revision = |revision| commit.is_none_or(|reference| reference.position == revision);match decoded { Operands::Ordinary { id: nested, payload, .. } => { if nested != id || (matches!(payload.command, Command::Debit { .. }) && !matches_debit_revision(payload.cut.revision)) { return Err(BAD); } } Operands::Legal { id: nested, .. } | Operands::Spend { id: nested, .. } if nested != id => { return Err(BAD); } Operands::Receipt { original, .. } => { if !same_original(&nested, &original) { return Err(WireError::InvalidSource); } let (family, operation) = body(&original)?; let (nested_id, payload, _) = native(&original, operation)?; if nested_id != id || (family == 1 && operation.opcode == Some(1) && !matches_debit_revision(payload.cut.revision)) { return Err(BAD); } } Operands::Response { .. } => return Err(BAD), _ => (),}if original.GetVersion() != core.GetVersion() || original.GetDeployment() != core.GetDeployment() || original.GetPrincipal() != namespace || original.GetSourceDomain() != core.GetTargetDomain() || original.GetTargetDomain() != core.GetSourceDomain() { return nil, ErrSource}// A committed Debit response names the revision that the Debit names.debitAt := func(family, opcode, revision uint64) bool { return family != 1 || opcode != 1 || !committed || position == revision}switch {case nested.Family == 5 && nested.Opcode == 2: return nil, ErrFormatcase nested.Native != nil: if nested.Native.Sequence != sequence || !debitAt(nested.Family, nested.Opcode, nested.Native.Revision) { return nil, ErrFormat }case nested.Family == 4 && nested.Opcode == 8: // A receipt request answers for the original call that it carries. inner := nested.Original if !sameOriginal(original, inner) { return nil, ErrSource } family, innerOp := Body(inner) if innerOp == nil { return nil, ErrFormat } call, _, err := native(inner,innerOp) if err != nil { return nil, err } if inner.GetPrincipal() != namespace || call.Sequence != sequence || !debitAt(family, innerOp.GetOpcode(), call.Revision) { return nil, ErrFormat }}The Rust response verifier then compares the response with the request the caller retained. A different original or call identifier is a REPLAY_CONFLICT, and a known code for another call type is INVALID_FORMAT. Go has no counterpart of this check.
fn validate_response( response: &CanonicalPacket, expected: &CanonicalPacket, max_nesting: usize,) -> Result<VerifiedResponse, WireError> { let Operands::Response { id, kind, code, commit, original, } = decode(response.core(), max_nesting)? else { return Err(BAD); }; let expected_operands = decode(expected.core(), 1)?; if original != expected.core_bytes() || id != caller_id(&expected_operands)? { return Err(WireError::ReplayConflict); } let code = ResponseStatus::decode(kind, code, commit.is_some())?; if code .known() .is_some_and(|code| !code.matches_call(&expected_operands)) { return Err(BAD); } Ok(VerifiedResponse { id, code, commit })}Version coexistence#
NormativeBytes, test vectors and proofs
- Peers select a channel profile from two signed offers and a signed confirmation. These use binary signing inputs separate from the envelope.
- An offer is ASCII
OIP, NUL, ASCIIHEARTBEAT-OFFER, NUL, then the deployment, sender, receiver, challenge and expiry as 8-byte big-endian integers, then the version, family, atomicity (opens in a new tab) and scheme sets. - Each set is an 8-byte big-endian count followed by its values in ascending order as 8-byte big-endian integers.
- The confirmation is ASCII
OIP, NUL, ASCIIHEARTBEAT-PROFILE, NUL, then the local and remote offers, each preceded by its length as an 8-byte big-endian integer, then the selected version as an 8-byte big-endian integer and the selected family, atomicity and scheme sets in the same set form. - The local offer is signed with the local key. The remote offer and the confirmation are signed with the remote key.
- The selection MUST bind the two peers, the deployment, a fresh challenge supplied by the host, each offer's expiry and complete sets, and the selected profile. The selected version is the highest version that both offers contain at or above the registered peer floor. Version 5 is eligible only when legacy is allowed.
- The selected sets are the intersections of the offered sets, and an intersection can be empty. Under version 5 the families are limited to QUERY (opens in a new tab) and HEARTBEAT (opens in a new tab) and the schemes to SINGLE (opens in a new tab). The channel expires at the earlier of the two offer expiries. An unverified, mismatched or expired offer cannot select a profile.
- An offer carries no size or nesting limit. The maximum frame size and nesting depth are configuration values of the receiving host.
- The reference sets up channels out of band. Its host verifies the signed offers and confirmation and installs the resulting channel. The reference does not accept HEARTBEAT frames as deliveries.
- Use explicit v0.5 compatibility only with an authenticated permitted legacy peer and a configured legacy codec.
- The reference legacy codec is a reference compatibility record and does not interoperate with the wire format of the public v0.5 documents. v0.5 correspondence (opens in a new tab) records how this version treats those documents. Deny v0.6-only capabilities in a legacy profile.
- Parse, signature, timeout and rejection paths cannot trigger fallback. Retire legacy through a trusted allowlist and version floor change after compatible routes and unresolved obligations have been handled.
- A fresh channel MAY negotiate the same profile without changing an already signed origin request. The signed immutable origin core is separate from mutable delivery information. A hop that changes processing MUST carry an authenticated, authorized, linked transformation.
- This version defines single-hop delivery only, so the reference provides no relay against which this requirement can be tested. The reference validates the immutable origin and its authorized local delivery scope before effect.
Offer and confirmation bytes#
Both implementations build the two signing inputs from the domain strings, big-endian integers and counted sets described above. Rust sets are ordered by type. Go writes each set in the order the caller supplies, and SelectProfile refuses an unordered set after the offer signatures verify.
fn append_set(out: &mut Vec<u8>, set: &BTreeSet<u64>) { out.extend((set.len() as u64).to_be_bytes()); for value in set { out.extend(value.to_be_bytes()); }}pub fn offer_bytes(offer: &Offer) -> Vec<u8> { let mut out = b"OIP\0HEARTBEAT-OFFER\0".to_vec(); for value in [ offer.deployment, offer.sender, offer.receiver, offer.challenge, offer.expires, ] { out.extend(value.to_be_bytes()); } for set in [ &offer.versions, &offer.families, &offer.atomicities, &offer.schemes, ] { append_set(&mut out, set); } out}pub fn confirmation_bytes(local: &Offer, remote: &Offer, profile: &Profile) -> Vec<u8> { let mut out = b"OIP\0HEARTBEAT-PROFILE\0".to_vec(); for offer in [local, remote] { let bytes = offer_bytes(offer); out.extend((bytes.len() as u64).to_be_bytes()); out.extend(bytes); } out.extend(profile.version.to_be_bytes()); for set in [&profile.families, &profile.atomicities, &profile.schemes] { append_set(&mut out, set); } out}func appendSet(out []byte, values []uint64) []byte { out = binary.BigEndian.AppendUint64(out, uint64(len(values))) for _, v := range values { out = binary.BigEndian.AppendUint64(out, v) } return out}func OfferBytes(offer Offer) []byte { out := []byte("OIP\x00HEARTBEAT-OFFER\x00") for _, v := range []uint64{offer.Deployment, offer.Sender, offer.Receiver, offer.Challenge, offer.Expires} { out = binary.BigEndian.AppendUint64(out, v) } for _, set := range [][]uint64{offer.Versions, offer.Families, offer.Atomicities, offer.Schemes} { out = appendSet(out, set) } return out}func ConfirmationBytes(local, remote Offer, profile Profile) []byte { out := []byte("OIP\x00HEARTBEAT-PROFILE\x00") for _, offer := range []Offer{local, remote} { bytes := OfferBytes(offer) out = binary.BigEndian.AppendUint64(out, uint64(len(bytes))) out = append(out, bytes...) } out = binary.BigEndian.AppendUint64(out, profile.Version) for _, set := range [][]uint64{profile.Families, profile.Atomicities, profile.Schemes} { out = appendSet(out, set) } return out}Profile selection#
Selection tries version 6 before version 5, and a version 5 profile keeps only QUERY, HEARTBEAT and SINGLE. Go refuses a set that is not strictly ascending here as INVALID_FORMAT. In Rust the offer sets are ordered sets, and the capabilities decoder refuses a non-ascending set as INVALID_FORMAT.
pub fn select_profile( local: &Offer, remote: &Offer, floor: u64, legacy_allowed: bool,) -> Result<Profile> { let version = [6, 5] .into_iter() .find(|&version| { version >= floor && (version != 5 || legacy_allowed) && local.versions.contains(&version) && remote.versions.contains(&version) }) .ok_or(WireError::UnsupportedVersion)?; Ok(Profile { version, families: local .families .intersection(&remote.families) .copied() .filter(|family| version != 5 || matches!(family, 4 | 6)) .collect(), atomicities: local .atomicities .intersection(&remote.atomicities) .copied() .collect(), schemes: local .schemes .intersection(&remote.schemes) .copied() .filter(|scheme| version != 5 || *scheme == 1) .collect(), })}func SelectProfile(local, remote Offer, floor uint64, legacyAllowed bool) (Profile, error) { for _, s := range [][]uint64{local.Versions, local.Families, local.Atomicities, local.Schemes, remote.Versions, remote.Families, remote.Atomicities, remote.Schemes} { if !strictlySorted(s) { return Profile{}, ErrFormat } } for _, v := range []uint64{6, 5} { if v >= floor && (v != 5 || legacyAllowed) && slices.Contains(local.Versions, v) && slices.Contains(remote.Versions, v) { families := intersect(local.Families, remote.Families) schemes := intersect(local.Schemes, remote.Schemes) if v == 5 { families = intersect(families, []uint64{4, 6}) schemes = intersect(schemes, []uint64{1}) } return Profile{Version: v, Families: families, Atomicities: intersect(local.Atomicities, remote.Atomicities), Schemes: schemes}, nil } } return Profile{}, ErrVersion}Negotiation checks#
A mismatched or expired offer is INVALID_SOURCE and a failed offer signature is INVALID_SIGNATURE, both before a profile is selected. The confirmation is then verified with the remote key, and the channel takes the earlier expiry.
pub fn negotiate(context: &Negotiation, signed: SignedOffers<'_>) -> Result<Channel> { let local = signed.local; let remote = signed.remote; if local.deployment != context.deployment || remote.deployment != context.deployment || local.sender != context.local || local.receiver != context.remote || remote.sender != context.remote || remote.receiver != context.local || local.challenge != context.challenge || remote.challenge != context.challenge || local.expires < context.now || remote.expires < context.now { return Err(WireError::InvalidSource); } if !verify( &context.local_key, &offer_bytes(local), signed.local_signature, ) || !verify( &context.remote_key, &offer_bytes(remote), signed.remote_signature, ) { return Err(WireError::InvalidSignature); } let profile = select_profile(local, remote, context.floor, context.legacy_allowed)?; if !verify( &context.remote_key, &confirmation_bytes(local, remote, &profile), signed.confirmation, ) { return Err(WireError::InvalidSignature); } Ok(Channel { profile, deployment: context.deployment, local: context.local, remote: context.remote, expires: local.expires.min(remote.expires), challenge: context.challenge, peer_keys: [context.local_key, context.remote_key], })}func Negotiate(context Negotiation, signed SignedOffers) (Channel, error) { l, r := signed.Local, signed.Remote if l.Deployment != context.Deployment || r.Deployment != context.Deployment || l.Sender != context.Local || l.Receiver != context.Remote || r.Sender != context.Remote || r.Receiver != context.Local || l.Challenge != context.Challenge || r.Challenge != context.Challenge || l.Expires < context.Now || r.Expires < context.Now { return Channel{}, ErrSource } if !verify(context.LocalKey, OfferBytes(l), signed.LocalSignature) || !verify(context.RemoteKey, OfferBytes(r), signed.RemoteSignature) { return Channel{}, ErrSignature } profile, err := SelectProfile(l, r, context.Floor, context.LegacyAllowed) if err != nil { return Channel{}, err } if !verify(context.RemoteKey, ConfirmationBytes(l, r, profile), signed.Confirmation) { return Channel{}, ErrSignature } return Channel{profile: profile, deployment: context.Deployment, local: context.Local, remote: context.Remote, challenge: context.Challenge, expires: min(l.Expires, r.Expires), keys: [2][32]byte{context.LocalKey, context.RemoteKey}}, nil}In the conformance vectors, only an authenticated legacy-only peer reaches version 5, and an unauthenticated offer selects no profile.
Columnsidoperationinputsexpectedevidence
explicit-authenticated-legacy-common-profile negotiate local_versions=5,6;remote_versions=5;floor=5;auth=true;common_operation=query selected=5;legacy_common_query=true;fallback=false an_authenticated_legacy_only_peer_negotiates_an_explicit_compatibility_profile,legacy_profile_uses_only_the_explicit_compatibility_adapter highest-common-no-authenticated-downgrade negotiate local_versions=5,6;remote_versions=5,6;floor=5;auth=true;requested_lower=5 selected=6;lower_rejected=true the_same_authenticated_offer_negotiates_current_when_both_support_it,authenticated_highest_common_version_cannot_downgrade unauthenticated-offer-and-no-parse-fallback negotiate local_versions=5,6;remote_versions=5;auth=false;modern_parse_failed=true selected=none;legacy_called=false an_unverified_offer_never_selects_a_legacy_or_current_profile,canonical_parse_failure_never_calls_the_legacy_decoder Network coordination context#
DesignCarried from v0.5, without bytes or test vectors in this version
Peer discovery and the complete network exchange of coordination messages belong to the architecture described in v0.5. The reference host installs an authenticated channel out of band and defines a single-hop delivery boundary. A HEARTBEAT layout in the schema does not supply an executable peer-discovery service.
Related Pages#
Edition history#
- 0.6.0: Added this edition. The v0.5 specification remains available at its original addresses.
