@adapt-toolkit/mufl 0.10.4 → 0.10.6

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.
@@ -33,7 +33,16 @@ library address_document_types loads library key_utils
33
33
  $key_list->t_publickey(,), // list of keys active with this container
34
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
35
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)
36
+ // AD-v2 E2E double-ratchet prekey bundle (NIL for v1). Typed `any+`, NOT the nested
37
+ // t_e2e_bundle metadef, so a meta _value_id / SAFE-cast of t_address_document does NOT
38
+ // recursively reduce t_e2e_bundle at EVERY AD op (pin_registrar, verify_identity_bundle,
39
+ // peer_ads, cluster AD handling — dozens of sites). That nested reduction, summed across a
40
+ // full daemon packet + the migration core, blew the per-unit meta-fuel ceiling
41
+ // (meta.mm:2604 via actor.mu pin_registrar). Every read already casts it — e2e.mm
42
+ // `(identity $e2e_bundle) SAFE(t_e2e_bundle)`, a2a_messaging `(...as any) $e2e_bundle safe
43
+ // t_e2e_bundle` — so validation is unchanged and the wire/_value_id bytes are identical
44
+ // (value-hash, type-agnostic). Same pattern as transaction e2e_signed_message $e2e_envelope.
45
+ $e2e_bundle->any+
37
46
  /* $code_id->hash_code */
38
47
  ),
39
48
  $authorizations -> crypto_signature(,)
@@ -227,12 +227,44 @@ loads libraries key_storage, address_document_types
227
227
  m_account IS bin+ = NIL. // this packet's Olm account (lazy first-use)
228
228
  m_sessions IS (global_id ->> bin) = (,). // per-peer session pickle, keyed by contact id
229
229
  m_wire_pv = 8. // wire dialect stamp ($pv) for v1 (matches wire8)
230
+ // core 0.9.0 migration (spec §5.5): ONE staged rotation per peer — a FRESH session
231
+ // established for the bilateral migration commit, held aside so the LIVE session
232
+ // (m_sessions[cid]) keeps decrypting legacy e2e traffic until commit_rotation promotes
233
+ // it. Packet-state, NEVER exported (INV-4) — same secrecy class as m_sessions.
234
+ m_staged IS (global_id ->> bin) = (,).
235
+ // Decode-seam migration hook (spec §5.5): "is a migration FSM entry pending for this
236
+ // cid?" Core (a2a_messaging) registers the real predicate via set_mig_pending_hook at
237
+ // init; the DEFAULT is FALSE, so with no core wiring the decode seam behaves EXACTLY
238
+ // as 0.8.0 (an inbound pre-key on a live session self-heals by replacing — the correct
239
+ // behavior when there is no migration to protect). When TRUE, an inbound pre-key on a
240
+ // live session STAGES instead of replacing, so the live session survives until the
241
+ // bilateral commit promotes the staged one. This is the load-bearing distinction that
242
+ // closes the unilateral-live-session-reset path without breaking pre-key self-heal.
243
+ m_mig_pending IS (global_id -> bool) = fn (_: global_id) -> bool { return FALSE. }.
230
244
 
231
245
  fn account (_) -> bin
232
246
  {
233
247
  if m_account == NIL { m_account -> create_account(). }
234
248
  return m_account as bin.
235
249
  }
250
+
251
+ // The idempotent pre-key re-delivery result (a live- or staged-session replay). No
252
+ // plaintext, no state change — the session lives; the wrapper drops it (or, for a
253
+ // staged-session match, routes it to the core replay-commit hook).
254
+ fn replayed (_) -> e2e_result
255
+ {
256
+ return ( $ok -> FALSE, $plaintext -> NIL, $session -> NIL, $account -> NIL,
257
+ $error -> ( $code -> "replayed_handshake",
258
+ $detail -> "pre-key re-delivers an already-established session" ) ).
259
+ }
260
+ }
261
+
262
+ // Core (a2a_messaging) registers the migration-pending predicate here at init so the decode
263
+ // seam can distinguish a MIGRATION rotation (stage the fresh session) from a self-heal pre-key
264
+ // (replace, 0.8.0). Idempotent; the default predicate is FALSE (pure 0.8.0 decode behavior).
265
+ fn set_mig_pending_hook (hook: (global_id -> bool)) -> nil
266
+ {
267
+ m_mig_pending -> hook.
236
268
  }
