@adapt-toolkit/mufl 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/meta/meta.mm CHANGED
@@ -2496,8 +2496,20 @@ library meta {
2496
2496
  names_root "::_crypto_verify_hash_signature" -> function [hash_code,crypto_signature,publickey_sign] truenil.
2497
2497
  names_root "::_crypto_encrypt_message" -> function [secretkey_encrypt,publickey_encrypt,bin] crypto_message.
2498
2498
  names_root "::_crypto_decrypt_message" -> function [secretkey_encrypt,publickey_encrypt,crypto_message] bin.
2499
- names_root "::_crypto_get_key_id" -> function [ crypto ] bin.
2500
- names_root "::_crypto_get_key_id_target" -> function [ crypto_message ] bin.
2499
+ names_root "::_crypto_get_key_id" -> function [ crypto ] bin.
2500
+ names_root "::_crypto_get_key_id_target" -> function [ crypto_message ] bin.
2501
+ // adapt-e2e-core (Olm / X3DH + Double-Ratchet) engine primitives (domain_e2e).
2502
+ // All pickle-in/pickle-out; the trailing bin is the 32-byte pickle_key.
2503
+ names_root "::_e2e_account_create" -> function [bin] bin.
2504
+ names_root "::_e2e_account_gen_otks" -> function [bin, int, bin] bin.
2505
+ names_root "::_e2e_account_gen_fallback" -> function [bin, bin] bin.
2506
+ names_root "::_e2e_account_bundle" -> function [bin, bin] bin.
2507
+ names_root "::_e2e_session_outbound" -> function [bin, bin, bin, bin] (record::type ($session->bin, $account->bin)).
2508
+ names_root "::_e2e_session_inbound" -> function [bin, bin, bin, bin] (record::type ($session->bin, $account->bin, $plaintext->bin)).
2509
+ names_root "::_e2e_encrypt" -> function [bin, bin, bin] (record::type ($msg->bin, $msg_type->int, $session->bin)).
2510
+ names_root "::_e2e_decrypt" -> function [bin, int, bin, bin] (record::type ($plaintext->bin, $session->bin)).
2511
+ names_root "::_e2e_session_id" -> function [bin, bin] bin.
2512
+ names_root "::_e2e_matches_inbound" -> function [bin, bin, bin] int.
2501
2513
  names_root "::_construct_blinded_dictionary" -> functor::make_any_x_ret_blinded_0 (immutable::set (immutable::array basics::any) ).
2502
2514
  names_root "::_construct_blinded_dictionary_custom" -> functor::make_any_x_y_ret_blinded_0 (immutable::set (immutable::array basics::any) ) (immutable::type str any).
2503
2515
  names_root "::_construct_blinded_with_absence" -> functor::make_any_x_y_ret_blinded_0 (immutable::set (immutable::array basics::any) ) (immutable::set (immutable::array basics::any) ).
@@ -19,6 +19,7 @@ config script {
19
19
  $key_storage_api->#"cryptography/key_storage_api.mm",
20
20
  $key_storage->#"cryptography/key_storage.mm",
21
21
  $key_utils->#"cryptography/key_utils.mm",
22
+ $e2e->#"cryptography/e2e.mm",
22
23
  $registration_proof->#"cryptography/registration_proof.mm",
23
24
  $attestation_document->#"cryptography/attestation/attestation_document.mm",
24
25
  $container_associated_data->#"cryptography/attestation/container_associated_data.mm",
@@ -63,6 +64,7 @@ config script {
63
64
  $key_storage_api->#"cryptography/key_storage_api.mm",
64
65
  $key_storage->#"cryptography/key_storage.mm",
65
66
  $key_utils->#"cryptography/key_utils.mm",
67
+ $e2e->#"cryptography/e2e.mm",
66
68
  $registration_proof->#"cryptography/registration_proof.mm",
67
69
  $attestation_document->#"cryptography/attestation/attestation_document.mm",
68
70
  $container_associated_data->#"cryptography/attestation/container_associated_data.mm",
@@ -23,7 +23,7 @@ and address document is a record of keys that are attested to belong to a certai
23
23
 
24
24
  */
25
25
 
26
- library address_document loads libraries address_document_types, key_storage, key_utils, transaction_queue, current_transaction_info uses transactions
26
+ library address_document loads libraries address_document_types, key_storage, key_utils, transaction_queue, current_transaction_info, e2e uses transactions
27
27
  {
28
28
  using key_utils.
29
29
  using address_document_types.
@@ -43,11 +43,11 @@ library address_document loads libraries address_document_types, key_storage, ke
43
43
  */
44
44
 
45
45
  hidden {
46
- m_current_version IS int = 1.
47
- m_authorizing_containers IS t_container_id(,) = (,).
46
+ m_current_version IS int = 2. // AD-v2: identity embeds the E2E prekey bundle ($e2e_bundle)
47
+ m_authorizing_containers IS t_container_id(,) = (,).
48
48
  m_ad_signatures IS (t_container_id ->> crypto_signature) = (,).
49
- m_max_allowed_number_of_authorizations = 10.
50
- m_valid_versions = (1,).
49
+ m_max_allowed_number_of_authorizations = 10.
50
+ m_valid_versions = (1, 2). // accept v1 (no bundle) and v2 (bundle) peers — dual-support
51
51
  m_my_address_document IS t_address_document+ = NIL.
52
52
  }
53
53
 
@@ -146,9 +146,18 @@ library address_document loads libraries address_document_types, key_storage, ke
146
146
 
147
147
  produce_my_address_document = fn (_) -> t_address_document
148
148
  {
149
- my_identity = key_storage::get_my_identity().
150
- signatures IS crypto_signature(,) = (,).
151
- hash = _value_id my_identity.
149
+ base_identity = key_storage::get_my_identity().
150
+ // AD-v2: embed our E2E prekey bundle into the identity so peers can open outbound
151
+ // sessions to us and the far-side decode hook can read our identity key. Rebuild the
152
+ // record explicitly (no in-place mutation) and compute the hash AFTER embedding, so
153
+ // the self-signature — and every authorizing-container signature over _value_id
154
+ // identity — commits to the bundle (belt-and-suspenders with each per-key $sig).
155
+ my_identity = ( $key_list -> (base_identity $key_list),
156
+ $default_keys -> (base_identity $default_keys),
157
+ $container_id -> (base_identity $container_id),
158
+ $e2e_bundle -> e2e::my_public_bundle() ).
159
+ signatures IS crypto_signature(,) = (,).
160
+ hash = _value_id my_identity.
152
161
 
153
162
  sc my_identity $key_list -- (key -> ) ?? key_get_function key == $SIGN {
154
163
  sig = key_storage::sign hash (_crypto_get_key_id key).
@@ -1,16 +1,41 @@
1
1
  library address_document_types loads library key_utils
2
2
  {
3
3
  using key_utils.
4
- metadef t_address_document:
5
- (
4
+
5
+ // AD-v2 E2E prekey bundle (double-ratchet / Olm handshake material), published inside
6
+ // the container identity so a peer can open an outbound session to us and so the
7
+ // decode-level receive hook can read the sender's identity key. adapt OWNS this type;
8
+ // the ours-mufl-core companion registers it in a2a_versions (class-B versioned, $pv
9
+ // discriminator + try_narrow). Each prekey carries a host Ed25519 signature over the
10
+ // key bytes — belt-and-suspenders alongside the AD root signature that already binds
11
+ // the whole identity (including this bundle).
12
+ //
13
+ // v1 posture (empty one-time keys): the underlying engine (vodozemac) exposes an
14
+ // identity keypair + a reusable *fallback* key + one-time keys — there is NO distinct
15
+ // signed prekey. So v1 publishes $fallback (the establishment prekey) with an EMPTY
16
+ // $one_time_keys list, and leaves $signed_prekey NIL. The field is kept optional for
17
+ // forward-compat (a real rotated SPK can be added later with no re-shape).
18
+ metadef t_e2e_prekey: ( $id -> int, $key -> bin, $sig -> crypto_signature ).
19
+ metadef t_e2e_bundle:
20
+ (
21
+ $ik_curve -> bin, // Curve25519 identity key (the sender_ik peers establish against)
22
+ $ik_ed -> bin, // Ed25519 identity key
23
+ $signed_prekey -> t_e2e_prekey+, // NIL under empty-OTK v1 (no distinct SPK in the engine)
24
+ $one_time_keys -> t_e2e_prekey[], // EMPTY [] in v1 (one-time-key reuse impossible)
25
+ $fallback -> t_e2e_prekey // reusable establishment prekey, host-signed
26
+ ).
27
+
28
+ metadef t_address_document:
29
+ (
6
30
  $version->int, // version of the address document
7
31
  $identity -> t_container_identity: // specifies the identity of the container
8
32
  (
9
33
  $key_list->t_publickey(,), // list of keys active with this container
10
34
  $default_keys->t_function->>t_key_id, // exactly two keys, one for signing, one for encryption, that are the default keys for this container
11
- $container_id->t_container_id // id of the container
12
- /* $code_id->hash_code */
13
- ),
35
+ $container_id->t_container_id, // id of the container
36
+ $e2e_bundle->t_e2e_bundle+ // AD-v2: E2E double-ratchet prekey bundle (NIL for v1 identities)
37
+ /* $code_id->hash_code */
38
+ ),
14
39
  $authorizations -> crypto_signature(,)
15
40
  ).
16
41
  }
@@ -0,0 +1,304 @@
1
+ /*
2
+ ** e2e — the end-to-end (Double-Ratchet / Olm) encryption library.
3
+ **
4
+ ** Wraps the domain_e2e engine primitives (_e2e_*, over the adapt-e2e-core C-ABI)
5
+ ** behind ergonomic functions that return a CLEAN VALUE or a TYPED ERROR — a caller
6
+ ** never sees a raw crate return code. The recoverable failures (a tampered or
7
+ ** wrong-session ciphertext, an exhausted one-time key, a corrupt inbound pickle)
8
+ ** arrive from the primitives as PROVISIONAL aborts carrying a stable "e2e:*" token;
9
+ ** we catch them here and fold them into an `e2e_result`. Fatal faults (a
10
+ ** length/version/seed contract breach, a crate-internal error or panic) stay
11
+ ** provisional-uncatchable and abort the transaction — they are never returned as data.
12
+ **
13
+ ** This module is the STATELESS transform core (pickle-in / pickle-out): every fn
14
+ ** takes the opaque state blob(s) and returns the new one(s). The adapt-owned,
15
+ ** packet-state SEND/RECEIVE facade (single Olm-session owner; encrypt_to / the
16
+ ** __t_wrapper decode-receive) is built on top of these transforms.
17
+ **
18
+ ** pickle_key is derived INTERNALLY from this packet's root SIGN secret — never a
19
+ ** caller concern, never persisted, never exported.
20
+ */
21
+ library e2e
22
+ loads libraries key_storage, address_document_types
23
+ {
24
+ using key_storage.
25
+ using address_document_types.
26
+
27
+ // A typed, caller-facing error. $code is one of the stable strings below;
28
+ // $detail carries the underlying engine text for logs (never parsed by callers).
29
+ // "no_session" — a normal (olm_type=1) message but no session for the sender
30
+ // "tampered" — ciphertext failed authentication (flipped bits / wrong key)
31
+ // "session_mismatch" — a pre-key message for a different session than we hold
32
+ // "otk_exhausted" — the peer's one-time key was already consumed
33
+ // "internal" — an unexpected provisional token (should-never-happen)
34
+ // (A malformed/corrupt pickle is NOT recoverable — it aborts the tx fatally, since it
35
+ // can indicate local state corruption, not just a bad inbound message. See e2e_core.cpp.)
36
+ metadef e2e_error: ( $code -> str, $detail -> str ).
37
+
38
+ // The public prekey material a peer needs to open an outbound session to us is the
39
+ // AD-v2 t_e2e_bundle (address_document_types) — identity keys + a host-signed fallback
40
+ // prekey, empty one-time-key list under v1. A peer establishes via $fallback $key.
41
+
42
+ // The unified inbound result. On $ok=TRUE, $plaintext + $session are present, and
43
+ // $account is present ONLY on the establish path (a pre-key message rotates the
44
+ // account by consuming a prekey) — a normal-message decrypt leaves $account NIL, so
45
+ // the caller must overwrite its stored account ONLY when this field is non-NIL.
46
+ // On $ok=FALSE, $error is present and the message should be DROPPED (session lives).
47
+ metadef e2e_result:
48
+ (
49
+ $ok -> bool,
50
+ $plaintext -> bin+,
51
+ $session -> bin+,
52
+ $account -> bin+,
53
+ $error -> e2e_error+
54
+ ).
55
+
56
+ hidden
57
+ {
58
+ // The 32-byte AEAD pickle key that seals every account/session blob. Derived
59
+ // ONCE per boot from the packet's stable root SIGN secret (survives key roll /
60
+ // reseed) with domain separation, then memoised. _value_id yields a HashCode;
61
+ // _hash_code_to_binary lowers it to the 32-byte binary the primitives require.
62
+ // NEVER persisted, NEVER exported — re-derived from the identity on each boot.
63
+ m_pickle_key IS bin+ = NIL.
64
+
65
+ fn pickle_key (_) -> bin
66
+ {
67
+ if m_pickle_key == NIL
68
+ {
69
+ secret = key_storage::export_identity_signing_secret().
70
+ m_pickle_key -> _hash_code_to_binary
71
+ (_value_id ($purpose -> "adapt/e2e/pickle/v1", $seed -> secret)).
72
+ }
73
+ return m_pickle_key as bin. // narrow bin+ -> bin (guaranteed set above)
74
+ }
75
+
76
+ // Map a caught provisional token (a string that CONTAINS one of the primitives'
77
+ // "e2e:*" markers, wrapped in the "EVAL_ERROR: " prefix + best-effort frame
78
+ // trace) to a typed error. Substring match is robust to that wrapping.
79
+ fn has_token (haystack: str, needle: str) -> bool
80
+ {
81
+ return (_str_find haystack needle 0) >= 0.
82
+ }
83
+
84
+ fn map_error (err: str+) -> e2e_error
85
+ {
86
+ text = err as str. // the caught message (guaranteed non-NIL: success paths RETURN)
87
+ if has_token text "e2e:tampered" { return ( $code -> "tampered", $detail -> text ). }
88
+ if has_token text "e2e:session_mismatch" { return ( $code -> "session_mismatch", $detail -> text ). }
89
+ if has_token text "e2e:otk_exhausted" { return ( $code -> "otk_exhausted", $detail -> text ). }
90
+ // A provisional abort with no known e2e marker should be impossible (only the
91
+ // e2e primitives raise provisionals inside decrypt's body). Surface it as
92
+ // "internal" WITH the full text, so it is typed but never silently lost.
93
+ return ( $code -> "internal", $detail -> text ).
94
+ }
95
+ }
96
+
97
+ // Create a fresh identity account: identity keys + a reusable fallback key, and
98
+ // (empty-OTK v1 posture) NO one-time keys — so a pre-key handshake rests on the
99
+ // reusable fallback key. This makes one-time-key reuse *impossible* (none are
100
+ // published), but the tradeoff is real: initial-message forward secrecy now rests
101
+ // on the multi-use fallback rather than single-use one-time keys, so it is WEAKER
102
+ // for the first message(s) until the Double Ratchet advances. Steady-state FS/PCS
103
+ // after the first round-trip is unaffected. (See /tmp/e2e-otk-options.md.) The app
104
+ // persists the returned blob.
105
+ fn create_account (_) -> bin
106
+ {
107
+ a = _e2e_account_create (pickle_key()).
108
+ return _e2e_account_gen_fallback a (pickle_key()).
109
+ }
110
+
111
+ // Our published AD-v2 prekey bundle, for a peer to open an outbound session to us and
112
+ // for the decode-level receive hook on the far side to read our identity key. Built
113
+ // FRESH from the account's raw public bundle (never mutates the immutable engine blob),
114
+ // with a host Ed25519 signature over the fallback key bytes (belt-and-suspenders with
115
+ // the AD root signature). Under empty-OTK v1 there are no one-time keys and no distinct
116
+ // signed prekey, so a peer establishes via $fallback $key.
117
+ fn my_bundle (account: bin) -> t_e2e_bundle
118
+ {
119
+ b = _e2e_account_bundle account (pickle_key()).
120
+ // Raw bundle layout (crate src/mgmt/bundle.rs):
121
+ // ik_curve[0:32] ik_ed[32:64] fb_count(u8)@64 fb_id(u32 LE)[65:69] fb_key[69:101]
122
+ // otk_count(u32 LE)@101 ... (empty-OTK v1 => otk_count 0, no otk entries).
123
+ // Offset 69 is the fallback key ONLY when a fallback is present (fb_count@64 == 1).
124
+ // Guard it: without this, an account with no fallback would slice the wrong bytes
125
+ // (or overrun). Accounts made by create_account always carry one, but my_bundle is
126
+ // public and must not emit a garbage "fallback" key for an arbitrary account blob.
127
+ abort "e2e::my_bundle: account has no fallback key (empty-OTK v1 requires one)" WHEN (b 64) != 1.
128
+ fb_key = _binary_slice b 69 32.
129
+ return
130
+ (
131
+ $ik_curve -> (_binary_slice b 0 32),
132
+ $ik_ed -> (_binary_slice b 32 32),
133
+ $signed_prekey -> NIL, // v1: engine exposes no distinct signed prekey
134
+ $one_time_keys -> [], // empty-OTK v1 (one-time-key reuse impossible)
135
+ // vodozemac fallback key-id is not surfaced in v1 ($id -> 0): the receiver
136
+ // establishes from material inside the pre-key ciphertext and the sender uses
137
+ // only the key bytes, so the id is functionally unused here.
138
+ $fallback -> ( $id -> 0,
139
+ $key -> fb_key,
140
+ $sig -> (key_storage::default_sign (_value_id fb_key)) )
141
+ ).
142
+ }
143
+
144
+ // Establish an OUTBOUND session to a peer from the peer's published AD-v2 bundle (picks
145
+ // the fallback prekey under empty-OTK v1). Returns the new session and the
146
+ // (possibly rotated) account — the app persists BOTH.
147
+ fn create_outbound_session (account: bin, peer: t_e2e_bundle) -> ( $session -> bin, $account -> bin )
148
+ {
149
+ r = _e2e_session_outbound account (peer $ik_curve) (peer $fallback $key) (pickle_key()).
150
+ return ( $session -> r $session, $account -> r $account ).
151
+ }
152
+
153
+ // Encrypt a plaintext on an established session. Returns the ciphertext, its
154
+ // olm_type (0=pre-key until the peer replies, 1=normal thereafter), and the
155
+ // advanced session — the app persists $session (the ratchet step).
156
+ fn encrypt (session: bin, plaintext: bin) -> ( $ciphertext -> bin, $olm_type -> int, $session -> bin )
157
+ {
158
+ r = _e2e_encrypt session plaintext (pickle_key()).
159
+ return ( $ciphertext -> r $msg, $olm_type -> r $msg_type, $session -> r $session ).
160
+ }
161
+
162
+ // The ONE unified inbound call. The library decides establish-vs-decrypt from
163
+ // olm_type + session presence and maps every recoverable failure to a typed
164
+ // e2e_result — the caller NEVER branches on olm_type or handles a return code.
165
+ // olm_type=0 -> (re)establish an inbound session from the pre-key message;
166
+ // yields plaintext + new session + rotated account.
167
+ // olm_type=1, session -> decrypt on the existing session; yields plaintext + session.
168
+ // olm_type=1, NO session-> typed "no_session" (drop; the sender's pre-key repeat self-heals).
169
+ fn decrypt (account: bin, session: bin+, sender_ik: bin, olm_type: int, ciphertext: bin) -> e2e_result
170
+ {
171
+ if olm_type == 1 && session == NIL
172
+ {
173
+ return ( $ok -> FALSE, $plaintext -> NIL, $session -> NIL, $account -> NIL,
174
+ $error -> ( $code -> "no_session",
175
+ $detail -> "normal (olm_type=1) message but no established session for sender" ) ).
176
+ }
177
+
178
+ WHEN provisional abort err
179
+ {
180
+ if olm_type == 0
181
+ {
182
+ r = _e2e_session_inbound account sender_ik ciphertext (pickle_key()).
183
+ return ( $ok -> TRUE, $plaintext -> r $plaintext, $session -> r $session,
184
+ $account -> r $account, $error -> NIL ).
185
+ }
186
+ r = _e2e_decrypt (session as bin) 1 ciphertext (pickle_key()).
187
+ return ( $ok -> TRUE, $plaintext -> r $plaintext, $session -> r $session,
188
+ $account -> NIL, $error -> NIL ).
189
+ }
190
+ // Reached ONLY when the body provisional-aborted (both success branches RETURN):
191
+ // `err` holds the token text → typed drop, the session survives.
192
+ return ( $ok -> FALSE, $plaintext -> NIL, $session -> NIL, $account -> NIL,
193
+ $error -> (map_error err) ).
194
+ }
195
+
196
+ // Idempotent re-delivery check: TRUE iff `session` is the channel that this pre-key
197
+ // ciphertext would establish (so a re-sent handshake can be recognised and dropped
198
+ // by the stateful facade before re-establishing). Pure read.
199
+ fn session_matches (session: bin, ciphertext: bin) -> bool
200
+ {
201
+ return (_e2e_matches_inbound session ciphertext (pickle_key())) == 1.
202
+ }
203
+
204
+ // The session's stable 32-byte id (shared by both ends of a channel). Pure read.
205
+ fn session_id (session: bin) -> bin
206
+ {
207
+ return _e2e_session_id session (pickle_key()).
208
+ }
209
+
210
+ // =====================================================================================
211
+ // STATEFUL FACADE — adapt is the SINGLE owner of the Olm account + per-peer sessions.
212
+ // Because RECEIVE happens transparently at the __t_wrapper decode seam (adapt-owned
213
+ // packet-state, committed in the guaranteed-mutating decode tx), SEND must be adapt-owned
214
+ // too — one Olm session cannot have two owners or the ratchet desyncs. So the ours-mcp
215
+ // layer never holds session state: it calls encrypt_to to send and the decode hook calls
216
+ // decrypt_and_commit to receive. Both mutate this library's packet-state.
217
+ // =====================================================================================
218
+
219
+ // The E2E message envelope (opaque ciphertext + routing stamp) and its outer
220
+ // sender-authentication signature. NEW additive variant — the existing plain/signed/
221
+ // encrypted envelopes are untouched. The $e2e_envelope field is the decode MARKER.
222
+ metadef t_e2e_envelope: ( $session_id -> bin, $olm_type -> int, $ciphertext -> bin, $pv -> int ).
223
+ metadef e2e_signed_message: ( $e2e_envelope -> t_e2e_envelope, $emsignature -> crypto_signature ).
224
+
225
+ hidden
226
+ {
227
+ m_account IS bin+ = NIL. // this packet's Olm account (lazy first-use)
228
+ m_sessions IS (global_id ->> bin) = (,). // per-peer session pickle, keyed by contact id
229
+ m_wire_pv = 8. // wire dialect stamp ($pv) for v1 (matches wire8)
230
+
231
+ fn account (_) -> bin
232
+ {
233
+ if m_account == NIL { m_account -> create_account(). }
234
+ return m_account as bin.
235
+ }
236
+ }
237
+
238
+ // Our own published AD-v2 prekey bundle (from our lazily-created account) — embedded in
239
+ // our address document ($identity.$e2e_bundle) so a peer can open an outbound session to
240
+ // us and the far-side decode hook can read our identity key.
241
+ fn my_public_bundle (_) -> t_e2e_bundle
242
+ {
243
+ return my_bundle (account()).
244
+ }
245
+
246
+ // SEND: encrypt tx_body (already serialized to bin by the caller) to a peer, lazily
247
+ // establishing the outbound session from the peer's bundle on first send, ADVANCING +
248
+ // COMMITTING our session, and returning the fully-built, sender-signed envelope ready to
249
+ // relay. ours-mcp send collapses to: action::send cid (e2e::encrypt_to cid body peer).
250
+ fn encrypt_to (cid: global_id, plaintext: bin, peer: t_e2e_bundle) -> e2e_signed_message
251
+ {
252
+ if (m_sessions cid) == NIL
253
+ {
254
+ est = create_outbound_session (account()) peer.
255
+ m_account -> est $account.
256
+ m_sessions cid -> est $session.
257
+ }
258
+ e = encrypt ((m_sessions cid) as bin) plaintext.
259
+ m_sessions cid -> e $session. // commit the ratchet advance
260
+ env = ( $session_id -> (session_id (e $session)), $olm_type -> (e $olm_type),
261
+ $ciphertext -> (e $ciphertext), $pv -> m_wire_pv ).
262
+ // S1: bind the SENDER ($from = us) and RECIPIENT ($to = cid) into the emsig pre-image,
263
+ // alongside the wire-versioned $env (which carries $pv). This authenticates the routing,
264
+ // so a captured envelope cannot be reflected to a third party or re-targeted. The envelope
265
+ // STRUCTURE is unchanged (adapt signs here; the adapt decode hook verifies over the same
266
+ // reconstructed context) — no companion/wire-corpus change.
267
+ sig_context = ( $from -> _get_container_id(), $to -> cid, $envelope -> env ).
268
+ return ( $e2e_envelope -> env,
269
+ $emsignature -> (key_storage::default_sign (_value_id sig_context)) ).
270
+ }
271
+
272
+ // The supported wire dialect stamp ($pv) — the receiver validates an inbound envelope's $pv
273
+ // against this to reject an unknown/downgraded dialect (S2). Pure read.
274
+ fn wire_pv (_) -> int { return m_wire_pv. }
275
+
276
+ // RECEIVE: decrypt an inbound envelope for a known sender and COMMIT the advanced session
277
+ // (and, on the establish path only, the rotated account). Returns the typed e2e_result.
278
+ // The caller (the __t_wrapper decode hook) authenticates $emsignature FIRST and supplies
279
+ // the sender's e2e identity key (read from the same AD identity it origin-binds against).
280
+ fn decrypt_and_commit (from_cid: global_id, sender_ik: bin, olm_type: int, ciphertext: bin) -> e2e_result
281
+ {
282
+ // Pre-key (establish) REPLAY guard. v1 publishes a MULTI-USE fallback key (empty OTK
283
+ // list), so a replayed olm_type=0 pre-key would re-establish successfully — re-executing
284
+ // the inner transaction AND overwriting the live (possibly advanced) session. If we
285
+ // already hold a session for this sender and THIS pre-key is the same handshake that
286
+ // established it, it is an idempotent re-delivery: drop it recoverably (no re-establish,
287
+ // no re-deliver, session untouched). session_matches is a pure read on the existing
288
+ // session; a genuine NEW handshake (different ephemeral) does not match and re-establishes.
289
+ existing IS bin+ = m_sessions from_cid.
290
+ if olm_type == 0 && existing != NIL && (session_matches (existing as bin) ciphertext)
291
+ {
292
+ return ( $ok -> FALSE, $plaintext -> NIL, $session -> NIL, $account -> NIL,
293
+ $error -> ( $code -> "replayed_handshake",
294
+ $detail -> "pre-key re-delivers an already-established session" ) ).
295
+ }
296
+ r = decrypt (account()) (m_sessions from_cid) sender_ik olm_type ciphertext.
297
+ if r $ok
298
+ {
299
+ m_sessions from_cid -> (r $session) as bin. // commit the ratchet advance
300
+ if (r $account) != NIL { m_account -> (r $account) as bin. } // establish path rotates the account
301
+ }
302
+ return r.
303
+ }
304
+ }
@@ -10,7 +10,15 @@ library key_storage loads library key_utils {
10
10
  _read_or_abort -> read.
11
11
  }
12
12
 
13
- using key_utils.
13
+ // Deserialize untrusted external bytes (a decrypted peer payload) through the injected
14
+ // D_Code-FILTERED reader — the same defence decrypt_message applies to peer crypto_messages.
15
+ // Used by the E2E decode hook to turn a decrypted Olm plaintext back into an inner t_trn.
16
+ read_external = fn (data: bin) -> any
17
+ {
18
+ return _read_or_abort data.
19
+ }
20
+
21
+ using key_utils.
14
22
  metadef t_keypair_info:
15
23
  (
16
24
  $function->t_function,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adapt-toolkit/mufl",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "description": "The MUFL toolchain: prebuilt `mufl` (REPL) and `mufl-compile` (compiler) engine binaries plus the MUFL stdlib, meta, and transactions trees they need at run time. Linux x64 only; the binaries are proprietary (AEFL) — see LICENSE and NOTICE.",
Binary file
Binary file
@@ -3,16 +3,21 @@
3
3
  // to create a function that takes a string that contains JSON that encodes the value of that type
4
4
  // and performs all the necessary runtime checks.
5
5
 
6
- library __t_wrapper loads libraries transaction, key_storage, transaction_queue, identity_proof_document_types {
6
+ library __t_wrapper loads libraries transaction, key_storage, transaction_queue, identity_proof_document_types, e2e {
7
7
 
8
8
  metadef t_trn: ($name->str, $targ->any).
9
- metadef t_trn_extended: ($trn->t_trn, $is_signed->bool, $is_encrypted->bool).
9
+ metadef t_trn_extended: ($trn->t_trn, $is_signed->bool, $is_encrypted->bool, $is_e2e->bool+).
10
10
  metadef t_trn_message_info: (
11
11
  $decoded_envelope -> transaction::envelope::external::decoded_type || t_trn,
12
12
  $decoded_message -> t_trn,
13
13
  $is_signed -> bool,
14
14
  $is_encrypted -> bool,
15
- $has_envelope -> bool
15
+ $has_envelope -> bool,
16
+ // TRUE only for a decoded E2E (double-ratchet) envelope. The C++ transaction driver
17
+ // uses it to enforce the reject-readonly-E2E invariant (an E2E tx is never readonly,
18
+ // so the committing ratchet-advance always seals) and to drop a benign recoverable
19
+ // failure (empty $name) without poisoning the channel.
20
+ $is_e2e -> bool+
16
21
  ).
17
22
 
18
23
  hidden {
@@ -25,7 +30,11 @@ library __t_wrapper loads libraries transaction, key_storage, transaction_queue,
25
30
  return key_storage::check_signature hash signature.
26
31
  }
27
32
 
28
- check_origin_binding = fn (envelope: any) -> publickey_sign {
33
+ // Verify the enveloped sender's identity proof commits to $from and returns BOTH the
34
+ // origin-bound SIGN key (for signature checks) AND the parsed identity — the latter so
35
+ // the E2E decode hook can read the sender's e2e identity key ($e2e_bundle $ik_curve)
36
+ // off the SAME origin-bound identity, with no separate peer store.
37
+ check_origin_binding = fn (envelope: any) -> ( $sign_key -> publickey_sign, $identity -> any ) {
29
38
  ip_raw = envelope $ip_document.
30
39
  abort "Missing identity document on external message" WHEN ip_raw == NIL.
31
40
  ip = ip_raw SAFE(identity_proof_document_types::t_identity_proof_document) abort "Invalid identity proof document" WHEN IS NIL.
@@ -41,10 +50,13 @@ library __t_wrapper loads libraries transaction, key_storage, transaction_queue,
41
50
  }
42
51
  abort "SIGN key not found in identity key_list" WHEN sign_pub == NIL.
43
52
  abort "Origin address does not commit to signing key" WHEN (key_storage::address_of_key sign_pub?) != (envelope $from).
44
- return sign_pub?.
53
+ return ( $sign_key -> sign_pub?, $identity -> identity ).
45
54
  }
46
55
 
47
- decode_message_with_keys = fn (message: any, origin_sign_key: publickey_sign+) -> t_trn_extended {
56
+ // origin_sign_key / from_cid / sender_ik are the origin-bound sender key, the sender's
57
+ // container id ($from), and the sender's e2e identity key ($e2e_bundle $ik_curve) — all
58
+ // NIL for non-enveloped (internal) messages, which never carry $e2e_envelope.
59
+ decode_message_with_keys = fn (message: any, origin_sign_key: publickey_sign+, from_cid: global_id+, to_cid: global_id+, sender_ik: bin+) -> t_trn_extended {
48
60
  if message $encrypted_message {
49
61
  is_ptsignature IS bool = TRUE.
50
62
  signature IS crypto_signature+ = NIL.
@@ -58,12 +70,52 @@ library __t_wrapper loads libraries transaction, key_storage, transaction_queue,
58
70
  decrypted_message = decrypt_message encrypted_message.
59
71
  res = decrypted_message SAFE(t_trn).
60
72
  if is_ptsignature && (check_signature signature? decrypted_message) {
61
- return ($trn -> res, $is_signed -> TRUE, $is_encrypted -> TRUE).
73
+ return ($trn -> res, $is_signed -> TRUE, $is_encrypted -> TRUE, $is_e2e -> FALSE).
62
74
  } elif (!is_ptsignature && check_signature signature? encrypted_message) {
63
- return ($trn -> res, $is_signed -> TRUE, $is_encrypted -> TRUE).
75
+ return ($trn -> res, $is_signed -> TRUE, $is_encrypted -> TRUE, $is_e2e -> FALSE).
64
76
  } else {
65
77
  abort "Signature verification failed." WHEN TRUE.
66
- return ($trn -> ($name -> "", $targ -> NIL), $is_signed -> FALSE, $is_encrypted -> FALSE).
78
+ return ($trn -> ($name -> "", $targ -> NIL), $is_signed -> FALSE, $is_encrypted -> FALSE, $is_e2e -> FALSE).
79
+ }
80
+ } elif message $e2e_envelope {
81
+ // === E2E (double-ratchet) decode hook — transparent decrypt at the decode seam ===
82
+ // The inner transaction rides as opaque Olm ciphertext; here we authenticate the
83
+ // sender, decrypt+commit the ratchet in this (guaranteed-mutating) decode tx, and
84
+ // hand the plaintext inner t_trn back to the driver. $is_e2e=TRUE lets the C++
85
+ // driver enforce reject-readonly-E2E (post-findTransaction, where the inner tx's
86
+ // readonly flag is known) and drop a benign failure (empty $name) cleanly.
87
+ env = message $e2e_envelope.
88
+ // Outer sender authentication: $emsignature over the SENDER+RECIPIENT+envelope
89
+ // context (S1) by the SAME SIGN key check_origin_binding tied to $from. E2E is
90
+ // enveloped-only, so origin_sign_key, $from, $to and the sender's e2e key must all
91
+ // be present. from_cid/to_cid are the outer envelope's $from/$to.
92
+ abort "E2E envelope requires an origin-bound sender key" WHEN origin_sign_key == NIL.
93
+ abort "E2E envelope missing sender container id" WHEN from_cid == NIL.
94
+ abort "E2E envelope missing recipient container id" WHEN to_cid == NIL.
95
+ abort "E2E sender published no e2e bundle" WHEN sender_ik == NIL.
96
+ // S2: reject an unknown/downgraded wire dialect before any decrypt/commit.
97
+ abort "E2E envelope wire-version mismatch" WHEN ((env $pv) as int) != (e2e::wire_pv()).
98
+ emsig = (message $emsignature) SAFE(crypto_signature) abort "Missing E2E envelope signature" WHEN IS NIL.
99
+ // S1: recompute the signed context from the ACTUAL outer $from/$to + envelope, so a
100
+ // captured envelope cannot be reflected to a third party or re-targeted.
101
+ sig_context = ( $from -> from_cid?, $to -> to_cid?, $envelope -> env ).
102
+ abort "E2E envelope signature verification failed"
103
+ WHEN (_crypto_verify_hash_signature (_value_id sig_context |) emsig? origin_sign_key?) != TRUE.
104
+ r = e2e::decrypt_and_commit from_cid? sender_ik? ((env $olm_type) as int) ((env $ciphertext) as bin).
105
+ if r $ok {
106
+ // The plaintext is the peer's inner t_trn serialized with _write. Deserialize
107
+ // with the D_Code-FILTERED external reader (untrusted ingress) that key_storage
108
+ // owns — the same defence the encrypted_message path relies on.
109
+ inner = key_storage::read_external (r $plaintext |).
110
+ inner_trn = (inner SAFE(t_trn)) abort "E2E: decrypted payload is not a transaction" WHEN IS NIL.
111
+ return ($trn -> inner_trn?, $is_signed -> TRUE, $is_encrypted -> TRUE, $is_e2e -> TRUE).
112
+ } else {
113
+ // Recoverable failure (no_session | tampered | session_mismatch): deliver a
114
+ // benign no-op. An empty $name + $is_e2e signals the driver to drop silently
115
+ // without poisoning the channel (a failed decrypt commits no state, so the
116
+ // session is untouched). $is_signed stays TRUE (the emsig verified) so the
117
+ // enveloped-must-be-signed guard below does not abort.
118
+ return ($trn -> ($name -> "", $targ -> NIL), $is_signed -> TRUE, $is_encrypted -> TRUE, $is_e2e -> TRUE).
67
119
  }
68
120
  } elif message $trn {
69
121
  tn = message $trn.
@@ -73,16 +125,16 @@ library __t_wrapper loads libraries transaction, key_storage, transaction_queue,
73
125
  } else {
74
126
  abort "Signature verification failed for message." WHEN ((check_signature signature? tn) == NIL).
75
127
  }
76
- return ($trn -> (tn safe(t_trn)), $is_signed -> TRUE, $is_encrypted -> FALSE).
128
+ return ($trn -> (tn safe(t_trn)), $is_signed -> TRUE, $is_encrypted -> FALSE, $is_e2e -> FALSE).
77
129
  } else {
78
- return ($trn -> (message safe(t_trn)), $is_signed -> FALSE, $is_encrypted -> FALSE).
130
+ return ($trn -> (message safe(t_trn)), $is_signed -> FALSE, $is_encrypted -> FALSE, $is_e2e -> FALSE).
79
131
  }
80
132
  }
81
133
  }
82
134
 
83
135
  fn decode_message (message: any) -> t_trn_extended
84
136
  {
85
- return decode_message_with_keys message NIL.
137
+ return decode_message_with_keys message NIL NIL NIL NIL.
86
138
  }
87
139
 
88
140
  /*
@@ -112,10 +164,19 @@ library __t_wrapper loads libraries transaction, key_storage, transaction_queue,
112
164
  has_envelope is bool = FALSE.
113
165
  message is any = NIL.
114
166
  origin_sign_key IS publickey_sign+ = NIL.
167
+ from_cid IS global_id+ = NIL. // the sender's container id ($from), enveloped only
168
+ to_cid IS global_id+ = NIL. // the recipient's container id ($to), enveloped only (S1)
169
+ sender_ik IS bin+ = NIL. // the sender's e2e identity key, read off the origin-bound identity
115
170
 
116
171
  // This is the case of external message packed into the transaction envelope
117
172
  if envelope $body != NIL {
118
- origin_sign_key -> check_origin_binding envelope.
173
+ check_origin_binding envelope => ($sign_key -> ob_key, $identity -> ob_identity).
174
+ origin_sign_key -> ob_key.
175
+ from_cid -> (envelope $from) as global_id.
176
+ if (envelope $to) { to_cid -> (envelope $to) as global_id. }
177
+ // AD-v2 peers publish an $e2e_bundle in their identity; a v1 peer has none (and
178
+ // cannot send E2E). Read the sender's identity key for the E2E establish path.
179
+ if (ob_identity $e2e_bundle) { sender_ik -> ((ob_identity $e2e_bundle $ik_curve) as bin). }
119
180
  message -> envelope $body.
120
181
  has_envelope -> TRUE.
121
182
  } else {
@@ -123,7 +184,7 @@ library __t_wrapper loads libraries transaction, key_storage, transaction_queue,
123
184
  has_envelope -> FALSE.
124
185
  }
125
186
 
126
- decode_message_with_keys message origin_sign_key => ($trn -> decoded_message, $is_signed -> is_signed, $is_encrypted -> is_encrypted).
187
+ decode_message_with_keys message origin_sign_key from_cid to_cid sender_ik => ($trn -> decoded_message, $is_signed -> is_signed, $is_encrypted -> is_encrypted, $is_e2e -> is_e2e).
127
188
 
128
189
  abort "Unsigned external message rejected: enveloped messages must be signed." WHEN has_envelope && (!is_signed).
129
190
 
@@ -134,7 +195,8 @@ library __t_wrapper loads libraries transaction, key_storage, transaction_queue,
134
195
  $decoded_message -> decoded_message,
135
196
  $is_signed -> is_signed,
136
197
  $is_encrypted -> is_encrypted,
137
- $has_envelope -> has_envelope
198
+ $has_envelope -> has_envelope,
199
+ $is_e2e -> is_e2e
138
200
  ) safe t_trn_message_info.
139
201
  } else {
140
202
  return (
@@ -142,7 +204,8 @@ library __t_wrapper loads libraries transaction, key_storage, transaction_queue,
142
204
  $decoded_message -> decoded_message,
143
205
  $is_signed -> is_signed,
144
206
  $is_encrypted -> is_encrypted,
145
- $has_envelope -> has_envelope
207
+ $has_envelope -> has_envelope,
208
+ $is_e2e -> is_e2e
146
209
  ) safe t_trn_message_info.
147
210
  }
148
211
  }
@@ -14,7 +14,8 @@ config script {
14
14
  $key_storage->#"../mufl_stdlib",
15
15
  $transaction_queue->#"../mufl_stdlib",
16
16
  $key_utils->#"../mufl_stdlib",
17
- $identity_proof_document_types->#"../mufl_stdlib"
17
+ $identity_proof_document_types->#"../mufl_stdlib",
18
+ $e2e->#"../mufl_stdlib"
18
19
  ),
19
20
  $applications->(,)
20
21
  )