diff --git a/docs/nostr-mail-spec.md b/docs/nostr-mail-spec.md index 4ffe3fb..67d004a 100644 --- a/docs/nostr-mail-spec.md +++ b/docs/nostr-mail-spec.md @@ -19,16 +19,20 @@ All Nostr-Mail content is enclosed in ASCII armor blocks using `-----` delimiter | `BEGIN NOSTR SIGNED BODY` | Signed plaintext | Plaintext body content | | `BEGIN NOSTR SIGNATURE` | Proof of authorship + identity | Schnorr signature (64 bytes) followed by sender's pubkey (32 bytes) | | `BEGIN NOSTR SEAL` | Identity declaration | Sender's Nostr public key (unsigned messages only) | -| `BEGIN NOSTR HYBRID ENCRYPTED BODY` | Multi-recipient encrypted content | AES-256-GCM ciphertext under a per-message Content Encryption Key (CEK) | -| `BEGIN NOSTR RECIPIENTS` | Recipient key-wrap + roles | Per-recipient NIP-44-wrapped CEK, pubkey, and role | +| `BEGIN NOSTR ENCRYPTED BODY` | Multi-recipient encrypted content | AES-256-GCM ciphertext under a per-message Content Encryption Key (CEK). Identified as the envelope (CEK) path by the accompanying RECIPIENTS block — there is no distinct keyword | +| `BEGIN NOSTR RECIPIENTS` | Recipient key-wrap + roles | A per-message `ephemeral` pubkey header, then per-recipient NIP-44-wrapped CEK (sealed via the ephemeral key), pubkey, and role | | `BEGIN NOSTR CONSENT` | Explicit consent marker | A signatory's binding consent to a specific agreement (document hash + signer pubkey); see Section 11.3 | +| `BEGIN NOSTR ATTACHMENTS` | Signed attachment hashes | Public messages only: one line per plaintext attachment (`id`, SHA-256, size, MIME, filename), bound by the signature; see Section 11.2 | | `END NOSTR MESSAGE` | Closing tag | Terminates the outermost block | | `END NOSTR SEAL` | Closing tag | Terminates standalone seal blocks | | `END NOSTR RECIPIENTS` | Closing tag | Terminates a standalone recipients block | | `END NOSTR CONSENT` | Closing tag | Terminates a standalone consent block | +| `END NOSTR ATTACHMENTS` | Closing tag | Terminates a standalone attachments block | The encryption type is embedded directly in the BEGIN tag (e.g., `BEGIN NOSTR NIP-44 ENCRYPTED BODY`), keeping the format self-describing without metadata lines. +**No `HYBRID` keyword.** The multi-recipient envelope reuses the generic `BEGIN NOSTR ENCRYPTED BODY` tag (AES-256-GCM under a CEK) rather than a dedicated keyword. This mirrors how the existing attachment manifest rides inside an ordinary encrypted body: a decoder does not need the tag to announce the scheme. The **presence of a RECIPIENTS block** is the unambiguous signal to take the CEK-envelope decryption path (unwrap the CEK, then AES-256-GCM-decrypt) instead of the pairwise NIP-04/NIP-44 path; see Sections 8 and 10.5. + ### 2.2 Legacy Tag Names (Backwards Compatibility) Decoders MUST accept the following legacy tag names: @@ -203,13 +207,14 @@ Decoders MUST also accept armor block delimiters preceded by `> ` quote prefixes When a message has more than one cryptographic recipient — for example multiple `To:` signatories and/or `Cc:` viewers — the body cannot be encrypted with a single pairwise NIP-44 shared secret, because each recipient derives a different secret. Instead the body is encrypted **once** under a random Content Encryption Key (CEK), and the CEK is wrapped to each recipient with NIP-44. This is the same hybrid construction already used for attachments (AES-256 for payload, NIP-44 for the key), generalized to N recipients. The envelope mechanics are normative in Section 10. ``` ------ BEGIN NOSTR HYBRID ENCRYPTED BODY ----- - +----- BEGIN NOSTR ENCRYPTED BODY ----- + ----- BEGIN NOSTR RECIPIENTS ----- -signer -signer -viewer -self +ephemeral +signer +signer +viewer +self ----- BEGIN NOSTR SIGNATURE ----- @ProfileName @@ -217,24 +222,28 @@ self ----- END NOSTR MESSAGE ----- ``` -Each RECIPIENTS entry is a single line of three space-separated tokens: ` ` (see Section 10.2). The sender's own `self` stanza makes the Sent copy decryptable on any device, mirroring the "wrap twice" behavior of NIP-17 DMs. +**Body encoding — glossia, not base64.** The body ciphertext MUST be glossia-encoded (Section 5), not base64. This is not merely steganographic: an agreement is a signed reply chain, and when a counterparty's email client quotes the prior message it prefixes lines with `> ` and re-wraps them. base64 is corrupted by those mutations, so the decoded bytes — and therefore every nested signature over them — would change and fail to verify. Glossia's word tokens survive quoting, wrapping, and reflow (decoders ignore `> ` and non-payload whitespace, Section 3.5.4), so the canonical decoded bytes are recovered intact. This is why email-native document signing cannot use base64 for the signed payload. (Large bodies/attachments still ride the base64 manifest path, which is not part of the signed reply chain.) + +Each RECIPIENTS entry is a single line of three or four space-separated tokens: ` [] []` (see Section 10.2). The sender's own `self` stanza makes the Sent copy decryptable on any device, mirroring the "wrap twice" behavior of NIP-17 DMs. -A multi-recipient message SHOULD be signed; the SIGNATURE then covers both the body and the recipients block (Section 4.2), making the membership and role set tamper-evident. An unsigned multi-recipient message MAY instead carry a SEAL block to supply the sender's pubkey for CEK unwrapping, but in that case the role set is unauthenticated and MUST NOT be relied upon to designate required signatories. +A multi-recipient message SHOULD be signed; the SIGNATURE then covers both the body and the recipients block (Section 4.2), making the membership and role set tamper-evident. An unsigned multi-recipient message MAY instead carry a SEAL block. The SEAL declares the sender's **identity** pubkey — the "family seal"/signet attesting authorship — not a key-unwrap input: with ephemeral wrapping the CEK is unwrapped via the RECIPIENTS `ephemeral` pubkey (Section 10.1), and the SEAL pubkey is the unwrap counterparty only for legacy envelopes that lack an `ephemeral` line. Being unsigned, the role set is unauthenticated and MUST NOT be relied upon to designate required signatories. #### 3.6.1 Multi-Recipient Reply Each encrypted level in a reply chain has its own independent CEK, and therefore its own RECIPIENTS block. The recipients block is **not** inherited from an enclosing or enclosed level. This both is cryptographically required (a single block cannot hand out different per-level CEKs) and records the recipient/role set as it stood at each point in the thread. ``` ------ BEGIN NOSTR HYBRID ENCRYPTED BODY ----- +----- BEGIN NOSTR ENCRYPTED BODY ----- ----- BEGIN NOSTR RECIPIENTS ----- +ephemeral signer signer self ------ BEGIN NOSTR HYBRID ENCRYPTED BODY ----- +----- BEGIN NOSTR ENCRYPTED BODY ----- ----- BEGIN NOSTR RECIPIENTS ----- +ephemeral signer signer self @@ -282,15 +291,15 @@ The Schnorr signature over `SHA-256(ciphertext_bytes)` serves as the authenticat NIP-44 messages are exempt from this requirement because NIP-44 uses ChaCha20 (no padding) with HMAC-SHA256 authentication built in. -### 4.2 Signature Coverage of the Recipients & Consent Blocks +### 4.2 Signature Coverage of the Recipients, Consent & Attachments Blocks -For multi-recipient messages (Section 3.6), the signature MUST cover the RECIPIENTS block — and, when present, the CONSENT block (Section 11.3) — in addition to the body, so that the membership list, per-recipient roles, and any declared consent cannot be altered without invalidating the signature. The per-level signing contribution becomes: +For multi-recipient messages (Section 3.6), the signature MUST cover the RECIPIENTS block — and, when present, the CONSENT block (Section 11.3) and the ATTACHMENTS block (Section 11.2) — in addition to the body, so that the membership list, per-recipient roles, any declared consent, and any attached document hashes cannot be altered without invalidating the signature. The per-level signing contribution becomes: ``` -level(L) = decode(body_L) || canonical(recipients_L) || canonical(consent_L) +level(L) = decode(body_L) || canonical(recipients_L) || canonical(consent_L) || canonical(attachments_L) ``` -where `decode(body_L)` is the level's decoded body bytes (as in Section 4); `canonical(recipients_L)` is the **canonicalized recipients block**: the lines between `BEGIN NOSTR RECIPIENTS` and the following delimiter, each stripped of trailing whitespace and any `> ` quote prefix, joined with `\n`, with no trailing newline; and `canonical(consent_L)` is the **canonicalized consent block**, formed by the identical rule over the lines between `BEGIN NOSTR CONSENT` and the following delimiter. The blocks are concatenated in the fixed order **body, then recipients, then consent** (Section 11.3.1). If a level has no RECIPIENTS block, `canonical(recipients_L)` is the empty string; if it has no CONSENT block, `canonical(consent_L)` is the empty string. A level with neither reduces to the Section 4 model. +where `decode(body_L)` is the level's decoded body bytes (as in Section 4); `canonical(recipients_L)` is the **canonicalized recipients block**: the lines between `BEGIN NOSTR RECIPIENTS` and the following delimiter, each stripped of trailing whitespace and any `> ` quote prefix, joined with `\n`, with no trailing newline; `canonical(consent_L)` is the **canonicalized consent block**, formed by the identical rule over the lines between `BEGIN NOSTR CONSENT` and the following delimiter; and `canonical(attachments_L)` is the **canonicalized attachments block**, formed by the identical rule over the lines between `BEGIN NOSTR ATTACHMENTS` and the following delimiter. The blocks are concatenated in the fixed order **body, then recipients, then consent, then attachments**. If a level lacks any of these blocks, the corresponding canonical string is empty; a level with none reduces to the Section 4 model. (The ATTACHMENTS block is only used by **public** messages — an encrypted message binds its attachments via the in-body manifest instead; Section 11.2.) The signing target for a level (and its nested levels) is then: @@ -302,9 +311,10 @@ This preserves the flat-concatenation, chain-of-custody property of Section 3.5: ## 5. Content Encoding (Glossia) -Body content, signatures, and pubkeys may be encoded using the Glossia steganographic encoding system. Each field has an independent encoding setting: +Body content, subjects, signatures, and pubkeys may be encoded using the Glossia steganographic encoding system. Each field has an independent encoding setting: -- **Body/Subject**: Glossia prose encoding (e.g., Latin, BIP39, or hex) +- **Body**: Glossia prose encoding (e.g., Latin, BIP39, or hex) +- **Subject**: Independent encoding setting (defaults to the body's when unset) - **Signature**: Independent encoding setting - **Pubkey**: Independent encoding setting @@ -483,6 +493,8 @@ Both `To:` and `Cc:` recipients receive a NIP-44-wrapped CEK and can decrypt the Because email headers are spoofable and may be rewritten on forward (Section 7), the authoritative role for each recipient is the `role` token inside the signed RECIPIENTS block, **not** the header. The headers are a transport convenience and a mirror for non-Nostr-Mail clients. Where the two disagree, decoders MUST trust the signed RECIPIENTS block. +To make the `(pubkey, email)` pairing itself authenticated rather than inferred from the spoofable header order, an encoder MAY include the delivered address as the optional fourth `email` token of each stanza (Section 10.2). Because the signature covers the canonicalized RECIPIENTS block (Section 10.6), an in-block `email` binds that address to its `pubkey` tamper-evidently — this is what the email↔npub binding handshake (issue #102) relies on. When a stanza carries an `email`, decoders MUST treat the in-block value, not the header, as the authoritative pairing. + `Bcc:` recipients, if supported, MUST NOT appear in the RECIPIENTS block of the copy sent to other recipients (doing so would disclose the blind recipient); each Bcc recipient instead receives a separately addressed copy. ## 7. Identity Model @@ -505,10 +517,10 @@ Because email headers are spoofable and may be rewritten on forward (Section 7), - Single-recipient / plaintext: `SHA-256(decoded_body_bytes)` (Section 4) - Multi-recipient (RECIPIENTS block present): include the canonicalized recipients block per Section 4.2 - For NIP-04: signature MUST be present and MUST verify. If the signature is missing or invalid, the decoder MUST reject the message **without proceeding to step 8** (see Section 4.1) - - For NIP-44 / hybrid: signature verification is recommended but not required (NIP-44 and AES-256-GCM provide their own authentication via HMAC / GCM tag) -8. **Decrypt** if encrypted: - - **Pairwise** (`NIP-04`/`NIP-44 ENCRYPTED BODY`, no RECIPIENTS): use the recipient's private key and the sender's pubkey - - **Multi-recipient** (`HYBRID ENCRYPTED BODY` + RECIPIENTS): locate the stanza whose pubkey matches the reader's own pubkey, NIP-44-unwrap the CEK using the reader's private key and the sender's pubkey, then AES-256-GCM-decrypt the body with the CEK. If no stanza matches the reader's pubkey, the reader is not a recipient of that level and cannot decrypt it (this is expected for levels predating the reader's addition to the thread — see Section 10.8) + - For NIP-44 / CEK-envelope: signature verification is recommended but not required (NIP-44 and AES-256-GCM provide their own authentication via HMAC / GCM tag) +8. **Decrypt** if encrypted. The path is selected by the **presence of a RECIPIENTS block**, not by a distinct body tag: + - **Pairwise** (`NIP-04`/`NIP-44 ENCRYPTED BODY`, **no** RECIPIENTS block): use the recipient's private key and the sender's pubkey + - **Multi-recipient** (generic `ENCRYPTED BODY` **with** a RECIPIENTS block): locate the stanza whose pubkey matches the reader's own pubkey, NIP-44-unwrap the CEK using the reader's private key and the message's **ephemeral pubkey** (the `ephemeral` line; falling back to the sender's pubkey for legacy envelopes — Section 10.1), then AES-256-GCM-decrypt the body with the CEK. If no stanza matches the reader's pubkey, the reader is not a recipient of that level and cannot decrypt it (this is expected for levels predating the reader's addition to the thread — see Section 10.8) ## 9. Spam Rescue & Folder Handling @@ -552,36 +564,49 @@ NIP-44 is pairwise: a ciphertext encrypted with the shared secret of `(senderPri ``` 1. Generate a random 256-bit Content Encryption Key (CEK). -2. Encrypt the body ONCE with AES-256-GCM under the CEK → one HYBRID ENCRYPTED BODY +2. Encrypt the body ONCE with AES-256-GCM under the CEK → one ENCRYPTED BODY (attachments are encrypted under the same CEK, as today). -3. For each pubkey P in { To ∪ Cc ∪ sender-self }: - wrapped[P] = NIP44_encrypt(CEK, senderPriv, P) → one RECIPIENTS stanza per P -4. Emit: [HYBRID ENCRYPTED BODY] + [RECIPIENTS block] + optional [SIGNATURE] +3. Generate a per-message ephemeral keypair (ephPriv, ephPub). → published as the + The ephemeral private key is used only for the wraps below, RECIPIENTS `ephemeral` line + then discarded. +4. For each pubkey P in { To ∪ Cc ∪ sender-self }: + wrapped[P] = NIP44_encrypt(CEK, ephPriv, P) → one RECIPIENTS stanza per P +5. Emit: [ENCRYPTED BODY] + [RECIPIENTS block] + optional [SIGNATURE] ``` -To read the message, a recipient locates the stanza addressed to their own pubkey, NIP-44-unwraps the CEK with their private key and the sender's pubkey, then AES-256-GCM-decrypts the body. The body ciphertext is produced once regardless of recipient count; only the 32-byte CEK is wrapped per recipient. +**Ephemeral key wrapping (AGE-style).** The CEK is wrapped against a fresh **per-message ephemeral key**, not the sender's identity key. The ephemeral public key is published as the RECIPIENTS block's `ephemeral ` header line (Section 10.2); each `wrapped-cek` is `NIP44_encrypt(CEK, ephPriv, recipientPub)`. This keeps the encryption layer separate from the signing/identity key: the long-term secret never performs the CEK ECDH, the ECDH is not reused across messages, and an unsigned envelope (SEAL) discloses no sender-derived key material in its wraps. Because the ephemeral line lives inside the RECIPIENTS block, it is covered by the level's SIGNATURE along with the recipient set. -The sender's pubkey (needed to unwrap the CEK) is taken from the SIGNATURE block, the SEAL block, or the `X-Nostr-Pubkey` header — a multi-recipient message MUST therefore carry one of these. +To read the message, a recipient locates the stanza addressed to their own pubkey, NIP-44-unwraps the CEK with **their private key and the message's ephemeral public key** (from the `ephemeral` line), then AES-256-GCM-decrypts the body. The body ciphertext is produced once regardless of recipient count; only the 32-byte CEK is wrapped per recipient. + +For backward compatibility, a decoder that finds no `ephemeral` line MUST fall back to unwrapping against the **sender's identity pubkey** (legacy envelopes wrapped the CEK directly to the sender's key). The sender's pubkey is taken from the SIGNATURE block, the SEAL block, or the `X-Nostr-Pubkey` header — a multi-recipient message MUST therefore carry one of these (the sender's pubkey also remains the unwrap counterparty for legacy envelopes). ### 10.2 RECIPIENTS Block Format -The RECIPIENTS block contains one entry per line. Each entry is exactly three space-separated tokens: +The RECIPIENTS block opens with an optional `ephemeral ` header line (Section 10.1) and then contains one recipient entry per line. Each recipient entry begins with the two required tokens `role` and `pubkey`, followed by any subset of the **optional, content-typed** tokens below, in any order: ``` - +ephemeral + [] [] [] ``` +The `ephemeral` line is **not** a recipient stanza: it publishes the per-message ephemeral public key against which every `wrapped-cek` in the block was sealed. Decoders MUST recognize a line whose first token is `ephemeral` as this header (its second token is the ephemeral pubkey) and MUST NOT treat it as a recipient. It SHOULD be the first non-blank line of the block. When absent, decoders fall back to the sender's identity pubkey as the unwrap counterparty (Section 10.1). + | Field | Encoding | Notes | |-------|----------|-------| | `role` | `signer` \| `viewer` \| `self` (lowercase token) | See Section 11.1. Unknown roles MUST be ignored for workflow purposes but still treated as recipients for decryption. | | `pubkey` | hex (64 chars) or npub (bech32, `npub1…`) | The recipient's Nostr public key. Glossia is **not** used here, to keep entries single-token and line-parseable. | -| `wrapped-cek` | base64 (NIP-44 payload) | `NIP44_encrypt(CEK)` to `pubkey`. Glossia is not used here. | +| `wrapped-cek` (optional) | base64 (NIP-44 payload) | `NIP44_encrypt(CEK)` to `pubkey`. **Absent** when the CEK is not carried in the email: a plaintext (public) agreement has no CEK at all (Section 11.8), and gift-wrap mode delivers it in the referenced DM (Section 11.7). | +| `email` (optional) | RFC 5322 addr-spec, no display name | The address this stanza was delivered to. Binds the recipient's `(pubkey, email)` pairing **inside** the signed block (Section 10.6), so it cannot be altered or re-paired without breaking the signature — the prerequisite for the email↔npub binding handshake (issue #102). | +| `reference` (optional) | `scheme:value` | A pointer to out-of-band per-recipient material — e.g. `evt:` for a NIP-59 gift-wrapped DM carrying this recipient's CEK and/or `(npub, email)` binding (Section 11.7). | + +**Tokens are typed by content, not position.** A decoder classifies each token after `pubkey` by inspection: a token containing `@` is the `email`; a token containing `:` is a `reference`; any other token is the base64 `wrapped-cek` (which never contains `@` or `:`). This makes every field independently optional and order-independent, with no sentinel or positional ambiguity. The first token of each type wins. Rules: -- Entries SHOULD be ordered deterministically: `To:` recipients in header order, then `Cc:` recipients in header order, then the `self` stanza last. Deterministic ordering keeps the signed canonical form stable across encoders. +- Entries SHOULD be ordered deterministically: `To:` recipients in header order, then `Cc:` recipients in header order, then the `self` stanza last. Within a stanza, encoders SHOULD emit fields in `wrapped-cek`, `email`, `reference` order. Deterministic ordering keeps the signed canonical form stable across encoders. - Display names are intentionally **omitted**; clients resolve names from the Nostr social registry / profile cache by pubkey. -- Decoders MUST tolerate additional trailing tokens on a line (forward compatibility) and MUST ignore blank lines and `> ` quote prefixes. +- A stanza with neither a `wrapped-cek` nor a `reference` is workflow-only (it declares a `pubkey` and `role` but carries no key material) — valid for a plaintext agreement or a participant known by pubkey alone. +- Decoders MUST tolerate additional unrecognized tokens on a line (forward compatibility) and MUST ignore blank lines and `> ` quote prefixes. Future positional extensions SHOULD use a distinguishable `key:value` or `key=value` shape so they are never mistaken for a bare `wrapped-cek`. ### 10.3 Roles @@ -589,14 +614,16 @@ Rules: ### 10.4 Self-Stanza -Every multi-recipient message MUST include a `self` stanza wrapping the CEK to the sender's own pubkey (`NIP44_encrypt(CEK, senderPriv, senderPub)`). This mirrors the "wrap twice — once to the recipient, once to yourself" behavior of NIP-17 DMs and is what makes sent agreements readable after a fresh install or on a second device. +Every multi-recipient message MUST include a `self` stanza wrapping the CEK to the sender's own pubkey (`NIP44_encrypt(CEK, ephPriv, senderPub)` — the same ephemeral wrap as every other stanza; Section 10.1). This mirrors the "wrap twice — once to the recipient, once to yourself" behavior of NIP-17 DMs and is what makes sent agreements readable after a fresh install or on a second device. + +The `self` role is an **accounting marker**, distinct from which pubkey appears in the stanza. The stanza's `pubkey` is the sender's own identity key (the unwrap target, so the sender can recover the CEK from the ephemeral pubkey), but the role is `self` precisely so the sender is **excluded** from signatory / required-signer / completion accounting (Sections 10.3, 11.5). Labeling the sender's own stanza `signer` or `viewer` would wrongly count the sender as a counterparty to their own agreement; `self` must therefore be used even though the pubkey is the sender's real identity. ### 10.5 Single-Recipient Gating & Backward Compatibility The envelope format is used **only** when a message has more than one cryptographic recipient (i.e. more than one of `To ∪ Cc`, excluding `self`). A message to a single recipient continues to use the pairwise `BEGIN NOSTR NIP-44 ENCRYPTED BODY` format with no RECIPIENTS block, so existing decoders are unaffected. -- Encoders MUST emit the pairwise format for single-recipient messages and the hybrid format for multi-recipient messages. -- Decoders MUST select the decryption path by block type: `HYBRID ENCRYPTED BODY` ⇒ envelope path (Section 8 step 8, multi-recipient); `NIP-44`/`NIP-04 ENCRYPTED BODY` ⇒ pairwise path. +- Encoders MUST emit the pairwise format for single-recipient messages and the envelope format (generic `ENCRYPTED BODY` + RECIPIENTS block) for multi-recipient messages. +- Decoders MUST select the decryption path by the **presence of a RECIPIENTS block**: a RECIPIENTS block present ⇒ envelope (CEK) path (Section 8 step 8, multi-recipient); absent ⇒ pairwise path keyed on the `NIP-44`/`NIP-04` body tag. There is no `HYBRID` keyword. - NIP-04 MUST NOT be used as the body cipher for multi-recipient messages (the body cipher is always AES-256-GCM under the CEK, which is authenticated; see Section 4.1 for why unauthenticated CBC is disallowed). ### 10.6 Signature Coverage @@ -618,7 +645,7 @@ Because each level is sealed under its own CEK wrapped only to that level's list ### 10.9 Privacy Considerations -The RECIPIENTS block lists each participant's pubkey in the (cleartext) `text/plain` body, so relays/mail servers that see the message learn the participant set. This is no worse than the `To:`/`Cc:` headers, which already expose the email addresses, but it is strictly less private than NIP-59 gift wrap, which hides recipients behind an ephemeral key. A future version MAY define a metadata-private mode that omits pubkey labels and requires readers to trial-decrypt each stanza (as the `age` format does); this is out of scope for v0.4. +The RECIPIENTS block lists each participant's pubkey in the (cleartext) `text/plain` body, so relays/mail servers that see the message learn the participant set. When the optional `email` token (Section 10.2) is included, the block also restates the addresses — but those already appear in the `To:`/`Cc:` headers, so this discloses nothing new to the transport. Overall this is no worse than the `To:`/`Cc:` headers, which already expose the email addresses, but it is strictly less private than NIP-59 gift wrap, which hides recipients behind an ephemeral key. The metadata-private mode noted below would omit both the pubkey labels and the `email` tokens. A future version MAY define a metadata-private mode that omits pubkey labels and requires readers to trial-decrypt each stanza (as the `age` format does); this is out of scope for v0.4. A planned future direction is a **Nostr-only agreement transport** that folds in SIGit's privacy model (Section 11.7): instead of carrying the agreement in the email body, the message body and document(s) would be sealed and NIP-59 gift-wrapped to each counterparty (optionally with large files on Blossom), hiding the participant set behind ephemeral keys. The consent/chain-of-custody semantics defined here (the document hash `H`, per-signatory consent, and signature chaining) are transport-independent and would carry over unchanged; only the envelope and recipient-addressing would differ. This is out of scope for v0.4 and noted here so the consent model is not specialized to the cleartext email envelope. @@ -640,8 +667,11 @@ The role is a **workflow** attribute, not an access attribute — every role can An agreement is initiated as a signed multi-recipient message: -- **Body**: the agreement cover text / terms, in a signed `HYBRID ENCRYPTED BODY`. -- **Attachment(s)**: the contract document(s), encrypted under the same CEK (existing hybrid-attachment path). +- **Body**: the agreement cover text / terms, in a signed `ENCRYPTED BODY` (the envelope is signalled by the RECIPIENTS block, not a keyword) — or, for a **plaintext (public) agreement**, a signed `SIGNED BODY` with the terms in the clear (Section 11.8). +- **Subject**: for an encrypted agreement the `Subject:` header is AES-256-GCM-encrypted under the **same CEK** as the body and **glossia-encoded** under the subject's own encoding setting (Section 5; defaults to the body's), so it reads as prose, survives header folding, is readable by exactly the recipients, and never leaks in cleartext; a reader recovers it by unwrapping the CEK from their RECIPIENTS stanza. For a plaintext (public) agreement the subject is sent in the clear. The subject is metadata and is **not** covered by the signature or `H` (GCM provides its own integrity). +- **Attachment(s)**: the contract document(s), bound to the message in one of two ways depending on whether it is encrypted: + - *Encrypted message* — when it carries attachments, the `ENCRYPTED BODY` is a **manifest** rather than the raw body: a nested AES-encrypted body blob plus one entry per attachment carrying that attachment's AES key (`keyWrap`), MIME type, and ciphertext hash (`cipherSha256`). The manifest is the plaintext body, so it is AES-256-GCM-encrypted by the outer layer — pairwise NIP-44 (1:1) or the per-recipient-wrapped CEK (multi-recipient) — and each attachment's ciphertext rides as an ordinary MIME part (`a1.dat`, `a2.dat`, …). Because the manifest (including every `cipherSha256`) is inside the signed, `H`-bound body, the attachments are bound tamper-evidently even though their bytes travel outside the armor. A reader recomputes each part's SHA-256 and **rejects** a mismatch. A message with no attachments encrypts the raw body directly (no manifest). The manifest is serialized as **Cap'n Proto** (schema `Manifest`/`EncryptedBlob`/`Attachment`) and rides behind a marker chosen by transport: `capnp:` (raw bytes) on the byte-clean CEK envelope, or `capnp64:` (base64) on the string-typed NIP-44 path. No inner armor is needed beyond that; the outer-layer ciphertext is glossia-encoded for transport. Legacy **JSON** manifests (leading `{`) are still read for old mail. + - *Public message* — the body is in the clear, so attachments ride as **plaintext** MIME parts and are bound by a signed `ATTACHMENTS` block listing, per attachment, its `id`, plaintext SHA-256, size, MIME type, and filename. The block is covered by the level signature (Section 4.2), so a swapped/added/removed attachment breaks the signature; a reader verifies each delivered file's SHA-256 against the block. - **RECIPIENTS**: a `signer` stanza for each required signatory, a `viewer` stanza for each viewer, and the `self` stanza. - **CONSENT** (optional): if the originator is themselves a required signatory, they include their own CONSENT block (Section 11.3) declaring consent. The originator's `self` stanza handles only decryption access and is never counted as a consent (Section 10.3) — an originator who signs MUST do so via a CONSENT block. - **SIGNATURE**: the originator's signature, covering body + recipients + any consent (Section 4.2), which fixes the set of required signatories and the exact document bytes. @@ -650,6 +680,44 @@ The originating message's body + RECIPIENTS define the **document hash** `H` tha Clients MAY include an `X-Nostr-Agreement` MIME header (boolean/identifier) to let IMAP filtering surface agreement threads without decrypting bodies. The authoritative agreement state always comes from the signed armor blocks, not the header. +#### 11.2.1 Externalized attachment keyring (PROPOSED — not yet implemented) + +> Status: **draft proposal.** The implemented behavior is the inline manifest of §11.2. This subsection specifies an optional, opt-in variant that moves the attachment key material out of the glossia-encoded body to save encoding size. It is gated behind a format-version bump; readers that do not understand it fall back cleanly. + +**Motivation.** In the inline manifest (§11.2) the encrypted body is `Manifest = { body blob, attachments[] }`. The whole thing is glossia-encoded at roughly a 6–7× character expansion (Section 5). The **body blob must stay inline** because the body is the conversation text and glossia is what lets it survive forwards, inline replies, and `>`-quoting (Section 5; `glossia-design.md`). The **attachment keyring** (`attachments[]`) does not share that requirement: each entry is only useful together with its `aN.dat` MIME part, and those parts are already stripped by clients on reply/inline-quote (they survive forwards only). So the keyring can ride as its own part — coupled to the same transport fate as the files it unlocks — with **no additional loss of chain-survival**, while leaving the glossia body free of the per-attachment key material. + +**Wire format.** A message using this variant emits: + +1. **Inline body** — the raw body text AES-256-GCM-encrypted under the CEK and glossia-encoded, identical to a *no-attachment* message (no `Manifest` wrapper). It carries the RECIPIENTS block, optional CONSENT, and the SIGNATURE as usual. +2. **Keyring part** — a MIME part `keyring.dat` (`application/octet-stream`) holding `AES-256-GCM(CEK, capnp Keyring)`, where `Keyring` is the existing `List(Attachment)` (the `body` field of `Manifest` is omitted). Padded to a fixed boundary like attachment parts to avoid leaking the attachment count precisely. +3. **Attachment parts** — `a1.dat`, `a2.dat`, … exactly as in §11.2. + +The signed body binds the keyring with a single reference line inside the armor (covered by the SIGNATURE / `H`, Section 4.2): + +``` +----- BEGIN NOSTR ATTACHMENTS ----- +keyring sha256=<64-hex of keyring.dat ciphertext> size= +----- END NOSTR ATTACHMENTS ----- +``` + +This is the encrypted-message analogue of the public `ATTACHMENTS` block (§11.2, *Public message*): there it lists each plaintext file hash; here it binds the single encrypted keyring's ciphertext hash. The keyring in turn binds each file via its existing `cipherSha256`, so the signature transitively commits to every attachment exactly as the inline manifest does. + +**Decryption.** + +1. Open the outer layer (unwrap CEK from the reader's RECIPIENTS stanza, or pairwise NIP-44). +2. Decrypt and display the inline body. +3. If a `NOSTR ATTACHMENTS` reference is present: locate `keyring.dat`, recompute its SHA-256, **reject** on mismatch with the signed reference, then AES-256-GCM-decrypt it under the CEK to recover the `Keyring`. +4. For each `Keyring` entry: locate `aN.dat`, verify its `cipherSha256`, AES-decrypt under `keyWrap`, and restore `origFilename`/`origMime`. + +**Degradation & versioning.** + +- On a **reply/inline-quote** that strips parts, the reader still recovers and displays the body inline (it is an ordinary encrypted body) and surfaces "attachments not included in this reply" — the same outcome as the files being absent under §11.2. +- A reader that predates this variant ignores the `NOSTR ATTACHMENTS` reference line and still reads the inline body as a normal encrypted body; it simply does not surface the attachments. The variant is therefore safe to send to mixed-version recipients only when the sender accepts that older readers won't see attachments — hence it is **opt-in** and version-flagged. + +**Security.** The SIGNATURE covers body + keyring hash; the keyring covers each file hash — identical tamper-evidence to the inline manifest. The keyring is CEK-encrypted, so its key material stays confidential to recipients, as in §11.2. The cleartext `NOSTR ATTACHMENTS` reference reveals only that the message has attachments and the keyring's size — information the presence of `aN.dat` parts already exposes. + +**Trade-off.** The saving is the keyring bytes (~115 B/attachment) moving from glossia (~6–7×) to base64 MIME (~1.33×); the body-text cost is unchanged. The win is therefore modest for 1–2 attachments and grows with attachment count. It buys size at zero additional chain-survival cost for the body, but at the cost of attachment visibility to older/forwarding-stripped readers. + ### 11.3 Consent Block Because every reply in a thread carries a SIGNATURE (it is how chain-of-custody works, Section 3.5.0), the mere presence of a signatory's signature over the document cannot, by itself, mean "I agree" — a signatory might be replying to negotiate, ask a question, or object. To make consent an **explicit, intentional act** rather than a side effect of replying, a signatory declares consent with a dedicated CONSENT block. This is the armor-grammar analogue of a purpose-built signing action: a comment carries no CONSENT block; a signature does. @@ -720,7 +788,49 @@ This agreement model is conceptually aligned with [SIGit](https://sigit.io)'s do | Sign event (Kind 938) added to `docSignatures` | the CONSENT block bound by the level's signature (Section 11.3) | | Offline-verifiable from the encrypted zip | offline-verifiable from the email thread (Section 11.6) | -The deliberate difference is transport and privacy. SIGit hides the participant set by gift-wrapping (NIP-59) metadata to ephemeral keys and storing encrypted files on Blossom; nostr-mail keeps the whole agreement in one email thread, which is more self-contained and offline-verifiable but exposes the participant set in the cleartext RECIPIENTS block (Section 10.9). A future **Nostr-only agreement transport** is planned that folds in SIGit's gift-wrap/Blossom approach for stronger metadata privacy; because the consent semantics here (`H`, per-signatory CONSENT, signature chaining) are transport-independent, that mode is expected to reuse this section's model with a different envelope. SIGit's own event-kind numbering is **not** adopted; nostr-mail consent lives in armor blocks, not relay-published Nostr events. +The deliberate difference is transport and privacy. SIGit hides the participant set by gift-wrapping (NIP-59) metadata to ephemeral keys and storing encrypted files on Blossom; nostr-mail keeps the whole agreement in one email thread, which is more self-contained and offline-verifiable but exposes the participant set in the cleartext RECIPIENTS block (Section 10.9). The **gift-wrap mode** below folds in SIGit's gift-wrap approach for stronger metadata privacy; because the consent semantics here (`H`, per-signatory CONSENT, signature chaining) are transport-independent, that mode reuses this section's model with a different addressing channel. SIGit's own event-kind numbering is **not** adopted; nostr-mail consent lives in armor blocks, not relay-published Nostr events. + +#### 11.7.1 Gift-Wrap Mode (metadata-private delivery) + +> **Status:** designed; the wire format (the `reference` token, Section 10.2) is reserved now, but the NIP-59 delivery and DM-aware decrypt/verify paths are a later milestone. + +The cleartext RECIPIENTS block exposes two things to relays and mail servers that the participants may wish to keep private: the per-recipient **wrapped CEKs** and the **`(npub, email)` bindings**. Gift-wrap mode moves this sensitive per-recipient material off the email and into a **NIP-59 gift-wrapped DM**, one per recipient, leaving the email to carry the document plus a minimal, signed reference. + +**What moves, what stays.** The split is chosen so the *workflow* stays tamper-evident and offline-checkable while the *sensitive* material becomes private: + +| Stays in the signed email | Moves to the per-recipient gift wrap | +|---|---| +| the body (`ENCRYPTED BODY` ciphertext, or `SIGNED BODY`) | the recipient's `wrapped-cek` | +| each recipient's `role` + `pubkey` + a `reference` token | the recipient's `(npub, email)` binding | +| the originator's SIGNATURE (covers the above) | — | + +So a gift-wrap-mode stanza is `signer evt:` — role and pubkey remain in the signed block (so the required-signatory set, the document hash `H`, and completion accounting are still authenticated and computable from the email), while the CEK and the address binding travel in the referenced DM. + +**The reference.** The `reference` token (`evt:`, Section 10.2) points to the gift wrap addressed to that recipient. The gift wrap's inner *rumor* SHOULD carry: the agreement reference (the email `Message-ID` and/or the document hash `H`), this recipient's `wrapped-cek` (if the body is encrypted), and the `(npub, email)` binding. The gift wrap is sealed and wrapped per NIP-59 (ephemeral outer key), so relays learn neither the sender nor the recipient. + +**Verification with the DM in hand.** A recipient: (1) verifies the email's signature chain and reads the signed `role`/`pubkey` set and `H` as usual (Section 11.6); (2) unwraps their gift wrap, confirms it references this `H`/`Message-ID`, and takes the CEK (to decrypt) and the `(npub, email)` binding; (3) completion is computed exactly as in Section 11.5 — the consents still live in the (email or DM) CONSENT blocks bound by signatures over `H`. + +**Privacy gradations.** This is the middle of three points on the privacy/self-containedness curve: + +1. **Cleartext (Sections 10–11):** everything in the email thread; fully self-contained and offline-verifiable by anyone; participant set, CEKs, and bindings are public. +2. **Gift-wrap mode (this section):** CEKs and `(npub, email)` bindings are private; the **pubkey + role set is still public** in the signed email (so the document and signatory set remain tamper-evident and the agreement is still anchored in one email). Decryption and binding-verification now also require the recipient's DM. +3. **Full metadata-private (future):** omit the RECIPIENTS block entirely; all addressing — including pubkeys — lives in gift wraps, and readers trial-decrypt (as the `age` format does). Maximum privacy, least self-contained; out of scope here. + +**Trade-off.** Gift-wrap mode trades the Section 11.6 property — *a third party can verify completion offline from the email alone* — for metadata privacy: a participant now needs their gift-wrapped DM (and thus a relay) to obtain the CEK and binding. The email still anchors the document, the signed signatory set, and `H`, so tampering is still detectable from the email; only the private openings require the DM. `Bcc:`-style blind participants are naturally expressible here, since a gift wrap to a recipient need not appear in any other recipient's view. + +### 11.8 Plaintext (Public) Agreements + +An agreement need not be confidential. A **plaintext (public) agreement** carries its terms in the clear so that anyone — not only a cryptographic recipient — can read them and verify completion offline. This suits open letters, public multi-party statements, and on-the-record contracts, where transparency is the point. + +A plaintext agreement is identical to the encrypted agreement (Sections 10–11) except for the body and the key-wrap: + +- **Body**: a signed `SIGNED BODY` (Section 3.2) instead of an `ENCRYPTED BODY`. The terms are glossia-encoded inside the armor (the signed payload) and also appear above the armor for non-Nostr-Mail clients. There is no CEK and nothing is encrypted. +- **RECIPIENTS**: the same block declares the signatories (`signer`) and viewers (`viewer`), but each stanza simply **omits the `wrapped-cek` token** (Section 10.2), since there is no CEK to wrap — a stanza is then `signer []`. The optional `email` token still binds the `(pubkey, email)` pairing (Sections 10.2, 6.3). There is **no `self` stanza** — nothing is encrypted, so the originator needs no self-wrap; the originator is identified by the SIGNATURE. +- **CONSENT**, **document hash `H`**, **signature coverage (§4.2)**, and **completion (§11.5)** are unchanged. `H = SHA-256(decode(body₁) || canonical(recipients₁))` is computed over the *decoded* (glossia → plaintext) body bytes, so the same machinery applies regardless of cipher. A counter-signature is a `SIGNED BODY` reply that nests the prior message and adds the signatory's CONSENT (Section 11.4). + +Path selection is unambiguous: a `SIGNED BODY` is never decrypted (it has no ciphertext), so the absent CEK is never an issue. A decoder MUST treat an `ENCRYPTED BODY` whose stanza carries no `wrapped-cek` (and no `reference`) as malformed. + +The trade-off versus the encrypted agreement is **confidentiality**: the terms and the participant set (pubkeys, and any `email` tokens) are public to anyone who sees the message. In return, the agreement is readable and its completion is verifiable by third parties who are not signatories — the same offline, self-contained verification of Section 11.6, without needing to be a recipient. ## 12. Versioning diff --git a/tauri-app/backend/schema/nostr_mail.capnp b/tauri-app/backend/schema/nostr_mail.capnp index 6f1087a..ce91a4d 100644 --- a/tauri-app/backend/schema/nostr_mail.capnp +++ b/tauri-app/backend/schema/nostr_mail.capnp @@ -39,16 +39,27 @@ enum NipVersion { # Attachments travel as separate MIME parts with opaque filenames # (a1.dat, a2.dat, …) and application/octet-stream content type. # -# ── Binary vs JSON detection ── +# ── Cap'n Proto vs JSON detection ── # -# After NIP decryption, the manifest payload is either: +# The encrypted body is either a manifest or an ordinary plaintext +# body. After the outer layer is opened (pairwise NIP-44, or the +# per-recipient-wrapped CEK), the decrypted payload is: # -# JSON (legacy) — first byte is 0x7B ('{') -# Cap'n Proto — first 4 bytes are 0x00000000 (single-segment table) +# Cap'n Proto — "capnp:" marker, then the raw serialized +# single-segment Manifest message (binary) +# Cap'n Proto — "capnp64:" marker, then base64 of that message +# JSON (legacy)— first byte is 0x7B ('{') +# plaintext — anything else (not a manifest) # -# The decoder checks the first byte to select the deserialization path. -# New messages SHOULD use Cap'n Proto; JSON is retained for reading -# older emails. +# The manifest never touches the wire in the clear: it is encrypted by +# the outer layer (CEK or NIP-44) and that ciphertext is what gets +# glossia-encoded for transport, so it needs no inner armor of its own. +# Two markers exist because the two transports differ: the multi- +# recipient CEK envelope is byte-clean and carries raw bytes ("capnp:"); +# the pairwise NIP-44 API is string-typed (decrypt yields a String) and +# cannot carry raw binary, so the 1:1 path base64-armors the same bytes +# ("capnp64:"). New messages use Cap'n Proto; JSON is retained for +# reading older emails. struct Manifest { body @0 :EncryptedBlob; diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs new file mode 100644 index 0000000..2a50453 --- /dev/null +++ b/tauri-app/backend/src/agreement.rs @@ -0,0 +1,1097 @@ +//! Multi-recipient (group) encryption and agreement-workflow primitives. +//! +//! This module implements the wire-level pieces of nostr-mail spec v0.4 that +//! support DocuSign-style agreements (spec Sections 10 & 11): +//! +//! * the `RECIPIENTS` block — per-recipient NIP-44-wrapped CEK + role +//! (Section 10.2), with deterministic canonicalization (Section 4.2); +//! * the `CONSENT` block — a signatory's explicit, intentional consent to a +//! specific document hash `H` (Section 11.3); +//! * the document hash `H` over the originating level (Section 11.3.1); +//! * the per-level signing contribution `level(L)` that folds in the +//! recipients and consent blocks so they are tamper-evident (Section 4.2); +//! * agreement completion accounting — "M of N signatories signed" +//! (Section 11.5). +//! +//! Most of these are pure functions over already-extracted block text; the +//! envelope encryption/decryption uses the primitives in [`crate::crypto`] +//! (`generate_cek`, `aes_gcm_encrypt_raw`, `wrap_cek`, `unwrap_cek`). +//! [`encode_hybrid_agreement`] composes them into a complete multi-recipient +//! armor message. Wiring into the recursive armor *parser* lives in `email.rs`. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Workflow role tokens used in a RECIPIENTS stanza (spec Section 10.2 / 11.1). +pub const ROLE_SIGNER: &str = "signer"; +pub const ROLE_VIEWER: &str = "viewer"; +pub const ROLE_SELF: &str = "self"; + +/// Leading marker for the RECIPIENTS block's ephemeral-pubkey header line +/// (spec Section 10.1). The line `ephemeral ` publishes the +/// per-message ephemeral public key against which every `wrapped-cek` in the +/// block was sealed. It is not a recipient stanza and is excluded from parsing +/// into [`Recipient`]s, but — living inside the signed RECIPIENTS block — it is +/// covered by the level's SIGNATURE. +pub const RECIPIENTS_EPHEMERAL: &str = "ephemeral"; + +/// A single entry in a `RECIPIENTS` block (spec Section 10.2). After the fixed +/// `role` and `pubkey`, the remaining tokens are **optional and typed by +/// content**, in any order: +/// +/// * `wrapped-cek` — base64 (no `@`, no `:`): `NIP44_encrypt(CEK)` to `pubkey` +/// * `email` — contains `@`: the address this stanza was delivered to +/// * `reference` — `scheme:value` (contains `:`): pointer to out-of-band +/// material, e.g. a NIP-59 gift-wrapped DM (`evt:`) +/// +/// Any field may be absent: a plaintext (public) agreement omits the cek; a +/// gift-wrap-mode stanza carries only a `reference` (cek + email travel in the +/// referenced DM); a bare `role pubkey` stanza is workflow-only. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Recipient { + /// `signer` | `viewer` | `self`, or an unknown future token (lowercased). + pub role: String, + /// Recipient's Nostr public key, hex (64 chars) or npub (bech32). + pub pubkey: String, + /// `NIP44_encrypt(CEK)` to `pubkey` (base64). `None` when the CEK is not + /// carried in the email — a plaintext agreement (no CEK) or gift-wrap mode + /// (the CEK travels in the referenced DM; see `reference`). + pub wrapped_cek: Option, + /// The address this stanza was delivered to (contains `@`). Authenticated by + /// the signature (§10.6), binding `(pubkey, email)` for the handshake (#102). + /// `None` when not carried in the email (gift-wrap mode delivers it in the DM). + pub email: Option, + /// A `scheme:value` pointer to out-of-band per-recipient material — e.g. a + /// NIP-59 gift-wrapped DM (`evt:`) carrying this recipient's CEK and/or + /// `(npub, email)` binding (spec §11.7 gift-wrap mode). `None` when all + /// material is in the email. + pub reference: Option, +} + +impl Recipient { + /// Serialize as its canonical line: `role pubkey [cek] [email] [reference]`, + /// emitting only the present fields. + pub fn to_line(&self) -> String { + let mut s = format!("{} {}", self.role, self.pubkey); + if let Some(cek) = &self.wrapped_cek { + s.push(' '); + s.push_str(cek); + } + if let Some(email) = &self.email { + s.push(' '); + s.push_str(email); + } + if let Some(reference) = &self.reference { + s.push(' '); + s.push_str(reference); + } + s + } + + /// True for the `signer` role (a required signatory; spec Section 11.1). + pub fn is_signer(&self) -> bool { + self.role == ROLE_SIGNER + } +} + +/// A `CONSENT` block: a signatory's binding consent to document `H` +/// (spec Section 11.3). The block carries no signature of its own — the level's +/// existing SIGNATURE binds it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Consent { + /// The document hash `H` being consented to, hex (64 chars). + pub agreement_hash: String, + /// The consenting party's pubkey, hex or npub. MUST equal this level's + /// SIGNATURE pubkey (spec Section 11.3). + pub signer: String, +} + +impl Consent { + /// Serialize as the two canonical lines of a CONSENT block body. + pub fn to_block_body(&self) -> String { + format!("agreement {}\nsigner {}", self.agreement_hash, self.signer) + } +} + +/// Strip leading email-quote prefixes (`>` optionally followed by a space, +/// possibly repeated for nested quoting) from a line. Mirrors how glossia +/// decoders ignore quote prefixes as non-payload (spec Sections 3.5.4, 10.2). +fn strip_quote_prefix(line: &str) -> &str { + let mut s = line; + loop { + if let Some(rest) = s.strip_prefix("> ") { + s = rest; + } else if let Some(rest) = s.strip_prefix('>') { + s = rest; + } else { + return s; + } + } +} + +/// Canonicalize a block body for signing (spec Section 4.2): for each line, +/// strip any `> ` quote prefix and trailing whitespace; drop the (now-empty) +/// blank lines that Sections 10.2 / 11.3 require decoders to ignore; join the +/// surviving lines with `\n` and emit no trailing newline. +/// +/// Used for both `canonical(recipients_L)` and `canonical(consent_L)`. An +/// absent block canonicalizes to the empty string (handled by the caller). +pub fn canonicalize_block(block_body: &str) -> String { + block_body + .split('\n') + .map(|line| strip_quote_prefix(line).trim_end()) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") +} + +/// Parse a `RECIPIENTS` block body (the lines between `BEGIN NOSTR RECIPIENTS` +/// and the following delimiter) into entries (spec Section 10.2). +/// +/// Each non-blank line is ` ` followed by any subset of the typed +/// optional tokens, in any order (spec Section 10.2): an `email` (contains `@`), +/// a `reference` (`scheme:value`, contains `:`), and a `wrapped-cek` (base64 — +/// neither `@` nor `:`). Classification is by content, so the cek is never +/// confused with an email or a reference. The first token of each type wins; +/// further unrecognized tokens are tolerated (forward compatibility) and ignored. +/// Blank lines and `> ` quote prefixes are ignored. Lines without a `pubkey` +/// (fewer than 2 tokens) are skipped. +pub fn parse_recipients_block(block_body: &str) -> Vec { + let mut out = Vec::new(); + for raw in block_body.split('\n') { + let line = strip_quote_prefix(raw).trim(); + if line.is_empty() { + continue; + } + let mut toks = line.split_whitespace(); + let role = match toks.next() { + Some(t) => t.to_ascii_lowercase(), + None => continue, + }; + // The `ephemeral ` header line is not a recipient stanza + // (spec §10.1); skip it here — see [`parse_recipients_ephemeral`]. + if role == RECIPIENTS_EPHEMERAL { + continue; + } + let pubkey = match toks.next() { + Some(t) => t.to_string(), + None => continue, + }; + let mut wrapped_cek = None; + let mut email = None; + let mut reference = None; + for tok in toks { + if tok.contains('@') { + email.get_or_insert_with(|| tok.to_string()); + } else if tok.contains(':') { + reference.get_or_insert_with(|| tok.to_string()); + } else { + wrapped_cek.get_or_insert_with(|| tok.to_string()); + } + } + out.push(Recipient { role, pubkey, wrapped_cek, email, reference }); + } + out +} + +/// Extract the per-message ephemeral public key from a RECIPIENTS block body +/// (spec Section 10.1): the `` of the first `ephemeral ` line. +/// +/// Returns `None` for legacy blocks that wrapped the CEK directly to the +/// sender's identity key (no `ephemeral` line). Callers unwrap against this key +/// when present, falling back to the sender's pubkey otherwise. Blank lines and +/// `> ` quote prefixes are ignored. +pub fn parse_recipients_ephemeral(block_body: &str) -> Option { + for raw in block_body.split('\n') { + let line = strip_quote_prefix(raw).trim(); + if line.is_empty() { + continue; + } + let mut toks = line.split_whitespace(); + if toks.next().map(|t| t.eq_ignore_ascii_case(RECIPIENTS_EPHEMERAL)) == Some(true) { + return toks.next().map(|t| t.to_string()); + } + } + None +} + +/// Serialize recipients into a canonical RECIPIENTS block body (no BEGIN/END +/// delimiters). Entries are emitted in the order given; the caller is +/// responsible for the deterministic ordering of spec Section 10.2 (To, then +/// Cc, then `self` last). +pub fn serialize_recipients(recipients: &[Recipient]) -> String { + recipients + .iter() + .map(Recipient::to_line) + .collect::>() + .join("\n") +} + +/// Parse a `CONSENT` block body into a [`Consent`] (spec Section 11.3). +/// +/// Recognizes the `agreement ` and `signer ` lines; unknown lines +/// are tolerated and ignored (forward compatibility), as are blank lines and +/// `> ` quote prefixes. Returns `None` if either required field is absent. +pub fn parse_consent_block(block_body: &str) -> Option { + let mut agreement_hash: Option = None; + let mut signer: Option = None; + for raw in block_body.split('\n') { + let line = strip_quote_prefix(raw).trim(); + if line.is_empty() { + continue; + } + let mut toks = line.split_whitespace(); + match toks.next() { + Some("agreement") => { + if let Some(v) = toks.next() { + agreement_hash = Some(v.to_string()); + } + } + Some("signer") => { + if let Some(v) = toks.next() { + signer = Some(v.to_string()); + } + } + _ => {} // unknown line — ignore + } + } + Some(Consent { + agreement_hash: agreement_hash?, + signer: signer?, + }) +} + +/// Normalize a pubkey (hex or npub) to lowercase 64-char hex for comparison. +/// Returns `None` if the input is neither a valid npub nor 32-byte hex. +pub fn normalize_pubkey_hex(pubkey: &str) -> Option { + use nostr_sdk::{FromBech32, PublicKey}; + let p = pubkey.trim(); + if let Ok(pk) = PublicKey::from_bech32(p) { + return Some(pk.to_hex()); + } + if let Ok(pk) = PublicKey::from_hex(p) { + return Some(pk.to_hex()); + } + None +} + +/// Compute the document hash `H` for an agreement (spec Section 11.3.1): +/// +/// ```text +/// H = SHA-256( decode(body_1) || canonical(recipients_1) ) +/// ``` +/// +/// `body_1_decoded` is the originating level's decoded body bytes (glossia- or +/// base64-decoded), and `canonical_recipients_1` is the canonicalized +/// originating RECIPIENTS block (see [`canonicalize_block`]). CONSENT blocks are +/// deliberately excluded so that `H` is fixed for the life of the agreement. +/// Returns the 32-byte hash; use [`hex::encode`] for the on-wire form. +pub fn document_hash(body_1_decoded: &[u8], canonical_recipients_1: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(body_1_decoded); + hasher.update(canonical_recipients_1.as_bytes()); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +} + +/// Compute one level's signing contribution `level(L)` (spec Section 4.2): +/// +/// ```text +/// level(L) = decode(body_L) || canonical(recipients_L) || canonical(consent_L) +/// ``` +/// +/// concatenated in the fixed order body → recipients → consent. Pass an empty +/// string for `canonical_recipients_l` / `canonical_consent_l` when the level +/// has no such block; a level with neither reduces to the plain Section 4 body +/// model. The full signing target for a level and its nested levels is +/// `SHA-256( level(L) || level(L-1) || … || level(1) )`. +pub fn level_signing_bytes( + body_l_decoded: &[u8], + canonical_recipients_l: &str, + canonical_consent_l: &str, +) -> Vec { + level_signing_bytes_with_attachments(body_l_decoded, canonical_recipients_l, canonical_consent_l, "") +} + +/// Like [`level_signing_bytes`] but also binds a public message's ATTACHMENTS +/// block (spec Section 11.2): +/// +/// ```text +/// level(L) = decode(body_L) || canonical(recipients_L) || canonical(consent_L) || canonical(attachments_L) +/// ``` +/// +/// For an **encrypted** message attachments are bound via the in-body manifest, +/// so `canonical_attachments_l` is empty there; only **public** (plaintext) +/// messages carry an ATTACHMENTS block. An absent block canonicalizes to the +/// empty string, so this is byte-identical to the 3-component form for every +/// existing message. +pub fn level_signing_bytes_with_attachments( + body_l_decoded: &[u8], + canonical_recipients_l: &str, + canonical_consent_l: &str, + canonical_attachments_l: &str, +) -> Vec { + let mut bytes = Vec::with_capacity( + body_l_decoded.len() + + canonical_recipients_l.len() + + canonical_consent_l.len() + + canonical_attachments_l.len(), + ); + bytes.extend_from_slice(body_l_decoded); + bytes.extend_from_slice(canonical_recipients_l.as_bytes()); + bytes.extend_from_slice(canonical_consent_l.as_bytes()); + bytes.extend_from_slice(canonical_attachments_l.as_bytes()); + bytes +} + +/// One entry in a public message's `ATTACHMENTS` block (spec Section 11.2): the +/// opaque id, the SHA-256 of the **plaintext** file (hex), its size, MIME type, +/// and original filename. Because the block is folded into the level signature, +/// these hashes bind the (cleartext) attachments to the message tamper-evidently +/// — the public-agreement analogue of the encrypted manifest's `cipherSha256`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentSpec { + pub id: String, + pub sha256: String, + pub size: u64, + pub mime: String, + pub filename: String, +} + +impl AttachmentSpec { + /// ` ` — filename last (may contain + /// spaces; it is the rest of the line). + pub fn to_line(&self) -> String { + format!("{} {} {} {} {}", self.id, self.sha256, self.size, self.mime, self.filename) + } +} + +/// Serialize attachment specs into a canonical ATTACHMENTS block body (no +/// delimiters), one entry per line in the given order. +pub fn serialize_attachments(specs: &[AttachmentSpec]) -> String { + specs.iter().map(AttachmentSpec::to_line).collect::>().join("\n") +} + +/// Parse an `ATTACHMENTS` block body (spec Section 11.2). Each non-blank line is +/// ` `; the filename is the remainder of +/// the line (so it may contain spaces). Blank lines and `> ` quote prefixes are +/// ignored; lines with fewer than 5 tokens are skipped. +pub fn parse_attachments_block(block_body: &str) -> Vec { + let mut out = Vec::new(); + for raw in block_body.split('\n') { + let line = strip_quote_prefix(raw).trim(); + if line.is_empty() { + continue; + } + let mut it = line.splitn(5, ' '); + let id = match it.next() { Some(t) => t.to_string(), None => continue }; + let sha256 = match it.next() { Some(t) => t.to_string(), None => continue }; + let size = match it.next().and_then(|t| t.parse::().ok()) { Some(v) => v, None => continue }; + let mime = match it.next() { Some(t) => t.to_string(), None => continue }; + let filename = match it.next() { Some(t) => t.to_string(), None => continue }; + out.push(AttachmentSpec { id, sha256, size, mime, filename }); + } + out +} + +/// Agreement completion summary (spec Section 11.5). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgreementStatus { + /// Number of required signatories with a verified consent over `H`. + pub m: usize, + /// Total number of required signatories `N`. + pub n: usize, + /// `true` iff every required signatory has consented (`m == n` and `n > 0`). + pub complete: bool, + /// The required signatory set, normalized to hex, deduplicated. + pub required_signers: Vec, + /// The subset of required signatories that have consented, normalized to hex. + pub consented_signers: Vec, + /// The agreement's document hash `H` (hex), set when computed over a thread + /// (Section 11.3.1). Empty for the bare [`compute_completion`] primitive, + /// which is given the signatory sets directly and does not see the document. + #[serde(default)] + pub document_hash: String, +} + +/// A proven email↔npub binding (issue #102): the `pubkey` is demonstrably +/// controlled by a party who also demonstrated read access to `email`. +/// +/// The proof is a self-contained thread, so this verdict is **stateless** — +/// re-derivable from the message alone, with no outstanding-challenge store. The +/// issuer asserted the `(pubkey, email)` pairing in a RECIPIENTS stanza of a +/// level *they* signed; the holder of `pubkey` proved control + read access by +/// signing an outer level that quotes (nests) that signed challenge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Binding { + /// The bound party's Nostr pubkey, hex — proven controlled (reply signature). + pub pubkey: String, + /// The address bound to `pubkey` — asserted by the issuer in a signed + /// RECIPIENTS stanza, proven by the reply's delivery/read access. + pub email: String, + /// The pubkey (hex) of the party who issued the challenge and asserted the + /// pairing (the verifier's own key, for an issuer-side verification). + pub issuer_pubkey: String, +} + +/// Compute "M of N signed" completion (spec Section 11.5). +/// +/// `required` is the required signatory set (the `signer` stanzas of the +/// originating RECIPIENTS block, plus the originator if they themselves +/// consented). `consented` is the set of pubkeys that carry a CONSENT block +/// over the agreement's `H` **bound by a verified signature** — the caller is +/// responsible for only passing pubkeys whose level signature verified. +/// +/// Both sets are normalized to hex and deduplicated. A consenting pubkey that +/// is not in the required set (e.g. a `viewer` who consents anyway) does not +/// change `N` and is excluded from `M` (Section 11.5); the caller MAY surface +/// it informationally. +pub fn compute_completion(required: &[String], consented: &[String]) -> AgreementStatus { + let required_signers = dedup_normalized(required); + let consented_norm: Vec = dedup_normalized(consented); + + let consented_signers: Vec = required_signers + .iter() + .filter(|pk| consented_norm.contains(pk)) + .cloned() + .collect(); + + let n = required_signers.len(); + let m = consented_signers.len(); + AgreementStatus { + m, + n, + complete: n > 0 && m == n, + required_signers, + consented_signers, + document_hash: String::new(), + } +} + +/// A cryptographic recipient for [`encode_hybrid_agreement`]: a `To:`/`Cc:` +/// party with a role (`signer` for `To:`, `viewer` for `Cc:`; spec Section 6.3) +/// and their Nostr pubkey (hex or npub). The `self` stanza is added automatically +/// by the encoder, so it MUST NOT be passed here. +#[derive(Debug, Clone)] +pub struct AgreementRecipientInput { + pub role: String, + pub pubkey: String, + /// The address this recipient was delivered to. When present it is written + /// as the stanza's fourth token, binding `(pubkey, email)` under the + /// signature (spec Sections 10.2, 10.6) — required for the binding handshake + /// (issue #102). `None` when the recipient is known only by pubkey. + pub email: Option, +} + +/// Compose a complete multi-recipient (group-encrypted) armor message — the +/// envelope of spec Sections 10–11. +/// +/// The body is AES-256-GCM-encrypted once under a fresh CEK and emitted in a +/// generic `ENCRYPTED BODY` block (there is no `HYBRID` keyword — the presence +/// of the RECIPIENTS block selects the CEK-envelope path; Sections 8, 10.5). +/// The CEK is NIP-44 wrapped to every recipient plus a trailing `self` stanza +/// (Section 10.1, 10.4). +/// When `originator_consents` is true, the originator is themselves a required +/// signatory and a CONSENT block over the document hash `H` is included +/// (Section 11.2). The SIGNATURE covers body + recipients + consent per +/// Section 4.2, so membership, roles, and consent are all tamper-evident. +/// +/// `recipients_in` should already be ordered `To:` (signers) then `Cc:` +/// (viewers) to match the deterministic ordering of Section 10.2; the `self` +/// stanza is appended last. Each recipient's optional `email` is written as the +/// stanza's fourth token, binding `(pubkey, email)` under the signature; pass +/// `sender_email` to do the same for the `self` stanza. Returns the armored +/// `text/plain` payload. +/// +/// The body ciphertext is glossia-encoded (Section 5) so it survives email +/// transport — quote prefixes, word-wrap, reflow — intact; base64 would corrupt +/// under quoting and break signed reply chains. The signature/pubkey are hex. +pub fn encode_hybrid_agreement( + sender_priv: &str, + sender_pub: &str, + sender_email: Option<&str>, + profile_name: &str, + body_plaintext: &[u8], + recipients_in: &[AgreementRecipientInput], + originator_consents: bool, + sign: bool, + encoding: Option<&str>, +) -> Result { + let cek = crate::crypto::generate_cek(); + encode_hybrid_agreement_with_cek( + &cek, sender_priv, sender_pub, sender_email, profile_name, body_plaintext, recipients_in, originator_consents, sign, encoding, + ) +} + +/// Like [`encode_hybrid_agreement`] but uses a caller-supplied CEK, so the same +/// key can also encrypt out-of-band material (e.g. the email subject) under one +/// envelope. The caller is responsible for generating a fresh random CEK +/// ([`crate::crypto::generate_cek`]) per message. `encoding` is the user's +/// Advanced glossia scheme (`None` ⇒ default). +/// +/// `sign`: when true, the message carries a SIGNATURE that authenticates the +/// body, the RECIPIENTS membership/roles, and any CONSENT (spec §4.2). When +/// false, it carries only a SEAL block supplying the sender's pubkey for CEK +/// unwrapping — the role set is then **unauthenticated** (§3.6), so an unsigned +/// envelope MUST NOT carry CONSENT and MUST NOT be used for agreements. +pub fn encode_hybrid_agreement_with_cek( + cek: &[u8; 32], + sender_priv: &str, + sender_pub: &str, + sender_email: Option<&str>, + profile_name: &str, + body_plaintext: &[u8], + recipients_in: &[AgreementRecipientInput], + originator_consents: bool, + sign: bool, + encoding: Option<&str>, +) -> Result { + if recipients_in.is_empty() { + return Err(anyhow::anyhow!( + "encode_hybrid_agreement requires at least one recipient (use the pairwise format for single-recipient messages)" + )); + } + let sender_pub_hex = normalize_pubkey_hex(sender_pub) + .ok_or_else(|| anyhow::anyhow!("invalid sender pubkey"))?; + + // 1–2. Encrypt the body once under the CEK (Section 10.1 steps 1–2), then + // glossia-encode the ciphertext so it survives email transport (quote + // prefixes / word-wrap) intact — base64 would break signed reply chains (§5). + let ciphertext = crate::crypto::aes_gcm_encrypt_raw(cek, body_plaintext)?; + let (body_encoded, body_decoded_bytes) = crate::email::glossia_encode_bytes_with(&ciphertext, encoding) + .ok_or_else(|| anyhow::anyhow!("glossia encode of agreement body failed"))?; + + // 3. Wrap the CEK with a per-message ephemeral key (AGE-style; Section 10.1 + // step 3, 10.4). The ephemeral private key seals the CEK to each recipient + // (and to the sender's own `self` stanza) via NIP-44 ECDH, then is discarded; + // its public key is published in the block so recipients can unwrap. This + // keeps the encryption layer off the sender's signing/identity key. + let (eph_priv, eph_pub_hex) = crate::crypto::generate_ephemeral_keypair()?; + let mut recipients: Vec = Vec::with_capacity(recipients_in.len() + 1); + for r in recipients_in { + let wrapped = crate::crypto::wrap_cek(&eph_priv, &r.pubkey, cek)?; + recipients.push(Recipient { + role: r.role.to_ascii_lowercase(), + pubkey: r.pubkey.clone(), + wrapped_cek: Some(wrapped), + email: r.email.clone(), + reference: None, + }); + } + // The `self` stanza lets the sender decrypt their own sent copy. Its role is + // `self` (not signer/viewer) so it is excluded from signatory/completion + // accounting (Sections 10.3/10.4/11.5); its pubkey is the sender's identity + // key (the unwrap target), but the CEK is wrapped against the ephemeral key + // like every other stanza. + let self_wrapped = crate::crypto::wrap_cek(&eph_priv, &sender_pub_hex, cek)?; + recipients.push(Recipient { + role: ROLE_SELF.to_string(), + pubkey: sender_pub_hex.clone(), + wrapped_cek: Some(self_wrapped), + email: sender_email.map(|s| s.to_string()), + reference: None, + }); + + // The ephemeral pubkey heads the block (a non-stanza line) so it is covered + // by the level's SIGNATURE along with the recipient set (Section 10.1). + let recipients_body = format!( + "{} {}\n{}", + RECIPIENTS_EPHEMERAL, + eph_pub_hex, + serialize_recipients(&recipients) + ); + let canon_recipients = canonicalize_block(&recipients_body); + + // The signed body bytes are the canonical decoded armor body — what + // decode_armor_section recovers (glossia-decoded), matching §4/§4.2. + let body_decoded = &body_decoded_bytes; + + // 4. Optional originator CONSENT over H (Sections 11.2, 11.3.1). Consent is + // bound by the signature, so it's only valid on a signed message. + let (consent_body, canon_consent) = if originator_consents && sign { + let h = document_hash(body_decoded, &canon_recipients); + let consent = Consent { + agreement_hash: hex::encode(h), + signer: sender_pub_hex.clone(), + }; + let body = consent.to_block_body(); + let canon = canonicalize_block(&body); + (Some(body), canon) + } else { + (None, String::new()) + }; + + // 5. Assemble body → RECIPIENTS → [CONSENT] → SIGNATURE | SEAL (§11.3.2). + // The body uses the generic `ENCRYPTED BODY` tag (AES-256-GCM under a CEK); + // the presence of the RECIPIENTS block — not a keyword — selects the + // CEK-envelope path over pairwise NIP-44 (spec Sections 8, 10.5). + let mut out = String::new(); + out.push_str("----- BEGIN NOSTR ENCRYPTED BODY -----\n"); + out.push_str(&body_encoded); + out.push('\n'); + out.push_str("----- BEGIN NOSTR RECIPIENTS -----\n"); + out.push_str(&recipients_body); + out.push('\n'); + if let Some(ref cbody) = consent_body { + out.push_str("----- BEGIN NOSTR CONSENT -----\n"); + out.push_str(cbody); + out.push('\n'); + } + if sign { + // Signature covers body || recipients || consent (§4.2). + let signing_bytes = level_signing_bytes(body_decoded, &canon_recipients, &canon_consent); + let sig_hex = crate::crypto::sign_data_bytes(sender_priv, &signing_bytes)?; + out.push_str("----- BEGIN NOSTR SIGNATURE -----\n@"); + out.push_str(profile_name); + out.push('\n'); + out.push_str(&sig_hex); + out.push('\n'); + out.push_str(&sender_pub_hex); + out.push('\n'); + } else { + // Unsigned: SEAL carries the sender's *identity* pubkey — the "family + // seal"/signet attesting who sent the message, not a key-unwrap input. + // The CEK is unwrapped via the RECIPIENTS `ephemeral` pubkey (§10.1), so + // SEAL is purely an identity claim here; and being unsigned, its role set + // is unauthenticated and MUST NOT be used for agreements (§3.6). + out.push_str("----- BEGIN NOSTR SEAL -----\n@"); + out.push_str(profile_name); + out.push('\n'); + out.push_str(&sender_pub_hex); + out.push('\n'); + } + out.push_str("----- END NOSTR MESSAGE -----"); + Ok(out) +} + +/// Normalize each pubkey to hex (dropping any that fail to parse) and dedup, +/// preserving first-seen order. +fn dedup_normalized(pubkeys: &[String]) -> Vec { + let mut seen = Vec::new(); + for pk in pubkeys { + if let Some(hex) = normalize_pubkey_hex(pk) { + if !seen.contains(&hex) { + seen.push(hex); + } + } + } + seen +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEX_A: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const HEX_B: &str = "0000000000000000000000000000000000000000000000000000000000000002"; + + // A valid pubkey hex (generator point x-coord) and its npub, for normalization tests. + const PK_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + #[test] + fn test_strip_quote_prefix() { + assert_eq!(strip_quote_prefix("> hello"), "hello"); + assert_eq!(strip_quote_prefix(">> hello"), "hello"); + assert_eq!(strip_quote_prefix("> > hello"), "hello"); + assert_eq!(strip_quote_prefix(">hello"), "hello"); + assert_eq!(strip_quote_prefix("hello"), "hello"); + } + + #[test] + fn test_parse_recipients_basic() { + let block = format!( + "signer {} wrapcekA\nviewer {} wrapcekB\nself {} wrapcekS", + HEX_A, HEX_B, PK_HEX + ); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 3); + assert_eq!(recips[0], Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: Some("wrapcekA".into()), email: None, reference: None }); + assert_eq!(recips[1].role, "viewer"); + assert_eq!(recips[2].role, "self"); + assert!(recips.iter().all(|r| r.email.is_none() && r.reference.is_none())); + } + + #[test] + fn test_parse_recipients_typed_tokens_and_tolerates_quotes() { + let block = format!( + "> signer {} wrapcekA bob@example.com\n\n>> viewer {} wrapcekB\n", + HEX_A, HEX_B + ); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 2); + // base64 token → cek; '@' token → email. + assert_eq!(recips[0].wrapped_cek.as_deref(), Some("wrapcekA")); + assert_eq!(recips[0].email.as_deref(), Some("bob@example.com")); + // A stanza without an email leaves it None. + assert_eq!(recips[1].role, "viewer"); + assert!(recips[1].email.is_none()); + } + + #[test] + fn test_parse_recipients_classifies_by_content_any_order() { + // email (@), reference (scheme:value), cek (base64) — given out of order. + let block = format!("signer {} bob@example.com evt:deadbeef wrapcekA", HEX_A); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 1); + assert_eq!(recips[0].wrapped_cek.as_deref(), Some("wrapcekA")); + assert_eq!(recips[0].email.as_deref(), Some("bob@example.com")); + assert_eq!(recips[0].reference.as_deref(), Some("evt:deadbeef")); + } + + #[test] + fn test_parse_recipients_optional_fields() { + // Plaintext (no cek), gift-wrap (reference only), and bare stanzas. + let block = format!( + "signer {} bob@example.com\nviewer {} evt:abc123\nself {}", + HEX_A, HEX_B, PK_HEX + ); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 3); + // plaintext signatory: email, no cek + assert!(recips[0].wrapped_cek.is_none()); + assert_eq!(recips[0].email.as_deref(), Some("bob@example.com")); + // gift-wrap stanza: reference only + assert!(recips[1].wrapped_cek.is_none() && recips[1].email.is_none()); + assert_eq!(recips[1].reference.as_deref(), Some("evt:abc123")); + // bare workflow-only stanza + assert!(recips[2].wrapped_cek.is_none() && recips[2].email.is_none() && recips[2].reference.is_none()); + } + + #[test] + fn test_parse_recipients_skips_lines_without_pubkey() { + // `role pubkey` is the minimum; a lone token has no pubkey and is skipped. + let block = format!("justonetoken\nsigner {} wrapcekB", HEX_B); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 1); + assert_eq!(recips[0].pubkey, HEX_B); + } + + #[test] + fn test_serialize_recipients_roundtrip() { + let recips = vec![ + Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: Some("cekA".into()), email: Some("a@x.io".into()), reference: None }, + Recipient { role: "self".into(), pubkey: HEX_B.into(), wrapped_cek: Some("cekS".into()), email: None, reference: None }, + Recipient { role: "viewer".into(), pubkey: PK_HEX.into(), wrapped_cek: None, email: None, reference: Some("evt:abc".into()) }, + ]; + let body = serialize_recipients(&recips); + // Only present fields are emitted, in cek/email/reference order. + assert_eq!(body, format!("signer {} cekA a@x.io\nself {} cekS\nviewer {} evt:abc", HEX_A, HEX_B, PK_HEX)); + assert_eq!(parse_recipients_block(&body), recips); + } + + #[test] + fn test_canonicalize_block_strips_and_drops_blanks() { + let raw = "> signer abc cek \n\n>> viewer def cek2\n \n"; + let canon = canonicalize_block(raw); + assert_eq!(canon, "signer abc cek\nviewer def cek2"); + // No trailing newline. + assert!(!canon.ends_with('\n')); + } + + #[test] + fn test_canonicalize_is_quote_prefix_invariant() { + // The same logical block with and without email quoting canonicalizes identically. + let plain = format!("signer {} cekA\nself {} cekS", HEX_A, HEX_B); + let quoted = format!("> signer {} cekA\n> self {} cekS", HEX_A, HEX_B); + assert_eq!(canonicalize_block(&plain), canonicalize_block("ed)); + } + + #[test] + fn test_parse_consent_basic() { + let block = format!("agreement {}\nsigner {}", HEX_A, PK_HEX); + let consent = parse_consent_block(&block).unwrap(); + assert_eq!(consent.agreement_hash, HEX_A); + assert_eq!(consent.signer, PK_HEX); + } + + #[test] + fn test_parse_consent_tolerates_unknown_lines_and_quotes() { + let block = format!("> agreement {}\n> future-field xyz\n> signer {}", HEX_A, PK_HEX); + let consent = parse_consent_block(&block).unwrap(); + assert_eq!(consent.agreement_hash, HEX_A); + assert_eq!(consent.signer, PK_HEX); + } + + #[test] + fn test_parse_consent_missing_field_is_none() { + assert!(parse_consent_block(&format!("agreement {}", HEX_A)).is_none()); + assert!(parse_consent_block(&format!("signer {}", PK_HEX)).is_none()); + } + + #[test] + fn test_consent_roundtrip() { + let consent = Consent { agreement_hash: HEX_A.into(), signer: PK_HEX.into() }; + let parsed = parse_consent_block(&consent.to_block_body()).unwrap(); + assert_eq!(parsed, consent); + } + + #[test] + fn test_document_hash_excludes_consent_and_is_stable() { + let body = b"This Mutual NDA is entered into as of 2026-06-13."; + let recips = format!("signer {} cekA\nself {} cekS", HEX_A, HEX_B); + let h1 = document_hash(body, &canonicalize_block(&recips)); + // Recomputing with the (quote-prefixed) same logical recipients yields identical H. + let recips_quoted = format!("> signer {} cekA\n> self {} cekS", HEX_A, HEX_B); + let h2 = document_hash(body, &canonicalize_block(&recips_quoted)); + assert_eq!(h1, h2); + // Changing the body changes H. + let h3 = document_hash(b"different terms", &canonicalize_block(&recips)); + assert_ne!(h1, h3); + // Changing the recipient/role set changes H. + let recips_tampered = format!("signer {} cekA\nsigner {} cekS", HEX_A, HEX_B); + let h4 = document_hash(body, &canonicalize_block(&recips_tampered)); + assert_ne!(h1, h4); + } + + #[test] + fn test_level_signing_bytes_order_and_emptiness() { + let body = b"body"; + let recips = "signer abc cek"; + let consent = "agreement H\nsigner abc"; + let full = level_signing_bytes(body, recips, consent); + let mut expected = Vec::new(); + expected.extend_from_slice(body); + expected.extend_from_slice(recips.as_bytes()); + expected.extend_from_slice(consent.as_bytes()); + assert_eq!(full, expected); + + // No recipients / consent → reduces to body only (Section 4 model). + assert_eq!(level_signing_bytes(body, "", ""), body.to_vec()); + } + + #[test] + fn test_normalize_pubkey_hex_accepts_hex_and_npub() { + use nostr_sdk::{PublicKey, ToBech32}; + let npub = PublicKey::from_hex(PK_HEX).unwrap().to_bech32().unwrap(); + assert_eq!(normalize_pubkey_hex(PK_HEX).unwrap(), PK_HEX); + assert_eq!(normalize_pubkey_hex(&npub).unwrap(), PK_HEX); + assert_eq!(normalize_pubkey_hex(&format!(" {} ", PK_HEX)).unwrap(), PK_HEX); + assert!(normalize_pubkey_hex("not-a-key").is_none()); + } + + #[test] + fn test_compute_completion_partial_and_full() { + let alice = PK_HEX.to_string(); + let bob = nostr_sdk::Keys::generate().public_key().to_hex(); + let required = vec![alice.clone(), bob.clone()]; + // Only Alice has consented → 1 of 2, not complete. + let status = compute_completion(&required, &[alice.clone()]); + assert_eq!((status.m, status.n), (1, 2)); + assert!(!status.complete); + + // Both consented → 2 of 2, complete. + let status = compute_completion(&required, &[alice.clone(), bob.clone()]); + assert_eq!((status.m, status.n), (2, 2)); + assert!(status.complete); + } + + #[test] + fn test_compute_completion_dedups_and_ignores_non_required() { + let alice = PK_HEX.to_string(); + let bob = nostr_sdk::Keys::generate().public_key().to_hex(); + let viewer = nostr_sdk::Keys::generate().public_key().to_hex(); + let required = vec![alice.clone(), bob.clone()]; + + // Alice consents twice (dedup → counts once); a non-required viewer consents (ignored). + let consented = vec![alice.clone(), alice.clone(), viewer.clone()]; + let status = compute_completion(&required, &consented); + assert_eq!((status.m, status.n), (1, 2)); + assert!(!status.complete); + assert_eq!(status.consented_signers, vec![alice]); + } + + #[test] + fn test_compute_completion_normalizes_npub_vs_hex() { + use nostr_sdk::{PublicKey, ToBech32}; + let alice_npub = PublicKey::from_hex(PK_HEX).unwrap().to_bech32().unwrap(); + // Required lists hex, consent lists npub for the same identity → must match. + let status = compute_completion(&[PK_HEX.to_string()], &[alice_npub]); + assert_eq!((status.m, status.n), (1, 1)); + assert!(status.complete); + } + + #[test] + fn test_compute_completion_empty_required_not_complete() { + let status = compute_completion(&[], &[]); + assert_eq!((status.m, status.n), (0, 0)); + assert!(!status.complete); + } + + // ── encode_hybrid_agreement round-trips ────────────────────────────── + + /// Recover the body from an encoded message as a given reader (by unwrapping + /// the matching RECIPIENTS stanza and AES-GCM-decrypting). Returns the bytes. + fn reader_recovers_body(armor: &str, reader_priv: &str, reader_pub_hex: &str, sender_pub_hex: &str) -> Vec { + // Extract the (glossia-encoded) body region and decode it to ciphertext. + let body_region: String = armor + .lines() + .skip_while(|l| !l.contains("BEGIN NOSTR ENCRYPTED BODY")) + .skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR") && !l.contains("END NOSTR")) + .collect::>() + .join("\n"); + let ciphertext = crate::email::decode_armor_section(&body_region).expect("decode body"); + + // Extract the RECIPIENTS block body. + let recip_body: String = armor + .lines() + .skip_while(|l| !l.contains("BEGIN NOSTR RECIPIENTS")) + .skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")) + .collect::>() + .join("\n"); + let recips = parse_recipients_block(&recip_body); + let stanza = recips + .iter() + .find(|r| normalize_pubkey_hex(&r.pubkey).as_deref() == Some(reader_pub_hex)) + .expect("reader has a stanza"); + // The CEK is wrapped against the per-message ephemeral key (§10.1). + let unwrap_pub = parse_recipients_ephemeral(&recip_body) + .unwrap_or_else(|| sender_pub_hex.to_string()); + let cek = crate::crypto::unwrap_cek(reader_priv, &unwrap_pub, stanza.wrapped_cek.as_deref().unwrap()).unwrap(); + crate::crypto::aes_gcm_decrypt_raw(&cek, &ciphertext).unwrap() + } + + #[test] + fn test_encode_hybrid_agreement_roundtrip_two_recipients() { + let sender = crate::crypto::generate_keypair().unwrap(); + let sender_pub_hex = crate::crypto::get_public_key_from_private(&sender.private_key).unwrap(); + let sender_pub_hex = normalize_pubkey_hex(&sender_pub_hex).unwrap(); + let alice = crate::crypto::generate_keypair().unwrap(); + let bob = crate::crypto::generate_keypair().unwrap(); + let alice_hex = normalize_pubkey_hex(&alice.public_key).unwrap(); + let bob_hex = normalize_pubkey_hex(&bob.public_key).unwrap(); + + let body = b"This Mutual NDA is entered into as of 2026-06-13."; + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: Some("alice@example.com".into()) }, + AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.org".into()) }, + ]; + let armor = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", body, &recips, false, true, None, + ).unwrap(); + + // Structure: generic encrypted body, recipients (incl. self last), no consent. + assert!(armor.contains("BEGIN NOSTR ENCRYPTED BODY")); + assert!(armor.contains("BEGIN NOSTR RECIPIENTS")); + assert!(!armor.contains("BEGIN NOSTR CONSENT")); + let recip_body: String = armor + .lines() + .skip_while(|l| !l.contains("BEGIN NOSTR RECIPIENTS")) + .skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")) + .collect::>() + .join("\n"); + let parsed = parse_recipients_block(&recip_body); + assert_eq!(parsed.len(), 3, "two recipients + self"); + assert_eq!(parsed[2].role, ROLE_SELF, "self stanza is last"); + + // The (pubkey, email) pairing is carried inside the signed block (§10.2/#102). + assert_eq!(parsed[0].email.as_deref(), Some("alice@example.com")); + assert_eq!(parsed[1].email.as_deref(), Some("bob@example.org")); + assert_eq!(parsed[2].email.as_deref(), Some("me@example.net")); + + // Each party (incl. self) recovers the exact body. + assert_eq!(reader_recovers_body(&armor, &alice.private_key, &alice_hex, &sender_pub_hex), body); + assert_eq!(reader_recovers_body(&armor, &bob.private_key, &bob_hex, &sender_pub_hex), body); + assert_eq!(reader_recovers_body(&armor, &sender.private_key, &sender_pub_hex, &sender_pub_hex), body); + } + + #[test] + fn test_envelope_wraps_cek_with_ephemeral_key_not_sender_identity() { + let sender = crate::crypto::generate_keypair().unwrap(); + let sender_pub_hex = normalize_pubkey_hex( + &crate::crypto::get_public_key_from_private(&sender.private_key).unwrap()).unwrap(); + let alice = crate::crypto::generate_keypair().unwrap(); + let alice_hex = normalize_pubkey_hex(&alice.public_key).unwrap(); + + let body = b"ephemeral wrap test"; + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: None, + }]; + let armor = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, None, "Originator", body, &recips, false, true, None, + ).unwrap(); + + let recip_body: String = armor + .lines() + .skip_while(|l| !l.contains("BEGIN NOSTR RECIPIENTS")) + .skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")) + .collect::>() + .join("\n"); + + // The block publishes a per-message ephemeral pubkey, distinct from the + // sender's identity key, and it is excluded from the parsed stanzas. + let eph = parse_recipients_ephemeral(&recip_body).expect("ephemeral line present"); + let eph = normalize_pubkey_hex(&eph).expect("ephemeral pubkey is valid"); + assert_ne!(eph, sender_pub_hex, "ephemeral key must differ from sender identity"); + let recips_parsed = parse_recipients_block(&recip_body); + assert!(recips_parsed.iter().all(|r| normalize_pubkey_hex(&r.pubkey).as_deref() != Some(eph.as_str())), + "ephemeral line is not a recipient stanza"); + + let stanza = recips_parsed.iter() + .find(|r| normalize_pubkey_hex(&r.pubkey).as_deref() == Some(alice_hex.as_str())) + .unwrap(); + let wrapped = stanza.wrapped_cek.as_deref().unwrap(); + // Unwrapping against the ephemeral key works… + assert!(crate::crypto::unwrap_cek(&alice.private_key, &eph, wrapped).is_ok()); + // …but unwrapping against the sender identity key does NOT (ephemeral wrap). + assert!(crate::crypto::unwrap_cek(&alice.private_key, &sender_pub_hex, wrapped).is_err()); + } + + #[test] + fn test_encode_hybrid_agreement_with_consent_has_matching_h() { + let sender = crate::crypto::generate_keypair().unwrap(); + let sender_pub_hex = normalize_pubkey_hex( + &crate::crypto::get_public_key_from_private(&sender.private_key).unwrap()).unwrap(); + let alice = crate::crypto::generate_keypair().unwrap(); + + let body = b"terms"; + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: None }, + ]; + let armor = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, None, "Originator", body, &recips, true, true, None, + ).unwrap(); + + assert!(armor.contains("BEGIN NOSTR CONSENT")); + let consent_body: String = armor + .lines() + .skip_while(|l| !l.contains("BEGIN NOSTR CONSENT")) + .skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")) + .collect::>() + .join("\n"); + let consent = parse_consent_block(&consent_body).expect("consent parses"); + assert_eq!(normalize_pubkey_hex(&consent.signer).unwrap(), sender_pub_hex); + + // The consent's H must equal SHA-256(decode(body) || canonical(recipients)). + let body_region: String = armor.lines() + .skip_while(|l| !l.contains("BEGIN NOSTR ENCRYPTED BODY")).skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR") && !l.contains("END NOSTR")) + .collect::>().join("\n"); + let ciphertext = crate::email::decode_armor_section(&body_region).unwrap(); + let recip_body: String = armor.lines() + .skip_while(|l| !l.contains("BEGIN NOSTR RECIPIENTS")).skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")).collect::>().join("\n"); + let h = document_hash(&ciphertext, &canonicalize_block(&recip_body)); + assert_eq!(consent.agreement_hash, hex::encode(h)); + } + + #[test] + fn test_encode_hybrid_agreement_rejects_empty_recipients() { + let sender = crate::crypto::generate_keypair().unwrap(); + let err = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, None, "X", b"x", &[], false, true, None); + assert!(err.is_err()); + } +} diff --git a/tauri-app/backend/src/crypto.rs b/tauri-app/backend/src/crypto.rs index 00f51d7..4f00ffe 100644 --- a/tauri-app/backend/src/crypto.rs +++ b/tauri-app/backend/src/crypto.rs @@ -391,6 +391,102 @@ pub fn aes_gcm_decrypt_raw(key_bytes: &[u8], encrypted_data: &[u8]) -> Result Result> { + if key_bytes.len() != 32 { + return Err(anyhow::anyhow!("AES key must be 32 bytes, got {}", key_bytes.len())); + } + let key = Key::::from_slice(key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher.encrypt(&nonce, plaintext) + .map_err(|e| anyhow::anyhow!("AES-GCM encryption failed: {}", e))?; + let mut combined = nonce.to_vec(); + combined.extend_from_slice(&ciphertext); + Ok(combined) +} + +/// Generate a random 256-bit Content Encryption Key (CEK) for the +/// multi-recipient hybrid envelope scheme (spec Section 10.1). +pub fn generate_cek() -> [u8; 32] { + use ::secp256k1::rand::RngCore; + let mut cek = [0u8; 32]; + ::secp256k1::rand::rngs::OsRng.fill_bytes(&mut cek); + cek +} + +/// Generate a per-message ephemeral keypair for CEK wrapping (spec Section 10.1, +/// AGE-style). Returns `(private_key_bech32, public_key_hex)`. The private key is +/// used only to wrap the CEK to each recipient and is then discarded; the public +/// key is published in the RECIPIENTS block so recipients can unwrap. Using an +/// ephemeral key (rather than the sender's identity key) for the ECDH wrap keeps +/// the encryption layer separate from the signing/identity key. +pub fn generate_ephemeral_keypair() -> Result<(String, String)> { + let keys = Keys::generate(); + Ok((keys.secret_key().to_bech32()?, keys.public_key().to_hex())) +} + +/// Wrap a CEK to a single recipient using NIP-44 (spec Section 10.1, step 3). +/// +/// The 32-byte CEK is hex-encoded and NIP-44 encrypted with the shared secret of +/// `(sender_priv, recipient_pub)`. The returned string is the NIP-44 payload +/// (base64), suitable for placement as the `wrapped-cek` token in a RECIPIENTS +/// stanza (spec Section 10.2). Accepts bech32 or hex for both keys. +pub fn wrap_cek(sender_priv: &str, recipient_pub: &str, cek: &[u8; 32]) -> Result { + let secret_key = SecretKey::from_bech32(sender_priv) + .or_else(|_| SecretKey::from_hex(sender_priv))?; + let public_key = PublicKey::from_bech32(recipient_pub) + .or_else(|_| PublicKey::from_hex(recipient_pub))?; + let cek_hex = hex::encode(cek); + let wrapped = nip44::encrypt(&secret_key, &public_key, &cek_hex, nip44::Version::default())?; + Ok(wrapped) +} + +/// Unwrap a CEK that was wrapped to the reader with NIP-44 (spec Section 8, step 8). +/// +/// `reader_priv` is the reader's private key, `sender_pub` is the sender's pubkey +/// (the same key used as the counterparty when wrapping). Returns the 32-byte CEK. +/// Accepts bech32 or hex for both keys. +pub fn unwrap_cek(reader_priv: &str, sender_pub: &str, wrapped_cek: &str) -> Result<[u8; 32]> { + let secret_key = SecretKey::from_bech32(reader_priv) + .or_else(|_| SecretKey::from_hex(reader_priv))?; + let public_key = PublicKey::from_bech32(sender_pub) + .or_else(|_| PublicKey::from_hex(sender_pub))?; + let cek_hex = nip44::decrypt(&secret_key, &public_key, wrapped_cek)?; + let cek_bytes = hex::decode(cek_hex.trim()) + .map_err(|e| anyhow::anyhow!("Unwrapped CEK is not valid hex: {}", e))?; + if cek_bytes.len() != 32 { + return Err(anyhow::anyhow!("Unwrapped CEK must be 32 bytes, got {}", cek_bytes.len())); + } + let mut cek = [0u8; 32]; + cek.copy_from_slice(&cek_bytes); + Ok(cek) +} + +/// Size boundary attachments are padded to before encryption, hiding the true +/// file size (spec §11.2 / capnp schema). Must match the decrypt side. +pub const PADDING_BOUNDARY: usize = 64 * 1024; + +/// AES-256-GCM encrypt with size-prefixed padding (inverse of +/// [`aes_gcm_decrypt_padded`]). The plaintext is `[4-byte LE original size] +/// [data][random padding]`, padded so the total is a multiple of +/// [`PADDING_BOUNDARY`]; the decrypt side recovers the original `data` using the +/// size header, so the exact padded length is not load-bearing for correctness. +pub fn aes_gcm_encrypt_padded(key_bytes: &[u8], data: &[u8]) -> Result> { + use ::secp256k1::rand::RngCore; + let original_size = data.len(); + let unpadded = 4 + original_size; + let padded_len = unpadded.div_ceil(PADDING_BOUNDARY) * PADDING_BOUNDARY; + let mut buf = Vec::with_capacity(padded_len); + buf.extend_from_slice(&(original_size as u32).to_le_bytes()); + buf.extend_from_slice(data); + let mut pad = vec![0u8; padded_len - unpadded]; + ::secp256k1::rand::rngs::OsRng.fill_bytes(&mut pad); + buf.extend_from_slice(&pad); + aes_gcm_encrypt_raw(key_bytes, &buf) +} + /// AES-256-GCM decrypt + remove padding. /// After decryption, first 4 bytes are original size (little-endian u32), /// followed by the original data padded to 64 KiB boundaries. @@ -518,4 +614,90 @@ mod tests { let decrypted = aes_gcm_decrypt_padded(&key_bytes, &combined).unwrap(); assert_eq!(decrypted, original_data); } + + #[test] + fn test_aes_gcm_encrypt_decrypt_raw_roundtrip() { + let key_bytes = generate_cek(); + let plaintext = b"multi-recipient body under a CEK"; + let combined = aes_gcm_encrypt_raw(&key_bytes, plaintext).unwrap(); + // nonce (12) + ciphertext + 16-byte GCM tag + assert!(combined.len() >= 12 + plaintext.len() + 16); + let decrypted = aes_gcm_decrypt_raw(&key_bytes, &combined).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_aes_gcm_encrypt_raw_bad_key_length() { + assert!(aes_gcm_encrypt_raw(&[0u8; 16], b"x").is_err()); + } + + #[test] + fn test_generate_cek_is_random_and_32_bytes() { + let a = generate_cek(); + let b = generate_cek(); + assert_eq!(a.len(), 32); + assert_ne!(a, b, "two CEKs should not collide"); + } + + #[test] + fn test_wrap_unwrap_cek_roundtrip() { + // Sender wraps the CEK to a recipient; recipient unwraps with their key + sender pubkey. + let sender = generate_keypair().unwrap(); + let recipient = generate_keypair().unwrap(); + let sender_pub = get_public_key_from_private(&sender.private_key).unwrap(); + + let cek = generate_cek(); + let wrapped = wrap_cek(&sender.private_key, &recipient.public_key, &cek).unwrap(); + let unwrapped = unwrap_cek(&recipient.private_key, &sender_pub, &wrapped).unwrap(); + assert_eq!(unwrapped, cek); + } + + #[test] + fn test_self_wrap_unwrap_cek_roundtrip() { + // The `self` stanza: sender wraps to their own pubkey so the Sent copy decrypts. + let sender = generate_keypair().unwrap(); + let sender_pub = get_public_key_from_private(&sender.private_key).unwrap(); + + let cek = generate_cek(); + let wrapped = wrap_cek(&sender.private_key, &sender.public_key, &cek).unwrap(); + let unwrapped = unwrap_cek(&sender.private_key, &sender_pub, &wrapped).unwrap(); + assert_eq!(unwrapped, cek); + } + + #[test] + fn test_unwrap_cek_wrong_recipient_fails() { + let sender = generate_keypair().unwrap(); + let recipient = generate_keypair().unwrap(); + let intruder = generate_keypair().unwrap(); + let sender_pub = get_public_key_from_private(&sender.private_key).unwrap(); + + let cek = generate_cek(); + let wrapped = wrap_cek(&sender.private_key, &recipient.public_key, &cek).unwrap(); + // An unintended party cannot unwrap the CEK. + assert!(unwrap_cek(&intruder.private_key, &sender_pub, &wrapped).is_err()); + } + + #[test] + fn test_full_hybrid_envelope_roundtrip() { + // End-to-end: encrypt body once under CEK, wrap CEK to two recipients + self, + // each recipient independently recovers the body (spec Section 10.1). + let sender = generate_keypair().unwrap(); + let alice = generate_keypair().unwrap(); + let bob = generate_keypair().unwrap(); + let sender_pub = get_public_key_from_private(&sender.private_key).unwrap(); + + let cek = generate_cek(); + let body = b"This Mutual NDA is entered into as of 2026-06-13."; + let ciphertext = aes_gcm_encrypt_raw(&cek, body).unwrap(); + + let wrapped_alice = wrap_cek(&sender.private_key, &alice.public_key, &cek).unwrap(); + let wrapped_bob = wrap_cek(&sender.private_key, &bob.public_key, &cek).unwrap(); + + // Alice recovers the body. + let cek_a = unwrap_cek(&alice.private_key, &sender_pub, &wrapped_alice).unwrap(); + assert_eq!(aes_gcm_decrypt_raw(&cek_a, &ciphertext).unwrap(), body); + // Bob recovers the body. + let cek_b = unwrap_cek(&bob.private_key, &sender_pub, &wrapped_bob).unwrap(); + assert_eq!(aes_gcm_decrypt_raw(&cek_b, &ciphertext).unwrap(), body); + } } \ No newline at end of file diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index f037432..cc1ab09 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -144,6 +144,24 @@ impl Header for XNostrRecipient { } } +/// `X-Nostr-Agreement` — a non-authoritative marker that a thread is an +/// agreement, so IMAP can surface agreement threads without decrypting bodies +/// (spec §6.1, §11.2). The authoritative state always comes from the armor. +#[derive(Debug, Clone)] +struct XNostrAgreement(String); + +impl Header for XNostrAgreement { + fn name() -> HeaderName { + HeaderName::new_from_ascii_str("X-Nostr-Agreement") + } + fn parse(s: &str) -> Result> { + Ok(XNostrAgreement(s.to_string())) + } + fn display(&self) -> HeaderValue { + HeaderValue::new(Self::name(), self.0.clone()) + } +} + /// Construct email headers without sending the email pub fn construct_email_headers( config: &EmailConfig, @@ -368,18 +386,24 @@ pub async fn send_email( include_sig_header: bool, recipient_pubkey: Option<&str>, include_recipient_header: bool, + cc: &[String], ) -> Result { debug_log!("[RUST] send_email: Starting email send process"); debug_log!("[RUST] send_email: SMTP Host: {}, Port: {}", config.smtp_host, config.smtp_port); debug_log!("[RUST] send_email: From: {}, To: {}", config.email_address, to_address); debug_log!("[RUST] send_email: Use TLS: {}", config.use_tls); - + let mut builder = Message::builder() .from(config.email_address.parse()?) .reply_to(config.email_address.parse()?) .to(to_address.parse()?) .subject(subject); + // Additional cleartext Cc recipients (plain multi-recipient email). + for addr in cc { + builder = builder.cc(addr.parse()?); + } + // Add custom message ID if provided if let Some(msg_id) = message_id { // Pass the message ID as Option to the builder @@ -593,6 +617,416 @@ pub async fn send_email( } } +/// Build an SMTP transport from the config (shared TLS/credentials setup). +fn build_mailer(config: &EmailConfig) -> Result { + let creds = Credentials::new(config.email_address.clone(), config.password.clone()); + let mut mailer_builder = SmtpTransport::relay(&config.smtp_host)? + .port(config.smtp_port) + .credentials(creds); + if config.use_tls { + let tls_params = lettre::transport::smtp::client::TlsParameters::new(config.smtp_host.clone())?; + mailer_builder = mailer_builder.tls(lettre::transport::smtp::client::Tls::Required(tls_params)); + } else { + mailer_builder = mailer_builder.tls(lettre::transport::smtp::client::Tls::None); + } + Ok(mailer_builder.build()) +} + +/// Send a built message via SMTP with the standard 60s timeout / blocking-thread +/// handling. +async fn send_message_with_timeout(mailer: SmtpTransport, email: Message) -> Result<()> { + let send_future = task::spawn_blocking(move || mailer.send(&email)); + match timeout(Duration::from_secs(60), send_future).await { + Ok(Ok(Ok(_))) => Ok(()), + Ok(Ok(Err(e))) => Err(anyhow::anyhow!("Failed to send email: {}", e)), + Ok(Err(e)) => Err(anyhow::anyhow!("Task join error: {}", e)), + Err(_) => Err(anyhow::anyhow!("SMTP send operation timed out after 60 seconds")), + } +} + +/// Compose an agreement: the armored `text/plain` body plus the subject to put +/// in the header (spec §§10–11). +/// +/// `to` parties become `signer` signatories and `cc` parties become `viewer`s +/// (spec §6.3). `encrypted` selects the multi-recipient envelope (CEK) form vs. +/// the plaintext (public) form (§11.8). `originator_consents` includes the +/// originator's own CONSENT over `H` (§11.2). +/// +/// In encrypted mode the **subject is AES-256-GCM-encrypted under the same CEK +/// as the body** and glossia-encoded, so it is readable by exactly the recipients +/// and never leaks in cleartext; in plaintext mode the subject is returned +/// unchanged. `encoding` is the user's Advanced glossia scheme for the body; +/// `subject_encoding` is the (independent) scheme for the subject, falling back +/// to `encoding` when `None`. SMTP/MIME assembly is the caller's job, so this is +/// unit-testable. +pub fn compose_agreement( + private_key: &str, + sender_pub: &str, + sender_email: Option<&str>, + profile_name: &str, + subject: &str, + body: &str, + to: &[crate::types::AgreementParty], + cc: &[crate::types::AgreementParty], + encrypted: bool, + originator_consents: bool, + is_agreement: bool, + encrypt_subject: bool, + sign: bool, + encoding: Option<&str>, + subject_encoding: Option<&str>, + // Plaintext attachments (base64 data). For an encrypted message these are + // AES-encrypted into a capnp manifest here (§11.2) and returned as `aN.dat` + // MIME parts in the result; for a public message they pass through as + // plaintext parts. Empty ⇒ no attachments. + attachments: &[crate::types::EmailAttachment], +) -> Result { + // An agreement is always signed (its signatory set must be authenticated, + // §3.6); a plain multi-recipient message honors the caller's choice and may + // be unsigned (SEAL). Consent is separate and only valid when signed. + let sign = sign || is_agreement; + use crate::agreement::{AgreementRecipientInput, ROLE_SIGNER, ROLE_VIEWER}; + + // `To:` are signatories only for an actual agreement; a plain multi-recipient + // email tags everyone `viewer` (read access, no signing) so it isn't surfaced + // as an unsigned agreement (spec §6.3, §11.1). + let to_role = if is_agreement { ROLE_SIGNER } else { ROLE_VIEWER }; + let mut recips: Vec = Vec::with_capacity(to.len() + cc.len()); + for p in to { + recips.push(AgreementRecipientInput { + role: to_role.to_string(), + pubkey: p.pubkey.clone(), + email: Some(p.email.clone()), + }); + } + for p in cc { + recips.push(AgreementRecipientInput { + role: ROLE_VIEWER.to_string(), + pubkey: p.pubkey.clone(), + email: Some(p.email.clone()), + }); + } + if recips.is_empty() { + return Err("a multi-recipient message needs at least one keyed To: or Cc: recipient".to_string()); + } + + if encrypted { + // With attachments, the encrypted body is a capnp manifest (§11.2): the + // body + each attachment are AES-encrypted under their own keys, the + // manifest holds those keys, and the encrypted attachment bytes ride as + // `aN.dat` MIME parts. Without attachments the raw body is encrypted. + let (body_payload, parts): (Vec, Vec) = + if attachments.is_empty() { + (body.as_bytes().to_vec(), Vec::new()) + } else { + let inputs = attachment_inputs_from(attachments)?; + let (payload, enc_parts) = crate::manifest::build_capnp_manifest(body, &inputs) + .map_err(|e| format!("manifest build failed: {}", e))?; + (payload, encrypted_parts_to_mime(enc_parts)) + }; + // One CEK for the body (manifest) and the subject. + let cek = crate::crypto::generate_cek(); + let armor = crate::agreement::encode_hybrid_agreement_with_cek( + &cek, private_key, sender_pub, sender_email, profile_name, &body_payload, &recips, originator_consents, sign, encoding, + ).map_err(|e| e.to_string())?; + // The subject is encrypted under the same CEK by default. The sender may + // leave it in the clear (`encrypt_subject = false`) so keyless Cc / + // witnesses have some context for what they're validating — the body + // stays encrypted regardless. + let out_subject = if encrypt_subject { + let subject_ct = crate::crypto::aes_gcm_encrypt_raw(&cek, subject.as_bytes()) + .map_err(|e| format!("subject encrypt failed: {}", e))?; + glossia_encode_bytes_with(&subject_ct, subject_encoding.or(encoding)) + .ok_or_else(|| "subject glossia encode failed".to_string())? + .0 + } else { + subject.to_string() + }; + Ok(crate::types::ComposedAgreement { armor, subject: out_subject, attachments: parts }) + } else { + // Public agreement: cleartext body. Attachments ride as plaintext MIME + // parts, bound by a signed ATTACHMENTS block (each file's plaintext hash) + // so they can't be swapped/added/removed without breaking the signature. + let att_specs = public_attachment_specs(attachments)?; + let armor = encode_signed_agreement( + private_key, sender_pub, profile_name, body, &recips, originator_consents, &att_specs, encoding, + )?; + // Public agreement: subject stays in the clear. + Ok(crate::types::ComposedAgreement { armor, subject: subject.to_string(), attachments: attachments.to_vec() }) + } +} + +/// Compute the signed ATTACHMENTS specs for a public message: each plaintext +/// attachment's SHA-256 (hex), size, MIME, and filename (spec §11.2). +fn public_attachment_specs( + attachments: &[crate::types::EmailAttachment], +) -> Result, String> { + use base64::Engine; + use sha2::{Digest, Sha256}; + attachments + .iter() + .enumerate() + .map(|(i, a)| { + let data = base64::engine::general_purpose::STANDARD + .decode(&a.data) + .map_err(|e| format!("attachment {} base64 decode failed: {}", a.filename, e))?; + Ok(crate::agreement::AttachmentSpec { + id: format!("a{}", i + 1), + sha256: hex::encode(Sha256::digest(&data)), + size: data.len() as u64, + mime: a.content_type.clone(), + filename: a.filename.clone(), + }) + }) + .collect() +} + +/// Decode plaintext [`EmailAttachment`]s (base64 `data`) into manifest inputs. +fn attachment_inputs_from( + attachments: &[crate::types::EmailAttachment], +) -> Result, String> { + use base64::Engine; + attachments + .iter() + .map(|a| { + let data = base64::engine::general_purpose::STANDARD + .decode(&a.data) + .map_err(|e| format!("attachment {} base64 decode failed: {}", a.filename, e))?; + Ok(crate::manifest::AttachmentInput { + filename: a.filename.clone(), + mime: a.content_type.clone(), + data, + }) + }) + .collect() +} + +/// Convert encrypted manifest parts into `aN.dat` MIME attachments. +fn encrypted_parts_to_mime( + parts: Vec, +) -> Vec { + use base64::Engine; + parts + .into_iter() + .map(|p| crate::types::EmailAttachment { + filename: p.filename, + content_type: "application/octet-stream".to_string(), + data: base64::engine::general_purpose::STANDARD.encode(&p.ciphertext), + size: p.ciphertext.len(), + is_encrypted: true, + encryption_method: Some("manifest_aes".to_string()), + algorithm: None, + original_filename: None, + original_type: None, + original_size: None, + }) + .collect() +} + +/// Wrap a NIP ciphertext in the ASCII armor a 1:1 body uses (mirrors the +/// frontend's `armorCiphertext`): `-----BEGIN NOSTR NIP-XX ENCRYPTED BODY-----`. +fn armor_ciphertext(ciphertext: &str, algorithm: &str) -> String { + let armor_type = if algorithm.eq_ignore_ascii_case("nip04") { "NIP-04" } else { "NIP-44" }; + format!( + "-----BEGIN NOSTR {} ENCRYPTED BODY-----\n{}\n-----END NOSTR MESSAGE-----", + armor_type, + ciphertext.trim() + ) +} + +/// Build a 1:1 (pairwise) encrypted manifest body in Rust (spec §11.2). +/// +/// AES-encrypts the body + each plaintext attachment into a Cap'n Proto manifest +/// (text-armored for the string-typed NIP transport), NIP-encrypts that manifest +/// to `recipient_pubkey`, and ASCII-armors the result. Returns the armored body +/// plus the encrypted `aN.dat` MIME parts. The subject, glossia encoding, and +/// signing remain the caller's responsibility (unchanged from the JSON flow). +pub fn encrypt_manifest_body( + sender_priv: &str, + recipient_pubkey: &str, + body: &str, + attachments: &[crate::types::EmailAttachment], + algorithm: &str, +) -> Result { + let inputs = attachment_inputs_from(attachments)?; + let (payload, parts) = crate::manifest::build_capnp_manifest_armored(body, &inputs) + .map_err(|e| format!("manifest build failed: {}", e))?; + let ciphertext = crate::crypto::encrypt_message(sender_priv, recipient_pubkey, &payload, Some(algorithm)) + .map_err(|e| format!("manifest encrypt failed: {}", e))?; + Ok(crate::types::EncryptedManifestBody { + armored_body: armor_ciphertext(&ciphertext, algorithm), + attachments: encrypted_parts_to_mime(parts), + }) +} + +/// Decrypt an agreement subject that was AES-256-GCM-encrypted under the message +/// CEK (the envelope path; counterpart to [`compose_agreement`]). Unwraps the CEK +/// from the reader's RECIPIENTS stanza, then decrypts. Returns the subject +/// unchanged if it isn't CEK-encrypted (e.g. a plaintext-agreement subject) or +/// if anything fails. The second tuple element is the ciphertext (for DM↔email +/// hash matching), mirroring [`decrypt_subject`]. +fn decrypt_subject_envelope( + subject: &str, + recipients: &[crate::agreement::Recipient], + sender_pub: &str, + private_key: &str, + user_pubkey_hex: &str, + ephemeral_pubkey: Option<&str>, +) -> (String, Option) { + if subject.is_empty() { + return (subject.to_string(), None); + } + let me = crate::agreement::normalize_pubkey_hex(user_pubkey_hex) + .unwrap_or_else(|| user_pubkey_hex.to_string()); + let stanza = recipients.iter().find(|r| { + crate::agreement::normalize_pubkey_hex(&r.pubkey).as_deref() == Some(me.as_str()) + }); + let wrapped = match stanza.and_then(|s| s.wrapped_cek.as_deref()) { + Some(w) => w, + None => return (subject.to_string(), None), // plaintext subject or not a recipient + }; + let unwrap_pub = ephemeral_pubkey.unwrap_or(sender_pub); + let cek = match crate::crypto::unwrap_cek(private_key, unwrap_pub, wrapped) { + Ok(c) => c, + Err(_) => return (subject.to_string(), None), + }; + // The subject ciphertext is glossia-encoded (or base64); decode_armor_section + // handles both. A cleartext subject won't decode to valid ciphertext, so AES + // fails and we fall back to it unchanged. + let ct = match decode_armor_section(subject.trim()) { + Some(b) => b, + None => return (subject.to_string(), None), + }; + match crate::crypto::aes_gcm_decrypt_raw(&cek, &ct) { + Ok(pt) => (String::from_utf8_lossy(&pt).into_owned(), Some(subject.to_string())), + Err(_) => (subject.to_string(), Some(subject.to_string())), + } +} + +/// Compose an agreement and send it to all signatories (`To:`) and viewers +/// (`Cc:`) in one message (spec §6.3). Adds the `X-Nostr-Agreement` marker +/// (§6.1) and, when enabled, the `X-Nostr-Pubkey`/`X-Nostr-Sig` headers. The +/// body's armor already carries the authoritative SIGNATURE. +pub async fn send_agreement_email( + config: &EmailConfig, + subject: &str, + profile_name: &str, + body: &str, + to: &[crate::types::AgreementParty], + cc: &[crate::types::AgreementParty], + cc_plain: &[String], + encrypted: bool, + originator_consents: bool, + is_agreement: bool, + encrypt_subject: bool, + sign: bool, + message_id: Option<&str>, + in_reply_to: Option<&str>, + references: Option<&str>, + include_pubkey_header: bool, + include_sig_header: bool, + encoding: Option<&str>, + subject_encoding: Option<&str>, + // Plaintext attachments (§11.2). compose_agreement AES-encrypts them into the + // manifest (encrypted message) or passes them through (public), returning the + // MIME parts to send. `None`/empty ⇒ a single-part text message. + attachments: Option<&Vec>, +) -> Result { + let private_key = config.private_key.as_deref() + .ok_or_else(|| anyhow::anyhow!("no private key configured; cannot sign the message"))?; + let sender_pub = crypto::get_public_key_from_private(private_key) + .map_err(|e| anyhow::anyhow!("could not derive sender pubkey: {}", e))?; + + let empty_atts = Vec::new(); + let composed = compose_agreement( + private_key, &sender_pub, Some(&config.email_address), profile_name, subject, body, to, cc, encrypted, originator_consents, is_agreement, encrypt_subject, sign, encoding, subject_encoding, + attachments.unwrap_or(&empty_atts), + ).map_err(|e| anyhow::anyhow!("{}", e))?; + let armor = composed.armor; + // compose_agreement returns the MIME parts to attach (encrypted aN.dat for an + // encrypted message, or plaintext parts for a public one). + let out_attachments = composed.attachments; + + let mut builder = Message::builder() + .from(config.email_address.parse()?) + .reply_to(config.email_address.parse()?) + .subject(composed.subject); + for p in to { + builder = builder.to(p.email.parse()?); + } + for p in cc { + builder = builder.cc(p.email.parse()?); + } + // Keyless Cc: no Nostr key, so no CEK wrap and no RECIPIENTS stanza — they + // appear in the Cc header only and receive the (encrypted) message without + // being able to decrypt it. They CAN still verify the in-body signature + // (useful for escrow/witness Cc). + for addr in cc_plain { + builder = builder.cc(addr.parse()?); + } + if let Some(m) = message_id { + builder = builder.message_id(Some(m.to_string())); + } + if let Some(r) = in_reply_to { + builder = builder.in_reply_to(r.to_string()); + } + if let Some(r) = references { + builder = builder.references(r.to_string()); + } + + // Agreement marker for decrypt-free IMAP filtering (§6.1) — only for actual + // agreements, not plain multi-recipient mail; non-authoritative. + if is_agreement { + builder = builder.header(XNostrAgreement("1".to_string())); + } + + // X-Nostr-Pubkey / X-Nostr-Sig mirror the in-body SIGNATURE (secondary path). + if include_pubkey_header { + builder = builder.header(XNostrPubkey(sender_pub.clone())); + if include_sig_header { + let binary = extract_ciphertext_binary(&armor); + if let Ok(signature) = crypto::sign_data_bytes(private_key, &binary) { + builder = builder.header(XNostrSig(signature)); + } + } + } + + // Single-part unless there are attachment parts (encrypted aN.dat or public + // plaintext, produced by compose_agreement), in which case build + // multipart/mixed: the armored body as the text part, then each attachment + // part — mirroring `send_email` (§11.2). + let email = if !out_attachments.is_empty() { + debug_log!("[RUST] send_agreement_email: building multipart with {} attachments", out_attachments.len()); + let mut multipart = MultiPart::mixed().singlepart( + SinglePart::builder().header(ContentType::TEXT_PLAIN).body(armor), + ); + for attachment in &out_attachments { + let attachment_data = match general_purpose::STANDARD.decode(&attachment.data) { + Ok(data) => data, + Err(e) => { + debug_log!("[RUST] send_agreement_email: base64 decode failed for {}: {}", attachment.filename, e); + continue; + } + }; + let content_type = attachment.content_type.parse::() + .unwrap_or(ContentType::parse("application/octet-stream").unwrap()); + let attachment_part = Attachment::new(attachment.filename.clone()) + .body(attachment_data, content_type); + multipart = multipart.singlepart(attachment_part); + } + builder.multipart(multipart)? + } else { + builder.body(armor)? + }; + let mailer = build_mailer(config)?; + send_message_with_timeout(mailer, email).await?; + let recipient_count = to.len() + cc.len() + cc_plain.len(); + Ok(format!( + "{} sent to {} recipient(s)", + if is_agreement { "Agreement" } else { "Message" }, recipient_count + )) +} + /// Delete a sent email from the IMAP server by moving it to Trash /// For Gmail, moves to [Gmail]/Trash /// For other providers, tries common trash folder names @@ -1690,34 +2124,22 @@ fn try_glossia_decode_to_bytes(text: &str) -> Option> { } } +/// Map a frontend encoding-scheme name (the user's Advanced "encoding" setting) +/// to glossia `(language, wordlist)`. `None`/empty defaults to english/bip39. +pub(crate) fn glossia_lang_wordlist(encoding: Option<&str>) -> (String, String) { + match encoding.unwrap_or("").to_lowercase().as_str() { + "" | "english" | "english - bip39" | "bip39" => ("english".to_string(), "bip39".to_string()), + "latin" => ("latin".to_string(), "default".to_string()), + other => (other.to_string(), "default".to_string()), + } +} + /// Glossia round-trip: encode plaintext bytes into the given language, then decode back /// to get canonical bytes. This ensures signature verification survives transport /// (word-wrap, quote prefixes, etc.) because the signature is on the decoded binary. /// Returns None if glossia encode/decode fails (caller should fall back to raw UTF-8 bytes). pub fn glossia_roundtrip_to_bytes(text: &str, encoding: &str) -> Option> { - let hex_input = glossia::hex_encode(text.as_bytes()); - // Map frontend encoding names to glossia parameters - let encoding_lower = encoding.to_lowercase(); - let (language, wordlist) = match encoding_lower.as_str() { - "latin" => ("latin", "default"), - "english" | "english - bip39" => ("english", "bip39"), - _ => (encoding_lower.as_str(), "default"), - }; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - glossia::encode_into_language( - &hex_input, language, wordlist, "body", - None, 42, false, None, None, None, None, - ) - })); - let encoded = match result { - Ok(Ok((encoded_text, _, _, _))) => encoded_text, - _ => { - debug_log!("[RUST] glossia_roundtrip_to_bytes: encode failed for encoding={}", encoding); - return None; - } - }; - // Decode back to bytes - try_glossia_decode_to_bytes(&encoded) + glossia_encode_bytes_with(text.as_bytes(), Some(encoding)).map(|(_, bytes)| bytes) } /// Decode a single section of armor body content (non-quoted lines only) to bytes. @@ -1753,72 +2175,198 @@ pub fn decode_armor_section(content: &str) -> Option> { None } -/// Parse armor structure with depth counting, separating outermost body from nested armor. -/// Returns (body_text, nested_armor) where nested_armor has one level of "> " prefix stripped. -/// Handles both non-quoted nested armor (reply chains) and > quoted nested armor. -fn parse_armor_depth(body: &str) -> Option<(String, Option)> { +/// The constituent parts of a single armor level, separated from the body's +/// armor region. Per spec Section 11.3.2 the on-wire order within a level is +/// body → RECIPIENTS → CONSENT → nested(inner) → SIGNATURE stack. +struct ArmorLevelParts { + /// The level's body content (ciphertext / encoded plaintext), with the + /// RECIPIENTS and CONSENT blocks removed. + body_text: String, + /// Raw RECIPIENTS block body (the lines between its BEGIN and the following + /// delimiter), if present (spec Section 10.2). + recipients_text: Option, + /// Raw CONSENT block body, if present (spec Section 11.3). + consent_text: Option, + /// Raw ATTACHMENTS block body, if present (spec Section 11.2; public + /// messages binding plaintext attachment hashes). + attachments_text: Option, + /// The nested (quoted) inner armor, with one level of `> ` prefix stripped, + /// ready for recursive parsing. `None` for a leaf level. + nested_armor: Option, +} + +/// Reduce a line to its armor-marker core: drop a leading email quote prefix +/// (`> ` or `>`), surrounding whitespace, and the `-----` rails. This makes a +/// marker recognized identically whether or not the line is quoted in a reply — +/// `BEGIN NOSTR SIGNED BODY` is the canonical start with or without the quote +/// delimiter. One quote level is stripped per parse pass; deeper nesting is +/// unwrapped on recursion (matching `nested_lines`' `> ` stripping). +fn marker_core(l: &str) -> &str { + let t = l.trim(); + let t = t.strip_prefix("> ").or_else(|| t.strip_prefix('>')).unwrap_or(t); + t.trim().trim_matches('-').trim() +} +fn level_is_begin_body(l: &str) -> bool { + let t = marker_core(l); + t.starts_with("BEGIN NOSTR NIP-") // pairwise NIP-04 / NIP-44 + || t.starts_with("BEGIN NOSTR ENCRYPTED") // generic AES-256-GCM under a CEK (multi-recipient envelope) + || t.starts_with("BEGIN NOSTR SIGNED") +} +fn level_is_begin_recipients(l: &str) -> bool { + marker_core(l) == "BEGIN NOSTR RECIPIENTS" +} +fn level_is_begin_consent(l: &str) -> bool { + marker_core(l) == "BEGIN NOSTR CONSENT" +} +fn level_is_begin_attachments(l: &str) -> bool { + marker_core(l) == "BEGIN NOSTR ATTACHMENTS" +} +fn level_is_end_attachments(l: &str) -> bool { + marker_core(l) == "END NOSTR ATTACHMENTS" +} +fn level_is_begin_sig_or_seal(l: &str) -> bool { + let t = marker_core(l); + t == "BEGIN NOSTR SIGNATURE" || t == "BEGIN NOSTR SEAL" +} +fn level_is_end_recipients(l: &str) -> bool { + marker_core(l) == "END NOSTR RECIPIENTS" +} +fn level_is_end_consent(l: &str) -> bool { + marker_core(l) == "END NOSTR CONSENT" +} +/// A message-closing END (`END NOSTR MESSAGE`, legacy `END NOSTR NIP-XX …`, or +/// `END NOSTR SEAL`) — but NOT the standalone RECIPIENTS/CONSENT terminators, +/// which must not affect nesting depth. +fn level_is_end_message(l: &str) -> bool { + let t = marker_core(l); + t.starts_with("END NOSTR") && t != "END NOSTR RECIPIENTS" && t != "END NOSTR CONSENT" && t != "END NOSTR ATTACHMENTS" +} + +/// Split one armor level into its body, RECIPIENTS, CONSENT, and nested-armor +/// parts (spec Sections 10–11). This is the shared primitive behind +/// [`parse_armor_depth`] and [`extract_ciphertext_binary`]; both must agree on +/// what counts as "the body" so that signing and verification stay consistent. +/// +/// Existing single-recipient / plaintext messages carry no RECIPIENTS or CONSENT +/// blocks, so `recipients_text`/`consent_text` are `None` and `body_text` is +/// byte-identical to the legacy behavior. +fn split_armor_level(body: &str) -> Option { let body = body.replace("\r\n", "\n"); let lines: Vec<&str> = body.lines().collect(); - let contains_begin_body = |l: &str| { - l.contains("BEGIN NOSTR NIP-") || l.contains("BEGIN NOSTR SIGNED") - }; - let contains_sig_seal = |l: &str| { - l.contains("BEGIN NOSTR SIGNATURE") || l.contains("BEGIN NOSTR SEAL") - }; - let contains_end = |l: &str| l.contains("END NOSTR"); - + #[derive(PartialEq)] + enum S { Before, Body, Recipients, Consent, Attachments, Nested } + let mut state = S::Before; let mut depth: i32 = 0; - let mut in_body = false; - let mut in_nested = false; let mut body_lines: Vec<&str> = Vec::new(); + let mut recipients_lines: Vec<&str> = Vec::new(); + let mut consent_lines: Vec<&str> = Vec::new(); + let mut attachments_lines: Vec<&str> = Vec::new(); let mut nested_lines: Vec<&str> = Vec::new(); for line in &lines { - if !in_body && !in_nested { - if contains_begin_body(line) { - depth = 1; - in_body = true; + match state { + S::Before => { + if level_is_begin_body(line) { + depth = 1; + state = S::Body; + } } - continue; - } - - if in_body { - if contains_begin_body(line) { - depth += 1; - in_nested = true; - in_body = false; - nested_lines.push(line); - continue; + S::Body => { + if level_is_begin_body(line) { + depth = 2; + state = S::Nested; + nested_lines.push(line); + } else if level_is_begin_recipients(line) { + state = S::Recipients; + } else if level_is_begin_consent(line) { + state = S::Consent; + } else if level_is_begin_attachments(line) { + state = S::Attachments; + } else if level_is_begin_sig_or_seal(line) || level_is_end_message(line) { + break; + } else { + body_lines.push(line); + } } - if depth == 1 && (contains_sig_seal(line) || contains_end(line)) { - break; + S::Recipients => { + if level_is_begin_body(line) { + depth = 2; + state = S::Nested; + nested_lines.push(line); + } else if level_is_begin_consent(line) { + state = S::Consent; + } else if level_is_begin_attachments(line) { + state = S::Attachments; + } else if level_is_begin_sig_or_seal(line) || level_is_end_message(line) { + break; + } else if level_is_end_recipients(line) { + state = S::Body; + } else { + recipients_lines.push(line); + } } - body_lines.push(line); - continue; - } - - if in_nested { - nested_lines.push(line); - if contains_begin_body(line) { - depth += 1; + S::Consent => { + if level_is_begin_body(line) { + depth = 2; + state = S::Nested; + nested_lines.push(line); + } else if level_is_begin_attachments(line) { + state = S::Attachments; + } else if level_is_begin_sig_or_seal(line) || level_is_end_message(line) { + break; + } else if level_is_end_consent(line) { + state = S::Body; + } else { + consent_lines.push(line); + } + } + S::Attachments => { + if level_is_begin_body(line) { + depth = 2; + state = S::Nested; + nested_lines.push(line); + } else if level_is_begin_sig_or_seal(line) || level_is_end_message(line) { + break; + } else if level_is_end_attachments(line) { + state = S::Body; + } else { + attachments_lines.push(line); + } } - if contains_end(line) { - depth -= 1; - if depth == 1 { - in_nested = false; - in_body = true; + S::Nested => { + nested_lines.push(line); + if level_is_begin_body(line) { + depth += 1; + } else if level_is_end_message(line) { + depth -= 1; + if depth == 1 { + state = S::Body; + } } } } } + if state == S::Before { + return None; + } + let body_text = body_lines.join("\n").trim().to_string(); if body_text.is_empty() { return None; } - let nested = if nested_lines.is_empty() { + let join_opt = |v: &[&str]| -> Option { + if v.is_empty() { + None + } else { + let s = v.join("\n").trim().to_string(); + if s.is_empty() { None } else { Some(s) } + } + }; + + let nested_armor = if nested_lines.is_empty() { None } else { let stripped: Vec<&str> = nested_lines.iter().map(|l| { @@ -1826,10 +2374,26 @@ fn parse_armor_depth(body: &str) -> Option<(String, Option)> { else if *l == ">" { "" } else { *l } }).collect(); - Some(stripped.join("\n").trim().to_string()) + let s = stripped.join("\n").trim().to_string(); + if s.is_empty() { None } else { Some(s) } }; - Some((body_text, nested)) + Some(ArmorLevelParts { + body_text, + recipients_text: join_opt(&recipients_lines), + consent_text: join_opt(&consent_lines), + attachments_text: join_opt(&attachments_lines), + nested_armor, + }) +} + +/// Parse armor structure with depth counting, separating outermost body from nested armor. +/// Returns (body_text, nested_armor) where nested_armor has one level of "> " prefix stripped. +/// Handles both non-quoted nested armor (reply chains) and > quoted nested armor. +/// RECIPIENTS / CONSENT blocks (spec Sections 10–11) are excluded from `body_text`. +fn parse_armor_depth(body: &str) -> Option<(String, Option)> { + let parts = split_armor_level(body)?; + Some((parts.body_text, parts.nested_armor)) } /// Decode a combined 96-byte signature+pubkey block. @@ -2056,6 +2620,11 @@ pub fn parse_armor_components(armor_text: &str) -> Option Option = normalized[armor_start..].lines().collect(); - let is_begin_body = |l: &str| -> bool { - let t = l.trim().trim_matches('-').trim(); - t.starts_with("BEGIN NOSTR NIP-") || t.starts_with("BEGIN NOSTR SIGNED") - }; + let is_begin_body = |l: &str| -> bool { level_is_begin_body(l) }; + let is_begin_recipients = |l: &str| -> bool { level_is_begin_recipients(l) }; + let is_begin_consent = |l: &str| -> bool { level_is_begin_consent(l) }; let is_begin_sig = |l: &str| -> bool { let t = l.trim().trim_matches('-').trim(); t == "BEGIN NOSTR SIGNATURE" @@ -2132,10 +2721,9 @@ fn populate_armor_from_text( let t = l.trim().trim_matches('-').trim(); t == "BEGIN NOSTR SEAL" }; - let is_end = |l: &str| -> bool { - let t = l.trim().trim_matches('-').trim(); - t.starts_with("END NOSTR") - }; + // Message-closing END only — RECIPIENTS/CONSENT standalone terminators must + // not affect nesting depth (spec Sections 10.2, 11.3). + let is_end = |l: &str| -> bool { level_is_end_message(l) }; let mut depth: i32 = 0; let mut state = "before"; @@ -2144,6 +2732,11 @@ fn populate_armor_from_text( let mut quoted_armor_lines: Vec<&str> = Vec::new(); let mut sig_lines: Vec<&str> = Vec::new(); let mut seal_lines: Vec<&str> = Vec::new(); + // RECIPIENTS / CONSENT block bodies (spec Sections 10–11). Captured here so + // body_text stays clean; serde exposure / capnp storage follows in a later + // phase. + let mut recipients_lines: Vec<&str> = Vec::new(); + let mut consent_lines: Vec<&str> = Vec::new(); for line in &lines { match state { @@ -2159,6 +2752,10 @@ fn populate_armor_from_text( depth += 1; state = "quoted"; quoted_armor_lines.push(line); + } else if is_begin_recipients(line) && depth == 1 { + state = "recipients"; + } else if is_begin_consent(line) && depth == 1 { + state = "consent"; } else if is_begin_sig(line) && depth == 1 { state = "sig"; } else if is_begin_seal(line) && depth == 1 { @@ -2169,6 +2766,42 @@ fn populate_armor_from_text( body_lines.push(line); } } + "recipients" => { + if is_begin_body(line) { + depth += 1; + state = "quoted"; + quoted_armor_lines.push(line); + } else if is_begin_consent(line) && depth == 1 { + state = "consent"; + } else if is_begin_sig(line) && depth == 1 { + state = "sig"; + } else if is_begin_seal(line) && depth == 1 { + state = "seal"; + } else if level_is_end_recipients(line) { + state = "body"; + } else if is_end(line) && depth == 1 { + state = "done"; + } else { + recipients_lines.push(line); + } + } + "consent" => { + if is_begin_body(line) { + depth += 1; + state = "quoted"; + quoted_armor_lines.push(line); + } else if is_begin_sig(line) && depth == 1 { + state = "sig"; + } else if is_begin_seal(line) && depth == 1 { + state = "seal"; + } else if level_is_end_consent(line) { + state = "body"; + } else if is_end(line) && depth == 1 { + state = "done"; + } else { + consent_lines.push(line); + } + } "quoted" => { quoted_armor_lines.push(line); if is_begin_body(line) { depth += 1; } @@ -2189,6 +2822,9 @@ fn populate_armor_from_text( _ => {} } } + // Keep the captured blocks observable to the compiler until serde/capnp + // wiring lands; deliberately unused for now. + let _ = (&recipients_lines, &consent_lines); if state == "before" { debug_log!("[RUST] populate_armor_from_text: state machine never left 'before'"); @@ -2453,6 +3089,12 @@ fn armor_message_to_serde(reader: crate::nostr_mail_capnp::armor_message::Reader quoted, quoted_armor_text, body_bytes_b64, + // RECIPIENTS/CONSENT live outside the capnp schema; attached by + // attach_agreement_blocks in parse_armor_components after this conversion. + recipients: Vec::new(), + recipients_text: None, + consent: None, + attachments: Vec::new(), } } @@ -2794,31 +3436,28 @@ fn glossia_decode_subject(subject: &str, nip_hint: &str) -> Option { // ── Decrypt pipeline ───────────────────────────────────────────────── -/// JSON manifest structs for legacy email format (serde deserialization). -#[derive(Debug, serde::Deserialize)] -struct JsonManifest { - body: Option, - attachments: Option>, -} - -#[derive(Debug, serde::Deserialize)] -struct JsonEncryptedBlob { - ciphertext: String, - #[allow(dead_code)] - cipher_sha256: Option, - #[allow(dead_code)] - cipher_size: Option, - key_wrap: String, -} - -#[derive(Debug, serde::Deserialize)] -struct JsonAttachment { - id: String, - orig_filename: String, - orig_mime: String, - cipher_sha256: Option, - cipher_size: Option, - key_wrap: String, +/// Interpret freshly decrypted body bytes (from either the pairwise NIP path or +/// the CEK-envelope path). If they are an attachment manifest (capnp or legacy +/// JSON; §11.2) — a nested AES-encrypted body blob plus per-attachment key wraps +/// — recover the body into `block.decrypted_text` and return the parsed manifest +/// so the caller can surface its attachment entries. Otherwise treat the bytes as +/// the plaintext body directly and return `None`. The manifest is already +/// protected by the outer layer (pairwise NIP-44 or the per-recipient-wrapped +/// CEK), so its key wraps are in the clear here only after that layer is opened. +fn apply_manifest_or_plaintext( + decrypted: Vec, + block: &mut crate::types::DecryptedBlock, +) -> Option { + if let Some(manifest) = crate::manifest::parse_manifest(&decrypted) { + match manifest.body_text { + Some(ref text) => block.decrypted_text = Some(text.clone()), + None => block.error = Some("Manifest body decrypt failed".to_string()), + } + return Some(manifest); + } + // Plain body: the decrypted content is the body text directly. + block.decrypted_text = Some(String::from_utf8_lossy(&decrypted).into_owned()); + None } /// Determine which pubkey to use for NIP decryption at a given armor level. @@ -2883,9 +3522,14 @@ fn decrypt_single_block( private_key: &str, user_pubkey_hex: &str, fallback_pubkey: &str, -) -> (crate::types::DecryptedBlock, Option) { - use base64::Engine; - + // RECIPIENTS stanzas for this level (spec §10). Non-empty selects the + // multi-recipient envelope (CEK) path instead of pairwise NIP decryption. + recipients: &[crate::agreement::Recipient], + // The per-message ephemeral pubkey from the RECIPIENTS block (spec §10.1), + // if present. The CEK is unwrapped against this key; `None` falls back to the + // sender's identity key (legacy envelopes without an `ephemeral` line). + ephemeral_pubkey: Option<&str>, +) -> (crate::types::DecryptedBlock, Option) { debug_log!("[RUST] decrypt_single_block: type={} nip={:?} sig_pk={:?} seal_pk={:?} fallback={:?} body_preview={:?}", body_type, encryption_nip, sig_pubkey_hex.map(|s| &s[..s.len().min(16)]), seal_pubkey_hex.map(|s| &s[..s.len().min(16)]), @@ -2913,24 +3557,105 @@ fn decrypt_single_block( return (block, None); } - let nip = encryption_nip.unwrap_or("nip44"); - - // Step 1: Glossia-decode body text → ciphertext string - let perf_glossia = std::time::Instant::now(); - let ciphertext = match glossia_decode_to_ciphertext(body_text, nip) { - Ok(ct) => { - debug_log!("[RUST] decrypt_single_block: glossia decoded, ciphertext len={}", ct.len()); - ct - } - Err(e) => { - debug_log!("[RUST] decrypt_single_block: glossia decode FAILED: {}", e); - block.error = Some(format!("Glossia decode failed: {}", e)); - return (block, None); - } - }; - let glossia_ms = perf_glossia.elapsed().as_millis(); - let ciphertext_len = ciphertext.len(); - + // Multi-recipient envelope path (spec §10): selected by the presence of a + // RECIPIENTS block, not a special body tag. Find the reader's stanza, + // NIP-44-unwrap the per-message CEK against the ephemeral pubkey (or the + // sender's pubkey for legacy envelopes), then AES-256-GCM-decrypt the body. + if !recipients.is_empty() { + let me = crate::agreement::normalize_pubkey_hex(user_pubkey_hex) + .unwrap_or_else(|| user_pubkey_hex.to_string()); + // The sender (message author) is the SIGNATURE, then SEAL, then fallback + // (other-party) pubkey. With ephemeral wrapping the CEK is unwrapped + // against the ephemeral key instead; the sender pubkey remains the + // legacy fallback counterparty. + let sender_pub = match sig_pubkey_hex.or(seal_pubkey_hex) + .map(|s| s.to_string()) + .or_else(|| (!fallback_pubkey.is_empty()).then(|| fallback_pubkey.to_string())) + { + Some(p) => p, + None => { + block.error = Some("Envelope message has no sender pubkey for CEK unwrap".to_string()); + return (block, None); + } + }; + let stanza = recipients.iter().find(|r| { + crate::agreement::normalize_pubkey_hex(&r.pubkey).as_deref() == Some(me.as_str()) + }); + let stanza = match stanza { + Some(s) => s, + None => { + // Expected when reading a level predating the reader's addition + // to the thread (spec §10.8). + block.error = Some("Not a recipient of this message level (no matching RECIPIENTS stanza)".to_string()); + return (block, None); + } + }; + let wrapped = match &stanza.wrapped_cek { + Some(w) => w, + None => { + // No CEK in the email: a plaintext (public) agreement stanza + // (§11.8) — malformed for an ENCRYPTED body — or gift-wrap mode + // (§11.7), where the CEK arrives in the referenced DM (not yet + // wired). Either way this body can't be decrypted from the email. + let why = if stanza.reference.is_some() { + "CEK is delivered out-of-band (gift-wrap reference); DM delivery not yet supported" + } else { + "RECIPIENTS stanza carries no CEK but the body is encrypted" + }; + block.error = Some(why.to_string()); + return (block, None); + } + }; + let unwrap_pub = ephemeral_pubkey.unwrap_or(&sender_pub); + let cek = match crate::crypto::unwrap_cek(private_key, unwrap_pub, wrapped) { + Ok(c) => c, + Err(e) => { + block.error = Some(format!("Envelope CEK unwrap failed: {}", e)); + return (block, None); + } + }; + let ciphertext = match decode_armor_section(body_text) { + Some(b) => b, + None => { + block.error = Some("Envelope body decode failed".to_string()); + return (block, None); + } + }; + match crate::crypto::aes_gcm_decrypt_raw(&cek, &ciphertext) { + Ok(pt) => { + debug_log!("[RUST] decrypt_single_block: envelope decrypt SUCCESS, {} bytes", pt.len()); + // The body plaintext may be an attachment manifest (§11.2), + // protected here by the per-recipient-wrapped CEK rather than + // pairwise NIP-44 — handle it the same way (shared helper). Pass + // raw bytes: a capnp manifest is binary, not UTF-8. + let manifest = apply_manifest_or_plaintext(pt, &mut block); + return (block, manifest); + } + Err(e) => { + block.error = Some(format!("Envelope AES-GCM decrypt failed: {}", e)); + return (block, None); + } + } + } + + let nip = encryption_nip.unwrap_or("nip44"); + + // Step 1: Glossia-decode body text → ciphertext string + let perf_glossia = std::time::Instant::now(); + let ciphertext = match glossia_decode_to_ciphertext(body_text, nip) { + Ok(ct) => { + debug_log!("[RUST] decrypt_single_block: glossia decoded, ciphertext len={}", ct.len()); + ct + } + Err(e) => { + debug_log!("[RUST] decrypt_single_block: glossia decode FAILED: {}", e); + block.error = Some(format!("Glossia decode failed: {}", e)); + return (block, None); + } + }; + let glossia_ms = perf_glossia.elapsed().as_millis(); + let ciphertext_len = ciphertext.len(); + // Step 2: Determine which pubkey to decrypt with let decrypt_pubkey_hex = match determine_decrypt_pubkey(sig_pubkey_hex, seal_pubkey_hex, user_pubkey_hex, fallback_pubkey) { Ok(pk) => { @@ -2972,64 +3697,11 @@ fn decrypt_single_block( debug_log!("[RUST-PERF] decrypt_single_block: nip={} glossia={}ms (ct={}b) nip_decrypt={}ms (out={}b)", nip, glossia_ms, ciphertext_len, nip_decrypt_ms, decrypted.len()); - // Step 4: Detect manifest vs legacy - let trimmed = decrypted.trim(); - if trimmed.starts_with('{') { - // Try JSON manifest parse - if let Ok(manifest) = serde_json::from_str::(trimmed) { - if let Some(ref body_blob) = manifest.body { - // AES decrypt the manifest body - let key_bytes = match base64::engine::general_purpose::STANDARD.decode(&body_blob.key_wrap) { - Ok(k) => k, - Err(e) => { - block.error = Some(format!("Manifest key_wrap base64 decode failed: {}", e)); - return (block, Some(manifest)); - } - }; - let ct_bytes = match base64::engine::general_purpose::STANDARD.decode(&body_blob.ciphertext) { - Ok(c) => c, - Err(e) => { - block.error = Some(format!("Manifest ciphertext base64 decode failed: {}", e)); - return (block, Some(manifest)); - } - }; - - match crate::crypto::aes_gcm_decrypt_raw(&key_bytes, &ct_bytes) { - Ok(plaintext_bytes) => { - // The plaintext is base64-encoded UTF-8 body (matching JS: atob(aesResult)) - match String::from_utf8(plaintext_bytes) { - Ok(b64_body) => { - // Decode the base64 to get the actual text - match base64::engine::general_purpose::STANDARD.decode(b64_body.trim()) { - Ok(body_bytes) => { - match String::from_utf8(body_bytes) { - Ok(text) => block.decrypted_text = Some(text), - Err(_) => block.decrypted_text = Some(b64_body), - } - } - Err(_) => { - // Not base64 — use as-is (the plaintext IS the body) - block.decrypted_text = Some(b64_body); - } - } - } - Err(_) => { - block.error = Some("Manifest body AES plaintext is not UTF-8".to_string()); - } - } - } - Err(e) => { - block.error = Some(format!("Manifest AES decrypt failed: {}", e)); - } - } - return (block, Some(manifest)); - } - } - } - - // Legacy format: decrypted content is the body text directly - block.decrypted_text = Some(decrypted); - (block, None) + // Step 4: Detect manifest vs legacy (shared with the CEK-envelope path). + // NIP-44 yields a UTF-8 string; the 1:1 path carries plaintext/legacy-JSON + // bodies here (the raw-binary capnp manifest rides the byte-clean CEK path). + let manifest = apply_manifest_or_plaintext(decrypted.into_bytes(), &mut block); + (block, manifest) } /// Walk the capnp ArmorMessage tree recursively, decrypting each encrypted block. @@ -3047,7 +3719,7 @@ fn decrypt_armor_tree( // message is rejected before decryption (the signature is NIP-04's only MAC). require_signature: bool, depth: usize, -) -> (Vec, Option) { +) -> (Vec, Option) { let perf_level = std::time::Instant::now(); let mut results = Vec::new(); let mut outer_manifest = None; @@ -3204,6 +3876,8 @@ fn decrypt_armor_tree( // Decrypt this level let perf_block = std::time::Instant::now(); + let level_ephemeral = parsed.recipients_text.as_deref() + .and_then(crate::agreement::parse_recipients_ephemeral); let (block, manifest) = decrypt_single_block( &parsed.body_text, &parsed.body_type, @@ -3214,6 +3888,8 @@ fn decrypt_armor_tree( private_key, user_pubkey_hex, fallback_pubkey, + &parsed.recipients, + level_ephemeral.as_deref(), ); let block_ms = perf_block.elapsed().as_millis(); if manifest.is_some() { @@ -3343,18 +4019,16 @@ pub fn decrypt_email_body_pipeline( let success = outer_block.map(|b| b.decrypted_text.is_some()).unwrap_or(false); let error = if success { None } else { outer_block.and_then(|b| b.error.clone()) }; - // Extract attachment metadata from manifest + // Extract attachment metadata from manifest (raw bytes → UI-facing base64/hex) let (is_manifest, attachments) = if let Some(ref m) = manifest { - let atts = m.attachments.as_ref().map(|att_list| { - att_list.iter().map(|a| crate::types::ManifestAttachmentInfo { - id: a.id.clone(), - orig_filename: a.orig_filename.clone(), - orig_mime: a.orig_mime.clone(), - key_wrap_b64: a.key_wrap.clone(), - cipher_sha256_hex: a.cipher_sha256.clone(), - cipher_size: a.cipher_size.unwrap_or(0), - }).collect() - }).unwrap_or_default(); + let atts = m.attachments.iter().map(|a| crate::types::ManifestAttachmentInfo { + id: a.id.clone(), + orig_filename: a.orig_filename.clone(), + orig_mime: a.orig_mime.clone(), + key_wrap_b64: a.key_wrap_b64(), + cipher_sha256_hex: Some(a.cipher_sha256_hex()), + cipher_size: a.cipher_size, + }).collect(); (true, atts) } else { (false, Vec::new()) @@ -3362,7 +4036,18 @@ pub fn decrypt_email_body_pipeline( // Decrypt subject — use armor's embedded pubkey as fallback when sender_pubkey wasn't provided let perf_subject = std::time::Instant::now(); - let (decrypted_subject, subject_ciphertext) = if parsed.body_type == "encrypted" { + let (decrypted_subject, subject_ciphertext) = if !parsed.recipients.is_empty() { + // Multi-recipient envelope: the subject is AES-256-GCM-encrypted under the + // same CEK as the body (or cleartext for a plaintext agreement). The + // sender (who wrapped the CEK) is the message author. + let sender_pub = parsed.sig_pubkey_hex.as_deref() + .or(parsed.seal_pubkey_hex.as_deref()) + .map(|s| s.to_string()) + .unwrap_or_else(|| fallback.to_string()); + let subj_ephemeral = parsed.recipients_text.as_deref() + .and_then(crate::agreement::parse_recipients_ephemeral); + decrypt_subject_envelope(subject, &parsed.recipients, &sender_pub, private_key, &user_pubkey_hex, subj_ephemeral.as_deref()) + } else if parsed.body_type == "encrypted" { let nip_hint = parsed.encryption_nip.as_deref().unwrap_or("nip44"); let subject_fallback = if fallback.is_empty() { // The armor signature/seal block contains the sender's pubkey @@ -3507,12 +4192,22 @@ pub fn decrypt_attachment_pipeline( .map_err(|e| format!("key_wrap base64 decode failed: {}", e))?; // Verify SHA-256 if provided (warn on mismatch but continue, matching JS behavior) + // Enforce the ciphertext hash recorded in the (signed) manifest: a mismatch + // means the attachment bytes were swapped/corrupted in transit, so reject + // rather than decrypt tampered content (spec §11.2). The hash binds the + // attachment to the signature transitively — the manifest is inside the + // signed body — so this check is what makes that binding enforceable. if let Some(expected_hash) = cipher_sha256_hex { - let mut hasher = Sha256::new(); - hasher.update(&encrypted_data); - let actual_hash = hex::encode(hasher.finalize()); - if actual_hash != expected_hash { - debug_log!("[RUST] decrypt_attachment_pipeline: hash mismatch (expected {}, got {}) — continuing anyway", expected_hash, actual_hash); + if !expected_hash.is_empty() { + let mut hasher = Sha256::new(); + hasher.update(&encrypted_data); + let actual_hash = hex::encode(hasher.finalize()); + if actual_hash != expected_hash { + return Err(format!( + "Attachment integrity check failed: ciphertext hash mismatch (expected {}, got {}) — the file was altered in transit", + expected_hash, actual_hash + )); + } } } @@ -3538,12 +4233,28 @@ pub fn decrypt_attachment_pipeline( /// decoded bytes from all levels (matching the JS signing behavior). /// For non-armored bodies: returns the UTF-8 bytes of the body text. pub fn extract_ciphertext_binary(body: &str) -> Vec { - // Use depth-counting parser to properly handle nested reply armor - if let Some((body_text, nested_armor)) = parse_armor_depth(body) { - if let Some(mut bytes) = decode_armor_section(&body_text) { - if let Some(ref nested) = nested_armor { + // Use depth-counting parser to properly handle nested reply armor. + // The per-level signing contribution is body || canonical(recipients) || + // canonical(consent) (spec Section 4.2); levels are concatenated outermost + // → innermost, matching Sections 3.5.0 / 3.6.1. For messages with no + // RECIPIENTS/CONSENT blocks (the common case) this reduces to decode(body) + // and is byte-identical to the legacy behavior. + if let Some(parts) = split_armor_level(body) { + if let Some(body_bytes) = decode_armor_section(&parts.body_text) { + let canon_recipients = parts.recipients_text.as_deref() + .map(crate::agreement::canonicalize_block) + .unwrap_or_default(); + let canon_consent = parts.consent_text.as_deref() + .map(crate::agreement::canonicalize_block) + .unwrap_or_default(); + let canon_attachments = parts.attachments_text.as_deref() + .map(crate::agreement::canonicalize_block) + .unwrap_or_default(); + let mut bytes = crate::agreement::level_signing_bytes_with_attachments( + &body_bytes, &canon_recipients, &canon_consent, &canon_attachments); + if let Some(ref nested) = parts.nested_armor { let nested_bytes = extract_ciphertext_binary(nested); - debug_log!("[RUST] extract_ciphertext_binary: concatenating {} outer + {} nested bytes", + debug_log!("[RUST] extract_ciphertext_binary: concatenating {} outer (body+recipients+consent+attachments) + {} nested bytes", bytes.len(), nested_bytes.len()); bytes.extend_from_slice(&nested_bytes); } @@ -3556,6 +4267,143 @@ pub fn extract_ciphertext_binary(body: &str) -> Vec { body.as_bytes().to_vec() } +/// Glossia-encode raw bytes for an armor body using the given encoding scheme +/// (the user's Advanced "encoding" setting; `None` ⇒ english/bip39). Returns the +/// encoded words and the canonical decoded bytes (what `decode_armor_section` +/// recovers, i.e. the bytes a signature is computed over). Glossia — not base64 — +/// is used because its word tokens survive email transport (quote `> ` prefixes, +/// word-wrapping, reflow) that would corrupt base64 and break signed reply chains +/// (spec §5). Decoding auto-detects the dialect, so only encoding needs the setting. +pub(crate) fn glossia_encode_bytes_with(data: &[u8], encoding: Option<&str>) -> Option<(String, Vec)> { + let hex_input = glossia::hex_encode(data); + let (language, wordlist) = glossia_lang_wordlist(encoding); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + glossia::encode_into_language( + &hex_input, &language, &wordlist, "body", + None, 42, false, None, None, None, None, + ) + })); + let encoded = match result { + Ok(Ok((enc, _, _, _))) => enc, + _ => return None, + }; + let canonical = try_glossia_decode_to_bytes(&encoded)?; + Some((encoded, canonical)) +} + +/// Glossia-encode plaintext for a `SIGNED BODY` (spec §3.2): returns the encoded +/// words and the canonical decoded bytes the signature is computed over (§4). +fn glossia_encode_signed_body(text: &str, encoding: Option<&str>) -> Option<(String, Vec)> { + glossia_encode_bytes_with(text.as_bytes(), encoding) +} + +/// Compose a **plaintext (public) agreement** — the unencrypted counterpart to +/// `agreement::encode_hybrid_agreement` (spec §11.8). The terms are carried in a +/// signed `SIGNED BODY` (readable by any client, with the plaintext also above +/// the armor per §3.2), and the RECIPIENTS block declares the signatories/viewers +/// with no `wrapped-cek` token (`Recipient.wrapped_cek == None`) since nothing +/// is encrypted. +/// +/// The document hash `H`, consent binding, signature coverage (§4.2), and +/// completion accounting (§11.5) are identical to the encrypted case — they +/// operate on the decoded body bytes regardless of cipher — so the agreement is +/// verifiable offline by **anyone**, not just recipients. The trade-off vs the +/// encrypted form is confidentiality: the terms and participant set are public. +/// +/// `recipients_in` are the signatories (`signer`) and viewers (`viewer`); no +/// `self` stanza is emitted (there is nothing to decrypt). When +/// `originator_consents` is true the originator's CONSENT over `H` is included +/// (the originator is then a required signatory, §11.2). +pub fn encode_signed_agreement( + sender_priv: &str, + sender_pub: &str, + profile_name: &str, + terms_plaintext: &str, + recipients_in: &[crate::agreement::AgreementRecipientInput], + originator_consents: bool, + attachment_specs: &[crate::agreement::AttachmentSpec], + encoding: Option<&str>, +) -> Result { + use crate::agreement::{ + canonicalize_block, document_hash, level_signing_bytes_with_attachments, + serialize_attachments, serialize_recipients, Consent, Recipient, + }; + if recipients_in.is_empty() { + return Err("encode_signed_agreement requires at least one signatory".to_string()); + } + let sender_pub_hex = crate::agreement::normalize_pubkey_hex(sender_pub) + .ok_or_else(|| "invalid sender pubkey".to_string())?; + + let (encoded_body, canonical_bytes) = glossia_encode_signed_body(terms_plaintext, encoding) + .ok_or_else(|| "glossia encode of agreement terms failed".to_string())?; + + // Signatories/viewers with no key wrap (plaintext): role pubkey [email]. + let recipients: Vec = recipients_in.iter().map(|r| Recipient { + role: r.role.to_ascii_lowercase(), + pubkey: r.pubkey.clone(), + wrapped_cek: None, + email: r.email.clone(), + reference: None, + }).collect(); + let recipients_body = serialize_recipients(&recipients); + let canon_recipients = canonicalize_block(&recipients_body); + + let (consent_body, canon_consent) = if originator_consents { + let h = document_hash(&canonical_bytes, &canon_recipients); + let consent = Consent { agreement_hash: hex::encode(h), signer: sender_pub_hex.clone() }; + let body = consent.to_block_body(); + let canon = canonicalize_block(&body); + (Some(body), canon) + } else { + (None, String::new()) + }; + + // Public attachments are bound by a signed ATTACHMENTS block carrying each + // file's plaintext SHA-256 (§11.2). Empty ⇒ no block, no signing change. + let (attachments_body, canon_attachments) = if attachment_specs.is_empty() { + (None, String::new()) + } else { + let body = serialize_attachments(attachment_specs); + let canon = canonicalize_block(&body); + (Some(body), canon) + }; + + let signing_bytes = level_signing_bytes_with_attachments( + &canonical_bytes, &canon_recipients, &canon_consent, &canon_attachments); + let sig_hex = crate::crypto::sign_data_bytes(sender_priv, &signing_bytes) + .map_err(|e| e.to_string())?; + + let mut out = String::new(); + // Plaintext above the armor for non-Nostr-Mail clients (spec §3.2). + out.push_str(terms_plaintext); + out.push_str("\n\n"); + out.push_str("----- BEGIN NOSTR SIGNED BODY -----\n"); + out.push_str(&encoded_body); + out.push('\n'); + out.push_str("----- BEGIN NOSTR RECIPIENTS -----\n"); + out.push_str(&recipients_body); + out.push('\n'); + if let Some(ref c) = consent_body { + out.push_str("----- BEGIN NOSTR CONSENT -----\n"); + out.push_str(c); + out.push('\n'); + } + if let Some(ref a) = attachments_body { + out.push_str("----- BEGIN NOSTR ATTACHMENTS -----\n"); + out.push_str(a); + out.push('\n'); + } + out.push_str("----- BEGIN NOSTR SIGNATURE -----\n@"); + out.push_str(profile_name); + out.push('\n'); + out.push_str(&sig_hex); + out.push('\n'); + out.push_str(&sender_pub_hex); + out.push('\n'); + out.push_str("----- END NOSTR MESSAGE -----"); + Ok(out) +} + /// Verify email signature using binary ciphertext extraction. /// Extracts the binary payload from ASCII armor (or uses raw text bytes), /// then verifies the schnorr signature against SHA-256(binary). @@ -3575,6 +4423,46 @@ pub fn verify_email_signature(sender_pubkey: &str, signature: &str, body: &str) /// Verify email signature using the in-body SIGNATURE block (primary trust path). /// Returns `Some(true/false)` if an inline signature was found, `None` if no inline sig exists. +/// Verify a delivered plaintext attachment against a public message's signed +/// ATTACHMENTS block (spec §11.2). +/// +/// Public attachments ride as plaintext MIME parts; the signed block binds each +/// file's plaintext SHA-256, and the block is covered by the message signature. +/// So a delivered file is trustworthy only when (a) the armor signature(s) are +/// valid — making the block authentic — and (b) the file's SHA-256 matches a +/// listed spec with that filename. Returns the full breakdown so callers can +/// distinguish "no such attachment in the message" from "hash mismatch". +pub fn verify_public_attachment( + armor: &str, + filename: &str, + data: &[u8], +) -> crate::types::PublicAttachmentVerification { + use sha2::{Digest, Sha256}; + let actual_sha256 = hex::encode(Sha256::digest(data)); + + // The block is only authentic if the message signature verifies. + let sigs = verify_all_signatures_inline(armor); + let signature_valid = !sigs.is_empty() && sigs.iter().all(|s| s.is_valid); + + let specs = parse_armor_components(armor) + .map(|p| p.attachments) + .unwrap_or_default(); + let named: Vec<&crate::agreement::AttachmentSpec> = + specs.iter().filter(|s| s.filename == filename).collect(); + let spec_found = !named.is_empty(); + let hash_match = named.iter().any(|s| s.sha256.eq_ignore_ascii_case(&actual_sha256)); + let expected_sha256 = named.first().map(|s| s.sha256.clone()); + + crate::types::PublicAttachmentVerification { + verified: signature_valid && spec_found && hash_match, + signature_valid, + spec_found, + hash_match, + expected_sha256, + actual_sha256, + } +} + pub fn verify_email_signature_inline(body: &str) -> Option { let parsed = parse_armor_components(body)?; let sig_hex = parsed.signature_hex.as_ref()?; @@ -3701,6 +4589,206 @@ fn verify_all_signatures_recursive(body: &str, depth: usize) -> Vec)>) { + if let Some(parts) = split_armor_level(armor) { + let recips = parts.recipients_text.as_deref() + .map(crate::agreement::parse_recipients_block) + .unwrap_or_default(); + out.push((depth, recips)); + if let Some(nested) = parts.nested_armor { + collect_level_recipients(&nested, depth + 1, out); + } + } +} + +/// Verify email↔npub bindings provable from a single self-contained thread — +/// **statelessly** (issue #102). There is no outstanding-challenge store: because +/// a reply nests the issuer's own signed challenge, "I issued this" is re-derived +/// by checking that the email-asserting level is signed by `my_pubkey`. This makes +/// the verdict a pure function of mailbox contents, so a fresh client reconstructs +/// all bindings by re-scanning (mirroring the stateless §9.1 spam-rescue design). +/// +/// A binding `(R, email)` is returned iff, in the same thread: +/// 1. every signature in the chain verifies (chain integrity, §4.2); and +/// 2. some level signed by `my_pubkey` carries a RECIPIENTS stanza pairing `R` +/// with `email` (the issuer's authenticated assertion); and +/// 3. an **outer** level (nesting that challenge) is signed by `R` — proving +/// `R` controls the npub and received/read the challenge. +/// +/// `my_pubkey` is the verifier's own key (hex or npub) — this is the issuer-side +/// check; the reverse binding (when the counterparty asserted *your* address in a +/// level they signed) is the symmetric call with their key. +pub fn verify_email_binding(thread: &str, my_pubkey: &str) -> Vec { + let me = match crate::agreement::normalize_pubkey_hex(my_pubkey) { + Some(h) => h, + None => return Vec::new(), + }; + + // 1. Chain integrity: every in-thread signature must verify (§4.2). A single + // broken signature (e.g. a tampered (npub,email) pairing) voids the proof. + let sigs = verify_all_signatures_inline(thread); + if sigs.is_empty() || sigs.iter().any(|s| !s.is_valid) { + return Vec::new(); + } + let signer_at = |depth: usize| -> Option { + sigs.iter() + .find(|s| s.depth == depth) + .and_then(|s| s.pubkey_hex.as_deref()) + .and_then(crate::agreement::normalize_pubkey_hex) + }; + + // 2. Per-level recipient stanzas. + let mut levels: Vec<(usize, Vec)> = Vec::new(); + collect_level_recipients(thread, 0, &mut levels); + + // 3. For each level I signed, each emailed recipient R who also signed an + // outer (quoting) level ⇒ proven binding. + let mut out: Vec = Vec::new(); + for (depth, recips) in &levels { + if signer_at(*depth).as_deref() != Some(me.as_str()) { + continue; // not a level I (the issuer) signed + } + for r in recips { + let email = match &r.email { + Some(e) => e, + None => continue, + }; + let r_hex = match crate::agreement::normalize_pubkey_hex(&r.pubkey) { + Some(h) => h, + None => continue, + }; + if r_hex == me { + continue; // skip the issuer's own (self) stanza + } + let replied = (0..*depth).any(|d| signer_at(d).as_deref() == Some(r_hex.as_str())); + if replied { + let binding = crate::agreement::Binding { + pubkey: r_hex.clone(), + email: email.clone(), + issuer_pubkey: me.clone(), + }; + if !out.contains(&binding) { + out.push(binding); + } + } + } + } + out +} + +/// One armor level's agreement-relevant content, for thread-wide accounting. +struct ThreadLevel { + depth: usize, + body_text: String, + /// Raw RECIPIENTS block body (needed verbatim to recompute the canonical + /// form for the document hash `H`), plus the parsed entries. + recipients_text: Option, + recipients: Vec, + consent: Option, +} + +/// Recursively collect each level's body / RECIPIENTS / CONSENT (0 = outermost), +/// using the same `split_armor_level` walk as signing so depths line up with +/// [`verify_all_signatures_inline`]. +fn collect_thread_levels(armor: &str, depth: usize, out: &mut Vec) { + if let Some(parts) = split_armor_level(armor) { + out.push(ThreadLevel { + depth, + body_text: parts.body_text.clone(), + recipients: parts.recipients_text.as_deref() + .map(crate::agreement::parse_recipients_block) + .unwrap_or_default(), + recipients_text: parts.recipients_text.clone(), + consent: parts.consent_text.as_deref() + .and_then(crate::agreement::parse_consent_block), + }); + if let Some(nested) = parts.nested_armor { + collect_thread_levels(&nested, depth + 1, out); + } + } +} + +/// Compute an agreement's "M of N signed" status from a self-contained thread +/// (spec Section 11.5), offline and server-free. +/// +/// Returns `None` when the thread is not an agreement (its originating level has +/// no RECIPIENTS block). Otherwise: +/// * the **originating** (innermost) level fixes the document hash +/// `H = SHA-256(decode(body₁) || canonical(recipients₁))` (Section 11.3.1) +/// and the required signatory set — its `signer` stanzas, plus the +/// originator iff the originating level itself carries a CONSENT block +/// (Section 11.2); +/// * a signatory counts as consented only via a CONSENT block over this `H` +/// whose `signer` equals that level's **verified** SIGNATURE pubkey +/// (Sections 11.3, 11.5) — a signed comment without consent never counts. +pub fn verify_agreement_status(thread: &str) -> Option { + let mut levels: Vec = Vec::new(); + collect_thread_levels(thread, 0, &mut levels); + + // The originating message is the innermost (deepest) level. + let orig = levels.iter().max_by_key(|l| l.depth)?; + let orig_recipients_text = orig.recipients_text.as_deref()?; // not an agreement otherwise + + // Per-level verified signer pubkey (hex), keyed by depth. + let sigs = verify_all_signatures_inline(thread); + let signer_at = |depth: usize| -> Option<(String, bool)> { + sigs.iter().find(|s| s.depth == depth).and_then(|s| { + s.pubkey_hex.as_deref() + .and_then(crate::agreement::normalize_pubkey_hex) + .map(|h| (h, s.is_valid)) + }) + }; + + // H over the originating body + canonicalized recipients (excludes consent). + let body_bytes = decode_armor_section(&orig.body_text)?; + let canon_recipients = crate::agreement::canonicalize_block(orig_recipients_text); + let h = hex::encode(crate::agreement::document_hash(&body_bytes, &canon_recipients)); + + // Required signatories: originating `signer` stanzas (+ originator if the + // originating level itself consented). + let mut required: Vec = orig.recipients.iter() + .filter(|r| r.is_signer()) + .map(|r| r.pubkey.clone()) + .collect(); + if orig.consent.is_some() { + if let Some((orig_signer, _valid)) = signer_at(orig.depth) { + required.push(orig_signer); + } + } + + // Consents: a CONSENT over H, whose signer matches that level's *verified* + // signature pubkey. + let mut consented: Vec = Vec::new(); + for level in &levels { + let consent = match &level.consent { Some(c) => c, None => continue }; + if !consent.agreement_hash.eq_ignore_ascii_case(&h) { + continue; + } + let (signer_hex, valid) = match signer_at(level.depth) { + Some(s) => s, + None => continue, + }; + if !valid { + continue; // consent bound by an invalid signature does not count + } + if crate::agreement::normalize_pubkey_hex(&consent.signer).as_deref() == Some(signer_hex.as_str()) { + consented.push(signer_hex); + } + } + + let mut status = crate::agreement::compute_completion(&required, &consented); + // No required signatories ⇒ this is a plain multi-recipient message (all + // viewers), not an agreement. + if status.n == 0 { + return None; + } + status.document_hash = h; + Some(status) +} + /// Extract message ID from email headers pub fn extract_message_id_from_headers(raw_headers: &str) -> Option { // Try multiple patterns to find Message-ID @@ -4796,6 +5884,1059 @@ nitela\n\ "glossia body should decode correctly with combined signature block"); } + // ============================================= + // Multi-recipient / agreement armor (spec §§10–11) + // ============================================= + + // hex pubkey for a generated keypair (verify_signature_bytes accepts hex/npub). + fn pubkey_hex(npub: &str) -> String { + crate::agreement::normalize_pubkey_hex(npub).expect("valid npub") + } + + #[test] + fn test_split_armor_level_separates_recipients_and_consent() { + let body = "----- BEGIN NOSTR ENCRYPTED BODY -----\n\ + QUJDREVG\n\ + ----- BEGIN NOSTR RECIPIENTS -----\n\ + signer aa bb\n\ + self cc dd\n\ + ----- BEGIN NOSTR CONSENT -----\n\ + agreement deadbeef\n\ + signer aa\n\ + ----- BEGIN NOSTR SIGNATURE -----\n\ + @Alice\n\ + sig pub\n\ + ----- END NOSTR MESSAGE -----"; + let parts = split_armor_level(body).expect("should split"); + assert_eq!(parts.body_text, "QUJDREVG"); + assert_eq!(parts.recipients_text.as_deref(), Some("signer aa bb\nself cc dd")); + assert_eq!(parts.consent_text.as_deref(), Some("agreement deadbeef\nsigner aa")); + assert!(parts.nested_armor.is_none()); + } + + #[test] + fn test_split_armor_level_no_blocks_matches_legacy_body() { + // A plain single-recipient message: body_text is exactly the ciphertext, + // and there are no recipients/consent blocks. + let body = "----- BEGIN NOSTR NIP-44 ENCRYPTED BODY -----\n\ + SGVsbG8=\n\ + ----- END NOSTR MESSAGE -----"; + let parts = split_armor_level(body).expect("should split"); + assert_eq!(parts.body_text, "SGVsbG8="); + assert!(parts.recipients_text.is_none()); + assert!(parts.consent_text.is_none()); + assert!(parts.nested_armor.is_none()); + } + + #[test] + fn test_extract_ciphertext_binary_includes_recipients_and_consent() { + // Section 4.2: level(L) = decode(body) || canonical(recipients) || canonical(consent) + let body_bytes = vec![0xDEu8, 0xAD, 0xBE, 0xEF]; + let b64 = general_purpose::STANDARD.encode(&body_bytes); + let recipients = "signer aa bb\nself cc dd"; + let consent = "agreement H\nsigner aa"; + let body = format!( + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n\ + ----- BEGIN NOSTR RECIPIENTS -----\n{}\n\ + ----- BEGIN NOSTR CONSENT -----\n{}\n\ + ----- END NOSTR MESSAGE -----", + b64, recipients, consent + ); + let got = extract_ciphertext_binary(&body); + let expected = crate::agreement::level_signing_bytes( + &body_bytes, + &crate::agreement::canonicalize_block(recipients), + &crate::agreement::canonicalize_block(consent), + ); + assert_eq!(got, expected); + } + + #[test] + fn test_multi_recipient_signed_message_verifies_section_4_2() { + // Build a multi-recipient (group-encrypted) message and sign over the Section 4.2 target, + // then confirm in-body verification succeeds and tampering breaks it. + let alice = crypto::generate_keypair().unwrap(); + let alice_pub_hex = pubkey_hex(&alice.public_key); + + let body_bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8]; + let b64 = general_purpose::STANDARD.encode(&body_bytes); + let recipients = format!("signer {} wrapA\nself {} wrapS", alice_pub_hex, alice_pub_hex); + + let signing_bytes = crate::agreement::level_signing_bytes( + &body_bytes, + &crate::agreement::canonicalize_block(&recipients), + "", + ); + let sig_hex = crypto::sign_data_bytes(&alice.private_key, &signing_bytes).unwrap(); + + let body = format!( + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n\ + ----- BEGIN NOSTR RECIPIENTS -----\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n\ + @Alice\n{}\n{}\n\ + ----- END NOSTR MESSAGE -----", + b64, recipients, sig_hex, alice_pub_hex + ); + + assert_eq!(verify_email_signature_inline(&body), Some(true), + "multi-recipient signature must verify over body+recipients"); + + // Tamper a recipient role (signer→viewer): the canonical recipients block + // changes, so the signature must no longer verify (membership is bound). + let tampered = body.replacen( + &format!("signer {} wrapA", alice_pub_hex), + &format!("viewer {} wrapA", alice_pub_hex), + 1, + ); + assert_eq!(verify_email_signature_inline(&tampered), Some(false), + "re-labeling a signatory as viewer must invalidate the signature"); + } + + #[test] + fn test_agreement_consent_message_verifies_and_tamper_detected() { + // A signatory's consenting reply: CONSENT block bound by the level signature. + let alice = crypto::generate_keypair().unwrap(); + let alice_pub_hex = pubkey_hex(&alice.public_key); + + let body_bytes = b"This Mutual NDA is entered into as of 2026-06-13.".to_vec(); + let b64 = general_purpose::STANDARD.encode(&body_bytes); + let recipients = format!("signer {} wrapA\nself {} wrapS", alice_pub_hex, alice_pub_hex); + let canon_recipients = crate::agreement::canonicalize_block(&recipients); + + // H = SHA-256(body || canonical(recipients)) — excludes consent (§11.3.1). + let h = crate::agreement::document_hash(&body_bytes, &canon_recipients); + let consent = format!("agreement {}\nsigner {}", hex::encode(h), alice_pub_hex); + let canon_consent = crate::agreement::canonicalize_block(&consent); + + let signing_bytes = crate::agreement::level_signing_bytes( + &body_bytes, &canon_recipients, &canon_consent); + let sig_hex = crypto::sign_data_bytes(&alice.private_key, &signing_bytes).unwrap(); + + let body = format!( + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n\ + ----- BEGIN NOSTR RECIPIENTS -----\n{}\n\ + ----- BEGIN NOSTR CONSENT -----\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n\ + @Alice\n{}\n{}\n\ + ----- END NOSTR MESSAGE -----", + b64, recipients, consent, sig_hex, alice_pub_hex + ); + + assert_eq!(verify_email_signature_inline(&body), Some(true), + "consent message must verify over body+recipients+consent"); + + // Tamper the consented document hash → signature must fail. + let tampered = body.replacen(&hex::encode(h), &"00".repeat(32), 1); + assert_eq!(verify_email_signature_inline(&tampered), Some(false), + "altering the consented document hash must invalidate the signature"); + } + + #[test] + fn test_encoded_hybrid_agreement_verifies_through_email_path() { + // Cross-module round-trip: the agreement encoder's output must verify + // through the email signature path (spec §4.2). + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER, ROLE_VIEWER}; + let sender = crypto::generate_keypair().unwrap(); + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: Some("alice@example.com".into()) }, + AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.org".into()) }, + ]; + let armor = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", + b"This Mutual NDA is entered into as of 2026-06-13.", &recips, true, true, None, + ).unwrap(); + + assert_eq!(verify_email_signature_inline(&armor), Some(true), + "encoder output must verify through the §4.2 email signature path"); + + let sigs = verify_all_signatures_inline(&armor); + assert_eq!(sigs.len(), 1); + assert!(sigs[0].is_valid); + + // The parser must keep the body clean of the RECIPIENTS/CONSENT blocks. + let parsed = parse_armor_components(&armor).expect("parses"); + assert!(!parsed.body_text.contains("signer ")); + assert!(!parsed.body_text.contains("agreement ")); + } + + #[test] + fn test_bound_email_in_recipients_is_tamper_evident() { + // Issue #102: the (pubkey, email) pairing lives inside the signed + // RECIPIENTS block, so re-pairing an npub to a different address must + // break the originator's signature (§10.6). + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let sender = crypto::generate_keypair().unwrap(); + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: Some("alice@example.com".into()) }, + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.org".into()) }, + ]; + let armor = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", + b"terms", &recips, false, true, None, + ).unwrap(); + assert_eq!(verify_email_signature_inline(&armor), Some(true)); + + // Swap alice's bound address to an attacker-controlled mailbox. + let tampered = armor.replacen("alice@example.com", "attacker@evil.test", 1); + assert_eq!(verify_email_signature_inline(&tampered), Some(false), + "re-pairing an npub to a different email must invalidate the signature"); + } + + // ============================================= + // Stateless email↔npub binding verification (issue #102) + // ============================================= + + /// Build Bob's signed reply nesting Alice's challenge unchanged, signing over + /// the §4.2 chain target (extract_ciphertext_binary ignores signature blocks, + /// so we can compute the target from a placeholder armor then substitute). + fn build_signed_reply(responder: &crate::types::KeyPair, challenge_armor: &str) -> String { + let responder_hex = crate::agreement::normalize_pubkey_hex(&responder.public_key).unwrap(); + let reply_b64 = general_purpose::STANDARD.encode(b"Confirmed - same terms."); + let template = format!( + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n@Responder\nSIGPLACEHOLDER\n{}\n\ + ----- END NOSTR MESSAGE -----", + reply_b64, challenge_armor, responder_hex + ); + let bytes = extract_ciphertext_binary(&template); + let sig = crypto::sign_data_bytes(&responder.private_key, &bytes).unwrap(); + template.replace("SIGPLACEHOLDER", &sig) + } + + fn issue_challenge(alice: &crate::types::KeyPair, bob_pub: &str, bob_email: &str) -> String { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob_pub.to_string(), email: Some(bob_email.into()), + }]; + encode_hybrid_agreement( + &alice.private_key, &alice.public_key, Some("alice@issuer.example"), + "Alice", b"Please confirm you control this address.", &recips, false, true, None, + ).unwrap() + } + + #[test] + fn test_verify_email_binding_completed_handshake() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_hex = crate::agreement::normalize_pubkey_hex(&alice.public_key).unwrap(); + let bob_hex = crate::agreement::normalize_pubkey_hex(&bob.public_key).unwrap(); + + let challenge = issue_challenge(&alice, &bob.public_key, "bob@example.com"); + let reply = build_signed_reply(&bob, &challenge); + + let bindings = verify_email_binding(&reply, &alice.public_key); + assert_eq!(bindings.len(), 1, "exactly one binding proven"); + assert_eq!(bindings[0], crate::agreement::Binding { + pubkey: bob_hex, + email: "bob@example.com".into(), + issuer_pubkey: alice_hex, + }); + } + + #[test] + fn test_verify_email_binding_requires_a_reply() { + // A single inbound challenge is never a sufficient proof — no reply, no binding. + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let challenge = issue_challenge(&alice, &bob.public_key, "bob@example.com"); + assert!(verify_email_binding(&challenge, &alice.public_key).is_empty()); + } + + #[test] + fn test_verify_email_binding_tampered_pairing_voids_proof() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let challenge = issue_challenge(&alice, &bob.public_key, "bob@example.com"); + let reply = build_signed_reply(&bob, &challenge); + + // Re-pair Bob's npub to an attacker mailbox inside the (signed) challenge. + let tampered = reply.replacen("bob@example.com", "attacker@evil.test", 1); + assert!(verify_email_binding(&tampered, &alice.public_key).is_empty(), + "a tampered (npub,email) pairing breaks the chain and yields no binding"); + } + + #[test] + fn test_verify_email_binding_wrong_issuer_perspective() { + // From Bob's perspective there is no level *he* signed asserting an email, + // so the same thread proves no binding for him (it's an issuer-side check). + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let challenge = issue_challenge(&alice, &bob.public_key, "bob@example.com"); + let reply = build_signed_reply(&bob, &challenge); + assert!(verify_email_binding(&reply, &bob.public_key).is_empty()); + } + + // ============================================= + // Agreement completion status (spec §11.5) + // ============================================= + + /// Alice originates an agreement she's also a signatory of (she consents), + /// with Bob as the other required signer. + fn originate_agreement(alice: &crate::types::KeyPair, bob_pub: &str) -> String { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob_pub.to_string(), email: None, + }]; + encode_hybrid_agreement( + &alice.private_key, &alice.public_key, None, "Alice", + b"This Mutual NDA is entered into as of 2026-06-13.", &recips, true, true, None, + ).unwrap() + } + + /// Read the document hash H out of a message's CONSENT block. + fn hash_from_consent(armor: &str) -> String { + let body: String = armor.lines() + .skip_while(|l| !l.contains("BEGIN NOSTR CONSENT")).skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")).collect::>().join("\n"); + crate::agreement::parse_consent_block(&body).unwrap().agreement_hash + } + + /// Build a consenting reply: nests the prior message, adds the responder's + /// CONSENT over H, signed over the §4.2 chain target. + fn build_consent_reply(responder: &crate::types::KeyPair, prior_armor: &str, h: &str) -> String { + let responder_hex = crate::agreement::normalize_pubkey_hex(&responder.public_key).unwrap(); + let reply_b64 = general_purpose::STANDARD.encode(b"I agree to the terms."); + let template = format!( + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n\ + ----- BEGIN NOSTR CONSENT -----\nagreement {}\nsigner {}\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n@Responder\nSIGPLACEHOLDER\n{}\n\ + ----- END NOSTR MESSAGE -----", + reply_b64, h, responder_hex, prior_armor, responder_hex + ); + let bytes = extract_ciphertext_binary(&template); + let sig = crypto::sign_data_bytes(&responder.private_key, &bytes).unwrap(); + template.replace("SIGPLACEHOLDER", &sig) + } + + #[test] + fn test_agreement_status_complete_when_all_consent() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let agreement = originate_agreement(&alice, &bob.public_key); + let h = hash_from_consent(&agreement); + let reply = build_consent_reply(&bob, &agreement, &h); + + let status = verify_agreement_status(&reply).expect("is an agreement"); + assert_eq!((status.m, status.n), (2, 2), "originator + signer both consented"); + assert!(status.complete); + assert_eq!(status.document_hash, h); + } + + #[test] + fn test_agreement_status_comment_does_not_count() { + // Bob replies without a CONSENT block (a comment): only Alice has consented. + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let agreement = originate_agreement(&alice, &bob.public_key); + let reply = build_signed_reply(&bob, &agreement); // no consent block + + let status = verify_agreement_status(&reply).expect("is an agreement"); + assert_eq!((status.m, status.n), (1, 2), "comment without consent is not counted"); + assert!(!status.complete); + } + + #[test] + fn test_agreement_status_tampered_consent_hash_not_counted() { + // Bob consents to the wrong document hash → his consent must not count. + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let agreement = originate_agreement(&alice, &bob.public_key); + let wrong_h = "00".repeat(32); + let reply = build_consent_reply(&bob, &agreement, &wrong_h); + + let status = verify_agreement_status(&reply).expect("is an agreement"); + assert_eq!((status.m, status.n), (1, 2), "consent over the wrong H is ignored"); + assert!(!status.complete); + } + + #[test] + fn test_agreement_status_none_for_non_agreement() { + // A plain pairwise message (no RECIPIENTS block) is not an agreement. + let body = "----- BEGIN NOSTR NIP-44 ENCRYPTED BODY -----\n\ + SGVsbG8gV29ybGQ=\n\ + ----- END NOSTR MESSAGE -----"; + assert!(verify_agreement_status(body).is_none()); + } + + // ============================================= + // Multi-recipient envelope decryption (spec §10, §8 step 8) + // ============================================= + + #[test] + fn test_envelope_decrypts_for_recipient_and_self() { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER, ROLE_VIEWER}; + let alice = crypto::generate_keypair().unwrap(); // sender + let bob = crypto::generate_keypair().unwrap(); // To: signer + let carol = crypto::generate_keypair().unwrap(); // Cc: viewer + let plaintext = "This Mutual NDA is entered into as of 2026-06-13."; + + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.com".into()) }, + AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: carol.public_key.clone(), email: Some("carol@example.org".into()) }, + ]; + let armor = encode_hybrid_agreement( + &alice.private_key, &alice.public_key, Some("alice@issuer.example"), + "Alice", plaintext.as_bytes(), &recips, false, true, None, + ).unwrap(); + + // Bob (signer) recovers the body. + let r = decrypt_email_body_pipeline( + &bob.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success, "recipient decrypt failed: {:?}", r.error); + assert_eq!(r.body, plaintext); + + // Carol (viewer) also recovers the body — role is workflow, not access. + let r = decrypt_email_body_pipeline( + &carol.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success, "viewer decrypt failed: {:?}", r.error); + assert_eq!(r.body, plaintext); + + // Alice's own Sent copy decrypts via her self stanza. + let r = decrypt_email_body_pipeline( + &alice.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success, "self decrypt failed: {:?}", r.error); + assert_eq!(r.body, plaintext); + } + + #[test] + fn test_envelope_with_attachment_manifest_roundtrips() { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD; + + let alice = crypto::generate_keypair().unwrap(); // sender + let bob = crypto::generate_keypair().unwrap(); // To: signer + + // Build the same manifest JS produces: a nested AES-encrypted body blob + // (body base64'd then AES-GCM'd under its own key) plus a per-attachment + // key wrap. The manifest as a whole becomes the CEK-envelope body. + let body_text = "Please review and sign the attached contract."; + let body_key = crypto::generate_cek(); + let body_b64 = b64.encode(body_text.as_bytes()); + let body_ct = crypto::aes_gcm_encrypt_raw(&body_key, body_b64.as_bytes()).unwrap(); + let att_key = crypto::generate_cek(); + let manifest = serde_json::json!({ + "body": { + "ciphertext": b64.encode(&body_ct), + "cipher_sha256": "00", + "cipher_size": body_ct.len(), + "key_wrap": b64.encode(body_key), + }, + "attachments": [{ + "id": "a1", + "orig_filename": "contract.pdf", + "orig_mime": "application/pdf", + "cipher_sha256": "deadbeef", + "cipher_size": 65536u64, + "key_wrap": b64.encode(att_key), + }], + }); + let manifest_json = serde_json::to_string(&manifest).unwrap(); + + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.com".into()), + }]; + let armor = encode_hybrid_agreement( + &alice.private_key, &alice.public_key, Some("alice@issuer.example"), + "Alice", manifest_json.as_bytes(), &recips, false, true, None, + ).unwrap(); + + // Bob unwraps the CEK, AES-decrypts the manifest, and the shared handler + // recovers the nested body and surfaces the attachment metadata. + let r = decrypt_email_body_pipeline( + &bob.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success, "manifest envelope decrypt failed: {:?}", r.error); + assert!(r.is_manifest, "expected a manifest body"); + assert_eq!(r.body, body_text); + assert_eq!(r.attachments.len(), 1); + assert_eq!(r.attachments[0].orig_filename, "contract.pdf"); + assert_eq!(r.attachments[0].key_wrap_b64, b64.encode(att_key)); + } + + #[test] + fn test_envelope_non_recipient_cannot_decrypt() { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let mallory = crypto::generate_keypair().unwrap(); // not a recipient + + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: None, + }]; + let armor = encode_hybrid_agreement( + &alice.private_key, &alice.public_key, None, "Alice", b"secret terms", &recips, false, true, None, + ).unwrap(); + + let r = decrypt_email_body_pipeline( + &mallory.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(!r.success, "a non-recipient must not decrypt the envelope"); + } + + #[test] + fn test_parse_armor_components_exposes_recipients_and_consent() { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.com".into()), + }]; + let armor = encode_hybrid_agreement( + &alice.private_key, &alice.public_key, Some("alice@x.example"), "Alice", b"terms", &recips, true, true, None, + ).unwrap(); + + let parsed = parse_armor_components(&armor).expect("parses"); + // Recipients (incl. self) and the originator's consent are surfaced on the struct. + assert_eq!(parsed.recipients.len(), 2); + assert!(parsed.recipients.iter().any(|r| r.is_signer() && r.email.as_deref() == Some("bob@example.com"))); + assert!(parsed.consent.is_some(), "originator consent surfaced"); + } + + // ============================================= + // Plaintext (public) agreements (spec §11.8) + // ============================================= + + fn originate_plaintext_agreement(alice: &crate::types::KeyPair, bob_pub: &str, terms: &str) -> String { + use crate::agreement::{AgreementRecipientInput, ROLE_SIGNER}; + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob_pub.to_string(), email: None, + }]; + encode_signed_agreement(&alice.private_key, &alice.public_key, "Alice", terms, &recips, true, &[], None).unwrap() + } + + fn build_plaintext_consent_reply(responder: &crate::types::KeyPair, prior_armor: &str, h: &str) -> String { + let responder_hex = crate::agreement::normalize_pubkey_hex(&responder.public_key).unwrap(); + let (reply_glossia, _) = glossia_encode_signed_body("I agree to the terms.", None).unwrap(); + let prior_only = &prior_armor[prior_armor.find("----- BEGIN NOSTR").unwrap()..]; + let template = format!( + "I agree to the terms.\n\n----- BEGIN NOSTR SIGNED BODY -----\n{}\n\ + ----- BEGIN NOSTR CONSENT -----\nagreement {}\nsigner {}\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n@Responder\nSIGPLACEHOLDER\n{}\n\ + ----- END NOSTR MESSAGE -----", + reply_glossia, h, responder_hex, prior_only, responder_hex + ); + let bytes = extract_ciphertext_binary(&template); + let sig = crypto::sign_data_bytes(&responder.private_key, &bytes).unwrap(); + template.replace("SIGPLACEHOLDER", &sig) + } + + #[test] + fn test_plaintext_agreement_is_readable_signed_and_tracked() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let terms = "This public statement is agreed to by the undersigned, dated 2026-06-13."; + let agreement = originate_plaintext_agreement(&alice, &bob.public_key, terms); + + // Terms are in the clear above the armor (readable by any client, §3.2). + assert!(agreement.starts_with(terms)); + // Public agreement uses a SIGNED BODY, not an ENCRYPTED BODY. + assert!(agreement.contains("BEGIN NOSTR SIGNED BODY")); + assert!(!agreement.contains("ENCRYPTED BODY")); + + // The originator's signature verifies over the §4.2 target. + assert_eq!(verify_email_signature_inline(&agreement), Some(true)); + + // Signatories are declared with no CEK token. + let parsed = parse_armor_components(&agreement).expect("parses"); + assert_eq!(parsed.recipients.len(), 1); + assert!(parsed.recipients[0].wrapped_cek.is_none()); + assert!(parsed.recipients[0].is_signer()); + + // Completion is tracked: originator consented, the other signatory pending. + let status = verify_agreement_status(&agreement).expect("is an agreement"); + assert_eq!((status.m, status.n), (1, 2)); + assert!(!status.complete); + } + + #[test] + fn test_plaintext_agreement_completes_with_countersignature() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let terms = "We, the undersigned, commit to the following terms."; + let agreement = originate_plaintext_agreement(&alice, &bob.public_key, terms); + let h = hash_from_consent(&agreement); + let reply = build_plaintext_consent_reply(&bob, &agreement, &h); + + // The whole plaintext signature chain verifies and completion reaches 2 of 2. + let sigs = verify_all_signatures_inline(&reply); + assert!(sigs.iter().all(|s| s.is_valid), "plaintext reply chain must verify"); + let status = verify_agreement_status(&reply).expect("is an agreement"); + assert_eq!((status.m, status.n), (2, 2)); + assert!(status.complete); + assert_eq!(status.document_hash, h); + } + + #[test] + fn test_plaintext_agreement_terms_tamper_breaks_signature() { + // The signed payload is the glossia SIGNED BODY (not the human-readable + // preface). Mutating it must break the originator's signature. + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let agreement = originate_plaintext_agreement(&alice, &bob.public_key, "Pay 100 on delivery."); + assert_eq!(verify_email_signature_inline(&agreement), Some(true)); + + let parsed = parse_armor_components(&agreement).unwrap(); + let body = parsed.body_text; + let tampered = agreement.replacen(&body, &format!("{} extra", body), 1); + assert_ne!(tampered, agreement, "body must appear verbatim in the armor"); + assert_eq!(verify_email_signature_inline(&tampered), Some(false)); + } + + // ============================================= + // compose_agreement_armor (send-path) — To→signer, Cc→viewer (§6.3) + // ============================================= + + #[test] + fn test_compose_agreement_encrypted_with_capnp_manifest_attachment() { + use crate::types::{AgreementParty, EmailAttachment}; + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD; + let alice = crypto::generate_keypair().unwrap(); // sender + let bob = crypto::generate_keypair().unwrap(); // To + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + let file_bytes = b"%PDF-1.7 the actual contract"; + let attachments = vec![EmailAttachment { + filename: "contract.pdf".into(), + content_type: "application/pdf".into(), + data: b64.encode(file_bytes), + size: file_bytes.len(), + is_encrypted: false, + encryption_method: None, + algorithm: None, + original_filename: None, + original_type: None, + original_size: None, + }]; + let composed = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@me.example"), "Alice", + "NDA", "Please sign the attached.", &to, &[], true, true, true, true, true, None, None, &attachments, + ).unwrap(); + + // The body is now a capnp manifest under the CEK; one encrypted MIME part. + assert_eq!(composed.attachments.len(), 1); + assert_eq!(composed.attachments[0].filename, "a1.dat"); + assert_eq!(composed.attachments[0].encryption_method.as_deref(), Some("manifest_aes")); + + // Bob recovers the body + attachment metadata from the manifest… + let r = decrypt_email_body_pipeline( + &bob.private_key, &composed.armor, &composed.subject, Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success, "decrypt failed: {:?}", r.error); + assert!(r.is_manifest); + assert_eq!(r.body, "Please sign the attached."); + assert_eq!(r.attachments.len(), 1); + assert_eq!(r.attachments[0].orig_filename, "contract.pdf"); + + // …and the encrypted MIME part decrypts back to the original file. + let dec = decrypt_attachment_pipeline( + &composed.attachments[0].data, + &r.attachments[0].key_wrap_b64, + r.attachments[0].cipher_sha256_hex.as_deref(), + &r.attachments[0].orig_filename, + &r.attachments[0].orig_mime, + ).unwrap(); + assert_eq!(b64.decode(dec.data_b64).unwrap(), file_bytes); + } + + #[test] + fn test_public_agreement_binds_attachments_in_signature() { + use crate::types::{AgreementParty, EmailAttachment}; + use base64::Engine; + use sha2::{Digest, Sha256}; + let b64 = base64::engine::general_purpose::STANDARD; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + + let file = b"the public contract document"; + let attachments = vec![EmailAttachment { + filename: "contract.pdf".into(), + content_type: "application/pdf".into(), + data: b64.encode(file), + size: file.len(), + is_encrypted: false, + encryption_method: None, + algorithm: None, + original_filename: None, + original_type: None, + original_size: None, + }]; + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + let composed = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@me.example"), "Alice", + "Public Statement", "We agree to the public terms.", &to, &[], + /* encrypted */ false, true, true, true, true, None, None, &attachments, + ).unwrap(); + + // Public: attachment passes through in the clear, and the armor carries a + // signed ATTACHMENTS block with the file's plaintext hash. + assert_eq!(composed.attachments.len(), 1); + assert!(!composed.attachments[0].is_encrypted); + let sha = hex::encode(Sha256::digest(file)); + assert!(composed.armor.contains("BEGIN NOSTR ATTACHMENTS")); + assert!(composed.armor.contains(&sha), "armor should record the plaintext hash"); + assert_eq!(verify_email_signature_inline(&composed.armor), Some(true)); + + // Tampering with the recorded hash (i.e. swapping the bound file) breaks + // the signature — the binding is enforced cryptographically. + let tampered = composed.armor.replace(&sha, &"f".repeat(64)); + assert_eq!(verify_email_signature_inline(&tampered), Some(false)); + } + + #[test] + fn test_encrypt_manifest_body_1v1_roundtrips() { + use crate::types::EmailAttachment; + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD; + let alice = crypto::generate_keypair().unwrap(); // sender + let bob = crypto::generate_keypair().unwrap(); // recipient + + let file = b"%PDF-1.7 the 1:1 attachment bytes"; + let atts = vec![EmailAttachment { + filename: "invoice.pdf".into(), + content_type: "application/pdf".into(), + data: b64.encode(file), + size: file.len(), + is_encrypted: false, + encryption_method: None, + algorithm: None, + original_filename: None, + original_type: None, + original_size: None, + }]; + + // Rust builds the capnp manifest, NIP-44-encrypts it to bob, and armors it. + let out = encrypt_manifest_body( + &alice.private_key, &bob.public_key, "Hello Bob, see attached.", &atts, "nip44", + ).unwrap(); + assert!(out.armored_body.contains("BEGIN NOSTR NIP-44 ENCRYPTED BODY")); + assert_eq!(out.attachments.len(), 1); + assert_eq!(out.attachments[0].filename, "a1.dat"); + + // Bob recovers the body + attachment metadata from the manifest (no + // signature yet — signing happens later in the send path). + let r = decrypt_email_body_pipeline( + &bob.private_key, &out.armored_body, "", Some(&alice.public_key), None, None, + /* require_signature */ false, false, + ).unwrap(); + assert!(r.success, "decrypt failed: {:?}", r.error); + assert!(r.is_manifest); + assert_eq!(r.body, "Hello Bob, see attached."); + assert_eq!(r.attachments.len(), 1); + assert_eq!(r.attachments[0].orig_filename, "invoice.pdf"); + + // …and the encrypted MIME part decrypts back to the original file. + let dec = decrypt_attachment_pipeline( + &out.attachments[0].data, + &r.attachments[0].key_wrap_b64, + r.attachments[0].cipher_sha256_hex.as_deref(), + &r.attachments[0].orig_filename, + &r.attachments[0].orig_mime, + ).unwrap(); + assert_eq!(b64.decode(dec.data_b64).unwrap(), file); + } + + #[test] + fn test_verify_public_attachment_accepts_and_rejects() { + use crate::types::{AgreementParty, EmailAttachment}; + use base64::Engine; + use sha2::{Digest, Sha256}; + let b64 = base64::engine::general_purpose::STANDARD; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + + let file = b"the public contract document"; + let attachments = vec![EmailAttachment { + filename: "contract.pdf".into(), + content_type: "application/pdf".into(), + data: b64.encode(file), + size: file.len(), + is_encrypted: false, + encryption_method: None, + algorithm: None, + original_filename: None, + original_type: None, + original_size: None, + }]; + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + let composed = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@me.example"), "Alice", + "Public Statement", "We agree.", &to, &[], + /* encrypted */ false, true, true, true, true, None, None, &attachments, + ).unwrap(); + + // The genuine file verifies: signature valid, spec found, hash matches. + let ok = verify_public_attachment(&composed.armor, "contract.pdf", file); + assert!(ok.verified); + assert!(ok.signature_valid && ok.spec_found && ok.hash_match); + + // A swapped file (same name) is rejected — hash no longer matches. + let tampered = b"a different document entirely"; + let bad = verify_public_attachment(&composed.armor, "contract.pdf", tampered); + assert!(!bad.verified); + assert!(bad.spec_found && !bad.hash_match); + assert_eq!(bad.expected_sha256, ok.expected_sha256); + + // A file the message never declared is not bound. + let unknown = verify_public_attachment(&composed.armor, "evil.exe", file); + assert!(!unknown.verified && !unknown.spec_found); + + // If the signed ATTACHMENTS block is tampered (here, its recorded hash), + // the signature no longer verifies, so nothing is trusted. + let genuine_sha = hex::encode(Sha256::digest(file)); + let forged = composed.armor.replace(&genuine_sha, &"f".repeat(64)); + let untrusted = verify_public_attachment(&forged, "contract.pdf", file); + assert!(!untrusted.signature_valid && !untrusted.verified); + } + + #[test] + fn test_compose_agreement_encrypted_maps_to_and_cc() { + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); // sender + let bob = crypto::generate_keypair().unwrap(); // To + let carol = crypto::generate_keypair().unwrap(); // Cc + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + let cc = vec![AgreementParty { email: "carol@example.org".into(), pubkey: carol.public_key.clone() }]; + let composed = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@me.example"), "Alice", + "Quarterly NDA", "Mutual NDA terms.", &to, &cc, true, true, true, true, true, None, None, &[], + ).unwrap(); + let armor = composed.armor; + + // The subject is encrypted under the CEK (not cleartext) for envelope mode. + assert_ne!(composed.subject, "Quarterly NDA"); + assert!(!composed.subject.contains("NDA")); + + assert_eq!(verify_email_signature_inline(&armor), Some(true)); + let parsed = parse_armor_components(&armor).expect("parses"); + // bob is a signer, carol a viewer, plus the sender's self stanza. + assert!(parsed.recipients.iter().any(|r| r.role == "signer" && r.email.as_deref() == Some("bob@example.com"))); + assert!(parsed.recipients.iter().any(|r| r.role == "viewer" && r.email.as_deref() == Some("carol@example.org"))); + assert!(parsed.recipients.iter().any(|r| r.role == "self")); + assert!(parsed.consent.is_some(), "originator consent included"); + + // The To: signatory recovers both the body and the subject. + let r = decrypt_email_body_pipeline( + &bob.private_key, &armor, &composed.subject, Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success); + assert_eq!(r.body, "Mutual NDA terms."); + assert_eq!(r.subject, "Quarterly NDA", "subject decrypts under the CEK"); + + // A non-recipient cannot read the subject either. + let mallory = crypto::generate_keypair().unwrap(); + let rm = decrypt_email_body_pipeline( + &mallory.private_key, &armor, &composed.subject, Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert_ne!(rm.subject, "Quarterly NDA", "non-recipient must not read the subject"); + } + + #[test] + fn test_compose_agreement_plaintext_is_public_and_signed() { + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + let composed = compose_agreement( + &alice.private_key, &alice_pub, None, "Alice", + "Public Statement", "We agree to the public terms.", &to, &[], false, true, true, true, true, None, None, &[], + ).unwrap(); + let armor = composed.armor; + + // Public agreement: subject stays in the clear. + assert_eq!(composed.subject, "Public Statement"); + assert!(armor.starts_with("We agree to the public terms.")); + assert!(armor.contains("BEGIN NOSTR SIGNED BODY")); + assert!(!armor.contains("ENCRYPTED BODY")); + assert_eq!(verify_email_signature_inline(&armor), Some(true)); + let status = verify_agreement_status(&armor).expect("is an agreement"); + assert_eq!((status.m, status.n), (1, 2)); + } + + #[test] + fn test_compose_agreement_rejects_no_recipients() { + let alice = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + let err = compose_agreement( + &alice.private_key, &alice_pub, None, "Alice", "Subj", "terms", &[], &[], true, false, true, true, true, None, None, &[]); + assert!(err.is_err()); + } + + #[test] + fn test_compose_plain_multi_recipient_is_not_an_agreement() { + // is_agreement = false → all recipients are `viewer` (no signatories), no + // X-Nostr-Agreement marker, and verify_agreement_status sees no agreement — + // a plain encrypted CC'd email, not a pseudo-agreement. + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let carol = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + let cc = vec![AgreementParty { email: "carol@example.org".into(), pubkey: carol.public_key.clone() }]; + + let composed = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@x.example"), "Alice", + "FYI", "Shared with both of you.", &to, &cc, true, false, /* is_agreement */ false, /* encrypt_subject */ true, /* sign */ true, None, None, &[], + ).unwrap(); + let armor = composed.armor; + + // No signatories ⇒ not surfaced as an agreement. + assert!(verify_agreement_status(&armor).is_none(), "plain CC'd email must not be an agreement"); + // Both recipients still decrypt (they're viewers with a wrapped CEK). + let parsed = parse_armor_components(&armor).expect("parses"); + assert!(parsed.recipients.iter().all(|r| r.role == "viewer" || r.role == "self")); + assert_eq!(verify_email_signature_inline(&armor), Some(true)); + let r = decrypt_email_body_pipeline( + &carol.private_key, &armor, &composed.subject, Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success); + assert_eq!(r.body, "Shared with both of you."); + } + + #[test] + fn test_unsigned_envelope_uses_seal_and_still_decrypts() { + // sign = false → a SEAL (no SIGNATURE); recipients still decrypt via the + // SEAL pubkey, but there's no signature to verify (spec §3.6). + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + + let composed = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@x.example"), "Alice", + "Subj", "Unsigned but encrypted.", &to, &[], true, false, + /* is_agreement */ false, /* encrypt_subject */ true, /* sign */ false, None, None, &[], + ).unwrap(); + let armor = composed.armor; + + assert!(armor.contains("BEGIN NOSTR SEAL")); + assert!(!armor.contains("BEGIN NOSTR SIGNATURE")); + assert_eq!(verify_email_signature_inline(&armor), None, "no signature to verify"); + let r = decrypt_email_body_pipeline( + &bob.private_key, &armor, &composed.subject, Some(&alice.public_key), None, None, false, false, + ).unwrap(); + assert!(r.success); + assert_eq!(r.body, "Unsigned but encrypted."); + + // A forced agreement ignores sign=false (must be signed to authenticate + // the signatory set, §3.6). + let signed = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@x.example"), "Alice", + "Subj", "terms", &to, &[], true, false, + /* is_agreement */ true, /* encrypt_subject */ true, /* sign */ false, None, None, &[], + ).unwrap(); + assert!(signed.armor.contains("BEGIN NOSTR SIGNATURE")); + assert!(!signed.armor.contains("BEGIN NOSTR SEAL")); + } + + #[test] + fn test_glossia_encode_bytes_honors_encoding_scheme() { + // The Advanced "encoding" setting must change the dialect used, while + // round-tripping to identical bytes. + let data: Vec = (0u8..48).collect(); + let (latin, latin_bytes) = glossia_encode_bytes_with(&data, Some("latin")).unwrap(); + let (english, english_bytes) = glossia_encode_bytes_with(&data, Some("english - bip39")).unwrap(); + assert_eq!(latin_bytes, data); + assert_eq!(english_bytes, data); + assert_ne!(latin, english, "different schemes must produce different encodings"); + } + + #[test] + fn test_compose_agreement_applies_encoding_setting() { + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + + // Compose the same plaintext agreement under two schemes; the encoded + // bodies must differ, yet both verify and decrypt. + let latin = compose_agreement( + &alice.private_key, &alice_pub, None, "Alice", "Subj", "Public terms.", &to, &[], false, true, true, true, true, Some("latin"), None, &[], + ).unwrap(); + let english = compose_agreement( + &alice.private_key, &alice_pub, None, "Alice", "Subj", "Public terms.", &to, &[], false, true, true, true, true, Some("english - bip39"), None, &[], + ).unwrap(); + assert_ne!(latin.armor, english.armor, "encoding setting must change the armor body"); + assert_eq!(verify_email_signature_inline(&latin.armor), Some(true)); + assert_eq!(verify_email_signature_inline(&english.armor), Some(true)); + } + + #[test] + fn test_compose_agreement_independent_subject_encoding() { + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + + // Body in latin, subject in english — the subject uses its own scheme, + // and the recipient still recovers both. + let composed = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@x.example"), "Alice", + "Quarterly NDA", "Mutual NDA terms.", &to, &[], true, true, true, true, true, + Some("latin"), Some("english - bip39"), &[], + ).unwrap(); + + // Subject is encrypted (not the cleartext) and decrypts back for the recipient. + assert_ne!(composed.subject, "Quarterly NDA"); + let r = decrypt_email_body_pipeline( + &bob.private_key, &composed.armor, &composed.subject, Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success); + assert_eq!(r.body, "Mutual NDA terms."); + assert_eq!(r.subject, "Quarterly NDA", "subject decrypts regardless of its own scheme"); + + // subject_encoding = None falls back to the body encoding. + let fallback = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@x.example"), "Alice", + "Quarterly NDA", "Mutual NDA terms.", &to, &[], true, true, true, true, true, + Some("latin"), None, &[], + ).unwrap(); + let rf = decrypt_email_body_pipeline( + &bob.private_key, &fallback.armor, &fallback.subject, Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert_eq!(rf.subject, "Quarterly NDA"); + } + + #[test] + fn test_glossia_body_survives_quote_prefix_and_wrap() { + // The reason agreements use glossia, not base64: a glossia-encoded body + // must decode to identical bytes after an email client quote-prefixes + // ("> ") and re-wraps it — base64 would be corrupted by the "> ". + let ciphertext: Vec = (0u8..64).collect(); + let (encoded, canonical) = glossia_encode_bytes_with(&ciphertext, None).unwrap(); + assert_eq!(canonical, ciphertext, "glossia round-trips the bytes"); + + let words: Vec<&str> = encoded.split_whitespace().collect(); + let mangled: String = words + .chunks(6) + .map(|w| format!("> {}", w.join(" "))) + .collect::>() + .join("\n"); + let recovered = decode_armor_section(&mangled) + .expect("decode quote-prefixed, re-wrapped glossia body"); + assert_eq!(recovered, ciphertext, "glossia body survives quoting + wrapping"); + } + // ============================================= // parse_armor_components tests // ============================================= @@ -5163,6 +7304,8 @@ nitela\n\ "plain", None, None, None, None, "nsec1fake", "aabb", "", + &[], + None, ); assert!(!block.was_encrypted); assert_eq!(block.decrypted_text.as_deref(), Some("Hello world")); @@ -5170,13 +7313,14 @@ nitela\n\ } #[test] - fn test_json_manifest_parse() { - let json = r#"{"body":{"ciphertext":"dGVzdA==","cipher_sha256":"abc123","key_wrap":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="},"attachments":[{"id":"a1","orig_filename":"test.pdf","orig_mime":"application/pdf","cipher_sha256":"def456","cipher_size":65536,"key_wrap":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="}]}"#; - let manifest: JsonManifest = serde_json::from_str(json).unwrap(); - assert!(manifest.body.is_some()); - assert_eq!(manifest.attachments.as_ref().unwrap().len(), 1); - assert_eq!(manifest.attachments.as_ref().unwrap()[0].id, "a1"); - assert_eq!(manifest.attachments.as_ref().unwrap()[0].orig_filename, "test.pdf"); + fn test_legacy_json_manifest_parse() { + // A legacy JSON manifest is still readable (the body blob here isn't a + // real GCM ciphertext, so body_text stays None; attachments still parse). + let json = r#"{"body":{"ciphertext":"dGVzdA==","cipher_sha256":"abc123","key_wrap":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="},"attachments":[{"id":"a1","orig_filename":"test.pdf","orig_mime":"application/pdf","cipher_sha256":"def456","cipher_size":65536,"key_wrap":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}]}"#; + let manifest = crate::manifest::parse_manifest(json.as_bytes()).expect("legacy JSON manifest parses"); + assert_eq!(manifest.attachments.len(), 1); + assert_eq!(manifest.attachments[0].id, "a1"); + assert_eq!(manifest.attachments[0].orig_filename, "test.pdf"); } } diff --git a/tauri-app/backend/src/lib.rs b/tauri-app/backend/src/lib.rs index 494ef1c..292eeea 100644 --- a/tauri-app/backend/src/lib.rs +++ b/tauri-app/backend/src/lib.rs @@ -5,6 +5,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] pub mod crypto; +pub mod agreement; +pub mod manifest; pub mod email; pub mod imap_pool; pub mod imap_idle; @@ -2046,7 +2048,7 @@ async fn publish_nostr_event(private_key: Option, content: String, kind: } #[tauri::command] -async fn send_email(mut email_config: EmailConfig, to_address: String, subject: String, body: String, nostr_npub: Option, message_id: Option, attachments: Option>, html_body: Option, in_reply_to: Option, references: Option, include_pubkey_header: Option, include_sig_header: Option, recipient_pubkey: Option, include_recipient_header: Option, state: tauri::State<'_, AppState>) -> Result<(), String> { +async fn send_email(mut email_config: EmailConfig, to_address: String, subject: String, body: String, nostr_npub: Option, message_id: Option, attachments: Option>, html_body: Option, in_reply_to: Option, references: Option, include_pubkey_header: Option, include_sig_header: Option, recipient_pubkey: Option, include_recipient_header: Option, cc: Option>, state: tauri::State<'_, AppState>) -> Result<(), String> { println!("[RUST] send_email called with {} attachments, html_body: {}", attachments.as_ref().map(|a| a.len()).unwrap_or(0), html_body.is_some()); // Resolve private key from state if not supplied — keychain migration moved @@ -2058,11 +2060,12 @@ async fn send_email(mut email_config: EmailConfig, to_address: String, subject: let include_pubkey = include_pubkey_header.unwrap_or(true); let include_sig = include_sig_header.unwrap_or(true); let include_recipient = include_recipient_header.unwrap_or(true); + let cc = cc.unwrap_or_default(); // Send the email via SMTP // Note: We don't save to database here - sent emails will be fetched from the server's sent folder via IMAP sync // This avoids duplicate entries and ensures we have the server's version with proper headers - email::send_email(&email_config, &to_address, &subject, &body, nostr_npub.as_deref(), message_id.as_deref(), attachments.as_ref(), html_body.as_deref(), in_reply_to.as_deref(), references.as_deref(), include_pubkey, include_sig, recipient_pubkey.as_deref(), include_recipient) + email::send_email(&email_config, &to_address, &subject, &body, nostr_npub.as_deref(), message_id.as_deref(), attachments.as_ref(), html_body.as_deref(), in_reply_to.as_deref(), references.as_deref(), include_pubkey, include_sig, recipient_pubkey.as_deref(), include_recipient, &cc) .await .map_err(|e| e.to_string())?; @@ -2082,6 +2085,158 @@ async fn construct_email_headers(mut email_config: EmailConfig, to_address: Stri .map_err(|e| e.to_string()) } +/// Build a 1:1 (pairwise) encrypted manifest body in Rust (spec §11.2): AES the +/// body + plaintext attachments into a Cap'n Proto manifest, NIP-encrypt it to +/// `recipientPubkey`, and ASCII-armor it. Returns `{ armoredBody, attachments }` +/// (the encrypted `aN.dat` parts). The frontend keeps handling subject, glossia, +/// and signing. See [`email::encrypt_manifest_body`]. +#[tauri::command] +fn encrypt_manifest_body( + private_key: Option, + recipient_pubkey: String, + body: String, + attachments: Vec, + algorithm: Option, + state: tauri::State, +) -> Result { + let private_key = resolve_private_key(private_key, &state)?; + let algo = algorithm.as_deref().unwrap_or("nip44"); + email::encrypt_manifest_body(&private_key, &recipient_pubkey, &body, &attachments, algo) +} + +/// Verify a delivered (plaintext) attachment against a public message's signed +/// ATTACHMENTS block (spec §11.2): recompute its SHA-256 and check it against the +/// signed spec, also confirming the message signature is valid. `data_base64` is +/// the delivered file's bytes. See [`email::verify_public_attachment`]. +#[tauri::command] +fn verify_public_attachment( + armor: String, + filename: String, + data_base64: String, +) -> Result { + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD + .decode(&data_base64) + .map_err(|e| format!("attachment base64 decode failed: {}", e))?; + Ok(email::verify_public_attachment(&armor, &filename, &data)) +} + +/// Build the armored body for an agreement without sending it (compose preview). +/// `to` parties become signatories, `cc` parties become viewers (spec §6.3). +/// `encrypted` selects the multi-recipient envelope vs the plaintext/public form. +#[tauri::command] +async fn compose_agreement( + mut email_config: EmailConfig, + subject: String, + body: String, + to: Vec, + cc: Vec, + encrypted: bool, + originator_consents: bool, + is_agreement: Option, + encrypt_subject: Option, + sign: Option, + profile_name: Option, + glossia_encoding: Option, + subject_encoding: Option, + attachments: Option>, + state: tauri::State<'_, AppState>, +) -> Result { + email_config.private_key = Some(resolve_private_key(email_config.private_key, &state)?); + let private_key = email_config.private_key.as_deref().unwrap(); + let sender_pub = crypto::get_public_key_from_private(private_key).map_err(|e| e.to_string())?; + email::compose_agreement( + private_key, + &sender_pub, + Some(&email_config.email_address), + profile_name.as_deref().unwrap_or(""), + &subject, + &body, + &to, + &cc, + encrypted, + originator_consents, + is_agreement.unwrap_or(true), + encrypt_subject.unwrap_or(true), + sign.unwrap_or(true), + glossia_encoding.as_deref(), + subject_encoding.as_deref(), + &attachments.unwrap_or_default(), + ) +} + +/// Compose and send an agreement to all signatories (`To:`) and viewers (`Cc:`) +/// in one message (spec §6.3). The armored body carries the authoritative +/// SIGNATURE; an `X-Nostr-Agreement` marker enables decrypt-free IMAP filtering. +#[tauri::command] +async fn send_agreement( + mut email_config: EmailConfig, + subject: String, + body: String, + to: Vec, + cc: Vec, + cc_plain: Option>, + encrypted: bool, + originator_consents: bool, + is_agreement: Option, + encrypt_subject: Option, + sign: Option, + profile_name: Option, + message_id: Option, + in_reply_to: Option, + references: Option, + include_pubkey_header: Option, + include_sig_header: Option, + glossia_encoding: Option, + subject_encoding: Option, + attachments: Option>, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + email_config.private_key = Some(resolve_private_key(email_config.private_key, &state)?); + let include_pubkey = include_pubkey_header.unwrap_or(true); + let include_sig = include_sig_header.unwrap_or(true); + let cc_plain = cc_plain.unwrap_or_default(); + email::send_agreement_email( + &email_config, + &subject, + profile_name.as_deref().unwrap_or(""), + &body, + &to, + &cc, + &cc_plain, + encrypted, + originator_consents, + is_agreement.unwrap_or(true), + encrypt_subject.unwrap_or(true), + sign.unwrap_or(true), + message_id.as_deref(), + in_reply_to.as_deref(), + references.as_deref(), + include_pubkey, + include_sig, + glossia_encoding.as_deref(), + subject_encoding.as_deref(), + attachments.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Compute an agreement's "M of N signed" completion status from a message/thread +/// armor (spec §11.5). Returns `None` when the armor is not an agreement. +#[tauri::command] +fn agreement_status(armor: String) -> Option { + email::verify_agreement_status(&armor) +} + +/// Verify email↔npub bindings provable from a self-contained thread (issue #102), +/// from the verifier's own pubkey's perspective. Stateless — no challenge store. +#[tauri::command] +fn verify_email_binding(armor: String, my_pubkey: String) -> Vec { + email::verify_email_binding(&armor, &my_pubkey) +} + #[tauri::command] async fn fetch_image(url: String) -> Result { nostr::fetch_image_as_data_url(&url).await.map_err(|e| e.to_string()) @@ -7483,6 +7638,12 @@ pub fn run() { publish_nostr_event, send_email, construct_email_headers, + compose_agreement, + send_agreement, + verify_public_attachment, + encrypt_manifest_body, + agreement_status, + verify_email_binding, fetch_image, fetch_multiple_images, fetch_profiles, diff --git a/tauri-app/backend/src/manifest.rs b/tauri-app/backend/src/manifest.rs new file mode 100644 index 0000000..1266354 --- /dev/null +++ b/tauri-app/backend/src/manifest.rs @@ -0,0 +1,411 @@ +//! Attachment manifest: build and parse the hybrid attachment envelope of spec +//! §11.2. +//! +//! When a message carries attachments (or a very large body), the encrypted +//! *body* is a serialized **manifest** rather than the raw text: a nested +//! AES-encrypted body blob plus one entry per attachment carrying that +//! attachment's AES key, MIME type, and ciphertext hash. The encrypted +//! attachment bytes themselves travel as separate MIME parts (`a1.dat`, …); the +//! manifest only carries the metadata + keys needed to find and decrypt them. +//! +//! The manifest is protected by whatever encrypts the body around it — pairwise +//! NIP-44 (1:1) or the per-recipient-wrapped CEK (multi-recipient) — so the +//! cleartext AES keys inside it are only readable once that layer is opened. +//! Because the manifest (including every `cipherSha256`) is inside the signed +//! body, attachments are bound to the message tamper-evidently even though their +//! bytes ride outside the armor. +//! +//! ## Wire format +//! +//! New manifests are **Cap'n Proto** (schema `Manifest`/`EncryptedBlob`/ +//! `Attachment`). The manifest is the plaintext *body*, encrypted by the outer +//! layer (pairwise NIP-44 or the per-recipient-wrapped CEK) and then glossia- +//! encoded for transport, so it never touches the wire in the clear. The +//! serialized capnp rides behind one of two markers depending on the transport: +//! +//! * [`CAPNP_PREFIX`] (`capnp:`) — raw bytes, for the byte-clean CEK envelope +//! (multi-recipient). +//! * [`CAPNP_B64_PREFIX`] (`capnp64:`) — base64, for the string-typed NIP-44 +//! transport (1:1), whose decrypt yields a `String` and so can't carry raw +//! binary. +//! +//! The decoder also still reads the **legacy JSON** manifest (first byte `{`) so +//! old emails keep opening. + +use anyhow::Result; +use base64::Engine; +use sha2::{Digest, Sha256}; + +/// Marker prefixing the *raw* Cap'n Proto manifest bytes, used on byte-clean +/// transports (the multi-recipient CEK envelope). A decoder distinguishes capnp +/// (`capnp:…`) from a legacy JSON manifest (`{…`) and from a plaintext body. +pub const CAPNP_PREFIX: &str = "capnp:"; + +/// Marker prefixing a *base64* Cap'n Proto manifest, used on the string-typed +/// transport (pairwise NIP-44, whose decrypt yields a `String` and so cannot +/// carry raw binary). The bytes after the marker are base64 of the same +/// serialized capnp message that [`CAPNP_PREFIX`] carries raw. +pub const CAPNP_B64_PREFIX: &str = "capnp64:"; + +fn b64() -> base64::engine::general_purpose::GeneralPurpose { + base64::engine::general_purpose::STANDARD +} + +/// A plaintext attachment to be encrypted into the manifest. +pub struct AttachmentInput { + pub filename: String, + pub mime: String, + pub data: Vec, +} + +/// An encrypted attachment, ready to ride as a MIME part named `.dat`. +pub struct EncryptedAttachmentPart { + pub opaque_id: String, + pub filename: String, + /// AES-256-GCM ciphertext (size-prefixed + padded); the MIME part body. + pub ciphertext: Vec, +} + +/// One parsed attachment entry (unified across capnp + legacy JSON). +pub struct ParsedAttachment { + pub id: String, + pub orig_filename: String, + pub orig_mime: String, + /// Raw 32-byte SHA-256 of the encrypted file (hex for the UI). + pub cipher_sha256: Vec, + pub cipher_size: u64, + /// Raw AES-256 key (base64 for the UI / `decrypt_attachment_pipeline`). + pub key_wrap: Vec, +} + +impl ParsedAttachment { + pub fn cipher_sha256_hex(&self) -> String { + hex::encode(&self.cipher_sha256) + } + pub fn key_wrap_b64(&self) -> String { + b64().encode(&self.key_wrap) + } +} + +/// A parsed manifest: the already-decrypted body text plus attachment metadata. +pub struct ParsedManifest { + /// `Some` once the nested body blob has been AES-decrypted; `None` if there + /// was no body blob or it failed to decrypt. + pub body_text: Option, + pub attachments: Vec, +} + +/// Serialize a Cap'n Proto manifest from a plaintext body + attachments. +/// +/// AES-encrypts the body (raw) and each attachment (size-prefixed + padded) +/// under independent random keys, records each in the manifest, and returns the +/// serialized capnp bytes (no marker) plus the encrypted attachment MIME parts. +/// Callers wrap the bytes for their transport via [`build_capnp_manifest`] (raw) +/// or [`build_capnp_manifest_armored`] (base64). +fn build_capnp_bytes( + body: &str, + attachments: &[AttachmentInput], +) -> Result<(Vec, Vec)> { + let mut message = ::capnp::message::Builder::new_default(); + let mut manifest = message.init_root::(); + manifest.set_version(0); + + // Body blob: AES-256-GCM under its own key (no inner base64 — capnp Data is + // binary-safe, unlike the legacy JSON manifest). + { + let body_key = crate::crypto::generate_cek(); + let body_ct = crate::crypto::aes_gcm_encrypt_raw(&body_key, body.as_bytes())?; + let sha = Sha256::digest(&body_ct); + let mut blob = manifest.reborrow().init_body(); + blob.set_ciphertext(&body_ct); + blob.set_cipher_sha256(&sha); + blob.set_key_wrap(&body_key); + blob.set_cipher_size(body_ct.len() as u64); + } + + let mut parts = Vec::with_capacity(attachments.len()); + { + let mut att_list = manifest + .reborrow() + .init_attachments(attachments.len() as u32); + for (i, att) in attachments.iter().enumerate() { + let id = format!("a{}", i + 1); + let att_key = crate::crypto::generate_cek(); + let ct = crate::crypto::aes_gcm_encrypt_padded(&att_key, &att.data)?; + let sha = Sha256::digest(&ct); + { + let mut entry = att_list.reborrow().get(i as u32); + entry.set_id(id.as_str()); + entry.set_orig_filename(att.filename.as_str()); + entry.set_orig_mime(att.mime.as_str()); + entry.set_cipher_sha256(&sha); + entry.set_cipher_size(ct.len() as u64); + entry.set_key_wrap(&att_key); + } + parts.push(EncryptedAttachmentPart { + opaque_id: id.clone(), + filename: format!("{}.dat", id), + ciphertext: ct, + }); + } + } + + let mut bytes = Vec::new(); + ::capnp::serialize::write_message(&mut bytes, &message)?; + Ok((bytes, parts)) +} + +/// Build a manifest for a **byte-clean** transport (the CEK envelope): the +/// [`CAPNP_PREFIX`] marker followed by the raw serialized capnp bytes (to be +/// encrypted as the body), plus the encrypted attachment MIME parts. +pub fn build_capnp_manifest( + body: &str, + attachments: &[AttachmentInput], +) -> Result<(Vec, Vec)> { + let (bytes, parts) = build_capnp_bytes(body, attachments)?; + let mut payload = CAPNP_PREFIX.as_bytes().to_vec(); + payload.extend_from_slice(&bytes); + Ok((payload, parts)) +} + +/// Build a manifest for a **string-typed** transport (pairwise NIP-44): the +/// [`CAPNP_B64_PREFIX`] marker followed by base64 of the serialized capnp bytes, +/// so the payload is text-safe for an API whose decrypt yields a `String`. The +/// returned payload is what the caller NIP-encrypts as the body. +pub fn build_capnp_manifest_armored( + body: &str, + attachments: &[AttachmentInput], +) -> Result<(String, Vec)> { + let (bytes, parts) = build_capnp_bytes(body, attachments)?; + Ok((format!("{}{}", CAPNP_B64_PREFIX, b64().encode(&bytes)), parts)) +} + +/// Parse a decrypted body payload as a manifest, decrypting the nested body blob. +/// +/// Returns `None` when the payload is not a manifest (an ordinary plaintext +/// body). Detects a Cap'n Proto manifest by the [`CAPNP_PREFIX`] marker (the +/// remaining bytes are the raw serialized capnp) and a legacy JSON manifest by a +/// leading `{`. +pub fn parse_manifest(payload: &[u8]) -> Option { + // base64 capnp (NIP-44 transport) — check before the raw `capnp:` prefix, + // which is NOT a prefix of `capnp64:`. + if let Some(rest) = payload.strip_prefix(CAPNP_B64_PREFIX.as_bytes()) { + let bytes = b64().decode(std::str::from_utf8(rest).ok()?.trim()).ok()?; + return parse_capnp_manifest(&bytes); + } + // raw capnp (CEK transport) + if let Some(rest) = payload.strip_prefix(CAPNP_PREFIX.as_bytes()) { + return parse_capnp_manifest(rest); + } + // Legacy JSON manifest (or an ordinary plaintext body): text-typed. + let t = std::str::from_utf8(payload).ok()?.trim(); + if t.starts_with('{') { + return parse_json_manifest(t); + } + None +} + +fn parse_capnp_manifest(bytes: &[u8]) -> Option { + let reader = ::capnp::serialize::read_message(bytes, ::capnp::message::ReaderOptions::new()).ok()?; + let manifest = reader + .get_root::() + .ok()?; + + let body_text = manifest.has_body().then(|| manifest.get_body()).and_then(|b| { + let blob = b.ok()?; + let ct = blob.get_ciphertext().ok()?; + let key = blob.get_key_wrap().ok()?; + let pt = crate::crypto::aes_gcm_decrypt_raw(key, ct).ok()?; + String::from_utf8(pt).ok() + }); + + let mut attachments = Vec::new(); + if let Ok(list) = manifest.get_attachments() { + for a in list.iter() { + let id = a.get_id().ok()?.to_str().ok()?.to_string(); + let orig_filename = a.get_orig_filename().ok()?.to_str().ok()?.to_string(); + let orig_mime = a.get_orig_mime().ok()?.to_str().ok()?.to_string(); + let cipher_sha256 = a.get_cipher_sha256().ok()?.to_vec(); + let key_wrap = a.get_key_wrap().ok()?.to_vec(); + attachments.push(ParsedAttachment { + id, + orig_filename, + orig_mime, + cipher_sha256, + cipher_size: a.get_cipher_size(), + key_wrap, + }); + } + } + Some(ParsedManifest { body_text, attachments }) +} + +/// Legacy JSON manifest (read-only). The body blob's AES plaintext is the +/// base64 of the UTF-8 body (a JS artifact), so it is base64-decoded after AES. +fn parse_json_manifest(json: &str) -> Option { + #[derive(serde::Deserialize)] + struct JBlob { + ciphertext: String, + key_wrap: String, + } + #[derive(serde::Deserialize)] + struct JAtt { + id: String, + orig_filename: String, + orig_mime: String, + cipher_sha256: Option, + cipher_size: Option, + key_wrap: String, + } + #[derive(serde::Deserialize)] + struct JManifest { + body: Option, + attachments: Option>, + } + let m: JManifest = serde_json::from_str(json).ok()?; + // A JSON object that isn't actually a manifest (no body, no attachments) is + // not a manifest — treat as plaintext. + if m.body.is_none() && m.attachments.is_none() { + return None; + } + + let body_text = m.body.and_then(|blob| { + let key = b64().decode(blob.key_wrap).ok()?; + let ct = b64().decode(blob.ciphertext).ok()?; + let pt = crate::crypto::aes_gcm_decrypt_raw(&key, &ct).ok()?; + let b64_body = String::from_utf8(pt).ok()?; + match b64().decode(b64_body.trim()) { + Ok(body_bytes) => Some(String::from_utf8(body_bytes).unwrap_or(b64_body)), + Err(_) => Some(b64_body), + } + }); + + let attachments = m + .attachments + .unwrap_or_default() + .into_iter() + .filter_map(|a| { + Some(ParsedAttachment { + id: a.id, + orig_filename: a.orig_filename, + orig_mime: a.orig_mime, + cipher_sha256: a + .cipher_sha256 + .and_then(|h| hex::decode(h).ok()) + .unwrap_or_default(), + cipher_size: a.cipher_size.unwrap_or(0), + key_wrap: b64().decode(a.key_wrap).ok()?, + }) + }) + .collect(); + Some(ParsedManifest { body_text, attachments }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capnp_manifest_roundtrips_body_and_attachments() { + let body = "Please review and sign the attached contract."; + let atts = vec![ + AttachmentInput { + filename: "contract.pdf".into(), + mime: "application/pdf".into(), + data: b"%PDF-1.7 fake contract bytes".to_vec(), + }, + AttachmentInput { + filename: "exhibit a.txt".into(), + mime: "text/plain".into(), + data: b"exhibit body".to_vec(), + }, + ]; + let (payload, parts) = build_capnp_manifest(body, &atts).unwrap(); + assert!(payload.starts_with(CAPNP_PREFIX.as_bytes())); + // The marker is followed by raw capnp bytes (no base64 armor): a single- + // segment message's serialized form opens with a `00 00 00 00` segment + // header, which is not printable base64 text. + assert_eq!(&payload[CAPNP_PREFIX.len()..CAPNP_PREFIX.len() + 4], &[0, 0, 0, 0]); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0].filename, "a1.dat"); + + let parsed = parse_manifest(&payload).expect("capnp manifest parses"); + assert_eq!(parsed.body_text.as_deref(), Some(body)); + assert_eq!(parsed.attachments.len(), 2); + assert_eq!(parsed.attachments[0].id, "a1"); + assert_eq!(parsed.attachments[0].orig_filename, "contract.pdf"); + assert_eq!(parsed.attachments[1].orig_filename, "exhibit a.txt"); + + // The encrypted MIME part decrypts back to the original file using the + // manifest's key + recorded hash (the receive path). + let dec = crate::email::decrypt_attachment_pipeline( + &b64().encode(&parts[0].ciphertext), + &parsed.attachments[0].key_wrap_b64(), + Some(&parsed.attachments[0].cipher_sha256_hex()), + &parsed.attachments[0].orig_filename, + &parsed.attachments[0].orig_mime, + ) + .unwrap(); + assert_eq!(b64().decode(dec.data_b64).unwrap(), b"%PDF-1.7 fake contract bytes"); + } + + #[test] + fn tampered_attachment_is_rejected() { + let atts = vec![AttachmentInput { + filename: "doc.txt".into(), + mime: "text/plain".into(), + data: b"original".to_vec(), + }]; + let (payload, parts) = build_capnp_manifest("body", &atts).unwrap(); + let parsed = parse_manifest(&payload).unwrap(); + // Flip a ciphertext byte → the recorded hash no longer matches → reject. + let mut tampered = parts[0].ciphertext.clone(); + tampered[0] ^= 0xff; + let err = crate::email::decrypt_attachment_pipeline( + &b64().encode(&tampered), + &parsed.attachments[0].key_wrap_b64(), + Some(&parsed.attachments[0].cipher_sha256_hex()), + "doc.txt", + "text/plain", + ); + assert!(err.is_err(), "tampered attachment must be rejected"); + assert!(err.unwrap_err().contains("integrity check failed")); + } + + #[test] + fn armored_capnp_manifest_roundtrips() { + // The base64 (`capnp64:`) variant used by the string-typed NIP-44 path: + // text-safe, and decodes back to the same body + attachments. + let atts = vec![AttachmentInput { + filename: "report.pdf".into(), + mime: "application/pdf".into(), + data: b"%PDF report bytes".to_vec(), + }]; + let (payload, parts) = build_capnp_manifest_armored("the 1:1 body", &atts).unwrap(); + assert!(payload.starts_with(CAPNP_B64_PREFIX)); + assert!(payload.is_ascii(), "armored payload must be text-safe for NIP-44"); + assert_eq!(parts.len(), 1); + + let parsed = parse_manifest(payload.as_bytes()).expect("armored capnp parses"); + assert_eq!(parsed.body_text.as_deref(), Some("the 1:1 body")); + assert_eq!(parsed.attachments.len(), 1); + assert_eq!(parsed.attachments[0].orig_filename, "report.pdf"); + + // The encrypted part decrypts back to the original file. + let dec = crate::email::decrypt_attachment_pipeline( + &b64().encode(&parts[0].ciphertext), + &parsed.attachments[0].key_wrap_b64(), + Some(&parsed.attachments[0].cipher_sha256_hex()), + &parsed.attachments[0].orig_filename, + &parsed.attachments[0].orig_mime, + ) + .unwrap(); + assert_eq!(b64().decode(dec.data_b64).unwrap(), b"%PDF report bytes"); + } + + #[test] + fn plaintext_is_not_a_manifest() { + assert!(parse_manifest(b"just a normal body").is_none()); + assert!(parse_manifest(b"{ not really json").is_none()); + } +} diff --git a/tauri-app/backend/src/nostr_mail_capnp.rs b/tauri-app/backend/src/nostr_mail_capnp.rs index 81ffc25..791d361 100644 --- a/tauri-app/backend/src/nostr_mail_capnp.rs +++ b/tauri-app/backend/src/nostr_mail_capnp.rs @@ -34,14 +34,13 @@ impl ::capnp::traits::HasTypeId for NipVersion { const TYPE_ID: u64 = 0x8cb3_b582_1985_84c5u64; } mod nip_version { -pub static ENCODED_NODE: [::capnp::Word; 28] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), +pub static ENCODED_NODE: [::capnp::Word; 27] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(197, 132, 133, 25, 130, 181, 179, 140), ::capnp::word(24, 0, 0, 0, 2, 0, 0, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(22, 2, 0, 0, 65, 2, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -146,11 +145,15 @@ pub mod manifest { pub fn has_attachments(&self) -> bool { !self.reader.get_pointer_field(1).is_null() } + #[inline] + pub fn get_version(self) -> u16 { + self.reader.get_data_field::(0) + } } pub struct Builder<'a> { builder: ::capnp::private::layout::StructBuilder<'a> } impl <> ::capnp::traits::HasStructSize for Builder<'_,> { - const STRUCT_SIZE: ::capnp::private::layout::StructSize = ::capnp::private::layout::StructSize { data: 0, pointers: 2 }; + const STRUCT_SIZE: ::capnp::private::layout::StructSize = ::capnp::private::layout::StructSize { data: 1, pointers: 2 }; } impl <> ::capnp::traits::HasTypeId for Builder<'_,> { const TYPE_ID: u64 = _private::TYPE_ID; @@ -232,6 +235,14 @@ pub mod manifest { pub fn has_attachments(&self) -> bool { !self.builder.is_pointer_field_null(1) } + #[inline] + pub fn get_version(self) -> u16 { + self.builder.get_data_field::(0) + } + #[inline] + pub fn set_version(&mut self, value: u16) { + self.builder.set_data_field::(0, value); + } } pub struct Pipeline { _typeless: ::capnp::any_pointer::Pipeline } @@ -246,18 +257,17 @@ pub mod manifest { } } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 55] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 69] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(255, 192, 250, 56, 88, 208, 193, 143), - ::capnp::word(24, 0, 0, 0, 1, 0, 0, 0), + ::capnp::word(24, 0, 0, 0, 1, 0, 1, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(154, 6, 0, 0, 244, 6, 0, 0), ::capnp::word(21, 0, 0, 0, 10, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(33, 0, 0, 0, 119, 0, 0, 0), + ::capnp::word(33, 0, 0, 0, 175, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(115, 99, 104, 101, 109, 97, 47, 110), @@ -266,21 +276,28 @@ pub mod manifest { ::capnp::word(77, 97, 110, 105, 102, 101, 115, 116), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 1, 0, 1, 0), - ::capnp::word(8, 0, 0, 0, 3, 0, 4, 0), + ::capnp::word(12, 0, 0, 0, 3, 0, 4, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(41, 0, 0, 0, 42, 0, 0, 0), + ::capnp::word(69, 0, 0, 0, 42, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(36, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(48, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(64, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(76, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(1, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(45, 0, 0, 0, 98, 0, 0, 0), + ::capnp::word(73, 0, 0, 0, 98, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(44, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(72, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(72, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(100, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(2, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 2, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(97, 0, 0, 0, 66, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(92, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(104, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(98, 111, 100, 121, 0, 0, 0, 0), ::capnp::word(16, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(245, 74, 141, 1, 126, 125, 50, 152), @@ -302,11 +319,20 @@ pub mod manifest { ::capnp::word(14, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(118, 101, 114, 115, 105, 111, 110, 0), + ::capnp::word(7, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(7, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ]; pub fn get_field_types(index: u16) -> ::capnp::introspect::Type { match index { 0 => ::introspect(), 1 => <::capnp::struct_list::Owned as ::capnp::introspect::Introspect>::introspect(), + 2 => ::introspect(), _ => panic!("invalid field index {}", index), } } @@ -319,9 +345,9 @@ pub mod manifest { members_by_discriminant: MEMBERS_BY_DISCRIMINANT, members_by_name: MEMBERS_BY_NAME, }; - pub static NONUNION_MEMBERS : &[u16] = &[0,1]; + pub static NONUNION_MEMBERS : &[u16] = &[0,1,2]; pub static MEMBERS_BY_DISCRIMINANT : &[u16] = &[]; - pub static MEMBERS_BY_NAME : &[u16] = &[1,0]; + pub static MEMBERS_BY_NAME : &[u16] = &[1,0,2]; pub const TYPE_ID: u64 = 0x8fc1_d058_38fa_c0ff; } } @@ -411,11 +437,15 @@ pub mod encrypted_blob { pub fn has_key_wrap(&self) -> bool { !self.reader.get_pointer_field(2).is_null() } + #[inline] + pub fn get_cipher_size(self) -> u64 { + self.reader.get_data_field::(0) + } } pub struct Builder<'a> { builder: ::capnp::private::layout::StructBuilder<'a> } impl <> ::capnp::traits::HasStructSize for Builder<'_,> { - const STRUCT_SIZE: ::capnp::private::layout::StructSize = ::capnp::private::layout::StructSize { data: 0, pointers: 3 }; + const STRUCT_SIZE: ::capnp::private::layout::StructSize = ::capnp::private::layout::StructSize { data: 1, pointers: 3 }; } impl <> ::capnp::traits::HasTypeId for Builder<'_,> { const TYPE_ID: u64 = _private::TYPE_ID; @@ -513,6 +543,14 @@ pub mod encrypted_blob { pub fn has_key_wrap(&self) -> bool { !self.builder.is_pointer_field_null(2) } + #[inline] + pub fn get_cipher_size(self) -> u64 { + self.builder.get_data_field::(0) + } + #[inline] + pub fn set_cipher_size(&mut self, value: u64) { + self.builder.set_data_field::(0, value); + } } pub struct Pipeline { _typeless: ::capnp::any_pointer::Pipeline } @@ -524,18 +562,17 @@ pub mod encrypted_blob { impl Pipeline { } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 67] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 82] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(245, 74, 141, 1, 126, 125, 50, 152), - ::capnp::word(24, 0, 0, 0, 1, 0, 0, 0), + ::capnp::word(24, 0, 0, 0, 1, 0, 1, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(193, 8, 0, 0, 214, 9, 0, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(33, 0, 0, 0, 175, 0, 0, 0), + ::capnp::word(33, 0, 0, 0, 231, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(115, 99, 104, 101, 109, 97, 47, 110), @@ -544,28 +581,35 @@ pub mod encrypted_blob { ::capnp::word(69, 110, 99, 114, 121, 112, 116, 101), ::capnp::word(100, 66, 108, 111, 98, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 1, 0, 1, 0), - ::capnp::word(12, 0, 0, 0, 3, 0, 4, 0), + ::capnp::word(16, 0, 0, 0, 3, 0, 4, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(69, 0, 0, 0, 90, 0, 0, 0), + ::capnp::word(97, 0, 0, 0, 90, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(68, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(80, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(96, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(108, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(1, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(77, 0, 0, 0, 106, 0, 0, 0), + ::capnp::word(105, 0, 0, 0, 106, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(76, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(88, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(104, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(116, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(2, 0, 0, 0, 2, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 2, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(85, 0, 0, 0, 66, 0, 0, 0), + ::capnp::word(113, 0, 0, 0, 66, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(108, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(120, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(3, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 3, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(80, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(92, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(117, 0, 0, 0, 90, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(116, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(128, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(99, 105, 112, 104, 101, 114, 116, 101), ::capnp::word(120, 116, 0, 0, 0, 0, 0, 0), ::capnp::word(13, 0, 0, 0, 0, 0, 0, 0), @@ -592,12 +636,22 @@ pub mod encrypted_blob { ::capnp::word(13, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(99, 105, 112, 104, 101, 114, 83, 105), + ::capnp::word(122, 101, 0, 0, 0, 0, 0, 0), + ::capnp::word(9, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(9, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ]; pub fn get_field_types(index: u16) -> ::capnp::introspect::Type { match index { 0 => <::capnp::data::Owned as ::capnp::introspect::Introspect>::introspect(), 1 => <::capnp::data::Owned as ::capnp::introspect::Introspect>::introspect(), 2 => <::capnp::data::Owned as ::capnp::introspect::Introspect>::introspect(), + 3 => ::introspect(), _ => panic!("invalid field index {}", index), } } @@ -610,9 +664,9 @@ pub mod encrypted_blob { members_by_discriminant: MEMBERS_BY_DISCRIMINANT, members_by_name: MEMBERS_BY_NAME, }; - pub static NONUNION_MEMBERS : &[u16] = &[0,1,2]; + pub static NONUNION_MEMBERS : &[u16] = &[0,1,2,3]; pub static MEMBERS_BY_DISCRIMINANT : &[u16] = &[]; - pub static MEMBERS_BY_NAME : &[u16] = &[1,0,2]; + pub static MEMBERS_BY_NAME : &[u16] = &[1,3,0,2]; pub const TYPE_ID: u64 = 0x9832_7d7e_018d_4af5; } } @@ -875,14 +929,13 @@ pub mod attachment { impl Pipeline { } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 114] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 113] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(28, 212, 54, 35, 214, 83, 152, 153), ::capnp::word(24, 0, 0, 0, 1, 0, 1, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), ::capnp::word(5, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(74, 12, 0, 0, 245, 13, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -1225,14 +1278,13 @@ pub mod armor_message { } } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 66] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 65] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(234, 81, 180, 96, 213, 107, 36, 181), ::capnp::word(24, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(231, 19, 0, 0, 155, 20, 0, 0), ::capnp::word(21, 0, 0, 0, 42, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -1557,18 +1609,17 @@ pub mod body { } } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 59] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 74] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(108, 83, 17, 5, 150, 133, 40, 251), ::capnp::word(24, 0, 0, 0, 1, 0, 1, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), - ::capnp::word(2, 0, 7, 0, 0, 0, 3, 0), + ::capnp::word(3, 0, 7, 0, 0, 0, 3, 0), ::capnp::word(1, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(38, 23, 0, 0, 211, 24, 0, 0), ::capnp::word(21, 0, 0, 0, 234, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(29, 0, 0, 0, 231, 0, 0, 0), + ::capnp::word(29, 0, 0, 0, 31, 1, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(115, 99, 104, 101, 109, 97, 47, 110), @@ -1576,35 +1627,42 @@ pub mod body { ::capnp::word(108, 46, 99, 97, 112, 110, 112, 58), ::capnp::word(66, 111, 100, 121, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 1, 0, 1, 0), - ::capnp::word(16, 0, 0, 0, 3, 0, 4, 0), - ::capnp::word(1, 0, 255, 255, 0, 0, 0, 0), + ::capnp::word(20, 0, 0, 0, 3, 0, 4, 0), + ::capnp::word(2, 0, 255, 255, 0, 0, 0, 0), ::capnp::word(1, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(190, 167, 79, 99, 38, 17, 55, 237), - ::capnp::word(97, 0, 0, 0, 82, 0, 0, 0), + ::capnp::word(125, 0, 0, 0, 82, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(2, 0, 254, 255, 0, 0, 0, 0), + ::capnp::word(3, 0, 254, 255, 0, 0, 0, 0), ::capnp::word(1, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(42, 213, 182, 156, 217, 207, 45, 149), - ::capnp::word(77, 0, 0, 0, 58, 0, 0, 0), + ::capnp::word(105, 0, 0, 0, 58, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(3, 0, 253, 255, 0, 0, 0, 0), + ::capnp::word(4, 0, 253, 255, 0, 0, 0, 0), ::capnp::word(1, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(233, 174, 9, 84, 82, 14, 191, 179), - ::capnp::word(53, 0, 0, 0, 50, 0, 0, 0), + ::capnp::word(81, 0, 0, 0, 50, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 4, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(29, 0, 0, 0, 58, 0, 0, 0), + ::capnp::word(57, 0, 0, 0, 58, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(52, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(64, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(1, 0, 0, 0, 2, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 5, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(61, 0, 0, 0, 122, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(24, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(36, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(60, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(72, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(101, 110, 99, 114, 121, 112, 116, 101), ::capnp::word(100, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(115, 105, 103, 110, 101, 100, 0, 0), @@ -1617,6 +1675,15 @@ pub mod body { ::capnp::word(16, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(101, 110, 99, 111, 100, 101, 100, 67), + ::capnp::word(111, 110, 116, 101, 110, 116, 0, 0), + ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ]; pub fn get_field_types(index: u16) -> ::capnp::introspect::Type { match index { @@ -1624,6 +1691,7 @@ pub mod body { 1 => ::introspect(), 2 => ::introspect(), 3 => ::introspect(), + 4 => <::capnp::text::Owned as ::capnp::introspect::Introspect>::introspect(), _ => panic!("invalid field index {}", index), } } @@ -1636,9 +1704,9 @@ pub mod body { members_by_discriminant: MEMBERS_BY_DISCRIMINANT, members_by_name: MEMBERS_BY_NAME, }; - pub static NONUNION_MEMBERS : &[u16] = &[3]; + pub static NONUNION_MEMBERS : &[u16] = &[3,4]; pub static MEMBERS_BY_DISCRIMINANT : &[u16] = &[0,1,2]; - pub static MEMBERS_BY_NAME : &[u16] = &[0,2,3,1]; + pub static MEMBERS_BY_NAME : &[u16] = &[4,0,2,3,1]; pub const TYPE_ID: u64 = 0xfb28_8596_0511_536c; } pub enum Which { @@ -1811,13 +1879,12 @@ pub mod body { impl Pipeline { } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 50] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 49] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(190, 167, 79, 99, 38, 17, 55, 237), ::capnp::word(29, 0, 0, 0, 1, 0, 1, 0), ::capnp::word(108, 83, 17, 5, 150, 133, 40, 251), - ::capnp::word(2, 0, 7, 0, 1, 0, 0, 0), - ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(3, 0, 7, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -2036,13 +2103,12 @@ pub mod body { impl Pipeline { } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 35] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 34] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(42, 213, 182, 156, 217, 207, 45, 149), ::capnp::word(29, 0, 0, 0, 1, 0, 1, 0), ::capnp::word(108, 83, 17, 5, 150, 133, 40, 251), - ::capnp::word(2, 0, 7, 0, 1, 0, 0, 0), - ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(3, 0, 7, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(21, 0, 0, 0, 34, 1, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -2245,13 +2311,12 @@ pub mod body { impl Pipeline { } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 34] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 33] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(233, 174, 9, 84, 82, 14, 191, 179), ::capnp::word(29, 0, 0, 0, 1, 0, 1, 0), ::capnp::word(108, 83, 17, 5, 150, 133, 40, 251), - ::capnp::word(2, 0, 7, 0, 1, 0, 0, 0), - ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(3, 0, 7, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(21, 0, 0, 0, 26, 1, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -2526,18 +2591,17 @@ pub mod signature_block { impl Pipeline { } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 67] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 83] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(75, 191, 229, 219, 43, 167, 171, 236), ::capnp::word(24, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), - ::capnp::word(3, 0, 7, 0, 0, 0, 0, 0), + ::capnp::word(4, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(246, 26, 0, 0, 204, 27, 0, 0), ::capnp::word(21, 0, 0, 0, 58, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(33, 0, 0, 0, 175, 0, 0, 0), + ::capnp::word(33, 0, 0, 0, 231, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(115, 99, 104, 101, 109, 97, 47, 110), @@ -2546,28 +2610,35 @@ pub mod signature_block { ::capnp::word(83, 105, 103, 110, 97, 116, 117, 114), ::capnp::word(101, 66, 108, 111, 99, 107, 0, 0), ::capnp::word(0, 0, 0, 0, 1, 0, 1, 0), - ::capnp::word(12, 0, 0, 0, 3, 0, 4, 0), + ::capnp::word(16, 0, 0, 0, 3, 0, 4, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(69, 0, 0, 0, 98, 0, 0, 0), + ::capnp::word(97, 0, 0, 0, 98, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(68, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(80, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(96, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(108, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(1, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(77, 0, 0, 0, 82, 0, 0, 0), + ::capnp::word(105, 0, 0, 0, 82, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(76, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(88, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(104, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(116, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(2, 0, 0, 0, 2, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 2, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(85, 0, 0, 0, 58, 0, 0, 0), + ::capnp::word(113, 0, 0, 0, 58, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(108, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(120, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(3, 0, 0, 0, 3, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 3, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(117, 0, 0, 0, 138, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(80, 0, 0, 0, 3, 0, 1, 0), - ::capnp::word(92, 0, 0, 0, 2, 0, 1, 0), + ::capnp::word(120, 0, 0, 0, 3, 0, 1, 0), + ::capnp::word(132, 0, 0, 0, 2, 0, 1, 0), ::capnp::word(112, 114, 111, 102, 105, 108, 101, 78), ::capnp::word(97, 109, 101, 0, 0, 0, 0, 0), ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), @@ -2594,12 +2665,23 @@ pub mod signature_block { ::capnp::word(13, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(101, 110, 99, 111, 100, 101, 100, 83), + ::capnp::word(105, 103, 80, 117, 98, 107, 101, 121), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ]; pub fn get_field_types(index: u16) -> ::capnp::introspect::Type { match index { 0 => <::capnp::text::Owned as ::capnp::introspect::Introspect>::introspect(), 1 => <::capnp::data::Owned as ::capnp::introspect::Introspect>::introspect(), 2 => <::capnp::data::Owned as ::capnp::introspect::Introspect>::introspect(), + 3 => <::capnp::text::Owned as ::capnp::introspect::Introspect>::introspect(), _ => panic!("invalid field index {}", index), } } @@ -2612,9 +2694,9 @@ pub mod signature_block { members_by_discriminant: MEMBERS_BY_DISCRIMINANT, members_by_name: MEMBERS_BY_NAME, }; - pub static NONUNION_MEMBERS : &[u16] = &[0,1,2]; + pub static NONUNION_MEMBERS : &[u16] = &[0,1,2,3]; pub static MEMBERS_BY_DISCRIMINANT : &[u16] = &[]; - pub static MEMBERS_BY_NAME : &[u16] = &[0,2,1]; + pub static MEMBERS_BY_NAME : &[u16] = &[3,0,2,1]; pub const TYPE_ID: u64 = 0xecab_a72b_dbe5_bf4b; } } @@ -2793,14 +2875,13 @@ pub mod seal_block { impl Pipeline { } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 51] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 50] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(143, 169, 45, 116, 228, 15, 115, 166), ::capnp::word(24, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), ::capnp::word(2, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(84, 29, 0, 0, 238, 29, 0, 0), ::capnp::word(21, 0, 0, 0, 18, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -3042,11 +3123,19 @@ pub mod email { pub fn has_attachments(&self) -> bool { !self.reader.get_pointer_field(11).is_null() } + #[inline] + pub fn get_signature_source(self) -> ::capnp::Result<::capnp::text::Reader<'a>> { + ::capnp::traits::FromPointerReader::get_from_pointer(&self.reader.get_pointer_field(12), ::core::option::Option::None) + } + #[inline] + pub fn has_signature_source(&self) -> bool { + !self.reader.get_pointer_field(12).is_null() + } } pub struct Builder<'a> { builder: ::capnp::private::layout::StructBuilder<'a> } impl <> ::capnp::traits::HasStructSize for Builder<'_,> { - const STRUCT_SIZE: ::capnp::private::layout::StructSize = ::capnp::private::layout::StructSize { data: 2, pointers: 12 }; + const STRUCT_SIZE: ::capnp::private::layout::StructSize = ::capnp::private::layout::StructSize { data: 2, pointers: 13 }; } impl <> ::capnp::traits::HasTypeId for Builder<'_,> { const TYPE_ID: u64 = _private::TYPE_ID; @@ -3320,6 +3409,22 @@ pub mod email { pub fn has_attachments(&self) -> bool { !self.builder.is_pointer_field_null(11) } + #[inline] + pub fn get_signature_source(self) -> ::capnp::Result<::capnp::text::Builder<'a>> { + ::capnp::traits::FromPointerBuilder::get_from_pointer(self.builder.get_pointer_field(12), ::core::option::Option::None) + } + #[inline] + pub fn set_signature_source(&mut self, value: impl ::capnp::traits::SetterInput<::capnp::text::Owned>) { + ::capnp::traits::SetterInput::set_pointer_builder(self.builder.reborrow().get_pointer_field(12), value, false).unwrap() + } + #[inline] + pub fn init_signature_source(self, size: u32) -> ::capnp::text::Builder<'a> { + self.builder.get_pointer_field(12).init_text(size) + } + #[inline] + pub fn has_signature_source(&self) -> bool { + !self.builder.is_pointer_field_null(12) + } } pub struct Pipeline { _typeless: ::capnp::any_pointer::Pipeline } @@ -3334,18 +3439,17 @@ pub mod email { } } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 272] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 287] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(37, 159, 86, 15, 201, 215, 213, 254), ::capnp::word(24, 0, 0, 0, 1, 0, 2, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), - ::capnp::word(12, 0, 7, 0, 0, 0, 0, 0), + ::capnp::word(13, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(150, 32, 0, 0, 43, 36, 0, 0), ::capnp::word(21, 0, 0, 0, 242, 0, 0, 0), ::capnp::word(33, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(29, 0, 0, 0, 135, 3, 0, 0), + ::capnp::word(29, 0, 0, 0, 191, 3, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(115, 99, 104, 101, 109, 97, 47, 110), @@ -3353,119 +3457,126 @@ pub mod email { ::capnp::word(108, 46, 99, 97, 112, 110, 112, 58), ::capnp::word(69, 109, 97, 105, 108, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 1, 0, 1, 0), - ::capnp::word(64, 0, 0, 0, 3, 0, 4, 0), + ::capnp::word(68, 0, 0, 0, 3, 0, 4, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(177, 1, 0, 0, 26, 0, 0, 0), + ::capnp::word(205, 1, 0, 0, 26, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(172, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(184, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(200, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(212, 1, 0, 0, 2, 0, 1, 0), ::capnp::word(1, 0, 0, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 1, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(181, 1, 0, 0, 42, 0, 0, 0), + ::capnp::word(209, 1, 0, 0, 42, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(176, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(188, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(204, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(216, 1, 0, 0, 2, 0, 1, 0), ::capnp::word(2, 0, 0, 0, 2, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 2, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(185, 1, 0, 0, 26, 0, 0, 0), + ::capnp::word(213, 1, 0, 0, 26, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(180, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(192, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(208, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(220, 1, 0, 0, 2, 0, 1, 0), ::capnp::word(3, 0, 0, 0, 3, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 3, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(189, 1, 0, 0, 66, 0, 0, 0), + ::capnp::word(217, 1, 0, 0, 66, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(184, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(196, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(212, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(224, 1, 0, 0, 2, 0, 1, 0), ::capnp::word(4, 0, 0, 0, 4, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 4, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(193, 1, 0, 0, 42, 0, 0, 0), + ::capnp::word(221, 1, 0, 0, 42, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(188, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(200, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(216, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(228, 1, 0, 0, 2, 0, 1, 0), ::capnp::word(5, 0, 0, 0, 5, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 5, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(197, 1, 0, 0, 74, 0, 0, 0), + ::capnp::word(225, 1, 0, 0, 74, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(196, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(208, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(224, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(236, 1, 0, 0, 2, 0, 1, 0), ::capnp::word(6, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 6, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(205, 1, 0, 0, 42, 0, 0, 0), + ::capnp::word(233, 1, 0, 0, 42, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(200, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(212, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(228, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(240, 1, 0, 0, 2, 0, 1, 0), ::capnp::word(7, 0, 0, 0, 64, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(209, 1, 0, 0, 58, 0, 0, 0), + ::capnp::word(237, 1, 0, 0, 58, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(204, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(216, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(232, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(244, 1, 0, 0, 2, 0, 1, 0), ::capnp::word(8, 0, 0, 0, 6, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 8, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(213, 1, 0, 0, 90, 0, 0, 0), + ::capnp::word(241, 1, 0, 0, 90, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(212, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(224, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(240, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(252, 1, 0, 0, 2, 0, 1, 0), ::capnp::word(9, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 9, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(221, 1, 0, 0, 106, 0, 0, 0), + ::capnp::word(249, 1, 0, 0, 106, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(220, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(232, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(248, 1, 0, 0, 3, 0, 1, 0), + ::capnp::word(4, 2, 0, 0, 2, 0, 1, 0), ::capnp::word(10, 0, 0, 0, 8, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 10, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(229, 1, 0, 0, 130, 0, 0, 0), + ::capnp::word(1, 2, 0, 0, 130, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(228, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(240, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(0, 2, 0, 0, 3, 0, 1, 0), + ::capnp::word(12, 2, 0, 0, 2, 0, 1, 0), ::capnp::word(11, 0, 0, 0, 9, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 11, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(237, 1, 0, 0, 82, 0, 0, 0), + ::capnp::word(9, 2, 0, 0, 82, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(236, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(248, 1, 0, 0, 2, 0, 1, 0), + ::capnp::word(8, 2, 0, 0, 3, 0, 1, 0), + ::capnp::word(20, 2, 0, 0, 2, 0, 1, 0), ::capnp::word(12, 0, 0, 0, 65, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 12, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(245, 1, 0, 0, 122, 0, 0, 0), + ::capnp::word(17, 2, 0, 0, 122, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(244, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(0, 2, 0, 0, 2, 0, 1, 0), + ::capnp::word(16, 2, 0, 0, 3, 0, 1, 0), + ::capnp::word(28, 2, 0, 0, 2, 0, 1, 0), ::capnp::word(13, 0, 0, 0, 66, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 13, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(253, 1, 0, 0, 106, 0, 0, 0), + ::capnp::word(25, 2, 0, 0, 106, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(252, 1, 0, 0, 3, 0, 1, 0), - ::capnp::word(8, 2, 0, 0, 2, 0, 1, 0), + ::capnp::word(24, 2, 0, 0, 3, 0, 1, 0), + ::capnp::word(36, 2, 0, 0, 2, 0, 1, 0), ::capnp::word(14, 0, 0, 0, 10, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 14, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(5, 2, 0, 0, 114, 0, 0, 0), + ::capnp::word(33, 2, 0, 0, 114, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(4, 2, 0, 0, 3, 0, 1, 0), - ::capnp::word(16, 2, 0, 0, 2, 0, 1, 0), + ::capnp::word(32, 2, 0, 0, 3, 0, 1, 0), + ::capnp::word(44, 2, 0, 0, 2, 0, 1, 0), ::capnp::word(15, 0, 0, 0, 11, 0, 0, 0), ::capnp::word(0, 0, 1, 0, 15, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(13, 2, 0, 0, 98, 0, 0, 0), + ::capnp::word(41, 2, 0, 0, 98, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(40, 2, 0, 0, 3, 0, 1, 0), + ::capnp::word(68, 2, 0, 0, 2, 0, 1, 0), + ::capnp::word(16, 0, 0, 0, 12, 0, 0, 0), + ::capnp::word(0, 0, 1, 0, 16, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(12, 2, 0, 0, 3, 0, 1, 0), - ::capnp::word(40, 2, 0, 0, 2, 0, 1, 0), + ::capnp::word(65, 2, 0, 0, 130, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(64, 2, 0, 0, 3, 0, 1, 0), + ::capnp::word(76, 2, 0, 0, 2, 0, 1, 0), ::capnp::word(105, 100, 0, 0, 0, 0, 0, 0), ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -3607,6 +3718,15 @@ pub mod email { ::capnp::word(14, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(115, 105, 103, 110, 97, 116, 117, 114), + ::capnp::word(101, 83, 111, 117, 114, 99, 101, 0), + ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(12, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), + ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ]; pub fn get_field_types(index: u16) -> ::capnp::introspect::Type { match index { @@ -3626,6 +3746,7 @@ pub mod email { 13 => ::introspect(), 14 => ::introspect(), 15 => <::capnp::struct_list::Owned as ::capnp::introspect::Introspect>::introspect(), + 16 => <::capnp::text::Owned as ::capnp::introspect::Introspect>::introspect(), _ => panic!("invalid field index {}", index), } } @@ -3638,9 +3759,9 @@ pub mod email { members_by_discriminant: MEMBERS_BY_DISCRIMINANT, members_by_name: MEMBERS_BY_NAME, }; - pub static NONUNION_MEMBERS : &[u16] = &[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]; + pub static NONUNION_MEMBERS : &[u16] = &[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]; pub static MEMBERS_BY_DISCRIMINANT : &[u16] = &[]; - pub static MEMBERS_BY_NAME : &[u16] = &[15,4,6,1,13,5,0,7,11,8,10,9,12,3,2,14]; + pub static MEMBERS_BY_NAME : &[u16] = &[15,4,6,1,13,5,0,7,11,8,10,9,16,12,3,2,14]; pub const TYPE_ID: u64 = 0xfed5_d7c9_0f56_9f25; } } @@ -3819,14 +3940,13 @@ pub mod transport_auth { impl Pipeline { } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 66] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 65] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(183, 201, 25, 216, 46, 193, 73, 219), ::capnp::word(24, 0, 0, 0, 1, 0, 1, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), ::capnp::word(1, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(208, 36, 0, 0, 97, 37, 0, 0), ::capnp::word(21, 0, 0, 0, 50, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -3944,14 +4064,13 @@ impl ::capnp::traits::HasTypeId for TransportAuthMethod { const TYPE_ID: u64 = 0x9e0e_6ed6_009e_4b13u64; } mod transport_auth_method { -pub static ENCODED_NODE: [::capnp::Word; 33] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), +pub static ENCODED_NODE: [::capnp::Word; 32] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(19, 75, 158, 0, 214, 110, 14, 158), ::capnp::word(24, 0, 0, 0, 2, 0, 0, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(159, 37, 0, 0, 223, 37, 0, 0), ::capnp::word(21, 0, 0, 0, 98, 1, 0, 0), ::capnp::word(41, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), @@ -4290,14 +4409,13 @@ pub mod email_attachment { impl Pipeline { } mod _private { - pub static ENCODED_NODE: [::capnp::Word; 164] = [ - ::capnp::word(0, 0, 0, 0, 6, 0, 6, 0), + pub static ENCODED_NODE: [::capnp::Word; 163] = [ + ::capnp::word(0, 0, 0, 0, 5, 0, 6, 0), ::capnp::word(14, 184, 11, 154, 54, 99, 86, 247), ::capnp::word(24, 0, 0, 0, 1, 0, 3, 0), ::capnp::word(128, 158, 242, 212, 161, 227, 197, 183), ::capnp::word(6, 0, 7, 0, 0, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), - ::capnp::word(216, 38, 0, 0, 182, 40, 0, 0), ::capnp::word(21, 0, 0, 0, 66, 1, 0, 0), ::capnp::word(37, 0, 0, 0, 7, 0, 0, 0), ::capnp::word(0, 0, 0, 0, 0, 0, 0, 0), diff --git a/tauri-app/backend/src/types.rs b/tauri-app/backend/src/types.rs index c275e33..cef1ffd 100644 --- a/tauri-app/backend/src/types.rs +++ b/tauri-app/backend/src/types.rs @@ -8,6 +8,34 @@ pub struct KeyPair { pub public_key: String, } +/// A party on an agreement, as supplied by the compose UI. `To:` parties become +/// `signer` signatories and `Cc:` parties become `viewer`s (spec §6.3). The +/// `pubkey` is hex or npub; the `email` is the transport address (and is bound +/// inside the signed RECIPIENTS block, spec §10.2 / #102). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgreementParty { + pub email: String, + pub pubkey: String, +} + +/// Output of composing an agreement: the armored `text/plain` body and the +/// subject to place in the header. For an encrypted (envelope) agreement the +/// `subject` is AES-256-GCM-encrypted under the same CEK as the body; for a +/// plaintext (public) agreement it is the cleartext subject. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComposedAgreement { + pub armor: String, + pub subject: String, + /// Attachment MIME parts to send alongside the armor. For an encrypted + /// message these are the AES-encrypted `aN.dat` parts whose keys live in the + /// (CEK-protected) manifest; for a public message they are the plaintext + /// attachments. Empty when there are none. (spec §11.2) + #[serde(default)] + pub attachments: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccountInfo { pub public_key: String, @@ -274,6 +302,53 @@ pub struct ParsedArmorMessage { pub quoted_armor_text: Option, /// Decoded body bytes as base64 (for signature verification without re-decoding) pub body_bytes_b64: Option, + /// Parsed RECIPIENTS block entries for this level (spec §10.2). Non-empty + /// marks the multi-recipient envelope (CEK) decryption path. Populated + /// outside the capnp schema, like `prefix_text`/`quoted_armor_text`. + #[serde(default)] + pub recipients: Vec, + /// Raw RECIPIENTS block body, retained verbatim so the canonical form (and + /// thus the document hash `H`) can be recomputed (spec §4.2, §11.3.1). + #[serde(default)] + pub recipients_text: Option, + /// Parsed CONSENT block for this level, if any (spec §11.3). + #[serde(default)] + pub consent: Option, + /// Parsed ATTACHMENTS block for this level (public messages binding plaintext + /// attachment hashes; spec §11.2). Empty when absent. + #[serde(default)] + pub attachments: Vec, +} + +/// Result of building a 1:1 (pairwise) encrypted manifest body in Rust (spec +/// §11.2): the NIP-encrypted, ASCII-armored body to place in the message, plus +/// the encrypted `aN.dat` attachment MIME parts to send alongside it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedManifestBody { + pub armored_body: String, + pub attachments: Vec, +} + +/// Result of checking a delivered plaintext attachment against a public message's +/// signed ATTACHMENTS block (spec §11.2). A file is trustworthy only when the +/// message signature is valid (so the block is authentic) *and* the file's +/// SHA-256 matches a listed spec. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublicAttachmentVerification { + /// `signature_valid && spec_found && hash_match` — the file is bound. + pub verified: bool, + /// The message's armor signature(s) verified (the ATTACHMENTS block is authentic). + pub signature_valid: bool, + /// The block lists an attachment with this filename. + pub spec_found: bool, + /// The delivered file's SHA-256 matches the listed spec. + pub hash_match: bool, + /// The SHA-256 recorded in the block for this filename, if any. + pub expected_sha256: Option, + /// The SHA-256 actually computed over the delivered bytes. + pub actual_sha256: String, } /// Per-signature verification result (one per nesting level in the armor chain). diff --git a/tauri-app/backend/tests/email_integration.rs b/tauri-app/backend/tests/email_integration.rs index f813454..d5efe53 100644 --- a/tauri-app/backend/tests/email_integration.rs +++ b/tauri-app/backend/tests/email_integration.rs @@ -106,6 +106,7 @@ async fn defaults_header_sig_roundtrip() { true, // include_sig_header Some(&bob_npub), // recipient_pubkey (default-on anchor for decryption) true, // include_recipient_header + &[], // cc ) .await .expect("send_email"); @@ -231,6 +232,7 @@ async fn glossia_body_latin_roundtrip() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -385,6 +387,7 @@ async fn defaults_full_roundtrip_with_inline_sig() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -488,6 +491,7 @@ async fn nip04_legacy_decrypt() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -568,6 +572,7 @@ async fn nip04_header_sig_fallback_unlocks_decrypt() { true, // include X-Nostr-Sig Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -707,6 +712,7 @@ async fn multipart_html_and_text() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -771,6 +777,7 @@ async fn non_ascii_subject_roundtrip() { false, None, // no recipient pubkey — this test isn't about encryption true, // default-on, but the None pubkey above makes it a no-op + &[], // cc ) .await .expect("send_email"); @@ -864,6 +871,7 @@ async fn quoted_printable_body_roundtrip() { false, None, true, + &[], // cc ) .await .expect("send_email"); @@ -945,6 +953,7 @@ async fn sent_mail_decrypts_via_recipient_header_without_dm() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -1042,6 +1051,7 @@ async fn sent_mail_undecryptable_without_any_counterparty_hint() { true, None, false, + &[], // cc ) .await .expect("send_email"); @@ -1151,6 +1161,7 @@ async fn reply_threading_headers_and_encoded_quote() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("alice send_email"); @@ -1189,6 +1200,7 @@ async fn reply_threading_headers_and_encoded_quote() { true, Some(&alice_npub), true, + &[], // cc ) .await .expect("bob send_email reply"); @@ -1406,6 +1418,7 @@ async fn tampered_body_invalidates_signature() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -1510,6 +1523,7 @@ async fn clearsigned_plaintext_verifies_via_header() { true, // sig header — send_email signs the same canonical bytes None, // no recipient (no encryption context) false, // skip recipient header + &[], // cc ) .await .expect("send_email"); @@ -1570,6 +1584,7 @@ async fn inline_valid_header_broken_reports_body() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -1652,6 +1667,7 @@ async fn inline_broken_header_valid_reports_header() { true, // sig header — will sign over the (broken-inline) body bytes correctly Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -1728,6 +1744,7 @@ async fn broken_pubkey_fails_verification() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("send_email"); @@ -1859,6 +1876,7 @@ async fn signed_plaintext_reply_preserves_nested_signature() { true, // include_sig_header None, // no recipient (clearsigned, no encryption context) false, // skip recipient header + &[], // cc ) .await .expect("alice send_email"); @@ -1931,6 +1949,7 @@ async fn signed_plaintext_reply_preserves_nested_signature() { true, None, false, + &[], // cc ) .await .expect("bob send_email reply"); @@ -2166,6 +2185,7 @@ async fn nip44_reply_preserves_nested_encrypted_armor() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("alice send_email"); @@ -2219,6 +2239,7 @@ async fn nip44_reply_preserves_nested_encrypted_armor() { true, Some(&alice_npub), true, + &[], // cc ) .await .expect("bob send_email reply"); @@ -2435,6 +2456,7 @@ async fn nip44_three_level_reply_chain() { true, Some(&bob_npub), true, + &[], // cc ) .await .expect("send hop 3"); diff --git a/tauri-app/frontend/index.html b/tauri-app/frontend/index.html index 46a1e59..7bfe167 100644 --- a/tauri-app/frontend/index.html +++ b/tauri-app/frontend/index.html @@ -25,6 +25,7 @@ +