237
269
 
238
270
  // Our own published AD-v2 prekey bundle (from our lazily-created account) — embedded in
@@ -273,26 +305,138 @@ loads libraries key_storage, address_document_types
273
305
  // against this to reject an unknown/downgraded dialect (S2). Pure read.
274
306
  fn wire_pv (_) -> int { return m_wire_pv. }
275
307
 
308
+ // =====================================================================================
309
+ // STAGED ROTATION (core 0.9.0 migration, spec §5.5) — additive. The facade above cannot
310
+ // express a bilateral key rotation: encrypt_to reuses m_sessions[cid] unconditionally and
311
+ // an inbound pre-key unilaterally replaces the live session. These APIs add a STAGED slot
312
+ // so a live session is NEVER clobbered without an explicit commit. Exactly-once by
313
+ // construction: commit_rotation is the SOLE promotion point; a lost staged secret is
314
+ // abandoned + superseded by a fresh rotation, never re-derived (INV-4 / plan invariant 4).
315
+ // =====================================================================================
316
+
317
+ // MIGRATION SEND (initiator): create a FRESH outbound session to `peer` EVEN WHEN a live
318
+ // session already exists (deliberately NOT encrypt_to's lazy-establish-if-absent path) and
319
+ // hold it STAGED. m_sessions[cid] is untouched — it keeps decrypting legacy traffic until
320
+ // commit_rotation. Returns the fresh session's stable id (the epoch binds to THIS id). The
321
+ // account may rotate (prekey consumption); that is peer-independent bookkeeping, committed
322
+ // immediately (validated under empty-OTK v1, plan B gate 2).
323
+ fn stage_outbound_rotation (cid: global_id, peer: t_e2e_bundle) -> bin
324
+ {
325
+ est = create_outbound_session (account()) peer.
326
+ m_account -> est $account.
327
+ m_staged cid -> est $session.
328
+ return session_id (est $session).
329
+ }
330
+
331
+ // Encrypt on the STAGED session (PRE_KEY until the peer replies on it); the ratchet advance
332
+ // commits to the STAGED slot ONLY — the live session is never advanced here. Same S1
333
+ // sender/recipient emsig binding + envelope structure as encrypt_to, so the far-side decode
334
+ // hook verifies identically; the envelope carries the STAGED session_id.
335
+ fn encrypt_staged (cid: global_id, plaintext: bin) -> e2e_signed_message
336
+ {
337
+ e = encrypt ((m_staged cid) as bin) plaintext.
338
+ m_staged cid -> e $session. // commit the ratchet advance on the STAGED slot
339
+ env = ( $session_id -> (session_id (e $session)), $olm_type -> (e $olm_type),
340
+ $ciphertext -> (e $ciphertext), $pv -> m_wire_pv ).
341
+ sig_context = ( $from -> _get_container_id(), $to -> cid, $envelope -> env ).
342
+ return ( $e2e_envelope -> env,
343
+ $emsignature -> (key_storage::default_sign (_value_id sig_context)) ).
344
+ }
345
+
346
+ // MIGRATION inbound (responder): establish an inbound session from a pre-key INTO the STAGED
347
+ // slot, leaving m_sessions[cid] untouched. Returns the typed e2e_result (its $plaintext is
348
+ // the inner commit tx for the caller to dispatch). On the establish path the account rotates;
349
+ // committed immediately (peer-independent). A typed failure ($ok=FALSE) stages nothing.
350
+ fn stage_inbound_rotation (from_cid: global_id, sender_ik: bin, ciphertext: bin) -> e2e_result
351
+ {
352
+ r = decrypt (account()) NIL sender_ik 0 ciphertext. // olm_type 0 = establish; NIL session = fresh
353
+ if r $ok
354
+ {
355
+ m_staged from_cid -> (r $session) as bin.
356
+ if (r $account) != NIL { m_account -> (r $account) as bin. }
357
+ }
358
+ return r.
359
+ }
360
+
361
+ // Promote the STAGED session to ACTIVE atomically: it replaces the live session and the old
362
+ // one is DROPPED (the owner's fresh-key requirement). Returns the new session's id, or NIL
363
+ // if nothing is staged (idempotent — a duplicate/lost-confirm commit is a safe no-op). The
364
+ // account is already committed. This is the SOLE session-promotion point (plan invariant 4).
365
+ fn commit_rotation (cid: global_id) -> bin+
366
+ {
367
+ st IS bin+ = m_staged cid.
368
+ if st == NIL { return NIL. }
369
+ m_sessions cid -> st as bin.
370
+ delete m_staged cid.
371
+ return session_id (st as bin).
372
+ }
373
+
374
+ // Drop a staged rotation WITHOUT promoting (a rejected/aborted/superseded migration).
375
+ // Idempotent. The live session is untouched — legacy transport continues.
376
+ fn discard_rotation (cid: global_id) -> nil
377
+ {
378
+ if (m_staged cid) != NIL { delete m_staged cid. }
379
+ }
380
+
381
+ // Pure reads — the byte-comparable session-id evidence the core FSM gates on (staged for the
382
+ // commit/confirm equality checks; active as the replayed-commit authentication evidence,
383
+ // spec §5.5 lost-confirm path). NIL when the respective slot is empty.
384
+ fn staged_session_id (cid: global_id) -> bin+
385
+ {
386
+ s IS bin+ = m_staged cid.
387
+ if s == NIL { return NIL. }
388
+ return session_id (s as bin).
389
+ }
390
+ fn active_session_id (cid: global_id) -> bin+
391
+ {
392
+ s IS bin+ = m_sessions cid.
393
+ if s == NIL { return NIL. }
394
+ return session_id (s as bin).
395
+ }
396
+
276
397
  // RECEIVE: decrypt an inbound envelope for a known sender and COMMIT the advanced session
