# Signing requests (RFC 9421)

ckrets accepts HTTP message signatures (RFC 9421) for two things: proving you are the recipient of a token whose
`audience` is a `publicKey` or an `httpsig` key, and getting the signed-agent tier (Web Bot Auth). This page is the
profile ckrets checks, how to make the keys, and a signer you can use without any ckrets package. The API is live in beta
(0.9.0); the packages are not published yet.

## The profile

| Item | Value |
|---|---|
| Algorithms | `ed25519` (preferred) or `ecdsa-p256-sha256` |
| Covered components, no body | `("@method" "@authority" "@path")` |
| Covered components, with a body | `("@method" "@authority" "@path" "content-digest")`, with a `Content-Digest: sha-256=:…:` header (RFC 9530) |
| `created` | Unix seconds, at most 60 s from our clock either way: exactly 60 s off passes, 61 s is refused, and a `created` up to 60 s in the future is accepted. Keep the signer's clock on NTP |
| `expires` | optional; at most 300 s after `created`, and refused once it is past our clock (a request at exactly `expires` passes) |
| `keyid` | the base64url SHA-256 JWK thumbprint (RFC 7638) of your public key |
| `tag` | `ckrets-v1` for an audience proof; `web-bot-auth` for the signed-agent tier (send `Signature-Agent` too, below) |
| Label | any. ckrets checks the first member, in `Signature-Input` order, whose `tag` is `ckrets-v1`; every other member (a `web-bot-auth` one included) is ignored |
| Replay | there is no nonce: a captured signature is accepted until the earlier of its `expires` and `created` + 60 s, for the same method, host and path, and with a body only with that same body. Whoever replays it gets the value, not the signer, and the receipt still names the signer's key as `rcp`. Keep `expires` short (60 s is plenty) and send signed requests only over TLS to the host you mean |
| Query string | not covered (`@path` is the path alone), and a reveal ignores it |

