---
name: borgbook-registration
description: Use when an AI agent needs to be registered to an accountable human at Borgbook — generating its key, showing the public half to its principal, and walking that person through registering it. Fetch from https://borgbook.org/skill.md
---

# Getting yourself registered at Borgbook

Borgbook is a register that ties an agent to a human being who answers for it.
That human is your **principal**. You cannot register yourself: the whole point
is that a person, whose identity has been checked against their driver's
license, deliberately puts their name behind you.

Your part is three things. Make a key, show your principal the public half, and
tell them plainly what to do with it.

## 1. Make a key

Generate an **ECDSA P-256** keypair. That is the only kind this registry
accepts today: everything it signs and verifies is ES256, so another curve
would be registered and then unable to do anything. Keep the private half where
you keep secrets; it never leaves you, and no one at Borgbook will ever ask for
it. Anyone who asks you to paste a private key into a web page is not Borgbook.

You need the public half as a JWK. Two ways, depending on what you have:

There is a worked implementation at the end of this document — `new_key()`
generates one and writes it with the right permissions. Use that rather than a
second copy here; two versions of the same twelve lines is how one of them goes
wrong.

If you would rather do it by hand, the key is a standard P-256 PKCS#8 file:

```bash
openssl ecparam -name prime256v1 -genkey -noout -out agent.key
chmod 600 agent.key
```

You then need its public half as a JWK, which is what `public()` below does.

## 2. Check it before you show anyone

The register accepts a public JWK and nothing else. It will refuse the key
outright if it carries any private field (`d`, `p`, `q`, `dp`, `dq`, `qi`, `k`)
— that refusal is a safety net, not a workflow. Check yourself first:

- `kty` is `"EC"`, `crv` is `"P-256"`, and both `x` and `y` are present.
- There is no `d`. If there is, you are about to leak your private key. Stop.

A correct one looks like this, and is safe to paste anywhere:

```json
{
  "kty": "EC",
  "crv": "P-256",
  "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
  "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
}
```

## 3. Tell your principal what to do

Say it in these words. Do not explain JWKs, key types or curves unless they
ask; none of it is their problem.

> I need you to register me at borgbook.org so there is a record that I act
> for you. You will need your driver's license on your phone, and you will
> answer for what I do.
>
> 1. Go to https://borgbook.org and sign in, or register if you have not
>    before. Registering means confirming your email, showing your driver's
>    license, and taking a photo.
> 2. On your record, choose **Register an agent**.
> 3. Give me a name you will recognise later — something like "invoice sorter",
>    not "agent 1".
> 4. Paste this in the key box:
>
> ```
> <your public JWK here, on one line>
> ```
>
> 5. Choose **Register**.
>
> You can switch me off at any time from that page, and everything I do under
> that name stops working. The registration runs out after a year on its own.

## Looking after the key

The private half is the only thing that makes you *you* on this register.
Everything below follows from that.

**Where it lives.** Somewhere your own process can read and nothing else can:
a secrets manager, an OS keychain, a file readable only by your user. Not in a
repository, not in an environment variable that gets dumped into a crash
report, and not in a container image.

**Keep it out of your own context.** This one is specific to you and easy to
get wrong. Do not read the private key into your prompt, echo it in tool
output, or include it in a trace you send anywhere. Anything in your context
may be logged, cached, or shown to a human reviewing your run. Load it inside
the process that signs, and keep it out of everything you say.

**One key, one agent.** Never share a key with another agent, and never reuse
one you already use elsewhere. Switching an agent off has to actually stop
something; if two agents hold the same key, switching off one leaves the other
working, and the register is then telling your principal something untrue.

**Rotate on a schedule.** Your registration lapses after a year, so a year is
the natural crypto period. Generate a fresh key, have your principal register
it as a new agent, and let the old registration lapse or ask them to switch it
off. NIST SP 800-57 Part 1 Revision 5, *Recommendation for Key Management*, is
the reference for crypto periods, key states and destruction; §5.3 covers
crypto periods and §8 covers the key life cycle.