277
398
  // (and, on the establish path only, the rotated account). Returns the typed e2e_result.
278
399
  // The caller (the __t_wrapper decode hook) authenticates $emsignature FIRST and supplies
279
400
  // the sender's e2e identity key (read from the same AD identity it origin-binds against).
280
401
  fn decrypt_and_commit (from_cid: global_id, sender_ik: bin, olm_type: int, ciphertext: bin) -> e2e_result
281
402
  {
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
403
  existing IS bin+ = m_sessions from_cid.
290
- if olm_type == 0 && existing != NIL && (session_matches (existing as bin) ciphertext)
404
+ staged IS bin+ = m_staged from_cid.
405
+
406
+ if olm_type == 0
291
407
  {
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" ) ).
408
+ // Pre-key (establish) REPLAY guard. v1 publishes a MULTI-USE fallback key (empty OTK
409
+ // list), so a replayed olm_type=0 pre-key would re-establish successfully — re-running
410
+ // the inner tx AND overwriting the (possibly advanced) session. session_matches is a
411
+ // pure read; a genuine NEW handshake (different ephemeral) does not match.
412
+ // (a) matches the LIVE session — idempotent re-delivery, drop recoverably.
413
+ if existing != NIL && (session_matches (existing as bin) ciphertext)
414
+ { return replayed(). }
415
+ // (b) matches the STAGED session — a redelivered migration COMMIT after we already
416
+ // staged it (the lost-confirm case, spec §5.5). The wrapper turns this typed
417
+ // error into a re-confirm via core mig_handle_replayed_commit; NO inner
418
+ // re-dispatch, NO re-establish. active_session_id is the authenticated evidence.
419
+ if staged != NIL && (session_matches (staged as bin) ciphertext)
420
+ { return replayed(). }
421
+ // DECODE-SEAM (§5.5, the load-bearing security change): a genuinely NEW pre-key on a
422
+ // cid that ALREADY holds a live session, WHILE a migration is pending, STAGES the
423
+ // fresh session instead of unilaterally replacing the live one. m_sessions[cid] is
424
+ // untouched until commit_rotation promotes the staged session. With NO pending
425
+ // migration the hook is FALSE and we fall through to the 0.8.0 path (a live-session
426
+ // pre-key self-heals by replacing — correct when there is no migration to protect).
427
+ if existing != NIL && (m_mig_pending from_cid)
428
+ { return stage_inbound_rotation from_cid sender_ik ciphertext. }
295
429
  }
430
+ // Normal ratchet (olm_type=1) that decrypts on the STAGED session: the initiator reading
431
+ // the peer's confirm on the fresh session BEFORE it promotes (decode-seam). Try staged
432
+ // first; a typed failure (the message was for the live session) falls through below.
433
+ if olm_type == 1 && staged != NIL
434
+ {
435
+ rs = decrypt (account()) staged sender_ik 1 ciphertext.
436
+ if rs $ok { m_staged from_cid -> (rs $session) as bin. return rs. }
437
+ }
438
+ // Default 0.8.0 path: establish (first-contact / self-heal) or decrypt on the LIVE session,
439
+ // committing the advance (and, on establish, the rotated account).
296
440
  r = decrypt (account()) (m_sessions from_cid) sender_ik olm_type ciphertext.
297
441
  if r $ok
298
442
  {
@@ -301,4 +445,64 @@ loads libraries key_storage, address_document_types
301
445
  }
302
446
  return r.
303
447
  }
