# Decrypting a SavItUp share

When SavItUp delivers an approved share to your endpoint, the values are
sealed to the P-256 public key you registered with us. SavItUp cannot read
them — the sealing happens on the person's phone, to your key, and we relay
the ciphertext. This page is everything you need to open it.

## What arrives

A directed request's delivery POST body:

```
{
  "kind": "share",
  "shareId": "…",
  "requestId": "…",
  "reference": "your-own-id",
  "templateId": "renewal-call",
  "complete": false,
  "received": ["PostalCode", "CurrentCarrier"],
  "missing": ["MonthlyPremium"],
  "payload": { "enc": "…", "iv": "…", "ciphertext": "…", "tag": "…", "aad": "…" }
}
```

`received` and `missing` are YOUR field names, from the template we
configured for you. `missing` can only ever contain OPTIONAL fields — a
person cannot approve a request without every required one. The decrypted
object is keyed by those same names.

## What arrives for a quote lead

A sealed quote lead's delivery POST body is a **different, smaller
shape** — do not assume it matches the share body above field-for-field:

```
{
  "leadId": "…",
  "product": "auto",
  "state": "CO",
  "zip": "80014",
  "fields": ["contact.firstName", "contact.email"],
  "encrypted": { "enc": "…", "iv": "…", "ciphertext": "…", "tag": "…", "aad": "…" },
  "catalogVersion": 3,
  "publicKeyId": "k1",
  "consent": { "profile": true },
  "acquisition": { "channel": "referral" }
}
```

`acquisition.channel` is only present when we actually have one on file
for the person — when we don't, `acquisition` arrives as an empty object
(`"acquisition": {}`), not with a `channel: null`.

Three things that differ from a share, all easy to get wrong if you're
reusing share-decrypt code unmodified:

- **The sealed blob is under `encrypted`, not `payload`.** There is no
  `payload` key on a quote lead at all.
- **`fields` is the disclosed field list, in place of `received`/
  `missing`** — a quote lead has no partial-approval concept, so there's
  nothing to distinguish "received" from "missing".
- **`fields` names are SavItUp's own canonical field names — the SAME
  ones listed as `required`/`optional` on `GET /quotes/template` — NOT
  the carrier field names the decrypted object is keyed by.** This is
  the opposite of a directed share, where `received`/`missing` above
  already ARE your own field names. These are two different
  vocabularies, and they diverge exactly where your own field mapping
  (the `rename` you gave us when you registered your template) renames a
  field. Concretely, if your mapping has
  `"rename": {"contact.firstName": "given_name"}`, a lead sharing that
  field arrives as:

  ```
  "fields": ["contact.firstName"]                 // SavItUp's canonical name
  decrypted: { "given_name": "Dana" }              // YOUR field name, per your rename
  ```

  Do not attempt to match entries in `fields` against keys in the
  decrypted object by string equality — for any field your mapping
  renames, they will not match. `fields` tells you WHICH of the fields
  you asked for were actually shared (useful for logging/reconciliation
  against your own template); to read the values themselves, decrypt and
  read by YOUR field names, exactly as they appear in your own mapping's
  `rename` (or, for a field you didn't rename, its canonical name
  unchanged).

There is no `kind`, `shareId`, `requestId`, `reference`, `templateId`, or
`complete` on a quote lead — those are share-only. `catalogVersion` and
`publicKeyId` pin the lead to the exact template/key it was sealed
against, which matters if you ever rotate keys (§ below on `keyUrl`) —
`publicKeyId` tells you which of your keys to decrypt with if you keep
more than one live at once.

## The scheme

```
enc    = 0x04 || Xe || Ye              (the phone's ephemeral P-256 public key)
shared = ECDH-P256(ephemeral, yours).x, 32 bytes big-endian
salt   = SHA-256(enc || yourPublicKeyRaw)
info   = "savitup/share-request/v1"
key    = HKDF-SHA256(shared, salt, info, 32)
plain  = AES-256-GCM-decrypt(key, iv, ciphertext || tag, aad)
aad    = "<requestId>|directed|<catalogVersion>"
```

The AAD is authenticated, not encrypted: pass it through exactly as
delivered. A code-flow share carries `"<requestId>|<code>|<catalogVersion>"`
instead; the snippet below handles both, because it never parses the AAD —
it only feeds it to AES-GCM.

