# instant.zip — agent & CLI guide

instant.zip is short-lived, end-to-end encrypted file sharing. Uploads are
chunked; recipients can start downloading while the upload is still running.
Transfers expire 24 hours after creation. Max file size is 10 GB.

Share links look like:

    https://instant.zip/f/<id>#k=<key>

The `#k=` fragment is the 32-byte master encryption key, base64url-encoded.
Fragments are never sent in HTTP requests, so the server cannot decrypt
anything. Treat a share link like the file itself.

## Quick start (scripts)

Both scripts are self-contained and print the share link on the **first line of
stdout as soon as the upload starts** — you can hand the link out immediately;
the download page streams chunks as they arrive. Progress goes to stderr.

Bash (needs `bash`, `curl`, `openssl`; folders also need `zip`):

    curl -fsSLO https://instant.zip/instant.sh
    bash instant.sh up ./report.pdf          # prints https://instant.zip/f/<id>#k=<key>
    bash instant.sh up ./photos/             # folders are zipped first
    bash instant.sh down 'https://instant.zip/f/<id>#k=<key>'   # quote it: # starts a comment

PowerShell 5.1+ (Windows, or pwsh on any OS):

    Invoke-WebRequest https://instant.zip/instant.ps1 -OutFile instant.ps1
    ./instant.ps1 up ./report.pdf
    ./instant.ps1 down 'https://instant.zip/f/<id>#k=<key>'

Options: `up ... --name NAME` / `-Name NAME` overrides the stored filename;
`down ... --out PATH` / `-Out PATH` sets the output path (default: the stored
name, never overwriting an existing file). Set `INSTANT_ZIP_ORIGIN` to target
another deployment. Uploads are always end-to-end encrypted.

## HTTP API

All request and response bodies are JSON unless noted. `:id` matches
`[a-zA-Z0-9_-]{16,64}`.

| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/api/transfers` | create a transfer |
| GET | `/api/transfers/:id` | transfer status |
| PUT | `/api/transfers/:id/chunks/:index` | upload one chunk (binary body) |
| GET | `/api/transfers/:id/chunks/:index` | download one chunk (binary body) |
| POST | `/api/transfers/:id/complete` | mark the upload finished |
| GET | `/f/:id` | human-facing download page |

### Create

`POST /api/transfers` with:

    {
      "name": "report.pdf",          // stored filename
      "size": 1234567,               // total PLAINTEXT size in bytes (max 10 GiB)
      "chunkSize": 8388608,          // optional; server aligns to 1 MiB, clamps to 1–64 MiB
      "encryption": {                // omit only for unencrypted transfers
        "algorithm": "AES-256-CTR-HMAC-SHA256",
        "nonceBase": "<base64url of 12 random bytes>"
      }
    }

The `201` response includes `id`, `shareUrl` (`/f/:id` — append `#k=<key>`
yourself), `status.expiresAt` (epoch ms), and `status.chunkSize` (the size the
server actually chose — chunk with this, not the size you asked for).

### Upload chunks

Split the plaintext into `chunkSize`-byte chunks (the last one may be short),
encrypt each (below), and `PUT /api/transfers/:id/chunks/:index` with
`Content-Type: application/octet-stream` and headers:

    x-instant-chunk-index: <index>
    x-instant-chunk-size: <ciphertext bytes, i.e. plaintext + 32>
    x-instant-plaintext-size: <plaintext bytes>
    x-instant-file-name: <name>
    x-instant-expires-at: <status.expiresAt>

Chunks may be uploaded in any order and in parallel. When every chunk is
uploaded, `POST /api/transfers/:id/complete` (returns `409` while chunks are
missing).

### Download chunks

`GET /api/transfers/:id` returns `size`, `chunkSize`, `chunkCount`, `complete`,
`name`, and `encryption`. Fetch each chunk with
`GET /api/transfers/:id/chunks/:index`; a `404` usually means the uploader has
not sent that chunk yet — poll every couple of seconds. `410` means the
transfer expired. Responses carry `X-Instant-Chunk-Checksum` (SHA-1 hex of the
stored bytes) for integrity re-checks. Verify, decrypt, and concatenate the
chunks in index order.

## Encryption format: AES-256-CTR-HMAC-SHA256

Everything is derived from the 32-byte master key `K` in the link fragment:

    encKey = HMAC-SHA256(K, "instant.zip enc v1")     // 32 bytes
    macKey = HMAC-SHA256(K, "instant.zip mac v1")     // 32 bytes

Per chunk `i` (0-based) with the transfer's 12-byte `nonceBase`:

    nonce_i    = nonceBase, with bytes 8..11 replaced by uint32_be(i)
    counter_0  = nonce_i || 0x00000000                 // 16-byte CTR block, 32 counter bits
    ciphertext = AES-256-CTR(encKey, counter_0, plaintext)
    tag        = HMAC-SHA256(macKey, uint32_be(i) || ciphertext)   // 32 bytes
    stored     = ciphertext || tag

To decrypt: split off the final 32 bytes, recompute the tag over
`uint32_be(i) || ciphertext`, refuse the chunk on mismatch, then apply the same
CTR keystream. Every value on the wire (`#k=`, `nonceBase`) is base64url
without padding.

One openssl-speak example for chunk 0 (hex keys/IVs):

    openssl enc -aes-256-ctr -K $ENC_KEY_HEX -iv ${NONCE_HEX:0:16}0000000000000000 -in plain -out ct
    { printf '\x00\x00\x00\x00'; cat ct; } | openssl dgst -sha256 -mac HMAC -macopt hexkey:$MAC_KEY_HEX

Transfers created by the site before this scheme used `"algorithm": "AES-GCM"`
(chunk = AES-256-GCM ciphertext + 16-byte tag, IV = `nonce_i`); the browser can
still decrypt those, the scripts cannot.

## Rules of thumb for agents

- Prefer the scripts over hand-rolling the API; they already handle chunking,
  waiting for in-flight chunks, checksums, and authentication failures.
- The first stdout line of `up` is the complete share link — capture it and
  keep the fragment intact. Everything else the scripts print goes to stderr.
- Quote share links in shells: the `#` fragment would otherwise be dropped.
- Links expire after 24 hours and cannot be refreshed; re-upload instead.
- Do not upload secrets you would not paste into a URL — anyone holding the
  full link (fragment included) can decrypt the file.