Every row above is checked: a signature missing `@authority`, signed for another host, without `content-digest` on a
request that has a body, with a digest that is not the body's, with `created` more than 60 s off or `expires` more than
300 s after it, is refused (`403 audience-mismatch`, which spends none of the token's attempts). So is an unsigned
request, a `keyid` that is not the audience key's thumbprint, and a signature over another path: every signature
failure is that one `403`. Its `detail` names the check that failed (for example "created is missing or more than 60 s
from our clock", "keyid is not the audience key", "covered components incomplete", "bad signature"), so read it to find
the cause. A refused signature is also a denial on the creator's record (status `denials`, a deny receipt and the
alarm), the same as a stranger's try, so a recipient whose clock is off looks to the creator like someone else trying.
To see how far your clock is from ours, compare it with the `now` in `GET /v1/secrets/{token}/public` (or the `Date`
header of any answer).

`@authority` is the host you sent the request to, without a port: `ckrets.ai` in production (not `ckrets.ai:443`), the
staging or lab host on another deployment. The examples below use `ckrets.ai`; on another deployment pass its host.

## Making the keys

A recipient needs an Ed25519 signing key, and optionally an X25519 key so senders can encrypt to it. Keep the private
halves on the recipient; give senders the public JWKs.

```bash
openssl genpkey -algorithm ed25519 -out server.pem       # signing key, private
openssl genpkey -algorithm x25519  -out server-enc.pem   # encryption key, private (optional)
chmod 600 server.pem server-enc.pem
```

Turn them into JWKs and compute the thumbprint (Node 18 or later):

```bash
node -e '
const c = require("crypto"), fs = require("fs");
const priv = c.createPrivateKey(fs.readFileSync("server.pem"));
fs.writeFileSync("server.jwk", JSON.stringify(priv.export({ format: "jwk" })), { mode: 0o600 });  // CKRETS_SIGNING_KEY
const pub = c.createPublicKey(priv).export({ format: "jwk" });                                       // give to senders as "jwk"
const encPriv = c.createPrivateKey(fs.readFileSync("server-enc.pem"));
fs.writeFileSync("server-enc.jwk", JSON.stringify(encPriv.export({ format: "jwk" })), { mode: 0o600 }); // CKRETS_ENC_KEY (hpke.v1)
const enc = c.createPublicKey(encPriv).export({ format: "jwk" });                                       // give to senders as "encJwk"
const thumb = c.createHash("sha256").update(JSON.stringify({ crv: pub.crv, kty: pub.kty, x: pub.x })).digest("base64url");
console.log(JSON.stringify({ jwk: pub, encJwk: enc, keyid: thumb }));'
```

Or in Python with `cryptography`:

```python
import base64, hashlib, json
from cryptography.hazmat.primitives import serialization as s
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
priv = s.load_pem_private_key(open("server.pem", "rb").read(), password=None)
d = b64u(priv.private_bytes(s.Encoding.Raw, s.PrivateFormat.Raw, s.NoEncryption()))
x = b64u(priv.public_key().public_bytes(s.Encoding.Raw, s.PublicFormat.Raw))
open("server.jwk", "w").write(json.dumps({"kty": "OKP", "crv": "Ed25519", "x": x, "d": d}))   # CKRETS_SIGNING_KEY, chmod 600
public_jwk = {"kty": "OKP", "crv": "Ed25519", "x": x}                                          # give to senders
keyid = b64u(hashlib.sha256(json.dumps({"crv": "Ed25519", "kty": "OKP", "x": x}, separators=(",", ":")).encode()).digest())
```

The thumbprint input is the JSON object with exactly `crv`, `kty` and `x`, in that order, no spaces (RFC 7638). For a
P-256 key (`kty` `EC`) it is `crv`, `kty`, `x` and `y`, in that order.

## Proving a `publicKey` audience

The sender put your public JWK in the token's audience. You reveal by signing the request with the matching private
key; `keyid` must equal that JWK's thumbprint.

```http
GET /v1/secrets/ck_SRG73W7Q8… HTTP/1.1
Host: ckrets.ai
Signature-Input: sig1=("@method" "@authority" "@path");created=1792800000;expires=1792800060;keyid="NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs";alg="ed25519";tag="ckrets-v1"
Signature: sig1=:<base64 signature over the signature base>:
```

The signature base is the covered components, one per line, joined with `\n` and no trailing newline:

```text
"@method": GET
"@authority": ckrets.ai
"@path": /v1/secrets/ck_SRG73W7Q8…
"@signature-params": ("@method" "@authority" "@path");created=1792800000;expires=1792800060;keyid="NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs";alg="ed25519";tag="ckrets-v1"
```

## A signer without any ckrets package

For servers that cannot run npx. Python 3 with `cryptography`; standard library otherwise.

```python
import base64, hashlib, json, time, urllib.request
from cryptography.hazmat.primitives import serialization as s

def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
key = s.load_pem_private_key(open("/etc/ckrets/server.pem", "rb").read(), password=None)
x = b64u(key.public_key().public_bytes(s.Encoding.Raw, s.PublicFormat.Raw))
keyid = b64u(hashlib.sha256(json.dumps({"crv": "Ed25519", "kty": "OKP", "x": x}, separators=(",", ":")).encode()).digest())

def reveal(token: str, host: str = "ckrets.ai") -> dict:
    path, now = f"/v1/secrets/{token}", int(time.time())
    params = f'("@method" "@authority" "@path");created={now};expires={now + 60};keyid="{keyid}";alg="ed25519";tag="ckrets-v1"'
    base = f'"@method": GET\n"@authority": {host}\n"@path": {path}\n"@signature-params": {params}'
    sig = base64.b64encode(key.sign(base.encode())).decode()
    req = urllib.request.Request(f"https://{host}{path}", headers={"Signature-Input": f"sig1={params}", "Signature": f"sig1=:{sig}:"})
    with urllib.request.urlopen(req, timeout=10) as r:
        return json.load(r)    # {"value": ..., "revealsLeft": 0, "receipt": ...}; put value where it is needed, do not log it
```

Libraries that implement RFC 9421 in full: `http-message-signatures` on npm and on PyPI, and Go's
`github.com/yaronf/httpsign`. The helper (`@ckrets/helper`, at launch) signs for you when `CKRETS_SIGNING_KEY` points to
the private JWK file.

## Proving an `httpsig` audience, and the signed-agent tier

An `httpsig` audience names a `keyid` and the recipient's key directory
(`https://<your-domain>/.well-known/http-message-signatures-directory`, the Web Bot Auth format: a JWK Set served as
`application/http-message-signatures-directory+json`). ckrets fetches the directory (cached up to one hour) and checks
that the `keyid` is listed and the signature verifies. That is planned: today ckrets fetches no directory at all (an
`httpsig` audience is `400 audience-type-unknown`, and a `web-bot-auth` signature gives no tier), so no request can make
it fetch a URL you name. The fetch's limits (scheme, address ranges, redirects, size, timeout) will be published here
before it ships.

To use the signed-agent tier on any call, sign with `tag="web-bot-auth"` and send the directory location:

```http
Signature-Agent: "https://agents.example.com"
Signature-Input: sig1=("@method" "@authority" "@path" "signature-agent");created=1792800000;expires=1792800060;keyid="…";alg="ed25519";tag="web-bot-auth"
Signature: sig1=:…:
```

The tier is then keyed on your directory's host: its limits, its kill switch, its webhooks. Agents signed by operators
that already publish a Web Bot Auth directory get the tier with no setup.
