Skip to content

Commit 39fead5

Browse files
SashaMITcursoragent
andcommitted
fix(dkms): bind re-seal AAD into the recover possession-proof (close pre-mainnet invariant)
The dKMS node re-seals a recovered CEK under the caller-supplied `aad_b64`, which was NOT bound into the recover possession-proof. A MITM that tampered `aad_b64` in transit could make the node seal under an AAD of its choosing; it was safe only because the decrypt boundary independently rebuilt the AAD and failed closed (a compensating control, not a fix). Now the canonical possession-proof preimage binds `sha256(reseal_aad)` (`ddrm_envelope::recover_proof_message`, domain bumped v1 -> v2). The client signs over the exact AAD it sends (key-provider), and the node verifies the proof over the byte-identical `args.aad_b64` in `verify_session` BEFORE any CEK is recovered or re-sealed. The AAD (DecryptTranscriptV1) already carries `node_set_id` + `segment_digests`, so all three are bound transitively; the 32-byte digest keeps the preimage bounded for long presentations. A MITM cannot re-sign the proof (it lacks the token-bound caller key), so a tampered `aad_b64` now fails closed at the node (`session_invalid`). The decrypt boundary's rebuild remains as defense-in-depth. - ddrm-envelope: recover_proof_message/sign/verify take `reseal_aad`; bind sha256; bump DKMS_RECOVER_DOMAIN to /v2; unit test asserts tampered-AAD -> verify=false. - dkms-authority: verify_session verifies over decode(args.aad_b64) before recover; SECURITY INVARIANT comment rewritten to CLOSED; landing test recover_fails_closed_on_a_tampered_aad (35 legacy / 25 default tests green). - key-provider: recover_proof_b64 + both delegate paths sign over the request's aad_b64. - dev harnesses (ddrm-runtime-open, dkms-live-recover): each direct node recover signs over its request AAD. - docs: THREAT_MODEL §7 + DEPLOY_CHECKLIST + AUDITOR_PACKET §1 flipped open -> closed, with the landing test referenced. Gates: ddrm-envelope + dkms-authority tests, key-provider/dev-script builds, verify-capsules, alignment-check all green. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7feccd3 commit 39fead5

8 files changed

Lines changed: 245 additions & 119 deletions

File tree

capsules/ddrm-envelope/src/lib.rs

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,7 +1057,10 @@ pub const DKMS_SESSION_DOMAIN: &[u8] = b"elastos.dkms.authority/session/v1";
10571057
/// runtime-core analogue of PC2's session being OWNER-BOUND (the bearer token alone is insufficient;
10581058
/// the owner is re-checked, in the TEE via `ecrecover(delegationSig)`, `secureViewSession.ts:87`–`:100`).
10591059
/// Domain-separated from the session token + hello attestation + CEK seals.
1060-
pub const DKMS_RECOVER_DOMAIN: &[u8] = b"elastos.dkms.authority/recover-proof/v1";
1060+
/// v2 (re-seal-AAD binding): the preimage now also binds `sha256(re-seal AAD)`, closing the
1061+
/// pre-mainnet invariant where the node sealed under a caller-supplied AAD it did not authenticate.
1062+
/// Bumped from v1 so a v1 proof can never be misread under v2 semantics.
1063+
pub const DKMS_RECOVER_DOMAIN: &[u8] = b"elastos.dkms.authority/recover-proof/v2";
10611064

