Skip to content

Implement multi-recipient agreement workflow with consent signing - #105

Open
asherp wants to merge 45 commits into
stagingfrom
claude/dreamy-carson-fc3qne
Open

Implement multi-recipient agreement workflow with consent signing#105
asherp wants to merge 45 commits into
stagingfrom
claude/dreamy-carson-fc3qne

Conversation

@asherp

@asherp asherp commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Summary

DocuSign-style multi-recipient agreement workflow (spec v0.4, §10–11) — encrypted/public messages to many recipients with consent/signature tracking — plus an attachment-binding hardening pass: Cap'n Proto manifests built in Rust (both 1:1 and multi-recipient), enforced ciphertext hashes, and a signed ATTACHMENTS block for public messages (enforced end-to-end).

Progress

✅ Done (backend tested — 268 lib + 21 integration tests green)

  • Multi-recipient agreement core (agreement.rs): per-recipient NIP-44-wrapped CEK + role (signer/viewer/self), CONSENT blocks, deterministic block canonicalization, M-of-N completion accounting, ephemeral-key CEK wrapping (AGE-style).
  • Cap'n Proto manifest in Rust (manifest.rs): builds the attachment manifest (AES-encrypts body + each attachment under independent keys), serializes capnp. Two transport markers: capnp: (raw, byte-clean CEK envelope) and capnp64: (base64, string-typed NIP-44).
  • Regenerated stale capnp bindings — the committed file predated the schema's Manifest.version field.
  • Unified decode (both transports) — pairwise NIP and CEK envelope route through one ParsedManifest; reads capnp:, capnp64:, and legacy JSON (old mail still opens).
  • Multi-recipient send → capnp in Rustcompose_agreement takes plaintext attachments, builds the manifest, emits encrypted aN.dat parts; frontend passes plaintext.
  • 1:1 send → capnp in Rustencrypt_manifest_body builds the capnp manifest, NIP-encrypts it to the recipient, and ASCII-armors it; the frontend's manifest branch calls it instead of building/AES-ing/NIP-ing a JSON manifest in JS. Subject/glossia/signing/DM unchanged. Tested encrypt→decrypt→attachment roundtrip in Rust.
  • Enforced attachment hashes (encrypted)cipher_sha256 mismatch now rejects the attachment instead of warning + decrypting anyway.
  • Signed ATTACHMENTS block for public messages — plaintext hashes folded into the level signature (§4.2: body‖recipients‖consent‖attachments, backward compatible). Tampering breaks the signature.
  • Public attachment enforcement on receiveverify_public_attachment recomputes a delivered file's SHA-256, checks the signed spec, and confirms the signature; the inbox download refuses a tampered/unsigned file.
  • Quoted nested-signature parsing fix — armor markers recognized in > -quoted reply lines (marker_core).
  • Spec + schema updated — §4.2, §11.2, armor-block table, capnp capnp:/capnp64: transport split.
  • CI fixtests/email_integration.rs updated for send_email's cc parameter.

⬜ Remaining

  • In-app testing of the frontend wiring — the JS-side changes (multi-recipient plaintext send, 1:1 manifest via Rust, public-attachment enforcement) are node --check-only; the underlying Rust is unit-tested, but the IPC calls + notification/branch logic need a Tauri runtime to exercise end-to-end. Suggested smoke tests: send a 1:1 encrypted email with an attachment and confirm the recipient decrypts body + file; send a public agreement with an attachment and confirm a tampered file is refused.

Notable implementation details

  • Two capnp transport markerscapnp: (raw) for the byte-clean CEK envelope; capnp64: (base64) for pairwise NIP-44, whose SDK decrypt yields a String and can't carry raw binary.
  • Deterministic canonicalization keeps signatures valid across email quoting.
  • Ephemeral key wrapping — CEK wrapped to each recipient via a per-message ephemeral keypair; ephemeral pubkey in the RECIPIENTS block.
  • Backward compatibility — legacy JSON manifests and identity-key-wrapped messages still supported.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K

claude added 30 commits June 13, 2026 15:12
Phase 1 of the CC/agreements spec (v0.4): the load-bearing backend
crypto and armor-grammar foundation, fully unit-tested.

crypto.rs — hybrid envelope primitives (spec §10.1):
  * aes_gcm_encrypt_raw — AES-256-GCM encrypt under a raw CEK
    (mirror of the existing aes_gcm_decrypt_raw)
  * generate_cek — random 256-bit Content Encryption Key
  * wrap_cek / unwrap_cek — NIP-44 wrap/unwrap of the CEK per recipient

agreement.rs — RECIPIENTS & CONSENT armor blocks and agreement workflow:
  * Recipient / Consent types with parse + serialize (spec §10.2, §11.3)
  * canonicalize_block — quote-prefix/whitespace-invariant canonical form
    for signing (spec §4.2)
  * document_hash — H over body+recipients, excluding consent (spec §11.3.1)
  * level_signing_bytes — per-level signing contribution body||recipients||
    consent (spec §4.2)
  * compute_completion — "M of N signatories signed" accounting (spec §11.5),
    npub/hex-normalized, deduped, viewer/self excluded

Pure functions over extracted block text; wiring into the recursive armor
parser/encoder and the To/Cc role mapping follows in later phases.

