Skip to content

Commit c4c0727

Browse files
committed
Sync exchange v2: ensure implicit aborts go to terminal state
1 parent 2d27907 commit c4c0727

5 files changed

Lines changed: 245 additions & 114 deletions

File tree

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcher.kt

Lines changed: 50 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.PAIRING_CANCELLED
2525
import com.duckduckgo.sync.impl.AccountErrorCodes.PAIRING_FAILED
2626
import com.duckduckgo.sync.impl.AccountErrorCodes.PAIRING_REJECTED
2727
import com.duckduckgo.sync.impl.AccountErrorCodes.PAIRING_UNAVAILABLE
28+
import com.duckduckgo.sync.impl.AccountErrorCodes.UNEXPECTED_EVENT
2829
import com.duckduckgo.sync.impl.exchange.v2.ExchangeV2CodeParseResult
2930
import com.duckduckgo.sync.impl.exchange.v2.ExchangeV2Event
3031
import com.duckduckgo.sync.impl.exchange.v2.ExchangeV2Message
@@ -120,40 +121,9 @@ class RealSyncCodeDispatcher @Inject constructor(
120121
}
121122
}
122123

123-
/**
124-
* Translate one runner event into a [DispatchOutcome] for the v2 Presenter flow,
125-
* or null for intermediate events the caller should ignore. Both Host and Joiner roles are
126-
* reachable here; mirrors [mapV2LinkingEventToOutcome] (login on Joiner.Done, preserve abort reason).
127-
*/
128124
private fun mapV2PresentEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
129125
is ExchangeV2Event.SessionStarted -> event.linkingCode?.let { DispatchOutcome.LinkingCodeReady(it) }
130-
is ExchangeV2Event.Transition -> when (event.to) {
131-
ExchangeV2State.Joiner.Confirming ->
132-
DispatchOutcome.JoinerConfirmationRequested(peerName = runner.peerName, peerKind = peerKind)
133-
ExchangeV2State.Host.Confirming ->
134-
DispatchOutcome.HostConfirmationRequested(peerName = runner.peerName, peerKind = peerKind)
135-
ExchangeV2State.Host.Done -> DispatchOutcome.LoggedIn(SetupPath.PAIRING, SetupRole.HOST, peerKind)
136-
ExchangeV2State.Host.Aborted -> hostAbortedToOutcome(event.localTrigger)
137-
ExchangeV2State.SameAccountAbort -> DispatchOutcome.AlreadyConnected
138-
ExchangeV2State.Joiner.Done -> {
139-
val received = (event.trigger as? ExchangeV2Message.RecoveryCodeResponse)?.recoveryCode
140-
if (received.isNullOrBlank()) {
141-
DispatchOutcome.Failed("Pairing completed without a recovery code", NO_RECOVERY_CODE.code)
142-
} else {
143-
loginWithV2RecoveryCode(received, peerKind)
144-
}
145-
}
146-
ExchangeV2State.Joiner.AbortedByHost -> when (event.trigger) {
147-
is ExchangeV2Message.RecoveryCodeDenied ->
148-
DispatchOutcome.Failed("Pairing declined by peer", PAIRING_REJECTED.code)
149-
is ExchangeV2Message.RecoveryCodeUnavailable ->
150-
DispatchOutcome.Failed("Peer has no recovery code", PAIRING_UNAVAILABLE.code)
151-
else -> DispatchOutcome.Failed("Pairing aborted by peer", PAIRING_REJECTED.code)
152-
}
153-
ExchangeV2State.Joiner.AbortedLocal -> DispatchOutcome.Failed("Pairing cancelled on this device", PAIRING_CANCELLED.code)
154-
ExchangeV2State.Aborted -> DispatchOutcome.Failed("negotiation_aborted", NEGOTIATION_ABORTED.code)
155-
else -> null
156-
}
126+
is ExchangeV2Event.Transition -> mapV2Transition(event, peerKind)
157127
is ExchangeV2Event.SessionError -> sessionErrorToOutcome(event.message)
158128
else -> null
159129
}
@@ -317,41 +287,41 @@ class RealSyncCodeDispatcher @Inject constructor(
317287
-> true
318288
}
319289