**A sealed quote lead carries a different AAD again:
`"<leadId>|quote|<catalogVersion>"`** — where `<leadId>` is byte-for-byte
the `leadId` field of the delivery body you received (the phone chose it,
sealed with it, and sent it; we store and forward that same id) — the
literal `quote` in place of
`directed` (or a code). Quote leads are the phone sealing an insurance
quote request straight to your registered key, mapped through the
template we serve you (`GET /quotes/template`). The delivery body's
outer shape differs from a share's, as shown above — but the encrypted
blob itself (`enc`/`iv`/`ciphertext`/`tag`/`aad`) and everything in this
"The scheme" section and the snippet below are exactly the same
mechanism for both: derive the same way, decrypt the same way, and feed
whichever `aad` string actually arrived straight to AES-GCM without
parsing it. The literal is authenticated into the GCM tag along with the
rest of the AAD, so a payload sealed for the quote path can never be
decrypted as a `directed` or code-flow share, or the reverse, even if
every other field happened to match. Do not special-case `quote` in your
decrypt code beyond accepting it as one more AAD value to pass through
unparsed.

`keyUrl`: `GET /quotes/template` (and provider registration) may also
carry a `keyUrl` pointing at `https://<your-domain>/.well-known/savitup-share-key`
if you've published one — see `docs/ssp-provider-onboarding.md` §11 for
what that is and why publishing it matters. It doesn't change anything
about decryption here; it's what lets the app confirm, before sealing,
that the key it's about to seal to is actually yours.

## The snippet

Node 18+, no dependencies. `sealed` is the encrypted blob — the delivery
body's `payload` for a share, or its `encrypted` for a quote lead; the
function itself doesn't care which key it came from, since both are the
same `{enc, iv, ciphertext, tag, aad}` shape. `privatePkcs8B64` is the
base64 PKCS#8 private half of the key you registered.

```js
const { webcrypto } = require('node:crypto');
const subtle = webcrypto.subtle;

const P256 = { name: 'ECDH', namedCurve: 'P-256' };
const un64 = (s) => new Uint8Array(Buffer.from(s, 'base64'));
const cat = (...parts) => {
  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
  let at = 0;
  for (const p of parts) { out.set(p, at); at += p.length; }
  return out;
};
const INFO = new TextEncoder().encode('savitup/share-request/v1');

async function decryptShare(sealed, privatePkcs8B64) {
  const privateKey = await subtle.importKey('pkcs8', un64(privatePkcs8B64), P256, true, ['deriveBits']);
  // Your own raw public point, re-derived from your private key -- it is
  // half the HKDF salt, so it must be the exact bytes you registered.
  const jwk = await subtle.exportKey('jwk', privateKey);
  const yourPublic = await subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }, P256, true, []);
  const yourRaw = new Uint8Array(await subtle.exportKey('raw', yourPublic));

  const encRaw = un64(sealed.enc);
  const ephemeralPublic = await subtle.importKey('raw', encRaw, P256, true, []);
  const shared = new Uint8Array(await subtle.deriveBits({ name: 'ECDH', public: ephemeralPublic }, privateKey, 256));
  const salt = new Uint8Array(await subtle.digest('SHA-256', cat(encRaw, yourRaw)));
  const ikm = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveBits']);
  const raw = await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: INFO }, ikm, 256);
  const key = await subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['decrypt']);

  // AES-GCM in WebCrypto expects ciphertext||tag as one buffer.
  const plain = await subtle.decrypt(
    { name: 'AES-GCM', iv: un64(sealed.iv), additionalData: new TextEncoder().encode(sealed.aad), tagLength: 128 },
    key, cat(un64(sealed.ciphertext), un64(sealed.tag)));
  return JSON.parse(new TextDecoder().decode(plain));
}

module.exports = { decryptShare };
```

If `decrypt` throws, do NOT fall back to anything: a failure means the
ciphertext, the tag or the AAD was altered in transit, and there is no
partial answer worth having.

## Test vectors

`shared/share-crypto-vectors.json` in this repo carries four vectors,
including `directed-mapped` — mapped field names, the `|directed|` AAD.
Run your implementation against all four before you go live.

**The private keys in that file are published TEST keys. Never use one
anywhere real.**