33 unit tests (18 agreement + 15 crypto) pass.
Phase 2a of the CC/agreements spec: teach the raw-text armor layer about
multi-recipient and agreement blocks, and fold them into the signing target.

  * split_armor_level — shared single-level splitter that separates a level's
    body from its RECIPIENTS (§10.2) and CONSENT (§11.3) blocks and its nested
    (quoted) armor, honoring the §11.3.2 order body→recipients→consent→nested
    →signature. parse_armor_depth now delegates to it.
  * level_* marker helpers recognize BEGIN/END NOSTR HYBRID/RECIPIENTS/CONSENT;
    RECIPIENTS/CONSENT standalone terminators are excluded from nesting depth.
  * extract_ciphertext_binary now signs over the §4.2 per-level target
    decode(body) || canonical(recipients) || canonical(consent), concatenated
    outermost→innermost. With no such blocks it reduces to decode(body) and is
    byte-identical to the legacy behavior, so existing messages are unaffected.
  * populate_armor_from_text recognizes HYBRID bodies and pulls RECIPIENTS/
    CONSENT out of the capnp body_text (kept clean; serde/capnp exposure of the
    block contents lands in the next phase).

Adds 5 tests: block separation, §4.2 signing-target inclusion, and full
verify-then-tamper coverage for a multi-recipient message and a consent
message (re-labeling a signatory or altering the document hash breaks the
signature). Full lib suite: 228 passed.
Phase 3 of the CC/agreements spec: encode_hybrid_agreement composes a complete
group-encrypted armor message (spec Sections 10–11):

  * AES-256-GCM-encrypts the body once under a fresh CEK, NIP-44-wraps the CEK
    to each To:/Cc: recipient plus a trailing self stanza (§10.1, §10.4)
  * emits body → RECIPIENTS → optional CONSENT → SIGNATURE in §11.3.2 order
  * when originator_consents, includes the originator's CONSENT over the
    document hash H (§11.2, §11.3.1)
  * signs the §4.2 per-level target (body || recipients || consent), so
    membership, roles, and consent are tamper-evident

Tests:
  * two-recipient round-trip — every party (incl. self) unwraps the CEK and
    AES-decrypts the exact body
  * consent message — emitted H matches SHA-256(ciphertext || canonical
    recipients); originator is the consenting signer
  * empty-recipient rejection
  * cross-module: encoder output verifies through the email §4.2 signature
    path, yields one valid signature, and leaves body_text clean of the
    RECIPIENTS/CONSENT blocks

Full lib suite: 232 passed.
…esence

The dedicated HYBRID body keyword was redundant: its only job was to tell the
decoder to take the CEK-envelope path, but the presence of a RECIPIENTS block
already signals that unambiguously — mirroring how the existing attachment
manifest rides inside an ordinary encrypted body and is detected by content,
not by a special tag.

Spec (docs/nostr-mail-spec.md):
  * multi-recipient bodies use the generic `BEGIN NOSTR ENCRYPTED BODY` tag
    (AES-256-GCM under a CEK); no HYBRID keyword
  * §8 / §10.5: decoders select pairwise vs envelope path by the presence of a
    RECIPIENTS block, not by body tag
  * §2.1 documents the rationale; examples updated

Implementation:
  * level_is_begin_body recognizes the generic `ENCRYPTED BODY` tag
  * encode_hybrid_agreement emits `ENCRYPTED BODY`
  * tests/comments updated

"Hybrid" is retained only where it names the AES+NIP-44 cryptographic
construction (the same one attachments use), not as a wire keyword.
Full lib suite: 232 passed.
…site)

Issue #102 (SecureJoin-style email↔npub binding) requires the (npub, email)
pairing to live *inside* the signed RECIPIENTS block rather than only in the
spoofable To/Cc headers, so the originator's signature authenticates it.

Spec (docs/nostr-mail-spec.md §10.2, §6.3, §10.9):
  * stanza grammar extended to `<role> <pubkey> <wrapped-cek> [<email>]` — the
    email is an optional fourth, trailing token
  * placed after wrapped-cek (not before) so a three-token decoder still
    recovers role/pubkey/cek and can decrypt; the '@' makes it unambiguous
    against the base64 wrapped-cek
  * §6.3: when present, the in-block email (not the header) is the authoritative
    (pubkey, email) pairing, authenticated via §10.6
  * §10.9: notes it restates addresses already in To/Cc, so no new disclosure

Implementation:
  * Recipient gains `email: Option<String>`; to_line appends it when present
  * parse_recipients_block captures the 4th token as email only if it contains
    '@' (else treated as an ignorable forward-compat token)
  * AgreementRecipientInput gains `email`; encode_hybrid_agreement takes
    `sender_email` for the self stanza and threads recipient emails through

Tests: email token parse/roundtrip, non-email trailing token ignored, encoder
carries the pairing, and a tamper test proving that re-pairing an npub to a
different email breaks the originator's signature. Full lib suite: 234 passed.

