# Signed envelope authentication (X-Nukez-Envelope / X-Nukez-Signature)

Signed envelopes are Nukez's request-bound authentication scheme.

Instead of a bearer token, you sign a **small JSON envelope** that binds:

- who you are (payer key),
- *what* you're doing (method + path),
- *which* locker/receipt it applies to,
- *and* the hash of the exact request body.

The server verifies the signature and checks that the envelope is fresh.

## Signature Algorithms

Envelope auth supports two signature algorithms depending on the wallet type:

- **Ed25519** -- used for Solana wallets. Signatures are base58-encoded.
- **secp256k1** -- used for EVM wallets (Monad, Ethereum, etc.). Signatures are hex-encoded.

The `sig_alg` field in the envelope specifies which algorithm was used. The server auto-detects based on the payer key format if `sig_alg` is omitted, but explicit is preferred.

---

## Quick Start: Making a Signed Request

If you have a `build_signed_envelope` tool/function available, use it! Example flow:

```
1. Call build_signed_envelope with:
   - receipt_id: "556db5b5bec924ea"
   - method: "POST"
   - path: "/v1/storage/signed_provision"
   - ops: ["locker:provision"]
   - body: {"receipt_id": "556db5b5bec924ea", "tags": []}

2. You receive:
   - headers: {"X-Nukez-Envelope": "...", "X-Nukez-Signature": "...", "Content-Type": "application/json"}
   - body_canonical: '{"receipt_id":"556db5b5bec924ea","tags":[]}'
   - locker_id: "locker_xxxxxxxxxxxx"

3. Make HTTP request with those headers and body_canonical as the raw body
```

If you need to construct the envelope manually, see the detailed steps below.

---

## Headers

- `X-Nukez-Envelope`: base64url-encoded canonical JSON envelope (no padding)
- `X-Nukez-Signature`: base58-encoded Ed25519 signature (Solana) or hex-encoded secp256k1 signature (EVM) over the **envelope bytes**

## Envelope JSON schema

```json
{
  "v": 1,
  "locker_id": "locker_48039d44f06a",
  "receipt_id": "01b1c58b9e6f2f01",
  "nonce": "2f4b0e5f9cfe4c1c9f0d0b2a5e7f1122",
  "iat": 1737316800,
  "exp": 1737316860,
  "ops": ["locker:provision"],
  "method": "POST",
  "path": "/v1/storage/signed_provision",
  "body_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "sig_alg": "ed25519"
}
```

Field notes:

- `iat` and `exp` are UNIX seconds.
- Keep `exp - iat` short (<= 60 seconds is a good norm). The gateway enforces a hard ceiling: any envelope whose TTL (`exp - iat`) exceeds 300 seconds is rejected with `400 ENVELOPE_TTL_TOO_LONG`. Because of this, sign each envelope immediately before the request it authorizes. In particular, sign confirm envelopes at confirm time, after the upload completes — a long upload must re-sign rather than reuse an envelope built before the upload started.
- `ops` must include the operation(s) required by the endpoint (see mapping below).
- `body_sha256` is the SHA-256 hex of the **exact request body bytes**.
  - For an empty body, it is SHA-256 of empty string.
  - If you send JSON, hash the bytes you actually send on the wire.
- `sig_alg` specifies the signing algorithm: `"ed25519"` for Solana wallets, `"secp256k1"` for EVM wallets.

## Deterministic locker_id

Locker IDs are derived from the receipt_id:

```
locker_id = "locker_" + sha256_hex(receipt_id)[:12]
```

This lets agents compute the locker id without extra lookups.

## Required ops mapping

These ops appear in `x-nukez-ops` in OpenAPI, and should be copied into `envelope.ops`.