320-
/**
321-
* Translate one runner event into a terminal [DispatchOutcome] for the v2 scanner flow,
322-
* or null for intermediate events the caller should ignore.
323-
*/
324290
private fun mapV2LinkingEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
325-
is ExchangeV2Event.Transition -> when (event.to) {
326-
ExchangeV2State.Joiner.Confirming ->
327-
DispatchOutcome.JoinerConfirmationRequested(peerName = runner.peerName, peerKind = peerKind)
328-
ExchangeV2State.Host.Confirming ->
329-
DispatchOutcome.HostConfirmationRequested(peerName = runner.peerName, peerKind = peerKind)
330-
ExchangeV2State.Joiner.Done -> {
331-
val received = (event.trigger as? ExchangeV2Message.RecoveryCodeResponse)?.recoveryCode
332-
if (received.isNullOrBlank()) {
333-
DispatchOutcome.Failed("Pairing completed without a recovery code", NO_RECOVERY_CODE.code)
334-
} else {
335-
loginWithV2RecoveryCode(received, peerKind)
336-
}
337-
}
338-
ExchangeV2State.Joiner.AbortedByHost -> when (event.trigger) {
339-
is ExchangeV2Message.RecoveryCodeDenied ->
340-
DispatchOutcome.Failed("Pairing declined by peer", PAIRING_REJECTED.code)
341-
is ExchangeV2Message.RecoveryCodeUnavailable ->
342-
DispatchOutcome.Failed("Peer has no recovery code", PAIRING_UNAVAILABLE.code)
343-
else -> DispatchOutcome.Failed("Pairing aborted by peer", PAIRING_REJECTED.code)
291+
is ExchangeV2Event.Transition -> mapV2Transition(event, peerKind)
292+
is ExchangeV2Event.SessionError -> sessionErrorToOutcome(event.message)
293+
else -> null
294+
}
295+
296+
private fun mapV2Transition(
297+
transition: ExchangeV2Event.Transition,
298+
peerKind: PeerKind?,
299+
): DispatchOutcome? = when (transition.to) {
300+
ExchangeV2State.Joiner.Confirming ->
301+
DispatchOutcome.JoinerConfirmationRequested(peerName = runner.peerName, peerKind = peerKind)
302+
ExchangeV2State.Host.Confirming ->
303+
DispatchOutcome.HostConfirmationRequested(peerName = runner.peerName, peerKind = peerKind)
304+
ExchangeV2State.Host.Done -> DispatchOutcome.LoggedIn(SetupPath.PAIRING, SetupRole.HOST, peerKind)
305+
ExchangeV2State.Host.Aborted -> hostAbortedToOutcome(transition.localTrigger, transition.trigger)
306+
// Per spec §"Same-account case": not an abort; both devices share an account already.
307+
ExchangeV2State.SameAccountAbort -> DispatchOutcome.AlreadyConnected
308+
ExchangeV2State.Joiner.Done -> {
309+
val received = (transition.trigger as? ExchangeV2Message.RecoveryCodeResponse)?.recoveryCode
310+
if (received.isNullOrBlank()) {
311+
DispatchOutcome.Failed("joiner_done_missing_recovery_code", NO_RECOVERY_CODE.code)
312+
} else {
313+
loginWithV2RecoveryCode(received, peerKind)
344314
}
345-
ExchangeV2State.Joiner.AbortedLocal -> DispatchOutcome.Failed("Pairing cancelled on this device", PAIRING_CANCELLED.code)
346-
ExchangeV2State.Host.Aborted -> hostAbortedToOutcome(event.localTrigger)
347-
// Per spec §"Same-account case": not an abort; both devices share an account already.
348-
ExchangeV2State.SameAccountAbort -> DispatchOutcome.AlreadyConnected
349-
// Elected Host and shared a recovery code — success from this device's perspective.
350-
ExchangeV2State.Host.Done -> DispatchOutcome.LoggedIn(SetupPath.PAIRING, SetupRole.HOST, peerKind)
351-
ExchangeV2State.Aborted -> DispatchOutcome.Failed("negotiation_aborted", NEGOTIATION_ABORTED.code)
352-
else -> null
353315
}
354-
is ExchangeV2Event.SessionError -> sessionErrorToOutcome(event.message)
316+
ExchangeV2State.Joiner.AbortedByHost -> when (transition.trigger) {
317+
is ExchangeV2Message.RecoveryCodeDenied ->
318+
DispatchOutcome.Failed("peer_denied_recovery_code", PAIRING_REJECTED.code)
319+
is ExchangeV2Message.RecoveryCodeUnavailable ->
320+
DispatchOutcome.Failed("peer_recovery_code_unavailable", PAIRING_UNAVAILABLE.code)
321+
else -> DispatchOutcome.Failed("peer_aborted", PAIRING_REJECTED.code)
322+
}
323+
ExchangeV2State.Joiner.AbortedLocal -> joinerAbortedLocalToOutcome(transition.localTrigger, transition.trigger)
324+
ExchangeV2State.Aborted -> DispatchOutcome.Failed("negotiation_aborted", NEGOTIATION_ABORTED.code)
355325
else -> null
356326
}
357327

@@ -364,12 +334,25 @@ class RealSyncCodeDispatcher @Inject constructor(
364334
}
365335
}
366336

367-
private fun hostAbortedToOutcome(localTrigger: LocalTrigger?): DispatchOutcome = when (localTrigger) {
368-
LocalTrigger.UserDeniedHost -> DispatchOutcome.Failed("user_denied", PAIRING_CANCELLED.code)
369-
LocalTrigger.HostUnavailable -> DispatchOutcome.Failed("host_unavailable", PAIRING_UNAVAILABLE.code)
337+
private fun hostAbortedToOutcome(
338+
localTrigger: LocalTrigger?,
339+
wireTrigger: ExchangeV2Message?,
340+
): DispatchOutcome = when {
341+
localTrigger == LocalTrigger.UserDeniedHost -> DispatchOutcome.Failed("user_denied", PAIRING_CANCELLED.code)
342+
localTrigger == LocalTrigger.HostUnavailable -> DispatchOutcome.Failed("host_unavailable", PAIRING_UNAVAILABLE.code)
343+
wireTrigger != null -> DispatchOutcome.Failed("host_protocol_error", UNEXPECTED_EVENT.code)
370344
else -> DispatchOutcome.Failed("host_aborted", NEGOTIATION_ABORTED.code)
371345
}
372346

347+
private fun joinerAbortedLocalToOutcome(
348+
localTrigger: LocalTrigger?,
349+
wireTrigger: ExchangeV2Message?,
350+
): DispatchOutcome = when {
351+
localTrigger == LocalTrigger.UserDeniedJoiner -> DispatchOutcome.Failed("user_denied_joiner", PAIRING_CANCELLED.code)
352+
wireTrigger != null -> DispatchOutcome.Failed("joiner_protocol_error", UNEXPECTED_EVENT.code)
353+
else -> DispatchOutcome.Failed("joiner_local_aborted", PAIRING_FAILED.code)
354+
}
355+
373356
/**
374357
* Apply a v2 recovery code received over the LinkingV2 channel (raw base64
375358
* `{recovery:{user_id, secret, cid, v}}`). Per spec §"Exchange Share Recovery Code → Joiner":
@@ -433,17 +416,6 @@ class RealSyncCodeDispatcher @Inject constructor(
433416
Base64.encodeToString(bytes, Base64.NO_WRAP)
434417
}.getOrNull()
435418

436-
/**
437-
* Map a repo [Result] to a [DispatchOutcome].
438-
*
439-
* If [codeForAccountSwitch] is supplied and the repo failed with [ALREADY_SIGNED_IN], switch
440-
* accounts transparently via [SyncAccountRepository.logoutAndJoinNewAccount] with no prompt:
441-
* the Confirmations phase is already the consent step, so the v1 `AskToSwitchAccount` dialog
442-
* does not apply.
443-
*
444-
* [codeForAccountSwitch] must be a v1 b64 shape [SyncAccountRepository.parseSyncAuthCode] can
445-
* re-parse, so v2 callers convert first (see [encodeV1RecoveryCodeAsB64]).
446-
*/
447419
/** Map the runner's raw peer credential id ("ddg"/"3party") to the telemetry [PeerKind], or null. */
448420
private fun String?.toPeerKind(): PeerKind? = when (this) {
449421
CID_DDG -> PeerKind.DDG

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,6 +1493,7 @@ enum class AccountErrorCodes(val code: Int) {
14931493
NEGOTIATION_ABORTED(61),
14941494
NO_RECOVERY_CODE(62),
14951495
PAIRING_FAILED(63),
1496+
UNEXPECTED_EVENT(64),
14961497
}
14971498

14981499
sealed interface SyncAuthCode {

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/ExchangeV2StateMachine.kt

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ internal class RealExchangeV2StateMachine(
8282
ExchangeV2State.Negotiating -> receiveInNegotiating(state, msg)
8383
ExchangeV2State.Joiner.Confirming -> receiveInJoinerConfirming(state, msg)
8484
ExchangeV2State.Joiner.Waiting -> receiveInJoinerWaiting(state, msg)
85-
// All other states: any known incoming message is an implicit abort.
8685
else -> abort(state, msg, RejectReason.ImplicitAbort)
8786
}
8887
}
@@ -98,7 +97,11 @@ internal class RealExchangeV2StateMachine(
9897
}
9998

10099
private fun receiveInBootstrapped(state: ExchangeV2State, msg: ExchangeV2Message): TransitionResult {
101-
return if (msg is Hello) accept(state, ExchangeV2State.Negotiating, msg) else abort(state, msg, RejectReason.ImplicitAbort)
100+
return if (msg is Hello) {
101+
accept(state, ExchangeV2State.Negotiating, msg)
102+
} else {
103+
abort(state, msg, RejectReason.ImplicitAbort)
104+
}
102105
}
103106

104107
private fun receiveInNegotiating(state: ExchangeV2State, msg: ExchangeV2Message): TransitionResult {
@@ -107,7 +110,7 @@ internal class RealExchangeV2StateMachine(
107110
// (Scanner: by scanning the QR; Presenter: consumed in receiveInBootstrapped).
108111
// Any further hello is a duplicate or the double-scan race → abort and close the
109112
// channel. Deliberate "record a pixel; abort (scope-cut)" (pixel deferred: Asana 1215473364991760).
110-
is Hello -> abort(state, msg, RejectReason.ImplicitAbort, newState = ExchangeV2State.Aborted)
113+
is Hello -> abort(state, msg, RejectReason.ImplicitAbort)
111114
is RecoveryCodeAvailable -> {
112115
if (localUserId != null && msg.userId == localUserId) {
113116
abort(state, msg, RejectReason.SameAccount, newState = ExchangeV2State.SameAccountAbort)
@@ -131,16 +134,8 @@ internal class RealExchangeV2StateMachine(
131134
}
132135
}
133136

134-
/**
135-
* The SM-spec diagram (1215056232572322) lists no valid *incoming wire message* in
136-
* Joiner.Confirming — the only expected transitions out are the local UserConfirmedJoiner /
137-
* UserDeniedJoiner — so per the implicit-abort rule every received message here would abort.
138-
* We extend that: if the peer explicitly tells us they're aborting (recovery_code_denied /
139-
* recovery_code_unavailable), we accept it and end the session instead of making the user
140-
* confirm a doomed pairing first. The remaining host-side messages (awaiting_confirmation,
141-
* confirmed, response) are still rejected here; the runner buffers them and replays after
142-
* the user confirms.
143-
*/
137+
// If the peer aborts while we're still showing the confirm prompt, act on it now instead of
138+
// making the user confirm a doomed pairing.
144139
private fun receiveInJoinerConfirming(state: ExchangeV2State, msg: ExchangeV2Message): TransitionResult {
145140
return when (msg) {
146141
is RecoveryCodeDenied -> accept(state, ExchangeV2State.Joiner.AbortedByHost, msg)
@@ -260,7 +255,7 @@ internal class RealExchangeV2StateMachine(
260255
from: ExchangeV2State,
261256
msg: ExchangeV2Message,
262257
reason: RejectReason,
263-
newState: ExchangeV2State = from,
258+
newState: ExchangeV2State = from.abortTerminal(),
264259
): TransitionResult {
265260
currentState = newState
266261
val event = if (newState == from) {
@@ -285,11 +280,11 @@ internal class RealExchangeV2StateMachine(
285280
from: ExchangeV2State,
286281
trigger: LocalTrigger,
287282
): TransitionResult {
288-
// Local triggers that are out-of-sequence are protocol misuse from the runner. We
289-
// surface them as a synthetic rejection rather than crashing
283+
val newState = from.abortTerminal()
284+
currentState = newState
290285
return TransitionResult(
291-
newState = from,
292-
event = ExchangeV2Event.Transition(clock.nowMs(), from, from, trigger = null, localTrigger = trigger),
286+
newState = newState,
287+
event = ExchangeV2Event.Transition(clock.nowMs(), from, newState, trigger = null, localTrigger = trigger),
293288
outcome = TransitionOutcome.Aborted(RejectReason.ImplicitAbort),
294289
)
295290
}
@@ -302,3 +297,18 @@ internal class RealExchangeV2StateMachine(
302297
)
303298
}
304299
}
300+
301+
/** Terminal state to drive into on an implicit abort from [this]. Terminals return themselves. */
302+
private fun ExchangeV2State.abortTerminal(): ExchangeV2State = when (this) {
303+
ExchangeV2State.Host.Confirming, ExchangeV2State.Host.Sending -> ExchangeV2State.Host.Aborted
304+
ExchangeV2State.Joiner.Confirming, ExchangeV2State.Joiner.Waiting -> ExchangeV2State.Joiner.AbortedLocal
305+
ExchangeV2State.Bootstrapped, ExchangeV2State.Negotiating -> ExchangeV2State.Aborted
306+
ExchangeV2State.Aborted,
307+
ExchangeV2State.SameAccountAbort,
308+
ExchangeV2State.Host.Aborted,
309+
ExchangeV2State.Host.Done,
310+
ExchangeV2State.Joiner.AbortedByHost,
311+
ExchangeV2State.Joiner.AbortedLocal,
312+
ExchangeV2State.Joiner.Done,
313+
-> this
314+
}

0 commit comments

Comments
 (0)