
# Multi-recipient

> Encrypt a payload once and let several recipients open it, each with their own key, in a JWE JSON object.

```ts
import { encryptMulti, decryptMulti } from "unjwt/jwe";
import { generateJWK } from "unjwt/jwk";

const alice = await generateJWK("RSA-OAEP-256", { kid: "alice" });
const bob = await generateJWK("ECDH-ES+A256KW", { kid: "bob" });
const backup = await generateJWK("A256KW", { kid: "backup" });

const jwe = await encryptMulti(
  { doc: "q3-report", level: "confidential" },
  [{ key: alice.publicKey }, { key: bob.publicKey }, { key: backup, header: { "x-route": "eu" } }],
  { sharedUnprotectedHeader: { app: "reports" }, aad: "doc:q3-report" },
);

const { payload, recipientIndex } = await decryptMulti(jwe, bob.privateKey);
// payload: { doc: "q3-report", level: "confidential" }, recipientIndex: 1
```

One random content key encrypts the payload once. That content key is then wrapped separately for each recipient with that recipient's `alg`. Whoever can unwrap any one copy can read the payload. The compact form holds a single recipient, so this uses the JSON serialization ([RFC 7516 §7.2](https://www.rfc-editor.org/rfc/rfc7516#section-7.2)).

## Signatures

```ts
function encryptMulti(
  payload: string | Uint8Array | Record<string, unknown>,
  recipients: JWEMultiRecipient[],
  options?: JWEMultiEncryptOptions,
): Promise<JWEGeneralSerialization>;

interface JWEMultiRecipient {
  key: JWEEncryptJWK; // a JWK with `alg`; its `kid` is copied into the header
  header?: JWEHeaderParameters; // per-recipient, not integrity protected
  p2s?: Uint8Array;
  p2c?: number;
  keyManagementIV?: Uint8Array;
  ecdh?: { ephemeralKey?; partyUInfo?: Uint8Array; partyVInfo?: Uint8Array };
}

function decryptMulti<T = JWTClaims>(
  jwe: JWEGeneralSerialization | JWEFlattenedSerialization,
  key: JWEDecryptJWK | JWKSet | CryptoKey | Uint8Array | string | JWEKeyLookup,
  options?: JWEDecryptOptions & {
    strictRecipientMatch?: boolean;
    limits?: { recipients?: number; keyCandidates?: number; pbes2Iterations?: number };
  },
): Promise<{
  payload: T;
  protectedHeader: JWEHeaderParameters;
  sharedUnprotectedHeader?: JWEHeaderParameters;
  recipientHeader?: JWEHeaderParameters;
  recipientIndex: number;
  cek?: Uint8Array;
  aad?: Uint8Array;
}>;

function generalToFlattened(jwe: JWEGeneralSerialization): JWEFlattenedSerialization;
```

## Options

`encryptMulti` takes the `encrypt` options that apply to the whole token: `enc` (default `A256GCM`), `protectedHeader`, the time options, `cek` and `contentEncryptionIV`. Per-recipient settings (`p2s`, `p2c`, `keyManagementIV`, `ecdh`) move onto each recipient. Two options are new:

| Option                    | Type                      | Default | What it does                                                                                                                                     |
| ------------------------- | ------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sharedUnprotectedHeader` | `Record<string, unknown>` | none    | Members for every recipient, written as `unprotected`. Not integrity protected.                                                                  |
| `aad`                     | `string \| Uint8Array`    | none    | Extra data bound to the ciphertext and carried as `aad` ([RFC 7516 §5.1](https://www.rfc-editor.org/rfc/rfc7516#section-5.1)). Empty means none. |

`decryptMulti` takes every [`decrypt()`](/jwt/jwe/decrypt#options) option, plus `strictRecipientMatch` and `limits.recipients` (default `16`). Pass the parsed object, never a string.

## The output

```ts
// jwe
{
  "protected": "eyJlbmMiOi…",          // {"enc":"A256GCM","typ":"JWT"}
  "unprotected": { "app": "reports" },
  "aad": "ZG9jOnEzLXJlcG9ydA",
  "recipients": [
    { "header": { "kid": "alice", "alg": "RSA-OAEP-256" }, "encrypted_key": "ddS6mVN3_x…" },
    { "header": { "kid": "bob", "epk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }, "alg": "ECDH-ES+A256KW" }, "encrypted_key": "x1rIp7NoYN…" },
    { "header": { "kid": "backup", "x-route": "eu", "alg": "A256KW" }, "encrypted_key": "Uf-vK-6Ftg…" }
  ],
  "iv": "emeCQkEYbB…",
  "ciphertext": "ngxEEPDg5A…",
  "tag": "cJfvu1Fphy…"
}
```

There are three header tiers:

| Tier                  | Holds                                                            | Integrity protected |
| --------------------- | ---------------------------------------------------------------- | ------------------- |
| `protected`           | `enc`, `typ`, your `protectedHeader`                             | yes                 |
| `unprotected`         | `sharedUnprotectedHeader`                                        | no                  |
| `recipients[].header` | `alg`, `kid`, `epk`, `p2s`, `p2c`, `iv`, `tag` and your `header` | no                  |

A name may appear in one tier only ([RFC 7516 §7.2.1](https://www.rfc-editor.org/rfc/rfc7516#section-7.2.1)). Overlap is `ERR_JWE_HEADER_PARAMS_NOT_DISJOINT`. `crit` belongs in the protected tier: elsewhere it is `ERR_INVALID_OPTION`. `zip` is refused in every tier.

```ts
await encryptMulti({ a: 1 }, [{ key: backup }], { sharedUnprotectedHeader: { kid: "x" } });
// JWTError ERR_JWE_HEADER_PARAMS_NOT_DISJOINT: JWE header parameter "kid" appears in multiple header tiers; protected, shared unprotected, and per-recipient headers must be disjoint (RFC 7516 §7.2.1).
```

## Recipient keys

- Each `key` is a JWK with an `alg`. A JWK without one, or a `CryptoKey`, is `ERR_JWE_RECIPIENT_ALG_INFERENCE`.
- **`dir` and bare `ECDH-ES` are refused** with `ERR_JWE_ALG_FORBIDDEN_IN_MULTI`. Both make the recipient's own key the content key, which cannot be shared with other recipients. A CEK JWK (`alg: "A256GCM"`) counts as `dir`. Use `ECDH-ES+A*KW` instead, or [`encrypt()`](/jwt/jwe/encrypt) for one recipient.
- A PBES2 recipient with no `p2c` is minted at 600,000 iterations with a fresh 16-byte salt.

```ts
await encryptMulti({ a: 1 }, [{ key: await generateJWK("A256GCM") }]);
// JWTError ERR_JWE_ALG_FORBIDDEN_IN_MULTI: Recipient[0] resolves to "dir", which requires exactly one recipient. Use encrypt() instead.
```

## Decrypting

`decryptMulti` tries the recipients in order and returns the first one your key opens:

```ts
const result = await decryptMulti(jwe, bob.privateKey);
// {
//   payload: { doc: "q3-report", level: "confidential" },
//   protectedHeader: { enc: "A256GCM", typ: "JWT" },
//   recipientIndex: 1,
//   sharedUnprotectedHeader: { app: "reports" },
//   recipientHeader: { kid: "bob", epk: { … }, alg: "ECDH-ES+A256KW" },
// }
```

- `protectedHeader` is only what was protected. `encryptMulti` writes `alg` per recipient, so read it from `recipientHeader`.
- `enc` is read from the protected or the shared unprotected header, never from a recipient's header.
- `recipients[]` is outside the authenticated data, so anyone can add or rewrite an entry. A failure in one recipient (bad header, disallowed `alg`, failed unwrap) moves on to the next. A failure of the whole token (protected header, shared header, content segments, claims) is thrown at once.
- `options.typ` is checked against the protected header only.
- `limits.pbes2Iterations` is one budget for the whole call, so a pile of injected PBES2 recipients cannot multiply the work.

When no recipient opens, the last failure is thrown, usually `ERR_JWE_DECRYPTION_FAILED`. With `strictRecipientMatch: true`, a recipient is tried only when your key could open it (a fitting key type, size or curve; a `kid` on the key is a hint and never rules it out). Nothing matching is `ERR_JWE_NO_MATCHING_RECIPIENT` without any crypto.

```ts
const stranger = await generateJWK("A256KW", { kid: "stranger" });
await decryptMulti(jwe, stranger);
// JWTError ERR_JWE_DECRYPTION_FAILED: JWE decryption failed.
await decryptMulti(jwe, stranger, { strictRecipientMatch: true });
// JWTError ERR_JWE_NO_MATCHING_RECIPIENT: No recipient matched the provided key under strictRecipientMatch.
```

## Flattened form

`encryptMulti` always returns the General form. `generalToFlattened` lifts a single recipient's `header` and `encrypted_key` to the top level, and throws `ERR_JWE_INVALID_SERIALIZATION` for any other count. `decryptMulti` accepts both forms.

```ts
const flat = generalToFlattened(await encryptMulti({ sub: "u" }, [{ key: backup }]));
Object.keys(flat); // ["protected", "iv", "ciphertext", "tag", "header", "encrypted_key"]
(await decryptMulti(flat, backup)).payload; // { sub: "u" }
```

## Errors

| Code                                 | When                                                                                                                       |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `ERR_JWE_INVALID`                    | No recipients, more than `limits.recipients`, or the `decrypt()` token checks.                                             |
| `ERR_JWE_RECIPIENT_ALG_INFERENCE`    | A recipient key that is not a JWK or has no `alg`.                                                                         |
| `ERR_JWE_ALG_FORBIDDEN_IN_MULTI`     | A recipient resolves to `dir` or bare `ECDH-ES`.                                                                           |
| `ERR_JWE_HEADER_PARAMS_NOT_DISJOINT` | A name in more than one header tier.                                                                                       |
| `ERR_JWE_INVALID_SERIALIZATION`      | Not an object, `recipients` mixed with top-level `header` or `encrypted_key`, or a flatten of more than one recipient.     |
| `ERR_JWE_NO_MATCHING_RECIPIENT`      | `strictRecipientMatch` found no candidate recipient.                                                                       |
| `ERR_INVALID_OPTION`                 | `crit` outside the protected tier, `zip` anywhere, an `aad` that is not text or bytes, plus every `encrypt()` option rule. |

`decryptMulti` also throws the [`decrypt()`](/jwt/jwe/decrypt#errors) codes for the recipient it settles on.
