Subscribes to the crypto signal channel. Returns an unsubscribe function.
Three of the five variants have a producer, and all three belong to
device verification. Subscribing installs the native observer, so a
listener registered here starts receiving as soon as this call returns;
the channel was silent for the whole of M1 and M2, and this is where that
stops being true.
verification_requested -- another device has asked to verify
itself against this one, and verificationId is what you pass to
acceptVerification. This is the only way this library hands
you that identifier: there is no call that lists inbound flows. The
value itself is the transaction_id on the wire, and before this
signal existed a product had to go and get it -- filter its own
to_device_events for m.key.verification.request and read the field
out of one, which is a protocol detail this library keeps to itself
everywhere else.
trust_changed -- something this library will now say differently about
user changed. Two things produce it and they are indistinguishable
from the value alone, which is why the rule for this variant is to
read rather than to count. A comparison finished and a device belonging
to user moved: getDeviceStatuses for that user says which.
A verification finished by a scanned code moves the same device and
produces no trust_changed at all, so a product that waits on this
variant after confirmScan or submitScannedCode waits
forever. Such a flow announces verification_completed instead, which
is the next bullet and says why the two are not one; reading
getDeviceStatuses is what both of them tell you to do. Or,
when user is your own user id, the account's private signing keys
arrived on this device by gossip from another of your devices, after
requestSelfVerification: getIdentityStatus says so, with
privateKeysHeld === true, and that is the moment a new login becomes
able to sign. A self-verification produces both, on consecutive syncs,
so a product that reads both answers when told is correct under either.
verification_completed -- a flow that was verified by scanning a
code has finished, and verificationId names it. It arrives on both
screens: the one that showed a code and called confirmScan, and
the one that read a code and called submitScannedCode. Without
it a product had no way at all to learn that a code verification
succeeded, because no call returns when the other side acknowledges,
and getVerificationStage would have to be polled.
It is not a trust_changed, and the difference is not cosmetic. In
two of the three modes the protocol defines, nothing about a device
changes at this moment: what those flows verify is an identity. And for
another user, getDeviceStatuses still reads unverified when this
arrives, because verifying them signs their master key and your store
does not carry that signature until a later key query brings it back.
So read the durable answers when you get this, exactly as for
trust_changed, and expect another user's devices to turn verified a
sync or two later rather than instantly.
Only a flow verified by a code produces it. A short-string
comparison announces its completion as trust_changed and nothing
else, and a flow that was refused or timed out announces nothing at
all: getVerificationStage is what says 'cancelled'.
That asymmetry is a known limit of this release, and it is the one
that will cost you code. The two variants carry disjoint halves of the
same fact: trust_changed names a user and no flow, so you cannot tell
which of two verifications with that user finished;
verification_completed names a flow and no trust. So "show a success
screen for this verification" is two paths, and the side that
received an invitation cannot know in advance which it will get,
because the peer decides that by scanning a code or by starting a string
comparison. Hold your own map from verificationId to what you are
showing, and treat either signal as "read the durable answer now".
The fix is additive and is deferred rather than forgotten: every
completed flow announcing verification_completed, with trust_changed
left exactly as it is. Nothing already true would stop being true. It is
not in this release because it reaches back into the short-string flows
settled two milestones ago, and re-settling those belongs to a change
that can carry them rather than to the corner of one that added codes.
unexpected_device and key_missing still have no producer. The
conditions they name do occur, and reach you elsewhere: a missing key
arrives as a rejected decryptEvent with kind missing_key, not
as a key_missing signal here.
When they arrive, and what has to have happened first
Every producer runs inside receiveSyncChanges. Nothing is
announced on a timer, and nothing is announced for an event you have not
fed in. A product that stops syncing stops being told.
An invitation from a device this library has never been told about
builds no flow, and so is not announced. That is not a gap in the
channel; it is the channel refusing to hand you an identifier no call
here would answer to. See acceptVerification for the recovery,
which is unchanged except that you no longer have to read anything out of
the event you kept.
What happens across an unsubscribe, which is less than you might fear
The channel re-offers what is still live rather than replaying what it
once sent. Nothing is queued for a subscriber that is not there; what
happens instead is that nothing is consumed while nobody is listening,
because the native producer does no work at all with no observer
installed. So an invitation that arrives while you are unsubscribed is
still requested when you come back, and the first
receiveSyncChanges after you resubscribe announces it. Subscribe
at start-up if you can, but useEffect(() => onCryptoSignal(h), []) does
not lose those -- with the one exception named below, which is a real
exception and not a hedge.
Four things it genuinely does not do. A comparison's trust_changed is
not re-offered -- ask getDeviceStatuses, which is the durable
answer and always was. Its sibling behaves the other way and the two are
worth keeping apart: the private-keys arrival is re-offered, because
the latch that makes it fire once is only touched while somebody is
listening, so an arrival that happened while you were away is announced on
the first sync after you resubscribe. getIdentityStatus is still
the durable answer to it, and reading that is still the rule.
A hot reload leaves the previous module copy's observer installed
until something subscribes again; an invitation arriving in that window
is consumed by a listener set nothing can reach. An unsubscribe can land
in the last instant of a sync, after the native side has read its
observer and before the signal reaches this module, and an invitation
caught there is not re-offered either -- narrower than it sounds, because
resubscribing before the delivery arrives still receives it, and closing
it entirely would mean your sync call holding a lock across a call into
JavaScript. And one shape of invitation is not re-offered at all: a
peer that opens the comparison
directly, without asking first -- see acceptVerification for who
does that and why it makes no difference to your code -- leaves nothing
behind that can be enumerated on a later sync. The sync that carried it
is its only witness, which is why "subscribe at start-up if you can"
above is the stronger advice for it.
A listener that throws does not affect the others, and does not affect
the sync that produced the signal: delivery happens on a thread of the
library's own, after the call that caused it has completed.
It throws rather than hand you a channel that is not there
Returning normally has exactly one meaning: the native observer is
installed and this listener is on it. Installing is the only thing this
call does that can fail, and it fails as a whole, so a failure is
reported by throwing out of here and never by returning an unsubscribe
function for something that will not deliver. The realistic cause is the
native module not being reachable, which index.ts describes; the throw
is whatever the generated binding raised, not a type of this library's
own, because there is nothing this layer could add to it.
Nothing is left behind by a throw. The listener is not registered, so a
caller that catches one is not subscribed and is not silently holding the
observer open behind an empty set, and the next call here attempts the
install again rather than assuming it is beyond help.
The ordinary integration subscribes inside an effect, where a throw
reaches the nearest error boundary rather than the code that called this,
and that is the intended outcome and not a wrinkle: it is the same
treatment the rest of this surface gives an unusable native module, and
it is louder than any value this function could return, all of which a
caller is free to ignore. What a product must never be able to do is wait
on this channel for a signal that was never going to arrive.
Subscribes to the crypto signal channel. Returns an unsubscribe function.
Three of the five variants have a producer, and all three belong to device verification. Subscribing installs the native observer, so a listener registered here starts receiving as soon as this call returns; the channel was silent for the whole of M1 and M2, and this is where that stops being true.
verification_requested-- another device has asked to verify itself against this one, andverificationIdis what you pass to acceptVerification. This is the only way this library hands you that identifier: there is no call that lists inbound flows. The value itself is thetransaction_idon the wire, and before this signal existed a product had to go and get it -- filter its ownto_device_eventsform.key.verification.requestand read the field out of one, which is a protocol detail this library keeps to itself everywhere else.trust_changed-- something this library will now say differently aboutuserchanged. Two things produce it and they are indistinguishable from the value alone, which is why the rule for this variant is to read rather than to count. A comparison finished and a device belonging tousermoved: getDeviceStatuses for that user says which. A verification finished by a scanned code moves the same device and produces notrust_changedat all, so a product that waits on this variant after confirmScan or submitScannedCode waits forever. Such a flow announcesverification_completedinstead, which is the next bullet and says why the two are not one; reading getDeviceStatuses is what both of them tell you to do. Or, whenuseris your own user id, the account's private signing keys arrived on this device by gossip from another of your devices, after requestSelfVerification: getIdentityStatus says so, withprivateKeysHeld === true, and that is the moment a new login becomes able to sign. A self-verification produces both, on consecutive syncs, so a product that reads both answers when told is correct under either.verification_completed-- a flow that was verified by scanning a code has finished, andverificationIdnames it. It arrives on both screens: the one that showed a code and called confirmScan, and the one that read a code and called submitScannedCode. Without it a product had no way at all to learn that a code verification succeeded, because no call returns when the other side acknowledges, and getVerificationStage would have to be polled.It is not a
trust_changed, and the difference is not cosmetic. In two of the three modes the protocol defines, nothing about a device changes at this moment: what those flows verify is an identity. And for another user, getDeviceStatuses still reads unverified when this arrives, because verifying them signs their master key and your store does not carry that signature until a later key query brings it back. So read the durable answers when you get this, exactly as fortrust_changed, and expect another user's devices to turn verified a sync or two later rather than instantly.Only a flow verified by a code produces it. A short-string comparison announces its completion as
trust_changedand nothing else, and a flow that was refused or timed out announces nothing at all: getVerificationStage is what says'cancelled'.That asymmetry is a known limit of this release, and it is the one that will cost you code. The two variants carry disjoint halves of the same fact:
trust_changednames a user and no flow, so you cannot tell which of two verifications with that user finished;verification_completednames a flow and no trust. So "show a success screen for this verification" is two paths, and the side that received an invitation cannot know in advance which it will get, because the peer decides that by scanning a code or by starting a string comparison. Hold your own map fromverificationIdto what you are showing, and treat either signal as "read the durable answer now".The fix is additive and is deferred rather than forgotten: every completed flow announcing
verification_completed, withtrust_changedleft exactly as it is. Nothing already true would stop being true. It is not in this release because it reaches back into the short-string flows settled two milestones ago, and re-settling those belongs to a change that can carry them rather than to the corner of one that added codes.unexpected_deviceandkey_missingstill have no producer. The conditions they name do occur, and reach you elsewhere: a missing key arrives as a rejected decryptEvent with kindmissing_key, not as akey_missingsignal here.When they arrive, and what has to have happened first
Every producer runs inside receiveSyncChanges. Nothing is announced on a timer, and nothing is announced for an event you have not fed in. A product that stops syncing stops being told.
An invitation from a device this library has never been told about builds no flow, and so is not announced. That is not a gap in the channel; it is the channel refusing to hand you an identifier no call here would answer to. See acceptVerification for the recovery, which is unchanged except that you no longer have to read anything out of the event you kept.
What happens across an unsubscribe, which is less than you might fear
The channel re-offers what is still live rather than replaying what it once sent. Nothing is queued for a subscriber that is not there; what happens instead is that nothing is consumed while nobody is listening, because the native producer does no work at all with no observer installed. So an invitation that arrives while you are unsubscribed is still
requestedwhen you come back, and the first receiveSyncChanges after you resubscribe announces it. Subscribe at start-up if you can, butuseEffect(() => onCryptoSignal(h), [])does not lose those -- with the one exception named below, which is a real exception and not a hedge.Four things it genuinely does not do. A comparison's
trust_changedis not re-offered -- ask getDeviceStatuses, which is the durable answer and always was. Its sibling behaves the other way and the two are worth keeping apart: the private-keys arrival is re-offered, because the latch that makes it fire once is only touched while somebody is listening, so an arrival that happened while you were away is announced on the first sync after you resubscribe. getIdentityStatus is still the durable answer to it, and reading that is still the rule. A hot reload leaves the previous module copy's observer installed until something subscribes again; an invitation arriving in that window is consumed by a listener set nothing can reach. An unsubscribe can land in the last instant of a sync, after the native side has read its observer and before the signal reaches this module, and an invitation caught there is not re-offered either -- narrower than it sounds, because resubscribing before the delivery arrives still receives it, and closing it entirely would mean your sync call holding a lock across a call into JavaScript. And one shape of invitation is not re-offered at all: a peer that opens the comparison directly, without asking first -- see acceptVerification for who does that and why it makes no difference to your code -- leaves nothing behind that can be enumerated on a later sync. The sync that carried it is its only witness, which is why "subscribe at start-up if you can" above is the stronger advice for it.A listener that throws does not affect the others, and does not affect the sync that produced the signal: delivery happens on a thread of the library's own, after the call that caused it has completed.
It throws rather than hand you a channel that is not there
Returning normally has exactly one meaning: the native observer is installed and this listener is on it. Installing is the only thing this call does that can fail, and it fails as a whole, so a failure is reported by throwing out of here and never by returning an unsubscribe function for something that will not deliver. The realistic cause is the native module not being reachable, which
index.tsdescribes; the throw is whatever the generated binding raised, not a type of this library's own, because there is nothing this layer could add to it.Nothing is left behind by a throw. The listener is not registered, so a caller that catches one is not subscribed and is not silently holding the observer open behind an empty set, and the next call here attempts the install again rather than assuming it is beyond help.
The ordinary integration subscribes inside an effect, where a throw reaches the nearest error boundary rather than the code that called this, and that is the intended outcome and not a wrinkle: it is the same treatment the rest of this surface gives an unusable native module, and it is louder than any value this function could return, all of which a caller is free to ignore. What a product must never be able to do is wait on this channel for a signal that was never going to arrive.