Scopes only the spec prerequisite; the handshake, challenge store, and
binding-verified trust UI (the rest of #102, gated on #101) are follow-ups.
Verify a binding purely from a self-contained thread — no outstanding-challenge
store. Because a reply nests the issuer's own signed challenge, "I issued this"
is re-derived by checking the email-asserting level is signed by my_pubkey, so
the verdict is a pure function of mailbox contents and a fresh client rebuilds
all bindings by re-scanning (mirroring the stateless §9.1 spam-rescue design).

  * agreement::Binding — serde type recording (pubkey, email, issuer_pubkey)
  * email::verify_email_binding(thread, my_pubkey) -> Vec<Binding>:
      1. every signature in the chain must verify (§4.2 integrity)
      2. a level I signed must pair R with an email in its RECIPIENTS stanza
      3. an outer (quoting) level must be signed by R
    ⇒ R controls the npub and demonstrated read access to that address
  * collect_level_recipients — depth-keyed recipient walk aligned with
    verify_all_signatures_inline

Tests: completed handshake yields the binding; a lone challenge does not (a
single message is never sufficient); a tampered (npub,email) pairing voids the
proof; and the check is issuer-side (wrong perspective yields nothing).
Full lib suite: 238 passed.
verify_agreement_status(thread) computes "M of N signed" offline from a
self-contained thread:

  * the innermost (originating) level fixes H = SHA-256(decode(body₁) ||
    canonical(recipients₁)) (§11.3.1) and the required signatory set — its
    `signer` stanzas plus the originator iff that level itself carries a
    CONSENT block (§11.2)
  * a signatory counts only via a CONSENT over this H whose `signer` equals
    that level's *verified* SIGNATURE pubkey; a signed comment without consent,
    or a consent over the wrong H / bound by an invalid signature, never counts
  * returns None when the thread isn't an agreement (no originating RECIPIENTS)

  * collect_thread_levels — depth-keyed body/recipients/consent walk aligned
    with verify_all_signatures_inline
  * AgreementStatus gains serde + a document_hash field (set here; empty for
    the bare compute_completion primitive)

Tests: all-consent ⇒ 2 of 2 complete; a comment reply ⇒ 1 of 2; a consent over
the wrong H ⇒ not counted; a non-agreement ⇒ None. Full lib suite: 242 passed.
…nsent

Phase 4b read path: incoming group-encrypted messages now decrypt, and the
parsed struct surfaces recipients/consent for the frontend.

  * ParsedArmorMessage gains recipients / recipients_text / consent, populated
    by attach_agreement_blocks (a split_armor_level walk in lock-step with the
    quoted chain) — outside capnp, like prefix_text/quoted_armor_text. Recipient
    and Consent gain serde derives.
  * decrypt_single_block takes the level's RECIPIENTS and, when present, takes
    the envelope path (spec §10, §8 step 8): locate the reader's stanza,
    NIP-44-unwrap the CEK against the sender's pubkey (SIGNATURE/SEAL/fallback),
    then AES-256-GCM-decrypt the body. Path selection is by RECIPIENTS presence,
    not a body tag. Self stanza handles the Sent copy (self-DH).
  * A reader with no matching stanza gets a clear "not a recipient" error
    (expected for levels predating their addition to the thread, §10.8).

Tests (full pipeline): To-signer, Cc-viewer, and the sender's self copy all
recover the exact body; a non-recipient is denied; parse_armor_components
exposes recipients + originator consent. Full lib suite: 245 passed.
Add transparent, unencrypted agreements: terms readable by anyone, completion
verifiable offline by third parties (not just recipients). Open letters, public
multi-party statements, on-the-record contracts.

The consent / H / §4.2 signing / completion / binding machinery already operates
on decoded bytes, so it works over a SIGNED BODY unchanged — the only additions
are a CEK-free stanza convention and a plaintext encoder:

  * agreement::NO_CEK ("-") — sentinel for a stanza with no key wrap; the
    signatory's (role, pubkey[, email]) is still authenticated by the signature
  * email::encode_signed_agreement — terms in a glossia SIGNED BODY (plus the
    plaintext above the armor, §3.2), RECIPIENTS with "-" ceks, no self stanza,
    optional originator CONSENT; signs the §4.2 target
  * envelope decrypt rejects an ENCRYPTED BODY paired with a "-" stanza
  * spec §11.8 + §10.2 sentinel + §11.2 note

Tests: a public agreement is readable above the armor, uses SIGNED BODY, signs
and verifies, exposes "-" signatory stanzas, tracks 1-of-2 then completes to
2-of-2 after a plaintext counter-signature, and breaks verification when the
signed terms are mutated. Full lib suite: 248 passed.
…p reference

Replace the fixed-position stanza (with the "-" no-CEK sentinel) with optional,
content-typed tokens, and reserve a reference token for the metadata-private
gift-wrap mode (spec §11.7.1).

Stanza is now `<role> <pubkey>` + any subset, in any order, classified by shape:
  * `@`  → email      (binding, #102)
  * `:`  → reference  (scheme:value, e.g. evt:<gift-wrap-id>; §11.7.1)
  * else → wrapped-cek (base64; never contains @ or :)

Both cek and email are co-equal optional fields (cek decrypts, email completes
binding validation) — neither is privileged. Absence means "not carried in the
email": plaintext agreements omit the cek; gift-wrap mode carries only a
reference (cek + binding travel in the referenced DM).

Code:
  * Recipient.wrapped_cek: String → Option<String>; add reference: Option<String>;
    drop the NO_CEK sentinel
  * parse_recipients_block classifies trailing tokens by content (min is
    role+pubkey; bare/optional stanzas now valid)
  * to_line emits only present fields; encoders set Some/None accordingly
  * envelope decrypt: no CEK → clear error (distinguishes plaintext vs a
    gift-wrap reference whose DM delivery isn't wired yet)

Spec:
  * §10.2 rewritten for content-typed optional tokens + the reference token
  * §11.7.1 "Gift-Wrap Mode": email keeps the signed pubkey/role set + reference;
    CEK and (npub,email) binding move to a NIP-59 gift-wrapped DM; documents the
    three privacy gradations and the self-containedness trade-off
  * §11.8 plaintext: omit the cek token (no sentinel)

NIP-59 delivery + DM-aware decrypt/verify remain a later milestone. Full lib
suite: 249 passed.
Wire the agreement encoders into a send path and expose them over IPC.

email.rs:
  * compose_agreement_armor — maps To→signer / Cc→viewer (§6.3) and builds the
    armor via encode_hybrid_agreement (encrypted) or encode_signed_agreement
    (plaintext/public); unit-testable (no SMTP)
  * send_agreement_email — composes, then sends ONE message to all signatories
    (To:) and viewers (Cc:); adds the X-Nostr-Agreement marker (§6.1) and the
    X-Nostr-Pubkey/Sig headers mirroring the in-body SIGNATURE
  * X-Nostr-Agreement header type; build_mailer / send_message_with_timeout
    helpers factored out of the SMTP path

types.rs: AgreementParty { email, pubkey } IPC arg.

lib.rs — four Tauri commands (registered):
  * compose_agreement  — returns the armor for preview (no send)
  * send_agreement     — compose + multi-recipient send
  * agreement_status   — "M of N signed" completion from armor (§11.5)
  * verify_email_binding — stateless (npub,email) binding check (#102)

Tests: encrypted compose maps To/Cc to signer/viewer (+ self stanza + consent)
and the To: party decrypts via the pipeline; plaintext compose is public and
signed and tracks 1-of-2; empty recipients rejected. Full lib suite: 252 passed.

Note: the agreement subject is sent in cleartext for now (the terms/body are
encrypted in envelope mode); per-recipient subject encryption is a follow-up.
The frontend compose UI / status rendering is the next layer.
For an encrypted (envelope) agreement, the Subject header is now AES-256-GCM
-encrypted under the same CEK as the body, so it is readable by exactly the
recipients and never leaks in cleartext. Plaintext (public) agreements keep a
cleartext subject.

  * agreement::encode_hybrid_agreement_with_cek — CEK-taking variant so one key
    covers both the body and the subject (encode_hybrid_agreement wraps it)
  * email::compose_agreement now takes the subject and returns ComposedAgreement
    { armor, subject } — subject base64(AES-GCM(CEK)) in envelope mode, cleartext
    in plaintext mode
  * decrypt_subject_envelope — unwraps the CEK from the reader's RECIPIENTS
    stanza and decrypts the subject; the pipeline routes here whenever a
    RECIPIENTS block is present (falls back gracefully to cleartext)
  * compose_agreement / send_agreement IPC + send_agreement_email updated to
    carry the (encrypted) subject; ComposedAgreement is the compose return type
  * spec §11.2: subject encrypted under the CEK (metadata, not signed/H-covered)

Tests: the To: signatory recovers body AND subject; a non-recipient gets
neither; plaintext agreement keeps a cleartext subject. Full lib suite: 252.
…egrity)

base64 is wrong for a signed reply chain over email: when a counterparty's
client quotes the prior message it adds "> " prefixes and re-wraps lines, which
corrupts base64 — so the decoded bytes, and every nested signature over them,
would fail to verify. Glossia's word tokens survive quoting/wrapping/reflow
(decoders ignore "> " and non-payload whitespace), so the canonical bytes are
recovered intact. This is why email-native document signing can't use base64.

  * glossia_encode_bytes (generalized from glossia_encode_signed_body) — encode
    raw bytes for an armor body, returning the encoded words + canonical bytes
  * encode_hybrid_agreement_with_cek now glossia-encodes the body ciphertext and
    signs over the canonical decoded bytes (not the raw base64)
  * compose_agreement glossia-encodes the subject ciphertext; the subject reads
    as prose and survives header folding
  * decrypt_subject_envelope decodes via decode_armor_section (glossia or base64)
  * spec §3.6 / §11.2: glossia required for the signed body; rationale documented

Test: a glossia body decodes to identical bytes after quote-prefixing AND
re-wrapping (base64 would break). Full lib suite: 253 passed.
The agreement encoders hardcoded english/bip39; they now use the encoding
scheme from the user's Advanced settings (the same glossia_encoding string the
rest of the signing path uses, e.g. "latin", "english - bip39").

  * glossia_lang_wordlist — shared encoding-name → (language, wordlist) map
    (None ⇒ english/bip39); glossia_roundtrip_to_bytes now routes through it
  * glossia_encode_bytes_with(data, encoding) — encode an armor body under the
    chosen scheme; encode_signed_agreement / encode_hybrid_agreement[_with_cek]
    thread an `encoding: Option<&str>` through to body AND subject
  * compose_agreement / send_agreement_email take the encoding; the
    compose_agreement / send_agreement IPC commands gain a glossia_encoding arg
  * decoding is unaffected — the dialect is auto-detected — so only the encode
    side needs the setting

Tests: latin vs english produce different encodings but identical decoded bytes;
a composed agreement under each scheme still verifies. Full lib suite: 255.
Add a dedicated subject_encoding scheme, separate from the body encoding, for
the agreement subject. Defaults to the body's encoding when unset, so a single
setting still drives both.

  * compose_agreement / send_agreement_email take subject_encoding: Option<&str>;
    the encrypted subject is glossia-encoded under subject_encoding.or(encoding)
  * compose_agreement / send_agreement IPC commands gain a subject_encoding arg
  * spec §5: Body and Subject are now listed as independent encoding settings
    (subject defaults to body); §11.2 updated

Tests: body in latin + subject in english both decrypt for the recipient;
subject_encoding=None falls back to the body scheme. Full lib suite: 256.
Frontend for the agreement workflow, calling the new backend IPC commands.

Compose:
  * "Make this an agreement" toggle in the compose form reveals a Cc (viewers)
    field, an Encrypted/Public visibility select, and an "I consent" checkbox
  * sendEmail() routes to sendAgreementCompose() when enabled: To: → signers,
    Cc: → viewers (§6.3), resolves each email to its contact's Nostr pubkey
    (errors listing any unknown recipients), then calls send_agreement with the
    body glossia encoding from settings

Status:
  * renderAgreementStatus() shows an "M of N signatories signed" banner at the
    top of the email detail (green complete / amber pending), the short document
    hash, and a per-signatory signed/pending list — computed via agreement_status
    from the raw armor (works without decrypting; §11.5)
  * detail-view encryption gate broadened to match the generic envelope tag
    (BEGIN NOSTR ENCRYPTED BODY) so multi-recipient agreements decrypt, not just
    pairwise NIP-XX

IPC wrappers: composeAgreement, sendAgreement, agreementStatus,
verifyEmailBinding (tauri-service.js). New styles/agreements.css.

Backend untouched (256 tests still green). JS syntax-checked; needs in-app
testing. Follow-ups: list-view lock/subject indicators for the generic envelope
tag, and a contact-picker for multi-recipient To/Cc.
… badge

  * one capture-phase click handler explains the trust badges in a modal
    (Signed, Unsigned, Email Verified/Unverified, Verified identity) — keyed on
    the existing badge classes, so no per-site badge-string edits; the invalid-
    signature badge keeps its own "recheck" click behavior (excluded)
  * showTrustBadgeExplanation(key) renders the explanation via app.showModal
  * the agreement status banner now also surfaces verified email↔npub bindings
    (issue #102) provable from the thread, as clickable "Verified identity"
    badges, via verify_email_binding
  * cursor/styling for the now-clickable badges

Frontend only; needs in-app testing. Renaming the badge label text and the
list-view regex broadening come with the Agreements tab next.
…toggle

Replaces the inbox "Make this an agreement" checkbox with a dedicated tab.

  * new "Agreements" nav tab (fa-file-signature) with its own list and a
    "Create agreement" (+) button; switchTab('agreements') → loadAgreements()
  * loadAgreements() = inbox emails filtered to agreements (X-Nostr-Agreement
    header or inline BEGIN NOSTR RECIPIENTS/CONSENT), rendered with the rich
    inbox rows; clicking opens the message in the inbox detail view
  * "Create agreement" → startNewAgreement(): opens compose in agreement mode
    (To→signers, Cc→viewers, visibility, consent). The old manual checkbox is
    hidden; compose shows an "agreement mode" banner with a "switch to normal
    email" link. Plain compose resets the mode.
  * broadened the list/thread/“is-encrypted” regexes to accept the generic
    BEGIN NOSTR ENCRYPTED BODY envelope (not just NIP-XX), so agreement subjects
    decrypt and the lock shows in the list — the gap explained earlier

Frontend only; needs in-app testing. Follow-ups: the tab is inbox-scoped (sent
agreements still live in Sent); multi-contact To/Cc picker; merge sent+received
into one agreement thread view.
Replace the comma-separated Cc text field with a one-at-a-time viewer picker:

  * pick a contact from a dropdown (contacts with both email + pubkey), or
  * enter an email + npub/hex pubkey manually and click Add

Added viewers show as removable chips; the send path reads the picker's
{email, pubkey} list directly (no email→pubkey resolution needed for Cc).
Wired into agreement mode (populated/rendered on entry; cleared after send).
To: signatories still use the text field + contact resolution.

Frontend only; needs in-app testing.
Mirror the Cc picker for To signatories and refactor both into one generic
picker (kind = 'to' | 'cc'):

  * agreement mode now shows in-panel To and Cc pickers (contact dropdown or
    manual email + npub, added one at a time as removable chips) and hides the
    normal single-recipient To block
  * sendAgreementCompose reads To/Cc from the pickers' {email, pubkey} lists —
    no email→pubkey resolution; requires at least one To signatory
  * generic _setupRecipientPicker/_populateRecipientSelect/addRecipient/
    _renderRecipientChips replace the Cc-specific versions; both cleared on
    start and after send
  * CSS classes generalized (.recipient-picker/.manual-entry)

Frontend only; needs in-app testing.
loadAgreements() now merges inbox and sent emails, filters to agreements, and
dedupes by thread (an agreement thread can appear in both stores — newest
wins), sorted newest-first. Each row is rendered with its source's renderer
(inbox/sent) and, on click, routed to the matching detail view
(showEmailDetail/showSentDetail/showThreadDetail with the right tab).

So an agreement you originated (sent, awaiting signatures) and one you received
both appear in one place. Frontend only; needs in-app testing.
…lowed

Implements the agreed model: To must be keyed (encrypted to them); Cc is
optional — keyed Cc get the CEK and decrypt, keyless Cc receive the message
(Cc header only) but can't decrypt, while still being able to verify the
signature (escrow/witness). Not an agreement: viewer roles, no X-Nostr-Agreement.

Backend:
  * compose_agreement / send_agreement_email gain is_agreement (To→viewer + no
    marker when false) and cc_plain (keyless Cc → Cc header only, not RECIPIENTS)
  * send_agreement / compose_agreement IPC commands gain is_agreement + cc_plain
  * verify_agreement_status returns None when there are no required signatories
    (a plain CC'd email is not a pseudo-agreement)
  * test: plain multi-recipient is not an agreement; both recipients decrypt

Frontend:
  * Cc picker on the normal compose page (contacts or manual email + optional
    npub); keyless entries shown as dashed "(no key)" chips
  * sending with Cc routes to sendMultiRecipientEmail → encrypted envelope
    (is_agreement=false); requires the To recipient to be keyed; keyless Cc go
    out as cc_plain with a "can't read it" notice
  * recipient pickers generalized to any kind; To requires a key, Cc optional;
    agreement Cc can now also be keyless (escrow witness)

Backend 257 tests pass; frontend syntax-checked, needs in-app testing.
…ncryption)

The Cc path wrongly forced encryption. Now it mirrors the existing 1:1 send
decision (encrypt only when the To recipient is keyed / auto-encrypt is on),
honoring the Encrypt/Sign buttons and auto-encrypt/auto-sign settings:

  * To keyed → encrypted multi-recipient envelope (keyed Cc decrypt; keyless Cc
    are header-only and can't decrypt, but can verify the signature)
  * No To key + encryption wanted (Encrypt on / auto-encrypt) → clear error
    asking for a key or to disable auto-encrypt
  * No To key + plaintext → cleartext multi-recipient email (To + Cc headers,
    no keys required); signing still follows the user's settings

Backend: send_email (+ command) gains a `cc: Vec<String>` for cleartext Cc
headers; frontend sendEmail wrapper passes it. sendMultiRecipientEmail branches
encrypted vs cleartext instead of always encrypting.

Backend 257 tests pass; frontend syntax-checked, needs in-app testing.
When encrypting, the sender can now leave the Subject in the clear so keyless
Cc recipients / witnesses have context for what they're validating — the body
stays encrypted. Default remains encrypt-subject ON.

  * compose_agreement / send_agreement_email (+ commands) gain encrypt_subject;
    when false the subject is emitted cleartext even on the encrypted envelope
  * "Encrypt the subject" checkbox in both the agreement options and the normal
    compose Cc section (default checked); send paths pass it through
  * sendAgreement IPC wrapper gains encryptSubject

Backend 257 tests pass; frontend syntax-checked, needs in-app testing.

Still open from this batch: sign↔SEAL parity on the encrypted envelope (Sign
button toggles signed vs SEAL, mirroring 1:1), and attachments on the Cc/
envelope path — both larger, planned next.
A plain encrypted multi-recipient message can now be unsigned: when the user
turns the Sign button off (and auto-sign is off), the envelope emits a SEAL
block (sender pubkey for CEK unwrap) instead of a SIGNATURE — mirroring the 1:1
behavior. Agreements stay forced-signed: their signatory set must be
authenticated (§3.6), independent of consent (which remains an optional CONSENT
block, included only when the signature represents consent).

  * encode_hybrid_agreement[_with_cek] gain `sign`; false → SEAL, no CONSENT
  * compose_agreement / send_agreement_email (+ commands) thread `sign`;
    sign = sign || is_agreement (agreements always signed)
  * frontend: encrypted multi-recipient send derives sign from the Sign button /
    auto-sign; agreements pass sign=true; sendAgreement wrapper gains `sign`

Test: unsigned envelope emits SEAL (no SIGNATURE), recipient still decrypts via
the SEAL pubkey, nothing to verify; a forced agreement ignores sign=false.
Backend 258 tests pass; frontend syntax-checked.
Wrap the per-message CEK against a fresh ephemeral keypair instead of the
sender's identity key, and publish the ephemeral pubkey as an 'ephemeral'
header line in the (signed) RECIPIENTS block. This separates the encryption
layer from the signing/identity key: the long-term secret no longer performs
the CEK ECDH, the ECDH is not reused across messages, and unsigned (SEAL)
envelopes disclose no sender-derived key material in their wraps.

Decoders unwrap against the ephemeral pubkey when the 'ephemeral' line is
present, falling back to the sender's identity pubkey for legacy envelopes.
The 'self' role is retained as an accounting marker (sender excluded from
signatory/completion accounting); only its wrap counterparty changes.

- crypto.rs: generate_ephemeral_keypair()
- agreement.rs: ephemeral wrap in encode_hybrid_agreement_with_cek;
  RECIPIENTS_EPHEMERAL marker; parse_recipients_ephemeral(); skip the
  ephemeral line in parse_recipients_block; new round-trip test
- email.rs: thread ephemeral pubkey into decrypt_single_block and
  decrypt_subject_envelope (fallback to sender pubkey)
- spec: update sections 8, 10.1, 10.2, 10.4 and examples

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
The SEAL block is the sender's identity attestation (the family seal /
signet ring), not a CEK-unwrap input. With ephemeral wrapping the CEK is
unwrapped via the RECIPIENTS 'ephemeral' pubkey; the SEAL pubkey is the
unwrap counterparty only for legacy envelopes. Update the stale comment in
encode_hybrid_agreement_with_cek and spec sections 3.6/10.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
Mirror the 1:1 hybrid-attachment model on the CEK envelope (spec §11.2):
the armored body becomes a JSON manifest (nested AES-encrypted body blob +
per-attachment key wraps) protected by the per-recipient-wrapped CEK instead
of pairwise NIP-44, and the encrypted attachment bytes ride as MIME parts.

- email.rs: extract apply_manifest_or_plaintext() shared by the pairwise and
  CEK-envelope decrypt paths; route the envelope branch through it so manifest
  bodies decrypt and surface attachment metadata. Add an attachments param to
  send_agreement_email and build multipart/mixed when present (mirrors
  send_email). New round-trip test.
- lib.rs: thread attachments through the send_agreement command.

The decrypt-side manifest parse and the entire frontend attachment-decrypt
flow (per-attachment key_wrap recovered from the manifest) are reused
unchanged.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
Add buildManifestBody(): AES-encrypt the body + each attachment under their
own keys and return the hybrid manifest JSON (§11.2), reusing the 1:1 manifest
shape minus the NIP/armor step. The encrypted multi-recipient compose path and
the encrypted Agreements-tab compose path now send the manifest as the
CEK-encrypted body with the encrypted bytes as MIME parts (via
prepareAttachmentsForEmail); public agreements send plaintext attachments.

- tauri-service.js: sendAgreement gains an optional attachments arg.
- email-service.js: buildManifestBody helper; wire both encrypted send paths.

The receive/download path is reused unchanged: downloadInboxAttachment already
decrypts .dat parts via decryptEmailBody (now manifest-aware on the envelope
path) + decryptManifestAttachment.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
claude and others added 15 commits June 15, 2026 14:51
The committed nostr_mail_capnp.rs predated the schema's Manifest.version
field (and the corresponding struct-size change). Regenerate with capnpc so
the Manifest/EncryptedBlob/Attachment builders match the schema, in
preparation for capnp manifest encode/decode.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
…dded

manifest.rs builds a Cap'n Proto attachment manifest in Rust (spec §11.2):
AES-encrypts the body + each attachment under independent keys, returns the
base64-armored 'capnp:'-prefixed payload plus the encrypted MIME parts. parse_
manifest decodes capnp (new) and legacy JSON (read-only), decrypting the nested
body blob and surfacing unified attachment metadata. crypto.rs gains
aes_gcm_encrypt_padded (inverse of aes_gcm_decrypt_padded).

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
Replace the JSON-only manifest structs + handling in email.rs with
crate::manifest::ParsedManifest. apply_manifest_or_plaintext now takes raw
bytes (a capnp manifest is binary/base64-armored, not necessarily UTF-8) and
delegates detection to manifest::parse_manifest, which reads both capnp and
legacy JSON. decrypt_single_block/decrypt_armor_tree return ParsedManifest;
attachment metadata is converted to the UI-facing base64/hex shape.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
compose_agreement now takes plaintext attachments and, when encrypted, builds
the capnp manifest (manifest::build_capnp_manifest): AES-encrypts the body +
each attachment, uses the manifest as the CEK-encrypted body, and returns the
encrypted aN.dat MIME parts in ComposedAgreement.attachments. send_agreement_
email attaches those parts. The frontend now passes plaintext body +
attachments (plainAttachmentsForEmail) instead of building a JSON manifest in
JS — crypto consolidated in Rust. Adds an end-to-end test.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
decrypt_attachment_pipeline now rejects an attachment whose SHA-256 doesn't
match the hash recorded in the (signed) manifest, instead of logging a warning
and decrypting anyway. Since the manifest lives inside the signed body, this
makes the attachment->signature binding actually enforceable for encrypted
messages (spec §11.2). Adds a tamper-rejection test.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
Public (plaintext) agreements now carry a BEGIN NOSTR ATTACHMENTS block
listing each attachment's plaintext SHA-256, size, MIME, and filename, folded
into the level signature (level(L) gains a fourth canonical(attachments)
component, empty for every existing message so it's backward compatible). This
closes the gap where public attachments rode unbound: swapping/adding/removing
a file now breaks the signature.

- agreement.rs: AttachmentSpec, serialize/parse, level_signing_bytes_with_attachments
- email.rs: encode_signed_agreement emits + signs the block; public_attachment_specs;
  split_armor_level + extract_ciphertext_binary handle ATTACHMENTS; parsed specs
  surfaced on ParsedArmorMessage. Test proves tampering breaks the signature.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
- §4.2: level(L) gains canonical(attachments_L) (public messages); fixed order
  body || recipients || consent || attachments
- §11.2: attachment binding split into encrypted (capnp manifest, base64-armored
  'capnp:' payload, enforced cipherSha256) vs public (signed ATTACHMENTS block);
  legacy JSON still read
- armor block table: add BEGIN/END NOSTR ATTACHMENTS
- capnp schema: correct the manifest detection note (capnp: base64 armor, since
  NIP-44 transport is text-typed)

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
The capnp manifest is the plaintext body: it is encrypted by the outer
layer (per-recipient-wrapped CEK or pairwise NIP-44) and that ciphertext
is glossia-encoded for transport. So the manifest needs no inner armor of
its own — base64 was protecting bytes that never touch the wire in the
clear.

Emit the raw serialized capnp behind the `capnp:` marker (now a byte
prefix) instead of base64-armoring it, and parse it back as raw bytes.
The CEK send path already encrypts raw bytes, so this is a pure shrink
with no wire-visible change. Legacy JSON manifests still parse.
Update the schema's Cap'n Proto-vs-JSON detection comment to match the
raw-bytes manifest format (no base64 armor) landed in the manifest change.

Add a PROPOSED §11.2.1 specifying an opt-in variant that moves the
attachment keyring out of the glossia body into a hash-referenced,
CEK-encrypted MIME part, keeping the body inline so it still survives
email chains. Clearly marked draft / not implemented.
Drop redundant base64 armor from the capnp manifest
send_email gained a trailing cc: &[String] parameter (multi-recipient CC
support), but the 22 call sites in tests/email_integration.rs still passed 14
args, so the integration-test target failed to compile — which failed the whole
`cargo test` run. Pass &[] (empty cc) at each call to restore compilation.

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
split_armor_level detected nested armor with anchored matching
(level_is_begin_body etc. via starts_with after trimming), which did not match
'> '-quoted markers. Quoted reply threads therefore lost their inner nested
signature — verify_all_signatures_inline returned only the outer signature.

Add marker_core(): strip one leading email quote prefix ('> '/'>') before the
existing dash/whitespace trim, and route every level_is_* detector through it.
A BEGIN/END NOSTR marker is now canonical with or without the quote delimiter
(one quote level per parse pass; deeper nesting unwrapped on recursion). Fixes
signed_plaintext_reply_preserves_nested_signature; full suite green (265 lib +
21 integration).

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
Add verify_public_attachment (Rust fn + tauri command): recompute a delivered
plaintext attachment's SHA-256 and check it against the message's signed
ATTACHMENTS block (§11.2), also confirming the armor signature is valid (so the
block is authentic). Returns a full breakdown {verified, signatureValid,
specFound, hashMatch, expected/actualSha256}.

The inbox download path now calls it for any message carrying an ATTACHMENTS
block and refuses to open a file whose hash doesn't match or whose signature is
invalid — completing the public-binding story (the binding existed; this
enforces it). Unit test covers accept / swapped-file / unknown-file / broken-
signature. Full backend suite green (266 lib + 21 integration).

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
The 1:1 (pairwise) send previously built a JSON manifest in JS, AES-encrypting
the body + attachments client-side. Move that to Rust so 1:1 emits capnp like
the multi-recipient path:

- manifest.rs: split out build_capnp_bytes; add build_capnp_manifest_armored
  (capnp64: base64) for the string-typed NIP-44 transport (its decrypt yields a
  String, so it can't carry the raw bytes the byte-clean CEK path uses).
  parse_manifest now reads capnp64:, capnp:, and legacy JSON.
- email.rs: encrypt_manifest_body builds the capnp manifest, NIP-encrypts it to
  the recipient, and ASCII-armors it; returns the armored body + encrypted
  aN.dat parts. Plus armor_ciphertext mirroring the frontend.
- lib.rs: encrypt_manifest_body tauri command (active-account key).
- Frontend: encryptEmailFields' manifest branch now calls encryptManifestBody
  instead of building/AES-ing/NIP-ing a JSON manifest in JS; it stamps each
  attachment's encryptedData from the returned parts so prepareAttachmentsFor
  Email emits the matching MIME parts. Subject NIP-encryption, glossia encoding,
  signing, DM, and _plainBody/_htmlBody rebuild are unchanged.
- Docs: schema + spec §11.2 document the capnp:/capnp64: transport split.

Rust is fully tested (manifest armored roundtrip + 1:1 encrypt->decrypt->
attachment roundtrip); 268 lib + 21 integration green. The JS rewiring is
node --check-only and needs in-app verification (no Tauri runtime here).

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
In dark mode, .form-group select used a lighter background (#232946) than text
inputs (#181a20), so the To/Cc contact-picker dropdowns stood out as lighter
boxes that didn't match the other inputs (e.g. on the Settings page). Set the
select background to #181a20 to match, keeping the #232946 border and light
dropdown arrow. Applies to all form selects (verified via a headless-Chromium
render: inputs and selects now share bg #181a20 / border #232946 / text
#e0e0e0).

https://claude.ai/code/session_01JNdBAs9JnBf7FLHBouBv4K
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants