Docs menu and this page

On this page

For agents: /docs/signing.md is this page as Markdown.

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

Scroll the table sideways.

ItemValue
Algorithmsed25519 (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)
createdUnix 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
expiresoptional; at most 300 s after created, and refused once it is past our clock (a request at exactly expires passes)
keyidthe base64url SHA-256 JWK thumbprint (RFC 7638) of your public key
tagckrets-v1 for an audience proof; web-bot-auth for the signed-agent tier (send Signature-Agent too, below)
Labelany. 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
Replaythere 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 stringnot 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.