Status: provided for review, not yet machine-checked in this repository. Tamarin is not installed on the machine these models were written on, so none of the lemmas below has been run through
tamarin-prover. The files were written by hand against Tamarin 1.8+ syntax using only builtin theories plus a small user-defined KEM signature. The first thing a reviewer should do is run them (see "Running") and record the outcome in this file.
| File | Contents |
|---|---|
nyxchat_v4_handshake.spthy |
Direct-link handshake (protocol v4): signed hellos with a per-handshake ML-KEM key, X3DH-style DH quadruple + KEM, initiator-hello hash in the response, responder nonce cache, master secret. 9 rules, 12 lemmas. |
nyxchat_v4_async.spthy |
Part 1: asynchronous (store-and-forward) initiation against pinned keys, both with a one-time ML-KEM prekey that the recipient registered over an authenticated link (consumed on use, private half deleted on acceptance) and with the long-term KEM key as the fallback. Part 2: the concurrent-initiation collision rule with two parties 'a' < 'b', including the ignoredInitEphs blacklist and the abandoned-init announcement (ab). 20 rules, 13 lemmas. |
Naming: HelloMessage.protocolVersion is 4 and the documentation calls the
protocol v4; the domain strings inside lib/core/crypto still read
NyxChat-...-v3 (NyxChat-Hello-v3, NyxChat-Handshake-v3,
NyxChat-Async-Session-v3, NyxChat-ID-v3), while the one-time-prekey
variant added in 3.2 is labelled NyxChat-Async-Prekey-Session-v4. The
models mirror the code and use the labels as opaque public constants;
renaming them changes nothing in the proofs.
Tamarin needs the Maude rewriting engine (3.1 or newer; 3.2+ recommended)
on PATH, and GraphViz (dot) for the interactive GUI.
macOS (Homebrew)
brew install tamarin-prover/tap/tamarin-prover
This pulls in Maude and GraphViz.
Linux (the recipe used for RESULTS.md and by .github/workflows/formal.yml;
no root needed)
mkdir -p ~/.local/bin && cd /tmp
curl -sSL -o tamarin.tar.gz https://github.com/tamarin-prover/tamarin-prover/releases/download/1.12.0/tamarin-prover-1.12.0-linux64-ubuntu.tar.gz
tar xzf tamarin.tar.gz && install -m 755 tamarin-prover ~/.local/bin/
curl -sSL -o maude.zip https://github.com/maude-lang/Maude/releases/download/Maude3.5.1/Maude-3.5.1-linux-x86_64.zip
unzip -oq maude.zip -d maude && install -m 755 maude/maude ~/.local/bin/ && cp maude/*.maude ~/.local/bin/
export PATH=$HOME/.local/bin:$PATH
Maude looks for prelude.maude next to its binary, hence the cp. Any
Maude 3.x works; the distribution package (apt install maude) is fine if
maude --version reports 3.1 or newer. apt install graphviz is needed only
for the interactive GUI and for the GraphViz line of tamarin-prover test.
Windows: use WSL2 and follow the Linux steps. The Tamarin manual (https://tamarin-prover.com/manual/) has the authoritative instructions.
Check the installation with tamarin-prover test (without GraphViz it ends
with "Some tests failed"; the 55 unification cases must all pass).
The script used locally and by CI runs the well-formedness pass and then
every lemma of formal/lemmas.conf under its own time limit, writes one
log per lemma plus summary.md to build/formal/, and exits non-zero on a
timeout or on a falsified lemma that RESULTS.md does not list as accepted:
tool/prove_formal.sh # everything, one lemma at a time
tool/prove_formal.sh --jobs 2 --skip-long # what CI runs
tool/prove_formal.sh --only secrecy # lemmas matching a regex
By hand, from the repository root:
# Parse, type-check and report well-formedness warnings (no proving).
# The message-derivation check needs more than its default 5 s here:
tamarin-prover --derivcheck-timeout=120 formal/nyxchat_v4_handshake.spthy
tamarin-prover --derivcheck-timeout=120 formal/nyxchat_v4_async.spthy
# Prove every lemma (the summary at the end lists verified / falsified):
tamarin-prover --prove formal/nyxchat_v4_handshake.spthy
tamarin-prover --prove formal/nyxchat_v4_async.spthy
# A single lemma:
tamarin-prover --prove=forward_secrecy formal/nyxchat_v4_handshake.spthy
# Interactive exploration (open http://127.0.0.1:3001):
tamarin-prover interactive formal/
Tamarin uses all cores by default; a single --prove run of one lemma
took up to about 3 GB of RAM here, so run at most two or three lemmas in
parallel on an 8 GB machine.
Sources. No sources lemma is needed: with the pinned-key pattern
described under "Abstractions" the raw sources of both theories are
complete ("17 cases, deconstructions complete" for the handshake model in
the interactive view). The message [Open Chains] Too many chain constraints, stopping precomputation that Tamarin prints while checking
the handshake model comes from the message-derivation checker's own
precomputation, not from the proof's; it is harmless.
Every lemma has a comment in the file stating what it means. The table
lists what Tamarin 1.12.0 reported (details, timings and command lines in
RESULTS.md).
| File | Lemma | Kind | Outcome |
|---|---|---|---|
| handshake | executable |
exists-trace | trace found |
| handshake | secrecy_master |
all-traces | verified |
| handshake | forward_secrecy |
all-traces | verified |
| handshake | kem_only_secrecy |
all-traces | verified |
| handshake | kem_only_forward_secrecy |
all-traces | verified (post-quantum forward secrecy via the per-handshake KEM key) |
| handshake | dh_only_secrecy |
all-traces | verified |
| handshake | mutual_auth_initiator |
all-traces | verified (full injective agreement) |
| handshake | mutual_auth_responder |
all-traces | verified (injective per responder) |
| handshake | replay_resistance |
all-traces | verified |
| handshake | response_replay_resistance |
all-traces | verified |
| handshake | hello_accepted_by_two_responders |
exists-trace | trace found (documents a harmless residual) |
| handshake | key_agreement_consistency |
all-traces | verified |
| async | async_executable |
exists-trace | trace found |
| async | async_secrecy |
all-traces | verified |
| async | async_secrecy_responder |
all-traces | verified |
| async | async_kem_only_secrecy |
all-traces | verified |
| async | async_dh_only_secrecy |
all-traces | verified |
| async | async_agreement |
all-traces | verified (prekey and fallback path) |
| async | async_executable_opk |
exists-trace | trace found |
| async | async_pq_forward_secrecy |
all-traces | verified (post-quantum forward secrecy of asynchronous sessions with one-time prekeys) |
| async | async_pq_forward_secrecy_initiator |
all-traces | verified (KEM-only variant) |
| collision | collision_executable |
exists-trace | trace found |
| collision | collision_consistency |
all-traces | undecided within 5 min (no counterexample; tagged long) |
| collision | abandoned_init_blacklisted |
all-traces | undecided within 5 min (falsified on the pre-fix model, see RESULTS.md) |
| collision | abandoned_init_never_adopted |
all-traces | undecided within 5 min (no counterexample; tagged long) |
| Model | Code |
|---|---|
Register_Keys, !Ltk($A, ~ik, ~sk, ~kk), !Pk(...) |
KeyManager (lib/core/crypto/key_manager.dart): X25519 identity key ~ik, Ed25519 signing key ~sk, long-term ML-KEM-768 key ~kk. !Pk doubles as the peer's pinned record (PinnedPeer in lib/core/storage/trust_store.dart). |
h(<'NyxChat_ID_v3', pk(sk), 'g'^ik>) |
NyxId.derive / NyxId.verify (lib/core/crypto/nyx_id.dart). |
'g'^~ek, !Eph($A, ~ek) |
CryptoUtils.newX25519KeyPair() inside Handshake.createInitiatorHello / Handshake.respond / Handshake.asyncInitiate. |
kempk(~ekk), !EphKem($A, ~ekk) |
KyberKem.generateKeyPair() in Handshake.createInitiatorHello; public half is HelloMessage.ephemeralKemKey (ekpk), private half InitiatorState.ephemeralKem, wiped after decapsulation. |
kempk(k), kemenc(pk, r), kemdec(ct, k) |
KyberKem.generateKeyPair / encapsulate / decapsulate (lib/core/crypto/hybrid_key_exchange.dart, ML-KEM-768 via PQClean); r is the KEM shared secret. |
tA = <'NyxChat_Hello_v3','role1',hA,$infoA,IK,SK,KPK,EK,nA,ekpk> |
HelloMessage.transcriptFor(isResponse:false, ..., ephemeralKemKey: ekpk) (lib/core/crypto/handshake.dart). $infoA stands for displayName, listeningPort and capabilities together; tuple order differs from the byte layout, which is irrelevant symbolically. |
tB = <'NyxChat_Hello_v3','role2',hB,$infoB,IK,SK,KPK,EK,nB,nA,kct,ih> |
HelloMessage.transcriptFor(isResponse:true, peerNonce: nA, kyberCiphertext: kct, initiatorHelloHash: ih). |
ih = h(tA) / Eq(ih, h(tA)) |
sha256(initiatorHello.transcript()) in Handshake.respond; constantTimeEquals(response.initiatorHelloHash, ownHash) in Handshake.completeInitiator. |
kct = kemenc(ekpk, ~kem) / kemdec(kct, ~ekk) |
KyberKem.encapsulate(initiatorHello.ephemeralKemKey) in respond; decapsulate(kct, state.ephemeralKem.privateKey) in completeInitiator. |
sign(t, ~sk) / Eq(verify(sig, t, pk), true) |
KeyManager.sign / CryptoUtils.ed25519Verify in Handshake._buildHello / Handshake._verifyHello. |
Eq(hA, h(<...>)) |
NyxId.verify call in Handshake._verifyHello. |
Neq($A, $B) |
the "peer claims our own identity" checks in Handshake.respond / completeInitiator. |
!Pk($A, ...) premise of the receiving rules |
TrustStore.check via ConnectionManager._checkTrust (lib/core/network/connection_manager.dart): presented keys must equal the pinned ones for that handle. |
RespNonce($B, nA) + restriction NonceCache |
ConnectionManager._recentInitiatorNonces (2048 entries) in _handleIncoming: a replayed initiator hello is refused before Handshake.respond runs. |
pattern ~nA inside tB in I_Recv_Response |
constantTimeEquals(response.peerNonce, state.nonce) in Handshake.completeInitiator. |
master = kdf(<'NyxChat_Handshake_v3', nA, nB, dh1, dh2, dh3, dh4, kem>) |
Handshake._derive: HKDF(salt = nA‖nB, ikm = 0xFF*32‖dh1‖dh2‖dh3‖dh4‖kem, info). |
SessionKey, InitKey, RespKey |
HandshakeResult.masterSecret. The ratchet root and link keys are HKDF outputs of master and inherit its secrecy. |
Async_Initiate |
SessionManager.encrypt with no session and a PinnedPeer -> Handshake.asyncInitiate (encapsulates to the pinned long-term KPK_B), DoubleRatchetSession.initAlice, SessionInitBlock(EK_A, kct). |
Async_Respond |
SessionManager.decrypt with an init block -> Handshake.asyncRespond, DoubleRatchetSession.initBob, decrypt-then-save. |
root = kdf(<'NyxChat_Async_Session_v3', dh1, dh2, kem>) |
Handshake.asyncRoot without a prekey id (the fallback). |
Register_Prekey, OPK($B, $A, ~opk), OPKPub($A, $B, kempk(~opk)) |
PrekeyStore.replenish (B generates ML-KEM-768 pairs for A, lib/core/crypto/prekey_store.dart) and PrekeyExchange (lib/core/network/prekey_exchange.dart): the Ed25519-signed, time-stamped PrekeyBundle (lib/core/crypto/prekey_bundle.dart) sent as a prekeys frame inside the link encryption after every handshake, verified by A against its pinned signing key of B (PrekeyBundle.validate, which also refuses anything not strictly newer than the bundle on file) and stored by PrekeyStore.replacePeerBundle. The 8-byte prekey id (truncated SHA-256 of the public key) stands in for kempk(~opk) itself in the model. |
Async_Initiate_OPK, consumption of OPKPub |
SessionManager.encrypt -> PrekeyStore.takePeerPrekey (removed from the pool before use, never re-admitted from a later bundle) -> Handshake.asyncInitiate(prekeyId:, prekeyPublicKey:); the id travels as SessionInitBlock.prekeyId (pk). |
Async_Respond_OPK, consumption of OPK |
SessionManager.decrypt -> PrekeyStore.findOwn -> Handshake.asyncRespond(prekeyId:, prekeyPrivateKey:) -> ratchet.decrypt -> PrekeyStore.deleteOwn (only after the first message decrypted; the private half is wiped and removed from the encrypted prekeys box). |
root = kdf(<'NyxChat_Async_Prekey_Session_v4', pkOPK, dh1, dh2, kem>) |
Handshake.asyncRoot with a prekey id: HKDF(salt = prekey id, ikm = 0xFF*32‖dh1‖dh2‖kem, info = "NyxChat-Async-Prekey-Session-v4"). |
Reveal_OPK |
compromise of an unused prekey's private half (readable from the encrypted prekeys box, or from a backup) before it is used. |
(no rule) an init_opk naming a prekey B does not hold |
UnknownPrekeyException -> signed PrekeyUnknownNotice in a control envelope (k: 'ct') back to the initiator, which discards the peer's pool, drops the pending session and re-initiates through Async_Initiate. The notice is an availability mechanism whose effect is the modelled fallback path; it is not modelled. |
senc('session_open', kdf(<'NyxChat_DR_Root_v3', root>)) |
the first ratchet envelope (InnerMessage.sessionOpen) encrypted under the first message key of a DoubleRatchetSession seeded with root. |
!Less('a','b'), C_Collision_Ignore, C_Collision_Adopt |
myId.compareTo(peerId) < 0 -> SessionCollisionException, otherwise re-derive and replace, in SessionManager.decrypt. |
Ignored(X, Y, eph), NotIgnored(X, Y, eph), restriction IgnoredInitsDropped |
SessionRecord.ignoredInitEphs (last 8 entries): appended when a collision is won and whenever a fast-path decrypt sees an ab field; checked before the collision and recovery branches of SessionManager.decrypt; copied when a session is replaced. |
Final(X, Y, root, ab), Abandoned(X, Y, eph), senc(<'reply', ab>, ...) in C_Reply |
SessionRecord.abandonedInitEph: set to the own pending ephemeral when a collision is lost (existing?.pendingInit?.ephemeralHex), sent as Envelope.abandonedInitEph (JSON ab, lib/core/protocol/envelope.dart) with every envelope while set; AAD-bound via Envelope.associatedDataFor(from, to, kind, abandonedInitEph:) (length-prefixed, empty string when absent) on both SessionManager.encrypt and decrypt, so a stripped or forged ab fails authentication. |
C_Recv_Reply_Final (AbCleared) |
fast path of SessionManager.decrypt on an envelope without an init block: existing.abandonedInitEph = null; every fast-path decrypt also runs _remember(ignoredInitEphs, envelope.abandonedInitEph). |
C_Confirm |
the fast path in SessionManager.decrypt that clears pendingInit. |
C_Recover |
the re-derive-and-replace branch taken when existing.pendingInit == null, acceptedInitEph != init.ephemeralHex and the ephemeral is not in ignoredInitEphs; the blacklist is carried into the new record. |
Reveal_LTK |
compromise of all three long-term private keys of one device. |
Reveal_KEM_Key / Reveal_KEM_Ephemeral |
compromise of the long-term / per-handshake ML-KEM private key (or a break of ML-KEM). |
Reveal_DH_Identity + Reveal_DH_Ephemeral |
compromise of every X25519 secret (or a break of X25519, e.g. by a quantum computer). |
- KEM. ML-KEM is modelled as public-key encryption of a fresh secret:
kemdec(kemenc(kempk(k), r), k) = r. Encapsulation is deterministic in the model (no coins), so two encapsulations of the same secret yield the same ciphertext; decapsulation with the wrong key is an opaque term (no failure), matching ML-KEM's implicit rejection. IND-CCA subtleties, malformed keys and ciphertext malleability are not represented. - KDF / HKDF. A free function
kdf/1over a tuple; the 0xFF prefix, salt/info split and output length are irrelevant symbolically. - Hello fields. displayName, port and capabilities are one public value
$info; the protocol version byte is the'role1'/'role2'tag. Field length prefixes (CryptoUtils.lengthPrefixed) are assumed to make the transcript unambiguous, which Tamarin tuples are by construction.ihis the hash of the model's tuple, not of the byte encoding. - Key pinning.
!Pk($A, ...)is the only source of a peer's keys, so a hello is accepted only if its keys are the registered ones for that identity. This models the post-TOFU state; the first-contact substitution window described in SECURITY.md is out of scope. An adversary-controlled identity is any$Ewhose keys were revealed withReveal_LTK. - One key set per identity (restriction
UniqueKeys); key rotation (signed key transitions) is not modelled. - Nonce cache and ignored-init list are unbounded in the model; the code keeps 2048 nonces and 8 ephemerals. An attacker who can push more than that many entries through before replaying is outside the model.
- Per-handshake KEM key reveal.
Reveal_KEM_Ephemeralexists only in the handshake model. The async model's counterpart is the one-time prekey (Reveal_OPK), which exists only until it is used; the fallback path has no such secret (it encapsulates to the pinned long-termKPK_B). - Prekey registration is an ideal authenticated channel. The signed
bundle, its issue-time replay check and the link encryption collapse
into direct state transfer (
OPKPub). Pool size (8), the 30-day expiry, the notice for an unknown prekey and the session record's fallback flag are not modelled; an init naming a prekey B does not hold matches no rule, and the fallback is the separately modelled long-term path. abis modelled as integrity-protected. The collision model puts the abandoned-init announcement inside the authenticated payload (senc(<'reply', ab>, k)); the code binds it into the AEAD associated data of every envelope (finding 6), which is equivalent for the lemmas.- No Double Ratchet, no link layer. The models stop at the master
secret / ratchet root. In the async model the first ratchet message is
abstracted as
senc('session_open', kdf(root)); the real first message key also mixes DH(ratchet key, IK_B), which does not change who can compute it. Per-message forward secrecy, healing and replay rejection of the ratchet are therefore not covered, and the async agreement lemma is non-injective for that reason. - No transport. TCP, BLE, mesh relays and Nostr are all the Dolev-Yao network. Padding, sealed-sender wrappers, pair keys and tokens are not modelled.
- Collision sub-model. Exactly two parties, fixed handles, no key compromise; each party initiates at most once toward the other (as in the code: a session, once present, is reused). Session reset and identity rotation are not modelled.
- Pinned-key pattern. Every rule that exponentiates a pinned X25519
public key from
!Pk/!CPkmatches it as'g'^~ikinstead of a free variable (Register_Keys/Collision_Setupare the only producers of those facts, so no rule instance is lost). Without it Tamarin enumerated about a thousand AC variants ofI_Recv_Responseand precomputation took 6-8 minutes per theory; with it, about 20 s. Those rules carry[no_derivcheck]because Tamarin's "unintended pattern matching" heuristic would otherwise flag~ikas underivable from the premises, which is exactly the intended match. SeeRESULTS.md, "Model fixes". - Sources. No
sourceslemma is needed; see "Running".
The models produced six findings (the sixth while modelling the fix for
the fifth); the code was changed in response (per-handshake KEM key, ih
in the responder transcript, responder nonce cache, ignoredInitEphs, the
abandoned-init announcement and its AAD binding). Status after the changes:
- Initiator agreement (fixed). Previously the responder's transcript
did not cover the initiator's identity, keys or the derived master, so an
adversary with its own valid identity could make A believe it had
authenticated B while B talked to the adversary (keys differed, no
secrecy loss). The response now carries
ih = SHA-256(initiator hello)and A refuses a mismatch, somutual_auth_initiatoris stated as full injective agreement on (A, B, nA, nB, EK_A, EK_B, master). - Initiator-hello replay (fixed, with a harmless residual). The
responder now refuses a nonce it has seen (
_recentInitiatorNonces);replay_resistanceand the injectivemutual_auth_responderfollow. The cache is per device and the hello names no intended responder, so a captured hello can still be delivered to a different responder, which answers with fresh keys that nobody else can compute (hello_accepted_by_two_responders). Resource use only. - Asynchronous root had no forward secrecy against the recipient's
long-term keys (addressed in 3.2 with one-time prekeys; fallback
documented). The async flow used to encapsulate to the pinned
long-term
KPK_Band B contributes no ephemeral, so an adversary who later obtainedIK_BandKPK_Brecovered every root B accepted before its first reply. Now every device hands each contact a pool of one-time ML-KEM prekeys on every direct link, an initiation encapsulates to one of them and B deletes the private half after the first message decrypts;async_pq_forward_secrecystates the resulting guarantee.async_secrecy_responderstill states the exact condition for the fallback, taken only when the initiator holds no prekey of B (never met, pool used up, or B lost its store), which the session record flags and the contact screen shows. - Post-quantum forward secrecy on direct links (fixed). The initiator
hello now carries a per-handshake ML-KEM key and the responder
encapsulates to it, so a harvest-now-decrypt-later adversary who breaks
X25519 and later seizes both devices' long-term keys still cannot
recover master (
kem_only_forward_secrecy). The long-term KEM key no longer plays any role in the direct-link handshake. The async flow gets the same property from one-time prekeys and keeps the long-term key as a flagged last resort (finding 3). - Stale-init divergence in collision handling (fixed). With handles
a < bboth initiating, a stale copy ofb's init delivered afterbhad adopteda's session used to take the recovery branch ataand replacea's session whilebkepta's.ignoredInitEphsclosed the ordering in whichahad ignoredb's init; the abandoned-init announcement (abandonedInitEph, envelope fieldab, recorded on every fast-path decrypt) closes the ordering in whichb's reply reachedafirst.collision_consistencyis now stated without any condition on the recovery path;abandoned_init_blacklistedandabandoned_init_never_adoptedstate the mechanism directly. Regression test:test/crypto/session_manager_test.dart, "a stale init arriving after the adopted reply is still rejected". abwas not authenticated (fixed). When the announcement was added,Envelope.associatedDataForbound only the domain string, sender, recipient and kind, so an active attacker or misbehaving relay could stripabfrom the loser's messages (re-opening finding 5 for the reply-first ordering) or inject anabnaming a genuine recovery ephemeral of the peer (blacklisting that recovery until a direct-link reset); availability effects only.Envelope.associatedDataFornow takesabandonedInitEphand appends it, length-prefixed (empty string when absent), to the AEAD associated data;SessionManagerpassesrec.abandonedInitEphon encrypt andenvelope.abandonedInitEphon decrypt, so a stripped or forgedabfails authentication and, because the ratchet commits only on success, leaves the session untouched. Regression test:test/crypto/session_manager_test.dart(both tampering cases). The model'ssenc(<'reply', ab>, k)is the symbolic equivalent of this binding. Demonstration of why it matters: moveabout of thesencinC_Reply,C_ConfirmandC_Recv_Reply_Finaland re-run;collision_consistencyshould then be falsified by the stripped-announcement trace.
-
tamarin-prover formal/nyxchat_v4_handshake.spthyand... formal/nyxchat_v4_async.spthyparse with no well-formedness errors; note any partial-deconstruction warning. -
--proveresults match the "Expected outcome" table. Record the Tamarin and Maude versions, the machine and the wall-clock time here. -
tA,tBand the async init block matchHelloMessage.transcriptForandSessionInitBlockfield for field (role byte, echoed nonce, KEM ciphertext,ekpkonly in the initiator hello,ihonly in the response). -
masterandrootin the model use the same DH pairs and inputs asHandshake._deriveandHandshake._asyncRoot(both nonces, all four DH values, the KEM secret; two DH values and the KEM secret), and the handshake KEM secret is the one decapsulated withInitiatorState.ephemeralKem, not the long-term key. - The
!Pklookup is an acceptable model ofTrustStore.check: one key set per handle, a key change refused before the responder sends its hello (ConnectionManager._handleIncoming) and before the initiator uses the link (ConnectionManager._finish). -
NonceCachematches_recentInitiatorNonces: the check happens beforeHandshake.respond, per device, and the 2048-entry bound is acceptable for the deployment. -
IgnoredInitsDroppedmatchesSessionRecord.ignoredInitEphs: the check precedes both the collision branch and the recovery branch, the list is persisted with the session and copied when a session is replaced, and the 8-entry bound is acceptable. - The abandoned-init announcement matches the code: set on collision
loss only (
existing?.pendingInit?.ephemeralHex), sent with every envelope while set, cleared on the first fast-path decrypt of an envelope without an init block, and recorded by the receiver on every successful fast-path decrypt (C_Collision_Adopt,C_Reply,C_Confirm,C_Recv_Reply_Final). - The prekey rules match the code:
PrekeyStore.takePeerPrekeyremoves the public half beforeHandshake.asyncInitiateruns;SessionManager.decryptcallsPrekeyStore.deleteOwnonly afterratchet.decryptsucceeded; the two root labels differ (NyxChat-Async-Prekey-Session-v4vsNyxChat-Async-Session-v3) and the prekey id is the HKDF salt; a bundle is accepted only from the authenticated link peer, with a signature by the pinned key, and with an issue time strictly newer than the bundle on file. - The reveal rules cover the compromise scenarios claimed in SECURITY.md ("Compromise of long-term keys", "always combined with X25519") and the lemma assumptions are not stronger than those claims.
- Findings 2 (residual) and 3 correspond to the current code paths, or the code has since changed and the model needs updating.
- The
abbinding matches the model's integrity assumption:Envelope.associatedDataFor(..., abandonedInitEph:)is used with the same value on bothSessionManager.encryptanddecrypt, and the absent case is encoded unambiguously (empty length-prefixed field). - Once checked: replace the status banner at the top of this file with
the recorded results and add the two
--proveruns to CI (Tamarin's exit code does not reflect falsified lemmas; grep the summary forfalsified).