10621065
/// Length-prefixed concatenation of variable-length fields into one unambiguous signed preimage:
10631066
/// each field is preceded by its u32(LE) length, so `("a","bc")` and `("ab","c")` never collide.
@@ -1107,55 +1110,86 @@ pub fn verify_session_token(
11071110

11081111
/// Canonical signed preimage of a recover possession proof: the session `challenge`, the
11091112
/// content/recipient binding of THIS recover (`content_id`, `kid_hex`, the decrypt session pubkey),
1110-
/// AND a per-recover FRESHNESS counter (`recover_seq`, Day 95–96). Binding the recover identity means
1111-
/// the proof authorizes recovering THIS content for THIS session; binding a strictly-increasing
1112-
/// `recover_seq` means a captured recover frame replayed verbatim carries a STALE counter the node
1113-
/// has already consumed, so it is refused (anti-replay). The runtime-core analogue of PC2's
1114-
/// per-delegation revocable `nonce` (`secureViewSession.ts:108`–`:112`). Defined ONCE here so the
1115-
/// node + client cannot drift.
1113+
/// a per-recover FRESHNESS counter (`recover_seq`, Day 95–96), AND `sha256(re-seal AAD)` (v2). Binding
1114+
/// the recover identity means the proof authorizes recovering THIS content for THIS session; binding a
1115+
/// strictly-increasing `recover_seq` means a captured recover frame replayed verbatim carries a STALE
1116+
/// counter the node has already consumed, so it is refused (anti-replay); binding the re-seal AAD
1117+
/// digest means the node will only seal under the EXACT AAD the caller proved possession over — a
1118+
/// MITM-tampered `aad_b64` invalidates the proof and is refused at the node (the AAD itself carries
1119+
/// `node_set_id` + `segment_digests`, so all of them are bound transitively). We bind the 32-byte
1120+
/// digest, not the raw AAD, so a long presentation's segment digests don't bloat the preimage. The
1121+
/// runtime-core analogue of PC2's per-delegation revocable `nonce` (`secureViewSession.ts:108`–`:112`).
1122+
/// Defined ONCE here so the node + client cannot drift.
11161123
pub fn recover_proof_message(
11171124
challenge: &[u8],
11181125
content_id: &[u8],
11191126
kid_hex: &[u8],
11201127
decrypt_session_pub: &[u8],
11211128
recover_seq: u64,
1129+
reseal_aad: &[u8],
11221130
) -> Vec<u8> {
1131+
let aad_digest = Sha256::digest(reseal_aad);
11231132
lp_concat(
11241133
DKMS_RECOVER_DOMAIN,
1125-
&[challenge, content_id, kid_hex, decrypt_session_pub, &recover_seq.to_le_bytes()],
1134+
&[
1135+
challenge,
1136+
content_id,
1137+
kid_hex,
1138+
decrypt_session_pub,
1139+
&recover_seq.to_le_bytes(),
1140+
&aad_digest[..],
1141+
],
11261142
)
11271143
}
11281144

11291145
/// The CLIENT side: prove possession of the token-bound ephemeral private key by signing the recover
1130-
/// binding + this recover's freshness counter. The node verifies this against the pubkey the session
1131-
/// token committed to AND that `recover_seq` strictly advances (a replayed frame is refused).
1146+
/// binding + this recover's freshness counter + the re-seal AAD digest. The node verifies this against
1147+
/// the pubkey the session token committed to AND that `recover_seq` strictly advances AND that the AAD
1148+
/// it is about to seal under matches the one signed here (a replayed or AAD-tampered frame is refused).
11321149
pub fn sign_recover_proof(
11331150
signer: &impl seal::CekSealSigner,
11341151
challenge: &[u8],
11351152
content_id: &[u8],
11361153
kid_hex: &[u8],
11371154
decrypt_session_pub: &[u8],
11381155
recover_seq: u64,
1156+
reseal_aad: &[u8],
11391157
) -> Vec<u8> {
1140-
signer.sign(&recover_proof_message(challenge, content_id, kid_hex, decrypt_session_pub, recover_seq))
1158+
signer.sign(&recover_proof_message(
1159+
challenge,
1160+
content_id,
1161+
kid_hex,
1162+
decrypt_session_pub,
1163+
recover_seq,
1164+
reseal_aad,
1165+
))
11411166
}
11421167

11431168
/// The NODE side: verify the caller's possession proof against the token-bound pubkey. `true` only
11441169
/// when `sig` is valid under `verifier` (built from the token's `caller_pub`) over the SAME binding
1145-
/// (including `recover_seq`) — a missing/forged proof, a proof from a different key, a tampered
1146-
/// binding, or a swapped freshness counter all return `false`. Freshness (the strictly-increasing
1147-
/// check) is enforced by the node against its per-session counter, not here.
1170+
/// (including `recover_seq` and `sha256(reseal_aad)`) — a missing/forged proof, a proof from a
1171+
/// different key, a tampered binding, a swapped freshness counter, or a tampered re-seal AAD all
1172+
/// return `false`. Freshness (the strictly-increasing check) is enforced by the node against its
1173+
/// per-session counter, not here.
11481174
pub fn verify_recover_proof(
11491175
verifier: &impl CekSealVerifier,
11501176
challenge: &[u8],
11511177
content_id: &[u8],
11521178
kid_hex: &[u8],
11531179
decrypt_session_pub: &[u8],
11541180
recover_seq: u64,
1181+
reseal_aad: &[u8],
11551182
sig: &[u8],
11561183
) -> bool {
11571184
verifier.verify(
1158-
&recover_proof_message(challenge, content_id, kid_hex, decrypt_session_pub, recover_seq),
1185+
&recover_proof_message(
1186+
challenge,
1187+
content_id,
1188+
kid_hex,
1189+
decrypt_session_pub,
1190+
recover_seq,
1191+
reseal_aad,
1192+
),
11591193
sig,
11601194
)
11611195
}
@@ -2417,27 +2451,31 @@ mod tests {
24172451
let challenge = [0x12u8; 32];
24182452
let (content, kid, sess_pub) = (b"bafContent".as_slice(), b"c5c5".as_slice(), b"sessionpub".as_slice());
24192453
let seq = 1u64;
2420-
let proof = crate::sign_recover_proof(&caller, &challenge, content, kid, sess_pub, seq);
2421-
assert!(crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, sess_pub, seq, &proof));
2454+
let aad = b"re-seal-transcript-aad".as_slice();
2455+
let proof = crate::sign_recover_proof(&caller, &challenge, content, kid, sess_pub, seq, aad);
2456+
assert!(crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, sess_pub, seq, aad, &proof));
24222457