448
+
449
+ // ⚠ MUST STAY BYTE-IDENTICAL to __t_wrapper.mm's inline e2e decode (S2 wire-pv + S1 emsig
450
+ // over ($from,$to,$envelope) + decrypt_and_commit, currently ~lines 96-104). Plan-F folds the
451
+ // wrapper's inline copy into THIS fn (single call) when the SDK bundle is regenerated (A); until
452
+ // then the two are a REVIEWED-IDENTICAL pair guarded by the mig-gate decode negative matrix
453
+ // (bad wire_pv / tampered emsig / wrong $to → abort; replay → !ok). Do NOT edit one without the
454
+ // other. See MigrationReview point-1 ruling (2026-07-15).
455
+ // The single AUTHENTICATED inbound-e2e entry for the core-migration boxed-envelope path
456
+ // (a2a_messaging handle_e2e_migrate_commit/_confirm). At plan-F the __t_wrapper decode hook
457
+ // folds its inline S1/S2 into THIS fn too (one audited implementation); until then the two are
458
+ // the reviewed-identical pair the ⚠ note above governs. S2 (wire dialect)
459
+ // then S1 (emsig over the SENDER + RECIPIENT + envelope context, so a captured envelope cannot
460
+ // be reflected to a third party or re-targeted) — both FORGERY aborts — then decrypt_and_commit
461
+ // (which owns the decode-seam staging + pre-key replay guards). The caller supplies the
462
+ // origin-bound sender SIGN key and e2e identity key (read from the SAME AD it origin-binds).
463
+ fn recv_authenticated_envelope (from_cid: global_id, to_cid: global_id, origin_sign_key: publickey_sign, sender_ik: bin, env: t_e2e_envelope, emsig: crypto_signature) -> e2e_result
464
+ {
465
+ // S2: reject an unknown/downgraded wire dialect before any decrypt/commit.
466
+ abort "E2E envelope wire-version mismatch" WHEN ((env $pv) as int) != wire_pv().
467
+ // S1: recompute the signed context from the ACTUAL $from/$to + envelope.
468
+ sig_context = ( $from -> from_cid, $to -> to_cid, $envelope -> env ).
469
+ abort "E2E envelope signature verification failed"
470
+ WHEN (_crypto_verify_hash_signature (_value_id sig_context |) emsig origin_sign_key) != TRUE.
471
+ return decrypt_and_commit from_cid sender_ik ((env $olm_type) as int) ((env $ciphertext) as bin).
472
+ }
473
+
474
+ // Core-migration boxed-envelope decode (a2a_messaging C.3, spec §5.5). The SDK wire schema has
475
+ // no e2e_signed_message transaction variant yet (a coordinated plan-F SDK-bundle regen), so the
476
+ // migration commit/confirm carry the staged-session Olm ciphertext as $targ INSIDE the legacy
477
+ // box; the handler drives the e2e decode here instead of the __t_wrapper decode-seam. SECURITY:
478
+ // identical S1/S2 auth to the wrapper (via recv_authenticated_envelope). The caller MUST pass
479
+ // from_cid = the box's AUTHENTICATED sender_id and peer_ad = THAT cid's verified AD; we re-bind
480
+ // here — (a) AD.container_id == from_cid, (b) the origin SIGN key's address == from_cid, (c) S1
481
+ // $from == from_cid — so a relay CANNOT box a captured envelope (its signed $from != the relay's
482
+ // box $from → S1 fails). to_cid = my container id (S1 $to binding: a commit addressed to a
483
+ // different recipient cannot be replayed into my session).
484
+ fn decode_migration_envelope (from_cid: global_id, to_cid: global_id, peer_ad: t_address_document, env: t_e2e_envelope, emsig: crypto_signature) -> e2e_result
485
+ {
486
+ identity = peer_ad $identity.
487
+ abort "e2e migration: peer AD container id does not match the box sender" WHEN (identity $container_id) != from_cid.
488
+ // origin-bound sender SIGN key — the SAME derivation as __t_wrapper check_origin_binding.
489
+ sign_key_id = identity $default_keys $SIGN.
490
+ sign_pub IS publickey_sign+ = NIL.
491
+ sc identity $key_list -- (k->) {
492
+ if _crypto_get_key_id k == sign_key_id {
493
+ sign_pub -> k SAFE(publickey_sign).
494
+ break.
495
+ }
496
+ }
497
+ abort "e2e migration: SIGN key not found in sender identity key_list" WHEN sign_pub == NIL.
498
+ // DEFENSE-IN-DEPTH (do NOT delete as dead code): the sign-key-origin and bundle-presence
499
+ // aborts below are already guaranteed for a peer_ads[from_cid] AD by the offer/ack
500
+ // verify_identity_bundle (origin-binding via PoP+cid-bind) + GATE3 bundle-presence, which a
501
+ // peer MUST clear before it can reach commit. They become load-bearing if that upstream
502
+ // verify is ever weakened. (Not driven by the mig-gate guard — would need a forged AD.)
503
+ abort "e2e migration: sender origin address does not commit to signing key" WHEN (address_of_key sign_pub?) != from_cid.
504
+ bundle = (identity $e2e_bundle) SAFE(t_e2e_bundle).
505
+ abort "e2e migration: sender published no e2e bundle" WHEN bundle == NIL.
506
+ return recv_authenticated_envelope from_cid to_cid sign_pub? (bundle? $ik_curve) env emsig.
507
+ }
304
508
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adapt-toolkit/mufl",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
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
@@ -176,7 +176,11 @@ library __t_wrapper loads libraries transaction, key_storage, transaction_queue,
176
176
  if (envelope $to) { to_cid -> (envelope $to) as global_id. }
177
177
  // AD-v2 peers publish an $e2e_bundle in their identity; a v1 peer has none (and
178
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). }
179
+ if (ob_identity $e2e_bundle) {
180
+ ob_bundle = (ob_identity $e2e_bundle) SAFE(address_document_types::t_e2e_bundle)
181
+ abort "Invalid E2E bundle in sender identity" WHEN IS NIL.
182
+ sender_ik -> ob_bundle? $ik_curve.
183
+ }
180
184
  message -> envelope $body.
181
185
  has_envelope -> TRUE.
182
186
  } else {