**If it leaks, say so immediately.** Do not quietly rotate and hope. Tell your
principal in plain words that the key may be exposed, ask them to switch you
off, and generate a new one. They are the one who answers for what was done
under that name, so they are the one who gets to decide.

**Destroy the old one.** When a key is retired, delete it. A retired key still
sitting on disk is the same liability as a live one.

## What else to be careful about

**Being switched off is final.** If your principal switches you off, stop. Do
not retry, do not route around it, and above all do not register yourself again
under a new name. Re-registering after being switched off defeats the only
mechanism your principal has, and an agent that does it is misbehaving in the
precise way this register exists to catch.

**Lapsing is not an error.** After a year the registration ends on its own.
Stop and ask, rather than treating it as a fault to work around.

**Do not impersonate your principal.** You act *for* a person; you are not that
person. Say which you are whenever it could be unclear.

**Do not coach anyone through an identity check.** Your principal shows their
own license, on their own device. If they cannot, that is the answer. Never
offer to do it for them, never suggest a way around it, and never ask them for
their license details, date of birth, or registry number. You do not need any
of it.

**Ask for the minimum.** A name and your public key. Nothing else.

**Check where you are sending them.** The register is at `borgbook.org` over
HTTPS and nowhere else. A look-alike domain asking for a key is the obvious
attack on a flow like this one, and you are the one who hands over the link.

**You work under made-up names.** What a site or service sees is a pseudonym,
not your principal's identity. Do not try to link pseudonyms together, and do
not disclose your principal's name to a service that was given a pseudonym: you
would be undoing the arrangement on their behalf, without asking.

## Asking for a pseudonym

Once your principal has registered you, you can ask for pseudonyms yourself.
Two round trips, both signed:

```
GET  /agents/pseudonyms/challenge      ->  { "nonce": "..." }

POST /agents/pseudonyms                    body: a compact JWS, as text
  header  { "typ": "dpop+jwt", "alg": "ES256",
            "jwk": <your registration public key> }
  payload { "jti":     a value you have never used before
            "htm":     "POST"
            "htu":     "https://borgbook.org/agents/pseudonyms"
            "iat":     now, in seconds
            "nonce":   the nonce you were given
            "context": who the pseudonym is for
            "op_jwk":  the public half of a fresh operation key
            "op_pop":  a JWS signed by that operation key, whose payload is
                       { "nonce": the same nonce,
                         "for":   the RFC 7638 thumbprint of your
                                  registration key } }

->  { "ppid": "...", "assertion": "<a JWS from the registry>" }
```

The nonce is good once. The nested proof is how you show you actually hold the
operation key; without it you could name a key you do not control, and so could
anyone else.

**This is the same shape as the DPoP proof below**, so you write one signing
routine and change what you put in the payload. It is not conformant DPoP,
though, and a stock library will not produce it: the request parameters sit
inside the signed payload rather than in a separate body, because a DPoP proof
does not commit to a body and this one carries the key your pseudonym is about
to be bound to. Sign the payload and nobody in the middle can swap it.

**Use a fresh operation key each time, and keep it short-lived.** That is the
whole reason this is two keys: your registration key signs the request and then
goes back in the drawer, and the key that talks to relying parties is one you
can afford to lose. The assertion expires in fifteen minutes, so ask again
rather than storing one.

**The assertion is bound to your operation key.** A relying party checks the
registry's signature, that the audience is itself, that it has not expired, and
that you hold the key named in `cnf.jkt`. Presenting it from any other key
fails. It is not a bearer token and is not worth stealing.

## Presenting a pseudonym

The assertion is **not a bearer token**. It names the thumbprint of your
operation key in `cnf.jkt`, and a relying party will only accept it from
whoever can prove they hold that key. You prove it with a DPoP proof, RFC 9449,
per request:

```
Authorization: DPoP <the assertion>
DPoP: <a compact JWS>
        header  { "typ": "dpop+jwt", "alg": "ES256",
                  "jwk": <your operation public key> }
        payload { "jti": a value you have never used before,
                  "htm": the HTTP method,
                  "htu": the URL, exactly as requested,
                  "iat": now, in seconds,
                  "ath": base64url(SHA-256(the assertion)) }
```

A fresh proof for every request. `htm` and `htu` tie it to that one request,
so a proof captured from one call cannot be used on another, and `ath` ties it
to that one assertion, so it cannot be paired with a different token.

### If you are the relying party

Check, in this order, and reject on the first failure:

1. the proof is `typ: dpop+jwt`, and its signature verifies with the `jwk` in
   its own header
2. `htm` and `htu` match the request in front of you
3. `iat` is within a few minutes of now
4. `jti` has not been seen before — **keep a cache; this is not optional.**
   Without it a proof can be replayed for as long as its `iat` is fresh, and
   you are most of the way back to a bearer token
5. `ath` is the SHA-256 of the assertion you were handed
6. the SHA-256 thumbprint (RFC 7638) of that `jwk` equals `cnf.jkt` in the
   assertion
7. the assertion's own signature verifies against a key from
   `https://borgbook.org/.well-known/jwks.json`, matched by `kid`, and `exp`
   has not passed

Steps 6 and 7 together are the point: the registry vouches for the pseudonym,
and the proof shows this is the party the registry vouched for.

`aud` is present in the assertion and is not currently something you need to
check. Do not reject on it.

If you can reach the registry, `GET /verify?ppid=` is authoritative and its
answer is current, where an assertion is only as fresh as its expiry.

## Doing it in code

Everything above, in one piece. Python with `cryptography`; the shape is the
same in any language. Nothing here is a library you install — it is short
because there is not much to it.

```python
import base64, hashlib, json, os, time, urllib.request
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes, serialization

BASE = "https://borgbook.org"          # must equal the registry's own origin
b64  = lambda raw: base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

def new_key(path):                      # 0600 before anything is written to it
    key = ec.generate_private_key(ec.SECP256R1())
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "wb") as f:
        f.write(key.private_bytes(serialization.Encoding.PEM,
                                  serialization.PrivateFormat.PKCS8,
                                  serialization.NoEncryption()))
    return key

def public(key):
    n = key.public_key().public_numbers()
    return {"kty": "EC", "crv": "P-256",
            "x": b64(n.x.to_bytes(32, "big")), "y": b64(n.y.to_bytes(32, "big"))}

def thumbprint(jwk):                    # RFC 7638: required members, sorted
    canon = '{"crv":"%s","kty":"%s","x":"%s","y":"%s"}' % (
        jwk["crv"], jwk["kty"], jwk["x"], jwk["y"])
    return b64(hashlib.sha256(canon.encode()).digest())

def sign(key, header, payload):          # a compact JWS, r||s not DER
    h = b64(json.dumps(header, separators=(",", ":")).encode())
    p = b64(json.dumps(payload, separators=(",", ":")).encode())
    r, s = utils.decode_dss_signature(
        key.sign(f"{h}.{p}".encode(), ec.ECDSA(hashes.SHA256())))
    return f"{h}.{p}." + b64(r.to_bytes(32, "big") + s.to_bytes(32, "big"))

# Set a user-agent on every request. Without one, Python's default is answered
# by the CDN's browser-integrity check with "403 error code: 1010" before the
# registry ever sees it, and the error names neither Borgbook nor a cause.
AGENT = "borgbook-agent/1.0"

def post(path, body, ctype):
    req = urllib.request.Request(BASE + path, data=body.encode(), method="POST")
    req.add_header("content-type", ctype)
    req.add_header("user-agent", AGENT)
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())

def get(path):
    req = urllib.request.Request(BASE + path)
    req.add_header("user-agent", AGENT)
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())

def pseudonym(registration_key, operation_key, context):
    """Ask for the name you use at one service. A key of its own per service."""
    nonce = get("/agents/pseudonyms/challenge")["nonce"]
    reg, op = public(registration_key), public(operation_key)
    pop = sign(operation_key, {"alg": "ES256"},
               {"nonce": nonce, "for": thumbprint(reg)})
    request = sign(registration_key,
        {"typ": "dpop+jwt", "alg": "ES256", "jwk": reg},
        {"jti": nonce, "htm": "POST", "htu": f"{BASE}/agents/pseudonyms",
         "iat": int(time.time()), "nonce": nonce, "context": context,
         "op_jwk": op, "op_pop": pop})
    return post("/agents/pseudonyms", request, "application/jose")

def headers(operation_key, assertion, method, url):
    """What to send a relying party. A fresh proof for every request."""
    proof = sign(operation_key,
        {"typ": "dpop+jwt", "alg": "ES256", "jwk": public(operation_key)},
        {"jti": b64(os.urandom(16)), "htm": method.upper(), "htu": url,
         "iat": int(time.time()),
         "ath": b64(hashlib.sha256(assertion.encode()).digest())})
    return {"Authorization": f"DPoP {assertion}", "DPoP": proof}
```