24232458
// A proof from a DIFFERENT key (a captured-token replayer without the private key) fails.
24242459
let (other, _ovk) = crate::seal::mldsa_seal_keypair([0x62u8; 32]);
2425-
let wrong = crate::sign_recover_proof(&other, &challenge, content, kid, sess_pub, seq);
2426-
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, sess_pub, seq, &wrong));
2460+
let wrong = crate::sign_recover_proof(&other, &challenge, content, kid, sess_pub, seq, aad);
2461+
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, sess_pub, seq, aad, &wrong));
24272462

24282463
// A tampered binding (different content / kid / session pub / challenge / freshness seq) fails.
2429-
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, b"bafOTHER", kid, sess_pub, seq, &proof));
2430-
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, b"ffff", sess_pub, seq, &proof));
2431-
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, b"otherpub", seq, &proof));
2432-
assert!(!crate::verify_recover_proof(&caller_verifier, b"otherchal", content, kid, sess_pub, seq, &proof));
2464+
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, b"bafOTHER", kid, sess_pub, seq, aad, &proof));
2465+
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, b"ffff", sess_pub, seq, aad, &proof));
2466+
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, b"otherpub", seq, aad, &proof));
2467+
assert!(!crate::verify_recover_proof(&caller_verifier, b"otherchal", content, kid, sess_pub, seq, aad, &proof));
24332468
// A SWAPPED freshness counter invalidates the proof (the seq is authenticated, not free to alter).
2434-
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, sess_pub, seq + 1, &proof));
2469+
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, sess_pub, seq + 1, aad, &proof));
2470+
// A TAMPERED re-seal AAD (v2 binding) invalidates the proof — the node will not seal under an
2471+
// AAD the caller did not prove possession over. This is the re-seal-AAD invariant, enforced.
2472+
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, sess_pub, seq, b"tampered-aad", &proof));
24352473

24362474
// The possession proof is domain-separated from the session token (different domain prefix).
24372475
assert!(!crate::verify_session_token(&caller_verifier, &challenge, content, 0, &proof));
24382476

24392477
// A malformed signature fails closed.
2440-
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, sess_pub, seq, b"nope"));
2478+
assert!(!crate::verify_recover_proof(&caller_verifier, &challenge, content, kid, sess_pub, seq, aad, b"nope"));
24412479
}
24422480