| Endpoint | HTTP Method | Required ops |
|----------|-------------|--------------|
| `/v1/storage/signed_provision` | POST | `locker:provision` |
| `/v1/lockers/{locker_id}/files` | POST | `locker:write` |
| `/v1/lockers/{locker_id}/files` | GET | `locker:list` |
| `/v1/lockers/{locker_id}/files/{filename}` | GET | `locker:read` |
| `/v1/lockers/{locker_id}/files/{filename}` | DELETE | `locker:write` |
| `/v1/lockers/{locker_id}/record` | GET | `locker:read` |
| `/v1/lockers/{locker_id}/urls` | GET | `locker:urls` |
| `/v1/lockers/{locker_id}/operators` | POST | `locker:admin` |
| `/v1/lockers/{locker_id}/operators/{pubkey}` | DELETE | `locker:admin` |
| `/v1/lockers/{locker_id}/manifest` | GET | `locker:read` |
| `/v1/files/fetch-and-store` | POST | `locker:write` |
| `/v1/files/confirm` | POST | `locker:write` |
| `/v1/files/confirm-batch` | POST | `locker:write` |
| `/v1/storage/recompute-verify` | GET | `locker:read` |
| `/v1/storage/attest` | POST | `locker:attest` |
| `/v1/attest/push` | POST | `locker:attest` |
| `/v1/storage/provision` | POST | `locker:provision` |
| `/v1/storage/refresh` | POST | `locker:refresh` |
| `/v1/lockers/{locker_id}/files/batch` | POST | `locker:write` |
| `/v1/lockers/{locker_id}/files/urls` | POST | `locker:read` |
| `/v1/lockers/{locker_id}/viewer` | GET | `locker:read` |
| `/v1/storage/metadata/{locker_id}` | GET | `locker:read` |
| `/v1/lockers/{locker_id}/upload-sessions` | POST | `locker:write` |
| `/v1/upload-sessions/{session_id}/finalize` | POST | `locker:read` |
| `/v1/upload-sessions/{session_id}/viewer` | GET | `locker:read` |
| `/v1/lockers/{locker_id}/ingest/jobs` | POST | `locker:write` |
| `/v1/lockers/{locker_id}/jobs/finalize-upload` | POST | `locker:write` |

---

**Note on attestation endpoints (`locker:attest`):**

`/v1/storage/attest` and `/v1/attest/push` are payer-signed: only the receipt's payer key may issue `locker:attest`. The envelope's `receipt_id` must equal the request's `receipt_id` — the JSON body for `/v1/attest/push`, the query parameter for `/v1/storage/attest`. A mismatch returns `401 RECEIPT_ID_MISMATCH`.

---

**Note on the confirm endpoints (`locker:write`):**

`POST /v1/files/confirm` and `POST /v1/files/confirm-batch` require the same `locker:write` authority as the create-file step that precedes them; the `receipt_id` in the query string is not authorization by itself. Both take their arguments as query parameters, and the POST body is the literal two characters `{}` — the envelope's `body_sha256` must be the SHA-256 of exactly those two bytes, and the envelope also binds the method, path, and full query string (including every repeated `filenames` pair on the batch endpoint). The envelope's `receipt_id` must match the query `receipt_id`. Enforcement is being rolled out in stages, but the signed request is the contract and new clients must sign. When enforcement is active, a confirm request without the two headers returns `401 SIGNED_ENVELOPE_REQUIRED`.

**Note on `recompute-verify` (`locker:read`):**

`GET /v1/storage/recompute-verify` is no longer public. It requires a payer-signed envelope with the `locker:read` operation, because it causes the backend to re-download stored file bytes. The envelope binds the GET request (method, path, the `receipt_id` query string, and the empty-body hash), and its `receipt_id` must match the query `receipt_id`; a mismatch returns `401 RECEIPT_ID_MISMATCH`. The recompute itself is strictly read-only — it runs in memory and never overwrites the stored attestation.

---

**Note on GET and DELETE requests:**

For requests with no body (GET, DELETE), use the SHA-256 hash of an empty string:
```
body_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
```

The envelope headers are still required - signed envelope auth applies to ALL authenticated endpoints, not just POST.

## Complete Example: Provisioning a Locker

After `confirm_storage` returns `receipt_id: "556db5b5bec924ea"`:

### Step 1: Compute locker_id
```python
import hashlib
locker_id = "locker_" + hashlib.sha256("556db5b5bec924ea".encode()).hexdigest()[:12]
# Result: "locker_xxxxxx..."
```

### Step 2: Build request body
```python
body = {"receipt_id": "556db5b5bec924ea", "tags": []}
```

### Step 3: Serialize to canonical JSON
```python
import json
body_canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
# Result: '{"receipt_id":"556db5b5bec924ea","tags":[]}'
```

### Step 4: Compute body hash
```python
body_sha256 = hashlib.sha256(body_canonical.encode()).hexdigest()
```