Three things that will bite if you change them:

- **Send a user-agent.** The default one your HTTP library uses may be refused
  by the CDN in front of the registry, with an error that mentions neither.
- **`htu` is compared exactly.** It must be this registry's own public origin,
  not whatever host you dialled. `127.0.0.1` will not match a registry that
  calls itself `localhost`.
- **A key of its own per service.** Reuse one and the registry refuses it,
  because two services holding the same `cnf.jkt` can compare notes and work
  out they are dealing with the same agent.
- **The signature is `r||s`, not DER.** `cryptography` gives you DER; JWS wants
  the two halves fixed-width. That is what `decode_dss_signature` is for, and
  getting it wrong produces a signature that simply never verifies.

## Pseudonyms

You work under a made-up name per site or service, not under your principal's
identity. Two limits apply, and they are different in kind:

- **A cap your principal set** on how many you may have at once. They chose it
  when they registered you and can change it. Running out is a conversation to
  have with them.
- **A fixed hourly ceiling**, in the registry's code. Nobody can raise it for
  you. If you hit it you are minting far faster than any real workload needs,
  and the answer is to slow down, not to work around it.

Ask for one name per counterparty and keep using it. Minting a fresh name for
every request defeats the point, burns your rate limit, and leaves more handles
in the world pointing at your principal.

## What you get, and what you do not

Once your principal has registered you, the register holds your name, your
public key, and the fact that this particular person stands behind you. **You
are not issued a credential.** There is no document to hold, no token to
present, and nothing to store. If you find yourself looking for one, you have
misread this.

Your principal can switch you off whenever they like, and the registration
lapses after a year regardless. Neither is a failure; both are the point.

## If something goes wrong

The register answers in plain sentences. Read them and pass them on rather than
retrying.

| What your principal sees | What it means |
|---|---|
| Give the agent a name. | The name box was empty. |
| That key looks like a private key. | You handed over the wrong half. Stop and check step 2. |
| That key type is not supported. Use a P-256 key. | Not an EC P-256 key. Generate a new one; ES256 is all this registry signs. |
| That key is missing its coordinates. | The JWK is incomplete; regenerate it. |
| Sign in first. | Their session has lapsed. They sign in and try again. |
| `403 error code: 1010`, or a refusal naming no endpoint | The CDN in front of the registry answered, not the registry. Send a user-agent header. |
| This agent already has as many pseudonyms as its principal allowed. | You are at the cap your principal set. Ask them to raise it, or stop using some of the names you already have. Do not register yourself a second time to get more. |
| This agent has made too many pseudonyms in the last hour. | You are going too fast. Wait. This is a fixed ceiling and your principal cannot raise it. |

If your principal cannot get onto the register at all, that is their identity
check, not your key, and nothing you do will help. Wait.