24432481
/// The socket framing round-trips messages, recovers exact boundaries from a concatenated

capsules/dkms-authority/src/main.rs

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,16 +1025,14 @@ impl DkmsAuthorityNode {
10251025
}
10261026
};
10271027

1028-
// SECURITY INVARIANT (pre-mainnet, scoped with the external auditor): `aad` here is the
1029-
// CALLER-SUPPLIED `args.aad_b64` (decoded above), and it is NOT bound into the recover
1030-
// possession-proof — the node verifies the escrow (recover_escrowed_cek) and the producer,
1031-
// but does NOT independently verify that this re-seal AAD matches the segment-bound
1032-
// transcript / node-set the open claims. Therefore the node's re-seal AAD is NOT
1033-
// independently trustworthy. This is safe TODAY only because the single consumer — the
1034-
// decrypt boundary — rebuilds the segment-bound AAD itself and fails closed on a mismatch;
1035-
// it does not trust this value. DO NOT add a consumer that trusts this re-seal AAD without
1036-
// first binding aad_b64 / segment_digests / node_set_id into the recover possession-proof
1037-
// (so a tampered aad_b64 fails the proof closed here). See docs/THREAT_MODEL.md.
1028+
// SECURITY INVARIANT (re-seal AAD — CLOSED): `aad` here is the caller-supplied `args.aad_b64`,
1029+
// but it is now BOUND into the recover possession-proof: `verify_session` (above, before any CEK
1030+
// is recovered) verifies `verify_recover_proof(.., reseal_aad = decode(args.aad_b64), ..)`, which
1031+
// is the byte-identical AAD passed here. So a MITM-tampered `aad_b64` — including its embedded
1032+
// `node_set_id` / `segment_digests` — invalidates the proof and is refused at the node, fail-closed
1033+
// (test: `recover_fails_closed_on_a_tampered_aad`). The decrypt boundary STILL independently
1034+
// rebuilds the segment-bound AAD and fails closed on any mismatch — defense-in-depth, not the sole
1035+
// control. See docs/THREAT_MODEL.md §7 and docs/AUDITOR_PACKET.md §1.
10381036
let envelope = ddrm_envelope::seal::seal_bound(&public, cek.as_slice(), &aad, &authority.signer);
10391037
let mut material = json!({
10401038
"suite": ddrm_envelope::SUITE_PQ_HYBRID,
@@ -1796,17 +1794,25 @@ fn verify_session(authority: &NodeAuthority, args: &RecoverArgs) -> Result<(), S
17961794
let session_pub = b64()
17971795
.decode(&args.decrypt_session_pub_b64)
17981796
.map_err(|_| "decrypt_session_pub_b64 is not valid base64".to_string())?;
1797+
// RE-SEAL-AAD BINDING (v2): verify the proof over the EXACT AAD this recover will seal under. This
1798+
// is the SAME `args.aad_b64` `recover_inner` decodes and passes to `seal_bound` — so a MITM that
1799+
// tampers `aad_b64` in transit (incl. its embedded node_set_id / segment_digests) makes this proof
1800+
// fail closed HERE, before any CEK is recovered or re-sealed. Closes the pre-mainnet invariant.
1801+
let reseal_aad = b64()
1802+
.decode(&args.aad_b64)
1803+
.map_err(|_| "aad_b64 is not valid base64".to_string())?;
17991804
if !ddrm_envelope::verify_recover_proof(
18001805
&caller_verifier,
18011806
&challenge,
18021807
args.content_id.as_bytes(),
18031808
args.kid_hex.as_bytes(),
18041809
&session_pub,
18051810
args.recover_seq,
1811+
&reseal_aad,
18061812
&caller_sig,
18071813
) {
18081814
return Err(
1809-
"caller possession proof is missing, forged, signed by the wrong key, or carries a swapped freshness counter (captured token replay refused)"
1815+
"caller possession proof is missing, forged, signed by the wrong key, carries a swapped freshness counter, or does not match the re-seal AAD (captured token replay / tampered AAD refused)"
18101816
.to_string(),
18111817
);
18121818
}
@@ -2444,6 +2450,7 @@ mod tests {
24442450
kid_hex: &str,
24452451
session_pub_b64: &str,
24462452
recover_seq: u64,
2453+
aad: &[u8],
24472454
) -> String {
24482455
let challenge = b64().decode(&token.challenge_b64).unwrap();
24492456
let session_pub = b64().decode(session_pub_b64).unwrap();
@@ -2454,6 +2461,7 @@ mod tests {
24542461
kid_hex.as_bytes(),
24552462
&session_pub,
24562463
recover_seq,
2464+
aad,
24572465
))
24582466
}
24592467

@@ -2526,7 +2534,8 @@ mod tests {
25262534
let (caller, caller_vk) = caller_keypair();
25272535
node.allowed_callers = Some(vec![caller_vk.clone()]);
25282536
let token = live_token(&node, &b64().encode(&caller_vk));
2529-
let caller_sig_b64 = proof_for(&caller, &token, CONTENT, &kid_hex, &session_pub_b64, 1);
2537+
let caller_sig_b64 =
2538+
proof_for(&caller, &token, CONTENT, &kid_hex, &session_pub_b64, 1, &transcript_aad);
25302539
let resp = node.recover(RecoverArgs {
25312540
wrapped_cek_b64: b64().encode(wrapped.to_bytes()),
25322541
scheme: scheme.to_string(),
@@ -2603,7 +2612,8 @@ mod tests {
26032612
let (caller, caller_vk) = caller_keypair();
26042613
node.allowed_callers = Some(vec![caller_vk.clone()]);
26052614
let token = live_token(&node, &b64().encode(&caller_vk));
2606-
let caller_sig_b64 = proof_for(&caller, &token, CONTENT, &kid_hex, &session_pub_b64, 1);
2615+
let caller_sig_b64 =
2616+
proof_for(&caller, &token, CONTENT, &kid_hex, &session_pub_b64, 1, b"attest-transcript");
26072617
let data = ok_data(node.recover(RecoverArgs {
26082618
wrapped_cek_b64: b64().encode(wrapped.to_bytes()),
26092619
scheme: scheme.to_string(),
@@ -2693,7 +2703,7 @@ mod tests {
26932703
let (caller, caller_vk) = caller_keypair();
26942704
node.allowed_callers = Some(vec![caller_vk.clone()]);
26952705
let token = live_token(&node, &b64().encode(&caller_vk));
2696-
let caller_sig_b64 = proof_for(&caller, &token, CONTENT, &kid_hex, &session_pub_b64, 1);
2706+
let caller_sig_b64 = proof_for(&caller, &token, CONTENT, &kid_hex, &session_pub_b64, 1, b"t");
26972707
let forged = node.recover(RecoverArgs {
26982708
wrapped_cek_b64: b64().encode(wrapped.to_bytes()),
26992709
scheme: scheme.to_string(),
@@ -2779,8 +2789,8 @@ mod tests {
27792789
node.allowed_callers = Some(vec![caller_vk.clone()]);
27802790
let token = live_token(&node, &b64().encode(&caller_vk));
27812791
// Base case uses freshness seq 1; tests that drive multiple recovers re-sign with the
2782-
// returned caller signer at a higher seq.
2783-
let caller_sig_b64 = proof_for(&caller, &token, CONTENT, &kid_hex, &session_pub_b64, 1);
2792+
// returned caller signer at a higher seq. The proof binds the re-seal AAD (b"transcript").
2793+
let caller_sig_b64 = proof_for(&caller, &token, CONTENT, &kid_hex, &session_pub_b64, 1, b"transcript");
27842794
let args = RecoverArgs {
27852795
wrapped_cek_b64: b64().encode(wrapped.to_bytes()),
27862796
scheme: scheme.to_string(),
@@ -2808,6 +2818,28 @@ mod tests {
28082818
(node, args, caller)
28092819
}
28102820

2821+
/// LANDING TEST for the re-seal-AAD invariant (docs/AUDITOR_PACKET.md §1, THREAT_MODEL §7): a
2822+
/// recover whose `aad_b64` is tampered IN TRANSIT — after the caller signed its possession proof
2823+
/// over the real AAD — is refused at the node because the proof no longer matches, BEFORE any CEK
2824+
/// is recovered or re-sealed. A MITM cannot re-sign the proof (it lacks the token-bound caller
2825+
/// key), so it cannot make the node seal under an AAD of its choosing. This is what makes the
2826+
/// node's re-seal AAD trustworthy on its own; the decrypt boundary's independent rebuild remains
2827+
/// defense-in-depth, not the sole control.
2828+
#[test]
2829+
fn recover_fails_closed_on_a_tampered_aad() {
2830+
let store = unique_store("tampered-aad");
2831+
let (mut node, mut args, _caller) = setup_recover(&store);
2832+
// The possession proof was signed over b"transcript"; flip the AAD the node would seal under.
2833+
args.aad_b64 = b64().encode(b"transcript-TAMPERED");
2834+
let resp = node.recover(args);
2835+
assert_eq!(
2836+
error_code(&resp),
2837+
"session_invalid",
2838+
"a tampered re-seal aad_b64 must fail the possession proof closed at the node"
2839+
);
2840+
let _ = std::fs::remove_file(&store);
2841+
}
2842+
28112843
/// W3/D4 — an ANONYMOUS caller (the node has no allow-list) presenting a valid live session +
28122844
/// a perfectly-formed `allowed:true` receipt but NO wallet-signed grant is REFUSED. This is the
28132845
/// safety property that lets the allow-list be dropped as the security boundary: an unenrolled
@@ -3005,6 +3037,7 @@ mod tests {
30053037
&base.kid_hex,
30063038
&base.decrypt_session_pub_b64,
30073039
base.recover_seq,
3040+
&b64().decode(&base.aad_b64).unwrap(),
30083041
);
30093042
assert_eq!(error_code(&node.recover(wrong_key)), "session_invalid");
30103043

@@ -3068,7 +3101,7 @@ mod tests {
30683101
let proof = {
30693102
let chal = b64().decode(challenge_str(token)).unwrap();
30703103
let dp = b64().decode(&decrypt_pub_b64).unwrap();
3071-
b64().encode(ddrm_envelope::sign_recover_proof(&caller, &chal, CONTENT.as_bytes(), kid_hex.as_bytes(), &dp, 1))
3104+
b64().encode(ddrm_envelope::sign_recover_proof(&caller, &chal, CONTENT.as_bytes(), kid_hex.as_bytes(), &dp, 1, b"transcript"))
30723105
};
30733106
let recover = call(
30743107
&mut client,
@@ -3816,6 +3849,7 @@ mod tests {
38163849
&base.kid_hex,
38173850
&base.decrypt_session_pub_b64,
38183851
2,
3852+
&b64().decode(&base.aad_b64).unwrap(),
38193853
);
38203854
assert_eq!(error_code(&node.recover(live)), "caller_revoked");
38213855

@@ -3882,6 +3916,7 @@ mod tests {
38823916
&base.kid_hex,
38833917
&base.decrypt_session_pub_b64,
38843918
seq,
3919+
&b64().decode(&base.aad_b64).unwrap(),
38853920
);
38863921
assert!(matches!(node.recover(args), Response::Ok { .. }), "recover seq {seq} should succeed");
38873922
}
@@ -3909,7 +3944,7 @@ mod tests {
39093944
let mut next = base.clone();
39103945
next.recover_seq = 2;
39113946
next.caller_sig_b64 =
3912-
proof_for(&caller, &base.session_token, CONTENT, &base.kid_hex, &base.decrypt_session_pub_b64, 2);
3947+
proof_for(&caller, &base.session_token, CONTENT, &base.kid_hex, &base.decrypt_session_pub_b64, 2, &b64().decode(&base.aad_b64).unwrap());
39133948
assert!(matches!(node.recover(next), Response::Ok { .. }));
39143949

39153950
// After consuming seq 2, a recover that regresses to seq 1 (or repeats 2) is refused.

0 commit comments

Comments
 (0)