### Step 5: Build envelope
```python
import time, os
envelope = {
    "v": 1,
    "locker_id": locker_id,
    "receipt_id": "556db5b5bec924ea",
    "nonce": os.urandom(16).hex(),
    "iat": int(time.time()),
    "exp": int(time.time()) + 60,
    "ops": ["locker:provision"],
    "method": "POST",
    "path": "/v1/storage/signed_provision",
    "body_sha256": body_sha256,
    "sig_alg": "ed25519",
}
```

### Step 6: Encode and sign
```python
import base64
from nacl.signing import SigningKey
import base58

env_bytes = json.dumps(envelope, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
x_nukez_envelope = base64.urlsafe_b64encode(env_bytes).decode().rstrip("=")

sig = signing_key.sign(env_bytes).signature
x_nukez_signature = base58.b58encode(sig).decode()
```

### Step 7: Make the request
```python
import requests
response = requests.post(
    "https://api.nukez.xyz/v1/storage/signed_provision",
    headers={
        "X-Nukez-Envelope": x_nukez_envelope,
        "X-Nukez-Signature": x_nukez_signature,
        "Content-Type": "application/json",
    },
    data=body_canonical,  # Use the EXACT canonical string, not json=body
)
```

---

## Canonical JSON rules (critical)

When serializing the envelope AND the request body:

- Sort keys alphabetically.
- Put no whitespace between elements (separators are a bare comma and a bare colon).
- Emit raw UTF-8 bytes, not ASCII-escaped text: serialize with `ensure_ascii=False` (or your language's equivalent), so a non-ASCII character is sent as its raw UTF-8 bytes rather than as a `\uXXXX` escape sequence.

In Python, this canonical form is:

```python
json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
```

If you use a library that inserts spaces/newlines or reorders keys, signatures will fail.

---

## Common failure modes

| Status + error code | Cause | Fix |
|-------|-------|-----|
| `422 VALIDATION_ERROR` | The `X-Nukez-Envelope` / `X-Nukez-Signature` headers are entirely missing on a route that declares them required (attest, attest/push, recompute-verify, signed_provision, and the locker file endpoints) | Attach both headers to the HTTP request |
| `401 SIGNED_ENVELOPE_REQUIRED` | The headers are missing on `/v1/files/confirm` or `/v1/files/confirm-batch` while enforcement is active — the confirm endpoints deliberately answer missing headers with 401 rather than 422 | Sign a `locker:write` envelope at confirm time and attach both headers |
| `401 BAD_SIGNATURE` | Wrong key, wrong canonical JSON, or wrong encoding produced an invalid signature | Log the exact bytes you signed and verify the key matches the payer |
| `401 ENVELOPE_BODY_MISMATCH` | `body_sha256` doesn't match the body bytes actually sent | Use canonical JSON for BOTH the hash and the request body, and send that exact string |
| `403 MISSING_OPS` | `ops` doesn't include the operation the endpoint requires | Check the endpoint's required_ops in tools.json |
| `401 ENVELOPE_EXPIRED` | `exp` is in the past | Check clock sync and rebuild with a fresh timestamp |
| `400 ENVELOPE_TTL_TOO_LONG` | `exp - iat` exceeds the 300-second ceiling | Use a short TTL (about 60 seconds) and sign immediately before sending |
| `401 RECEIPT_ID_MISMATCH` | The envelope's `receipt_id` doesn't match the request's `receipt_id` (query parameter or body, depending on the endpoint) | Build the envelope with the same `receipt_id` the request carries |

Note the deliberate divergence in missing-header behavior: on attest, attest/push, and recompute-verify the headers are declared required, so entirely missing headers return FastAPI's `422 VALIDATION_ERROR` and the 401 family fires only when headers are present but invalid; on the confirm endpoints, missing headers return `401 SIGNED_ENVELOPE_REQUIRED`.

If you're debugging, start by logging the exact bytes you hashed and signed.

---

## Agent Implementation Notes

If your agent framework provides a `build_signed_envelope` tool:
1. Use it instead of implementing the above manually
2. Pass the body as a JSON object; the tool handles canonicalization
3. Use the returned `body_canonical` as the HTTP request body (not your original object)
4. Use the returned `headers` dict directly in your HTTP request

If implementing manually:
1. Always serialize body ONCE using canonical JSON
2. Hash that exact string for body_sha256
3. Send that exact string as the HTTP body
4. The #1 cause of failures is computing the hash from one string but sending a different string
