# `Msg` — PollyWeb Message Envelope

`pollyweb.msg` implements the PollyWeb message envelope (`pollyweb.org/MSG:1.0`): a signed, tamper-evident JSON structure that domains exchange to communicate securely.

**See also:** [`Domain`](domain.md), [`KeyPair`](keypair.md)

---

## Quick start

### Sending via a `Domain` (recommended)

```python
import pollyweb as pw

msg = pw.Msg(To="receiver.dom", Subject="Hello@Host", Body={"text": "hi"})
pair = pw.KeyPair()
domain = pw.Domain(Name="sender.dom", KeyPair=pair, Selector="pw1")
signed = domain.sign(msg)

# If you need to publish DNS first:
dns_record = domain.dns()
# publish the TXT value at: {selector}._domainkey.pw.sender.dom
# domain.sign(msg) will write that selector into signed.Selector
```

### Direct signing

```python
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import pollyweb as pw

private_key = Ed25519PrivateKey.generate()
msg = pw.Msg(From="sender.dom", To="receiver.dom", Subject="Hello@Host", Selector="pw1", Body={"text": "hi"})
signed = msg.sign(private_key)

# Receiver: validate
public_key = private_key.public_key()
signed.validate(public_key)  # True, or raises pw.MsgValidationError
```

---

## `Msg` fields

`Msg` is a **frozen dataclass** — all fields are immutable after construction. Methods that modify state return a new instance.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `To` | `str` | ✅ | — | Receiver's domain name. |
| `Subject` | `str` | ✅ | — | Method to invoke on the receiver (e.g. `Hello@Host`). |
| `From` | `str` | — | `""` | Sender's domain name. Set by `Domain.sign()`. |
| `Selector` | `str` | — | `""` | Name of the sender's public key in DNS (e.g. `pw1` → `pw1._domainkey.pw.sender.dom`). When signing through a [`Domain`](domain.md), this selector is derived from [`Domain.dns()`](domain.md) and written by `Domain.sign()`. |
| `Body` | `dict` | — | `{}` | Arbitrary JSON-serialisable payload. |
| `Correlation` | `str` | — | UUID4 | Unique message ID generated by the sender, used for deduplication. |
| `Timestamp` | `str` | — | UTC now | ISO-8601 timestamp with millisecond precision (e.g. `2025-06-01T12:00:00.000Z`). |
| `Schema` | `str` | — | `pollyweb.org/MSG:1.0` | Wire-format schema version. |
| `Hash` | `str \| None` | — | `None` | SHA-256 hex digest of the canonical form. Set by `sign()`. |
| `Signature` | `str \| None` | — | `None` | Base64-encoded Ed25519 signature. Set by `sign()`. |

`validate()` requires `From`, `To`, `Subject`, `Correlation`, `Timestamp`, and `Selector` to be non-empty, so messages must have those fields populated (directly or via `Domain.sign()`) before validation.

---

## Methods

### `msg.canonical() → bytes`

Returns the canonical JSON bytes used for hashing and signing.

The canonical form contains only `Schema`, `Header`, and `Body` — never `Hash` or `Signature`. Keys are sorted and serialised with no whitespace (JCS, RFC 8785).

```python
msg.canonical()
# b'{"Body":{"text":"hi"},"Header":{"Correlation":"...","Selector":"pw1","From":"sender.dom",...}}'
```

### `msg.sign(private_key: Ed25519PrivateKey) → Msg`

Returns a **new** `Msg` with `Hash` and `Signature` set. The original is unchanged.

```python
signed = msg.sign(private_key)
assert msg.Hash is None       # original untouched
assert signed.Hash is not None
```

Internally: `SHA-256(canonical())` → `Hash`; `Ed25519.sign(canonical())` → base64 → `Signature`.

If you are signing through a [`Domain`](domain.md), prefer [`domain.sign(msg)`](domain.md), which fills in `From` and derives `Selector` from the current DNS/public-key state before signing with the domain's [`KeyPair`](keypair.md).

### `msg.validate(public_key: Ed25519PublicKey | None = None) → bool`

Validates structure and signature. Returns `True` on success, raises `MsgValidationError` otherwise.

`public_key` is optional. When omitted, the key is fetched from DNS using the
message's `Selector` and `From` domain:

```
{Selector}._domainkey.pw.{From}  (DNS TXT record)
```

