dove-core 0.1.1

The shared library behind dove — client-side-encrypted, expiring file sharing from a cloud you own.
Documentation
# dove request — design

**Status:** approved design, ready for an implementation plan.
**Depends on:** the `dove-core` extraction (crypto, the gate, the backend
registry, the scoped-key model) — see [DESIGN.md](DESIGN.md).

## Goal

Let someone **request a file** from another person over the same
encrypt-to-a-link mechanism as sharing — inverted. The requester sends a link
(plus a PIN, out of band); the other person opens it, sees who's asking and why,
and uploads a file that is **encrypted on their device** before any bytes leave
it. Only the requester can decrypt it. The server holds ciphertext and never sees
the key, the filename, or the trust text — the same guarantees as `share`, with
the file flowing the other way.

A request is `share` run backwards: **the link's creator is the eventual
receiver.**

## The container is share's, reused

The uploader's browser writes the exact same chunked AES-256-GCM container
`share` produces, encrypted with the fragment key (PIN-folded the same way), and
the filename rides the same encrypted metadata blob. So `dove requests get`
decrypts an upload with the identical code path `dove get` already uses, and the
golden-parity fixtures cover both directions. No new crypto.

## Roles and credentials

- **Requester (you):** creates the request, later collects the upload. Needs no
  new AWS permissions — the scoped IAM key already has `dynamodb:PutItem` (to
  write the request row) and `s3:GetObject` (to collect), after the scoped-key
  change in [DESIGN.md].
- **Uploader (them):** holds no credentials. Everything they do goes through the
  gate, which hands them a short-lived presigned **PUT**.
- **The gate role** gains exactly one permission: `s3:PutObject` on the bucket,
  so it can presign the uploader's PUT. (It already has DynamoDB
  GetItem/UpdateItem and S3 GetObject for the download side.)

## The gate — a mirror of the download side

New routes alongside the existing `/d`, `/meta`, `/verify`, `/dl`:

- **`GET /r/<id>`** — serve the request (upload) page. No side effect; static and
  cache-friendly, exactly like `/d`.
- **`GET /rmeta/<id>`** — the request's policy as JSON, free: the encrypted
  from/message blob, expiry, files-remaining, `pin_required`, and the
  **status**`waiting` | `received` (with the encrypted filename meta + size) |
  `failed` (with a `reason`). This is what both the upload page and `dove
  requests` read. MAC-gated: a forged/random id is rejected before any DB touch.
- **`GET /verify/<id>`** — reused verbatim: PIN pre-check, rate-limited, no
  side-effect on the file.
- **`GET /up/<id>`** — the new one. After the PIN check, it **atomically**
  decrements the upload budget and returns a **302 to a presigned PUT** for a
  **gate-chosen** object key (never uploader-chosen — nobody can overwrite an
  existing object), with a `content-length-range` condition so the upload is
  size-capped. On a successful PUT the row records the ciphertext's key + size +
  the uploader's encrypted filename meta, flipping status to `received`.

## The request row (DynamoDB) and rate limiting

A request row mirrors a share's policy row, with an **upload** budget instead of
downloads:

```
id, kind=request, uploads_remaining, uploads_total, expires_at,
pin_hash (optional), pin_attempts, upload_attempts,
meta (encrypted from/message trust blob),
status, reason,          # computed/served state
# set on fulfilment:
s3_key, size, upload_meta (encrypted filename blob), received_at
```

**Two counters, both anchored to the request's s3 key** (its id):
- **`pin_attempts`** — the share PIN-lockout, reused: each wrong PIN at `/verify`
  or `/up` increments it; at the ceiling the request is `locked`.
- **`upload_attempts`** — the new rate limit tied to the s3 key: each `/up` call
  that hands out a presigned PUT increments it; past its ceiling `/up` refuses,
  even with a correct PIN, so a leaked link with a known PIN still can't
  hammer/retry the upload forever.

Both are conditional writes (the same race-safe pattern as the share PIN
counter), so concurrent attempts can't slip past a ceiling. Either ceiling, or
the expiry, ends the request.

**Failure reasons** surfaced by `/rmeta` (and rendered by the CLI inbox):
`locked` (too many wrong PINs), `rate-limited` (too many upload attempts on the
key without a completed PUT), `expired` (window closed with no upload). A
completed upload is `received`; an untouched, unexpired request is `waiting`.

## The requester side (dove-core + CLI)

- **`dove request "the signed NDA" --from "Phil" --pin --expires 1d`** — mint the
  request: generate the fragment key, mint a MAC'd id, write the request row
  (upload budget, expiry, `pin_hash`, encrypted from/message), record
  `{id, fragment_key, description, created}` in a **local request ledger**, and
  print the link `<gate>/r/<id>#<fragment>` to send. `--from`/`--message` are the
  trust data — required in spirit (the whole point is the other person knowing
  who's asking); encrypted in the fragment, server-blind.
- **`dove requests`** — for each ledger entry, call `/rmeta` and render the live
  state: *waiting* · *received (name · size)* · *failed · locked after 5
  attempts*. A **live pull, not a poll** — checking the inbox *is* the query;
  dove stores no fulfilment state of its own (the row is the source of truth).
  (The desktop app will add a real inbox with a small internal daemon that
  watches in the background; the CLI deliberately does not.)
- **`dove requests get <id>`** — collect: `/rmeta` confirms `received` and yields
  the encrypted filename meta; `s3:GetObject` pulls the ciphertext with the
  scoped key; decrypt with the stored fragment key; save under the real filename.

## The browser page

`RequestSurface` (the mock in dove-site) is the UI; the build wires the crypto:
the **mirror of the decryptor page** — read the fragment key, take the PIN (fold
it into the key), encrypt the chosen file **client-side** into the container, and
PUT the ciphertext to the gate's presigned URL. The page's states
(`pin`/`verifying`/`wrong-pin`/`locked`/`ready`/`selected`/`uploading`/`complete`/
`expired`/`error`) map onto `/verify` + `/up` responses. Nothing uploads on page
open or PIN check — only on the explicit "Encrypt & send" click.

## Provisioning

The only infra change: `gate_role_policy` gains `s3:PutObject` on the bucket. The
scoped user's policy is unchanged (already covers create + collect). Applied
idempotently on the next `dove provision full`.

## Out of scope (v1)

- **Multiple files per request** — v1 is one file (matches the mock's "one
  file"). Multi means uploaders sharing one key can read each other's uploads, so
  it needs its own key handling; deferred.
- **The desktop app's inbox + background daemon** — the CLI is on-demand pull.
- **Cloud-backend requests** — self-hosted only; request/collect land as
  `SelfHosted` methods, structured so a `Requests` trait can wrap them later, the
  same way `Transfer` is set up.
- **Push notification of fulfilment** — poll/pull only.

## Decisions of record

- Request container = share's container, byte-identical, both directions.
- Gate-chosen object key + `content-length-range` cap + single-upload budget;
  a leaked link still needs the PIN and writes exactly one bounded object.
- Two counters per request (keyed to its s3 key): `pin_attempts` (→ `locked`) and
  `upload_attempts` (→ `rate-limited`); either ceiling, or expiry, ends it.
- Requester needs no new AWS permissions; only the gate role gains `s3:PutObject`.
- `dove requests` is a live pull; failure + reason are first-class inbox states.