The TXT record must follow the standard DKIM wire format:
`v=DKIM1; k=ed25519; p=<base64-encoded public key>`

**DNSSEC is required** — the DNS response must have the Authenticated Data (AD)
flag set. If the domain has not enabled DNSSEC, validation fails with
`MsgValidationError("DNSSEC not enabled for …")`.

Checks performed in order:

1. `Schema` matches `pollyweb.org/MSG:1.0`
2. All required header fields (`From`, `To`, `Subject`, `Correlation`, `Timestamp`, `Selector`) are non-empty
3. `Hash` and `Signature` are present
4. If no `public_key` supplied: resolve from DNS (DNSSEC required)
5. `SHA-256(canonical())` matches stored `Hash`
6. Ed25519 signature verifies against `canonical()`

```python
# Explicit key (no DNS lookup)
signed.validate(public_key)

# Explicit key from a KeyPair
signed.validate(pair.PublicKey)

# Key resolved from DNS (requires DNSSEC on sender.dom)
signed.validate()
```

### `msg.to_dict() → dict`

Serialises to a Python dict matching the wire format (ready for `json.dumps`).

```python
{
    "Header": {
        "From": "sender.dom",
        "To": "receiver.dom",
        "Subject": "Hello@Host",
        "Correlation": "3fa85f64-...",
        "Timestamp": "2025-06-01T12:00:00.000Z",
        "Selector": "pw1",
        "Schema": "pollyweb.org/MSG:1.0"
    },
    "Body": {"text": "hi"},
    "Hash": "ee6ca2a4...",
    "Signature": "Lw7sQp6z..."
}
```

`Hash` and `Signature` are omitted if `None` (i.e. unsigned messages).

### `Msg.from_dict(d: dict) → Msg`

Deserialises from a dict. Inverse of `to_dict()`.

```python
msg = Msg.from_dict(json.loads(raw_json))
```

---

## Wire format

```yaml
# YAML shown for readability; wire format is compact JSON
Header:
  Schema: pollyweb.org/MSG:1.0
  From: sender.dom
  To: receiver.dom
  Subject: Hello@Host
  Correlation: 3fa85f64-5717-4562-b3fc-2c963f66afa6
  Timestamp: 2025-06-01T12:00:00.000Z
  Selector: pw1

Body: { ... }

Hash: ee6ca2a43ec05d...
Signature: Lw7sQp6zkOGyJ+OzGn+B...
```

---

## Error handling

`MsgValidationError` is raised by `validate()` with a descriptive message:

| Message | Cause |
|---|---|
| `Unsupported schema: X` | `Schema` field is not `pollyweb.org/MSG:1.0` |
| `Missing From` / `To` / … | A required header field is empty |
| `Missing Hash` | `Hash` is `None` (message not signed) |
| `Missing Signature` | `Signature` is `None` |
| `Hash mismatch` | Body or header was tampered with after signing |
| `Invalid signature` | Signature does not verify (wrong key or tampered content) |
| `Malformed signature: …` | `Signature` is not valid base64 |

---

## Design notes

**Immutability** — `Msg` is a frozen dataclass. `sign()` returns a new instance rather than mutating in place, making it safe to pass unsigned messages around without risk of accidental modification.

**Ed25519 over RSA** — Ed25519 (RFC 8032 / RFC 8463) provides a ~128-bit security level with 64-byte signatures, is immune to timing attacks by design, and requires no padding-scheme decision. RSA-2048 with PKCS1v15 (the DKIM default) is deprecated in FIPS 186-5.

**JCS canonicalisation** — Keys are sorted and whitespace is removed before hashing, following RFC 8785. This guarantees the same bytes regardless of how the dict was constructed.

**`Header` as a wire concept only** — In Python, all fields sit flat on `Msg`. The `Header` object only appears inside `to_dict()` / `from_dict()` to match the JSON wire format.

**CamelCase fields** — Field names on `Msg` match the wire-format names (e.g. `From`, `To`, `Subject`) for consistency between the Python API and the JSON representation.

**`From` and `Selector` are optional at construction** — They default to `""` so that a `Msg` can be built without knowing the sender. `Domain.sign()` fills them in automatically, deriving `Selector` from [`Domain.dns()`](domain.md). `validate()` still requires them to be non-empty.
