Onboard users with one-time links instead of pasted keys

Issuing a key worked; getting it to the person it was issued for did not. The
operator had a secret on their terminal and no way to send it that did not
leave a bearer credential in a chat log forever.

`keys create --claim` prints a link instead. GET shows a button and consumes
nothing; POST reveals the key and burns the link. That split is not politeness
— chat clients, mail scanners and link previewers fetch URLs the moment they
are posted, so a GET that consumed the claim would mean the first thing to
unfurl the link destroys the key before anyone reads it.

A pending claim has to hold the key somewhere, which would otherwise put a
working credential in the database and undo what hashing bought. So the key is
sealed with a key derived from the link's own token, and the token is never
stored — only its hash. A database leak yields two useless hashes and a blob
nothing in the database can open. HKDF rather than the raw token, so the value
used for lookup can never become the value that decrypts.

Consuming is one UPDATE moving claimed_at off NULL, so two people opening the
same link produce one key rather than two copies. A used link, an expired one
and a token that was never real all render identically, because saying which
confirms that a token was once valid. The page is no-store, no-referrer and
noindex: the URL is itself the credential.

`GET /me` reports a key's own limits and spend, so nobody has to ask an
operator to read a dashboard they cannot reach. Outside /v1, because it is not
part of the OpenAI API.

docs/onboarding.md is written for whoever operates this, including the parts
that are deliberately absent — no self-service, no SSO, and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5 committed 2026-09-16 09:11:28 +02:00
1 parent cd3233ffe8
commit 3ed211b2f4
16 files changed
+1164 -23

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
# Onboarding a user
Written for whoever operates an llmgw deployment: the person with a shell on the
control plane machine.
Everything here runs as the user the server runs as, so the files it creates stay
readable by it:
```
sudo -u llmgw llmgw keys <command> --store /var/lib/llmgw/llmgw.db
```
The `--store` flag is repeated in every example below. Set it once in a shell
alias if you do this often; it deliberately has no server-side default beyond
`/var/lib/llmgw/llmgw.db`.
## 1. Decide where the user belongs
Keys hang off a project, projects off a team, teams off an organization. Limits
can be set at any of those levels and compose downward by taking the **most
restrictive** value — a key cannot raise what its team set, and a team cannot
raise what its organization set. That is the only rule under which a budget
means anything.
If you have no structure yet, one of each is fine:
```
llmgw keys init --store … --org mynodes --team default --project default
```
It is idempotent: running it again renames rather than duplicating, so it can
live in whatever provisions the machine.
Split it when you want separate limits or separate reporting, not before.
## 2. Issue the key as a one-time link
```
llmgw keys create --store … \
--project default \
--band gold \
--base-url https://llmgw.example \
--claim \
"ayse-laptop"
https://llmgw.example/claim/3mKq…
```
Send the link, not the key.
A key pasted into a chat window stays in that chat window forever, searchable by
everyone who joins the channel later. A link works once and then expires, so the
worst case is someone opening it before the intended person — which you find out
about, because they will tell you the link is dead.
The name is for you, not the user: it appears in `keys list` and is how you will
know which key to revoke.
**Why the link is safe to store.** The key is sealed with the link's own token,
and the token is never written down — only its hash is. A database leak yields
two useless hashes and a blob nothing in the database can open.
Without `--claim` the key is printed to stdout instead, for when you are
provisioning something that is not a person:
```
llmgw keys create --store … --project ci "github-runner" > /run/secrets/llmgw-key
```
## 3. Set limits
Limits are optional. A key with none is unlimited, which is the right default on
a private cluster and the wrong one the moment the gateway faces anything else.
```
# Everyone in this project
llmgw keys limits --store … --scope project --id default \
--rpm 60 --tpm 200000
# One key, tighter
llmgw keys limits --store … --scope key --id key-mpmip77twpt7qaxc \
--rpm 10
# Restrict which models
llmgw keys limits --store … --scope project --id default \
--models qwen-0.5b,llama-3.3-70b
```
An **absent** model list permits everything. An **empty** one permits nothing —
`--models ""` is a way to suspend a project without revoking its keys.
Allowlists compose by intersection: a key cannot add a model its team withheld.
A model's canonical name and any of its aliases name the same thing, so either
form works.
Budgets are counted in micro-units, and what a unit means is yours to decide.
Self-hosted models have no per-token price, so it can be GPU-seconds, a notional
rate, or 1 000 000 everywhere — which turns a budget into a plain token quota.
Models are priced in the server's `auth.pricing` section; a model priced at zero
is exempt from budget checks, which is what lets a key with nothing left still be
served a cheap fallback.
```
llmgw keys limits --store … --scope project --id default \
--budget 50000000 --period month # 50 units a month
```
## 4. What the user does
They open the link, press the button, and get the key once. The page shows them
the base URL and a working `curl`, and points at two endpoints:
- `GET /v1/models` — what this key may use
- `GET /me` — its limits and what it has spent
`/me` is there so nobody has to ask you "why was I rate limited" or "how much
have I used". It is outside `/v1` because it is not part of the OpenAI API.
## 5. Afterwards
```
llmgw keys list --store … --project default
```
`LAST USED` is written in batches rather than per request, so it lags by up to a
minute. It is the column that tells you whether a key is still in use, which is
what makes rotation safe:
**To rotate**, issue the new key, let both work while the user switches, watch
`LAST USED` on the old one stop moving, then revoke it.
```
llmgw keys revoke --store … key-mpmip77twpt7qaxc
```
Revocation is a soft delete: the key stops working, the row stays, and its usage
history keeps its subject. A running server keeps accepting a revoked key until
its cache expires — `auth.cache_ttl`, 30 seconds by default. That is the price of
keeping the database off the request path, and it is the number to lower if you
need revocation to bite faster.
## What this does not do
- **No self-service.** Every key needs someone with a shell here. That is
deliberate: an endpoint that mints credentials needs a login system, and this
project does not have one. If you outgrow it, that is the thing to build, not a
password field on the dashboard.
- **No SSO.** Same reason.
- **Rate limits are per gateway instance.** With two instances each counts its own,
so the effective limit doubles. v1 is single-instance by design.
+1
View File
@@ -37,6 +37,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
modernc.org/libc v1.75.7 // indirect
+2
View File
@@ -71,6 +71,8 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
+45 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"text/tabwriter"
@@ -78,15 +79,21 @@ func newKeyInitCmd(open opener) *cobra.Command {
}
func newKeyCreateCmd(open opener) *cobra.Command {
var project, band, profile string
var expires time.Duration
var project, band, profile, baseURL string
var expires, claimTTL time.Duration
var claim bool
cmd := &cobra.Command{
Use: "create <name>",
Short: "Issue an API key",
Long: "The key is printed once and never again: only its hash is stored, so there\n" +
"is nothing to recover it from. That is the point — a leaked database hands\n" +
"over no working credential.",
"over no working credential.\n\n" +
"With --claim it prints a one-time link instead. Send the link rather than\n" +
"the key: a key pasted into a chat window stays in that chat window forever,\n" +
"while a link works once and then expires. The key is sealed with the link's\n" +
"own token, which is not stored, so a pending claim is not a usable\n" +
"credential sitting in the database either.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := open(cmd.Context())
@@ -109,12 +116,32 @@ func newKeyCreateCmd(open opener) *cobra.Command {
project, err)
}
// The secret goes to stdout and everything else to stderr, so
// `keys create ... > key.txt` captures the key and nothing else.
// Cobra's Print writes to stderr, which would have made the one
// output that matters the one that cannot be piped.
fmt.Fprintln(cmd.OutOrStdout(), secret)
// The one output that matters goes to stdout and everything else to
// stderr, so `keys create ... > out.txt` captures it and nothing
// else. Cobra's Print writes to stderr, which would have made the
// useful half the half that cannot be piped.
out := cmd.OutOrStdout()
if claim {
if baseURL == "" {
return errors.New("--claim needs --base-url, the address users reach this gateway at")
}
token, err := store.NewClaimToken()
if err != nil {
return err
}
if err := s.CreateClaim(cmd.Context(), k.ID, token, secret, claimTTL); err != nil {
return err
}
link := strings.TrimRight(baseURL, "/") + "/claim/" + token
fmt.Fprintln(out, link)
cmd.PrintErrf("\nid %s\nproject %s\nexpires %s\n",
k.ID, k.ProjectID, time.Now().Add(orDefault(claimTTL, store.ClaimTTL)).Format(time.RFC3339))
cmd.PrintErrln("\nSend the link. It works once, and the key is shown only to whoever opens it.")
return nil
}
fmt.Fprintln(out, secret)
cmd.PrintErrf("\nid %s\nproject %s\n", k.ID, k.ProjectID)
if !k.ExpiresAt.IsZero() {
cmd.PrintErrf("expires %s\n", k.ExpiresAt.Format(time.RFC3339))
@@ -127,9 +154,19 @@ func newKeyCreateCmd(open opener) *cobra.Command {
cmd.Flags().StringVar(&band, "band", "", "admission band; empty inherits")
cmd.Flags().StringVar(&profile, "profile", "", "routing profile; empty inherits")
cmd.Flags().DurationVar(&expires, "expires-in", 0, "lifetime; 0 never expires")
cmd.Flags().BoolVar(&claim, "claim", false, "print a one-time link instead of the key")
cmd.Flags().StringVar(&baseURL, "base-url", "", "public address of the gateway, for --claim")
cmd.Flags().DurationVar(&claimTTL, "claim-ttl", store.ClaimTTL, "how long the link stays usable")
return cmd
}
func orDefault(d, fallback time.Duration) time.Duration {
if d <= 0 {
return fallback
}
return d
}
func newKeyListCmd(open opener) *cobra.Command {
var project string
cmd := &cobra.Command{
+71 -1
View File
@@ -90,6 +90,16 @@ func (r *Router) Handler() http.Handler {
// reading it is the one who does not have one yet.
mux.HandleFunc("GET /", web.Landing(version.Version, r.auth != nil))
mux.Handle("GET /_static/", web.Static())
mux.HandleFunc("GET /me", r.handleMe)
if r.store != nil {
// The one-time link that delivers a key to the person it was issued
// for. Served without a key, because the person opening it is the one
// who does not have one yet; what protects it is the token.
claim := web.Claim(r.store, version.Version, r.log)
mux.HandleFunc("GET /claim/{token}", claim)
mux.HandleFunc("POST /claim/{token}", claim)
}
if r.auth == nil {
return mux
@@ -97,7 +107,7 @@ func (r *Router) Handler() http.Handler {
// Wrapping the whole mux rather than checking inside each handler: adding a
// route must not be able to add an unauthenticated one by omission. Health
// stays open because a load balancer has no key.
return auth.Middleware(r.auth, r.log, "/health", "/", "/_static/")(mux)
return auth.Middleware(r.auth, r.log, "/health", "/", "/_static/", "/claim/")(mux)
}
// parsedRequest is what the router needs from a request. Everything else in the
@@ -507,3 +517,63 @@ func (p *parsedRequest) estimatedTokens() int64 {
}
return int64(p.PromptTokens + completion)
}
// handleMe reports what the presented key is and what it may do.
//
// It exists so a user can answer "how much have I used" and "why was I
// refused" without asking an operator to read a dashboard they cannot reach.
// Deliberately outside /v1: it is not part of the OpenAI API, and putting it
// there would imply it is.
func (r *Router) handleMe(w http.ResponseWriter, req *http.Request) {
p, ok := auth.FromContext(req.Context())
if !ok {
// With authentication off there is no identity to describe, and saying
// so is more useful than an empty object.
writeJSON(w, http.StatusOK, map[string]any{
"authenticated": false,
"note": "this gateway has no api keys configured",
})
return
}
out := map[string]any{
"authenticated": true,
"key_id": p.KeyID,
"key": p.KeyPrefix,
"project": p.ProjectID,
"team": p.TeamID,
"organization": p.OrgID,
}
if p.Band != "" {
out["band"] = p.Band
}
limits := map[string]any{}
if p.Limits.RPM != nil {
limits["requests_per_minute"] = *p.Limits.RPM
}
if p.Limits.TPM != nil {
limits["tokens_per_minute"] = *p.Limits.TPM
}
if p.Limits.AllowedModels != nil {
limits["allowed_models"] = p.Limits.AllowedModels
}
if p.Limits.Budget != nil {
limits["budget_units"] = float64(*p.Limits.Budget) / 1e6
limits["budget_period"] = string(p.Limits.BudgetPeriod)
}
out["limits"] = limits
if r.acct != nil {
spent, rpm, tpm := r.acct.Status(p)
usage := map[string]any{
"requests_per_minute": rpm,
"tokens_per_minute": tpm,
}
if p.Limits.Budget != nil {
usage["spent_units"] = float64(spent) / 1e6
usage["remaining_units"] = float64(*p.Limits.Budget-spent) / 1e6
}
out["usage"] = usage
}
writeJSON(w, http.StatusOK, out)
}
+151
View File
@@ -0,0 +1,151 @@
package store
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"time"
)
// ClaimTTL is how long an unclaimed link stays usable.
//
// Short enough that a link forwarded to the wrong place is unlikely to still
// work when it gets there; long enough to survive a weekend.
const ClaimTTL = 72 * time.Hour
// ErrClaimed says a claim link is gone: used, expired, or never real. The three
// are not distinguished, because saying which would confirm that a token was
// once valid.
var ErrClaimed = errors.New("this link is no longer valid")
// Claim is a pending one-time delivery of a key.
type Claim struct {
KeyID string
ExpiresAt time.Time
}
// NewClaimToken mints the token that goes in the link.
//
// The same 256 bits as an API key, for the same reason: it is a bearer
// credential for as long as it lives.
func NewClaimToken() (string, error) {
raw := make([]byte, keyBytes)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return encodeBase62(raw), nil
}
// CreateClaim records a sealed key for later collection.
//
// The secret is sealed with the token, which is not stored, so what lands in
// the database cannot be opened by anything that can read the database.
func (s *SQLite) CreateClaim(ctx context.Context, keyID, token, secret string, ttl time.Duration) error {
sealed, err := seal(token, []byte(secret))
if err != nil {
return err
}
if ttl <= 0 {
ttl = ClaimTTL
}
now := time.Now()
_, err = s.db.ExecContext(ctx,
`INSERT INTO key_claims (token_hash, key_id, sealed, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)`,
HashKey(token), keyID, sealed, millis(now), millis(now.Add(ttl)))
return err
}
// PeekClaim reports whether a link is still good, without consuming it.
//
// The page behind the link is shown before it is used, because link previewers
// follow URLs: a GET that consumed the claim would mean the first chat client
// to unfurl it burns the key.
func (s *SQLite) PeekClaim(ctx context.Context, token string) (Claim, error) {
row := s.db.QueryRowContext(ctx,
`SELECT key_id, expires_at, claimed_at FROM key_claims WHERE token_hash = ?`,
HashKey(token))
var c Claim
var expires int64
var claimed sql.NullInt64
if err := row.Scan(&c.KeyID, &expires, &claimed); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Claim{}, ErrClaimed
}
return Claim{}, err
}
c.ExpiresAt = fromMillis(expires)
if claimed.Valid || time.Now().After(c.ExpiresAt) {
return Claim{}, ErrClaimed
}
return c, nil
}
// ConsumeClaim opens the sealed key and marks the link used.
//
// The update is the lock: exactly one caller can move claimed_at from NULL, so
// two people opening the link at the same moment produce one winner rather than
// two copies of the key.
func (s *SQLite) ConsumeClaim(ctx context.Context, token string) (string, error) {
hash := HashKey(token)
now := time.Now()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer func() { _ = tx.Rollback() }()
var sealed []byte
var expires int64
err = tx.QueryRowContext(ctx,
`SELECT sealed, expires_at FROM key_claims WHERE token_hash = ? AND claimed_at IS NULL`,
hash).Scan(&sealed, &expires)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrClaimed
}
if err != nil {
return "", err
}
if now.After(fromMillis(expires)) {
return "", ErrClaimed
}
secret, err := open(token, sealed)
if err != nil {
return "", ErrClaimed
}
// Blank the sealed value as well as marking it claimed, so a used link
// leaves nothing behind even before the row is purged.
res, err := tx.ExecContext(ctx,
`UPDATE key_claims SET claimed_at = ?, sealed = x'' WHERE token_hash = ? AND claimed_at IS NULL`,
millis(now), hash)
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
return "", ErrClaimed
}
if err := tx.Commit(); err != nil {
return "", err
}
return string(secret), nil
}
// PurgeClaims deletes rows that can no longer be used.
//
// Housekeeping rather than security — an expired row is already refused and its
// contents are already unopenable — but a table that only grows is its own
// problem.
func (s *SQLite) PurgeClaims(ctx context.Context, before time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM key_claims WHERE expires_at < ? OR claimed_at IS NOT NULL`, millis(before))
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
+211
View File
@@ -0,0 +1,211 @@
package store
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
)
func claimFixture(t *testing.T) (*SQLite, string, string) {
t.Helper()
ctx := context.Background()
s, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "c.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
tree(t, s)
secret, k, err := NewKey("key1", "n", "proj1")
if err != nil {
t.Fatal(err)
}
k.CreatedAt = time.Now()
if err := s.CreateKey(ctx, k); err != nil {
t.Fatal(err)
}
token, err := NewClaimToken()
if err != nil {
t.Fatal(err)
}
if err := s.CreateClaim(ctx, k.ID, token, secret, ClaimTTL); err != nil {
t.Fatal(err)
}
return s, token, secret
}
func TestAClaimDeliversTheKeyExactlyOnce(t *testing.T) {
s, token, secret := claimFixture(t)
ctx := context.Background()
if _, err := s.PeekClaim(ctx, token); err != nil {
t.Fatalf("a fresh link is not valid: %v", err)
}
got, err := s.ConsumeClaim(ctx, token)
if err != nil {
t.Fatal(err)
}
if got != secret {
t.Fatal("the delivered key is not the one that was issued")
}
if _, err := s.ConsumeClaim(ctx, token); !errors.Is(err, ErrClaimed) {
t.Errorf("the link worked twice: %v", err)
}
if _, err := s.PeekClaim(ctx, token); !errors.Is(err, ErrClaimed) {
t.Errorf("a used link still looks valid: %v", err)
}
}
func TestTheDatabaseNeverHoldsAUsableKey(t *testing.T) {
// The property that makes a pending claim safe to store at all: what is in
// the table cannot be opened by anything that can read the table.
s, token, secret := claimFixture(t)
var sealed []byte
if err := s.db.QueryRow(`SELECT sealed FROM key_claims`).Scan(&sealed); err != nil {
t.Fatal(err)
}
if len(sealed) == 0 {
t.Fatal("nothing was stored")
}
if string(sealed) == secret {
t.Fatal("the key is sitting in the database in the clear")
}
// And the token itself is not there either, so nothing in the database can
// derive the key that opens it.
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM key_claims WHERE token_hash = ?`, []byte(token)).
Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("the claim token was stored, not just its hash")
}
if _, err := open("wrong token entirely", sealed); !errors.Is(err, ErrSealed) {
t.Errorf("a wrong token opened the blob: %v", err)
}
}
func TestAClaimedLinkLeavesNothingBehind(t *testing.T) {
s, token, _ := claimFixture(t)
if _, err := s.ConsumeClaim(context.Background(), token); err != nil {
t.Fatal(err)
}
var sealed []byte
if err := s.db.QueryRow(`SELECT sealed FROM key_claims`).Scan(&sealed); err != nil {
t.Fatal(err)
}
if len(sealed) != 0 {
t.Errorf("a used claim still holds %d bytes", len(sealed))
}
}
func TestOnlyOneOfTwoSimultaneousClaimsWins(t *testing.T) {
// Two people opening the same link at the same moment must produce one key,
// not two copies of it.
s, token, _ := claimFixture(t)
var wg sync.WaitGroup
var mu sync.Mutex
won := 0
for range 16 {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := s.ConsumeClaim(context.Background(), token); err == nil {
mu.Lock()
won++
mu.Unlock()
}
}()
}
wg.Wait()
if won != 1 {
t.Errorf("%d callers each received the key", won)
}
}
func TestAnExpiredLinkIsRefused(t *testing.T) {
ctx := context.Background()
s, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "c.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
tree(t, s)
secret, k, _ := NewKey("key1", "n", "proj1")
k.CreatedAt = time.Now()
must(t, s.CreateKey(ctx, k))
token, _ := NewClaimToken()
must(t, s.CreateClaim(ctx, k.ID, token, secret, ClaimTTL))
// Age it directly. CreateClaim treats a non-positive TTL as "use the
// default", which is the right thing for a caller and no way to build the
// row this test needs.
if _, err := s.db.Exec(`UPDATE key_claims SET expires_at = ?`,
time.Now().Add(-time.Hour).UnixMilli()); err != nil {
t.Fatal(err)
}
if _, err := s.PeekClaim(ctx, token); !errors.Is(err, ErrClaimed) {
t.Errorf("an expired link looks valid: %v", err)
}
if _, err := s.ConsumeClaim(ctx, token); !errors.Is(err, ErrClaimed) {
t.Errorf("an expired link delivered a key: %v", err)
}
}
func TestAnUnknownLinkLooksLikeAUsedOne(t *testing.T) {
// Distinguishing them would confirm that a token was once valid.
s, token, _ := claimFixture(t)
ctx := context.Background()
must(t, func() error { _, err := s.ConsumeClaim(ctx, token); return err }())
unknown, _ := NewClaimToken()
_, usedErr := s.PeekClaim(ctx, token)
_, unknownErr := s.PeekClaim(ctx, unknown)
if usedErr.Error() != unknownErr.Error() {
t.Errorf("used says %q, unknown says %q", usedErr, unknownErr)
}
}
func TestPurgeRemovesWhatCannotBeUsed(t *testing.T) {
s, token, _ := claimFixture(t)
ctx := context.Background()
must(t, func() error { _, err := s.ConsumeClaim(ctx, token); return err }())
n, err := s.PurgeClaims(ctx, time.Now())
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("purged %d rows", n)
}
}
func TestSealRoundTrips(t *testing.T) {
token, _ := NewClaimToken()
sealed, err := seal(token, []byte("sk-llmgw-secret"))
if err != nil {
t.Fatal(err)
}
got, err := open(token, sealed)
if err != nil {
t.Fatal(err)
}
if string(got) != "sk-llmgw-secret" {
t.Errorf("round trip gave %q", got)
}
// Tampering must fail rather than produce something.
sealed[len(sealed)-1] ^= 0xff
if _, err := open(token, sealed); !errors.Is(err, ErrSealed) {
t.Errorf("a tampered blob opened: %v", err)
}
}
@@ -0,0 +1,20 @@
-- A key has to reach the person it was issued for, and a key pasted into a chat
-- window stays in that chat window forever. A claim is a one-time link instead:
-- the operator sends the link, the person opens it once, and the link dies.
--
-- The key is not stored here in the clear. It is sealed with a key derived from
-- the claim token, and the token is never written down — only its hash is. So a
-- database leak yields two useless hashes and a blob nothing in the database can
-- open, which keeps the property the api_keys table has.
CREATE TABLE key_claims (
token_hash BLOB PRIMARY KEY,
key_id TEXT NOT NULL REFERENCES api_keys(id),
-- The sealed key. Emptied the moment it is claimed, so a claimed link leaves
-- nothing behind even before the row is purged.
sealed BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
claimed_at INTEGER
);
CREATE INDEX key_claims_by_key ON key_claims(key_id);
CREATE INDEX key_claims_by_expiry ON key_claims(expires_at);
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"errors"
"io"
"golang.org/x/crypto/hkdf"
)
// ErrSealed says a sealed value could not be opened: the wrong token, or a
// tampered blob. The two are deliberately not distinguished.
var ErrSealed = errors.New("cannot open")
// seal encrypts a secret so that only the holder of the token can read it.
//
// This is what keeps a pending claim from being a working credential sitting in
// the database. The token is never stored — only its hash — so the key needed
// to open this blob does not exist anywhere the database does.
//
// HKDF rather than using the token directly: the token is also hashed for
// lookup, and a value used both as a lookup key and an encryption key is one
// change away from having the lookup index hand out the decryption key.
func seal(token string, secret []byte) ([]byte, error) {
gcm, err := cipherFor(token)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, secret, nil), nil
}
// open reverses seal.
func open(token string, sealed []byte) ([]byte, error) {
gcm, err := cipherFor(token)
if err != nil {
return nil, err
}
if len(sealed) < gcm.NonceSize() {
return nil, ErrSealed
}
out, err := gcm.Open(nil, sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():], nil)
if err != nil {
return nil, ErrSealed
}
return out, nil
}
func cipherFor(token string) (cipher.AEAD, error) {
key := make([]byte, 32)
// The info string domain-separates this use of the token from its use as a
// lookup hash, so neither can ever be derived from the other.
if _, err := io.ReadFull(hkdf.New(sha256.New, []byte(token), nil, []byte("llmgw claim seal v1")), key); err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
+14 -14
View File
@@ -11,7 +11,7 @@ import (
"time"
)
func open(t *testing.T) *SQLite {
func openDB(t *testing.T) *SQLite {
t.Helper()
s, err := OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "llmgw.db"))
if err != nil {
@@ -81,7 +81,7 @@ func TestMigrationsAreIdempotent(t *testing.T) {
func TestTheKeyItselfIsNeverStored(t *testing.T) {
// The whole point of hashing: a database leak hands over no working
// credential.
s := open(t)
s := openDB(t)
project := tree(t, s)
secret, _ := mustKey(t, s, "key1", project)
@@ -108,7 +108,7 @@ func TestTheKeyItselfIsNeverStored(t *testing.T) {
}
func TestAuthenticationFindsAKeyByWhatWasPresented(t *testing.T) {
s := open(t)
s := openDB(t)
project := tree(t, s)
secret, created := mustKey(t, s, "key1", project)
@@ -131,7 +131,7 @@ func TestAuthenticationFindsAKeyByWhatWasPresented(t *testing.T) {
func TestRevocationKeepsTheAuditTrail(t *testing.T) {
// A revoked key must stop working and must still exist: usage rows
// reference it, and an audit trail that loses its subject is not one.
s := open(t)
s := openDB(t)
ctx := context.Background()
project := tree(t, s)
secret, k := mustKey(t, s, "key1", project)
@@ -153,7 +153,7 @@ func TestRevocationKeepsTheAuditTrail(t *testing.T) {
}
func TestExpiryIsCheckedAgainstTheClockNotTheDatabase(t *testing.T) {
s := open(t)
s := openDB(t)
project := tree(t, s)
secret, k := mustKey(t, s, "key1", project)
@@ -181,7 +181,7 @@ func TestExpiryIsCheckedAgainstTheClockNotTheDatabase(t *testing.T) {
func TestOrphansAreRefused(t *testing.T) {
// SQLite leaves foreign keys off by default, which would let the tenancy
// tree grow keys belonging to no project.
s := open(t)
s := openDB(t)
_, k, _ := NewKey("key1", "n", "no-such-project")
k.CreatedAt = time.Now()
if err := s.CreateKey(context.Background(), k); err == nil {
@@ -190,7 +190,7 @@ func TestOrphansAreRefused(t *testing.T) {
}
func TestLimitsResolveToTheMostRestrictive(t *testing.T) {
s := open(t)
s := openDB(t)
ctx := context.Background()
project := tree(t, s)
_, k := mustKey(t, s, "key1", project)
@@ -218,7 +218,7 @@ func TestLimitsResolveToTheMostRestrictive(t *testing.T) {
func TestANarrowerScopeCannotRaiseALimit(t *testing.T) {
// The only composition rule under which a budget means anything.
s := open(t)
s := openDB(t)
ctx := context.Background()
project := tree(t, s)
_, k := mustKey(t, s, "key1", project)
@@ -241,7 +241,7 @@ func TestANarrowerScopeCannotRaiseALimit(t *testing.T) {
}
func TestAllowlistsIntersectRatherThanReplace(t *testing.T) {
s := open(t)
s := openDB(t)
ctx := context.Background()
project := tree(t, s)
_, k := mustKey(t, s, "key1", project)
@@ -267,7 +267,7 @@ func TestAllowlistsIntersectRatherThanReplace(t *testing.T) {
}
func TestNoAllowlistMeansEveryModel(t *testing.T) {
s := open(t)
s := openDB(t)
project := tree(t, s)
_, k := mustKey(t, s, "key1", project)
@@ -286,7 +286,7 @@ func TestNoAllowlistMeansEveryModel(t *testing.T) {
func TestAnEmptyAllowlistForbidsEverything(t *testing.T) {
// Empty is not the same as absent, and the store must not conflate them:
// one is "no restriction", the other is "nothing".
s := open(t)
s := openDB(t)
ctx := context.Background()
project := tree(t, s)
_, k := mustKey(t, s, "key1", project)
@@ -349,7 +349,7 @@ func TestBudgetSurvivesARestart(t *testing.T) {
func TestLastUsedIsOnlyEverMovedForward(t *testing.T) {
// The batch writer sees keys out of order; an older timestamp must not
// rewind the newer one.
s := open(t)
s := openDB(t)
ctx := context.Background()
project := tree(t, s)
_, k := mustKey(t, s, "key1", project)
@@ -371,7 +371,7 @@ func TestLastUsedIsOnlyEverMovedForward(t *testing.T) {
func TestUpsertsAreIdempotent(t *testing.T) {
// Applying a configuration twice must not double the tree or fail.
s := open(t)
s := openDB(t)
ctx := context.Background()
tree(t, s)
tree(t, s)
@@ -391,7 +391,7 @@ func TestUpsertsAreIdempotent(t *testing.T) {
}
func TestResolveRejectsAnUnknownKey(t *testing.T) {
s := open(t)
s := openDB(t)
tree(t, s)
if _, err := s.Resolve(context.Background(), "nope"); !errors.Is(err, ErrNotFound) {
t.Errorf("resolving an unknown key: %v", err)
+98
View File
@@ -0,0 +1,98 @@
package web
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// ClaimData is the one-time key delivery page.
type ClaimData struct {
Secret string
Gone bool
Message string
ExpiresAt string
BaseURL string
Version string
Now string
}
// Claims is the store half of the claim flow.
type Claims interface {
PeekClaim(ctx context.Context, token string) (store.Claim, error)
ConsumeClaim(ctx context.Context, token string) (string, error)
}
// Claim serves the one-time link an operator sends to a new user.
//
// GET shows a button and consumes nothing; POST reveals the key and burns the
// link. That split is not politeness — chat clients, mail scanners and link
// previewers fetch URLs the moment they are posted, so a GET that consumed the
// claim would mean the first thing to unfurl the link destroys the key before
// anyone reads it.
//
// The page is served without a key, because the person opening it is the one
// who does not have one yet. What protects it is the token: 256 random bits,
// single use, and short-lived.
func Claim(claims Claims, version string, log *slog.Logger) http.HandlerFunc {
if log == nil {
log = slog.Default()
}
return func(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
d := ClaimData{
BaseURL: baseURL(r), Version: version,
Now: time.Now().UTC().Format(time.RFC3339),
}
// A key on a page is not something to leave in a proxy or a browser
// cache, and not something search engines should ever see.
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
if token == "" {
gone(w, &d)
return
}
if r.Method == http.MethodGet {
c, err := claims.PeekClaim(r.Context(), token)
if err != nil {
if !errors.Is(err, store.ErrClaimed) {
log.Warn("could not read a claim", "err", err)
}
gone(w, &d)
return
}
d.ExpiresAt = c.ExpiresAt.Format(time.RFC1123)
render(w, "claim", d)
return
}
secret, err := claims.ConsumeClaim(r.Context(), token)
if err != nil {
if !errors.Is(err, store.ErrClaimed) {
log.Warn("could not consume a claim", "err", err)
}
gone(w, &d)
return
}
log.Info("a key was collected")
d.Secret = secret
render(w, "claim", d)
}
}
// gone renders the one answer given for a used link, an expired one, and a
// token that was never real. Distinguishing them would confirm that a token was
// once valid.
func gone(w http.ResponseWriter, d *ClaimData) {
d.Gone = true
d.Message = store.ErrClaimed.Error()
w.WriteHeader(http.StatusGone)
render(w, "claim", *d)
}
+148
View File
@@ -0,0 +1,148 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// fakeClaims is one pending claim.
type fakeClaims struct {
secret string
consumed atomic.Bool
peeks atomic.Int64
}
func (f *fakeClaims) PeekClaim(context.Context, string) (store.Claim, error) {
f.peeks.Add(1)
if f.consumed.Load() {
return store.Claim{}, store.ErrClaimed
}
return store.Claim{KeyID: "key-1", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
func (f *fakeClaims) ConsumeClaim(context.Context, string) (string, error) {
if f.consumed.Swap(true) {
return "", store.ErrClaimed
}
return f.secret, nil
}
func do(t *testing.T, h http.Handler, method, target string) (int, string, http.Header) {
t.Helper()
// The handler reads the token from a path value, so it has to go through a
// mux that sets one.
mux := http.NewServeMux()
mux.Handle("GET /claim/{token}", h)
mux.Handle("POST /claim/{token}", h)
r := httptest.NewRequest(method, target, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
return w.Code, w.Body.String(), w.Header()
}
func TestOpeningTheLinkDoesNotBurnIt(t *testing.T) {
// The reason GET and POST differ. Chat clients, mail scanners and link
// previewers fetch a URL the moment it is posted; a GET that consumed the
// claim would mean the first thing to unfurl the link destroys the key
// before anyone reads it.
f := &fakeClaims{secret: "sk-llmgw-thesecret"}
h := Claim(f, "v1", nil)
for range 5 {
code, body, _ := do(t, h, http.MethodGet, "http://x/claim/tok")
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
if strings.Contains(body, "thesecret") {
t.Fatal("a GET revealed the key")
}
}
if f.consumed.Load() {
t.Fatal("a GET consumed the claim")
}
}
func TestRevealingShowsTheKeyOnceAndThenTheLinkIsGone(t *testing.T) {
f := &fakeClaims{secret: "sk-llmgw-thesecret"}
h := Claim(f, "v1", nil)
code, body, _ := do(t, h, http.MethodPost, "http://x/claim/tok")
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "sk-llmgw-thesecret") {
t.Fatal("the key was not shown")
}
code, body, _ = do(t, h, http.MethodPost, "http://x/claim/tok")
if code != http.StatusGone {
t.Errorf("a second reveal returned %d", code)
}
if strings.Contains(body, "thesecret") {
t.Fatal("the key was shown twice")
}
}
func TestTheKeyPageIsNotCached(t *testing.T) {
// A key rendered into a page must not sit in a proxy, a browser cache or a
// search index.
f := &fakeClaims{secret: "sk-llmgw-thesecret"}
_, _, hdr := do(t, Claim(f, "v1", nil), http.MethodPost, "http://x/claim/tok")
if cc := hdr.Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control is %q", cc)
}
if hdr.Get("X-Robots-Tag") == "" {
t.Error("the page is not marked noindex")
}
if hdr.Get("Referrer-Policy") != "no-referrer" {
t.Error("the URL — which is the credential — would leak in a referrer")
}
}
func TestAUsedLinkAndAnUnknownOneReadTheSame(t *testing.T) {
// Distinguishing them confirms that a token was once valid.
used := &fakeClaims{secret: "s"}
used.consumed.Store(true)
unknown := &fakeClaims{secret: "s"}
unknown.consumed.Store(true)
_, a, _ := do(t, Claim(used, "v1", nil), http.MethodGet, "http://x/claim/real")
_, b, _ := do(t, Claim(unknown, "v1", nil), http.MethodGet, "http://x/claim/never")
// Strip the timestamp, which differs by construction.
if strip(a) != strip(b) {
t.Error("a used link and an unknown one render differently")
}
}
func strip(s string) string {
if i := strings.Index(s, "<footer>"); i >= 0 {
return s[:i]
}
return s
}
func TestTheExamplesUseTheAddressTheUserReached(t *testing.T) {
f := &fakeClaims{secret: "sk-llmgw-thesecret"}
mux := http.NewServeMux()
mux.Handle("POST /claim/{token}", Claim(f, "v1", nil))
r := httptest.NewRequest(http.MethodPost, "http://10.0.0.140:8080/claim/tok", nil)
r.Header.Set("X-Forwarded-Proto", "https")
r.Header.Set("X-Forwarded-Host", "llmgw.mynodes.xyz")
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
body := w.Body.String()
if !strings.Contains(body, "https://llmgw.mynodes.xyz/v1/chat/completions") {
t.Error("the example names an address the user cannot use")
}
}
+6
View File
@@ -49,3 +49,9 @@ pre { background: var(--card); border: 1px solid var(--line); border-radius: 6px
.note { color: var(--muted); font-size: .85rem; margin: .4rem 0 0; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line);
color: var(--muted); font-size: .8rem; display: flex; justify-content: space-between; gap: 1rem; }
button {
font: inherit; font-weight: 600; cursor: pointer;
background: var(--accent); color: var(--bg); border: none;
border-radius: 6px; padding: .55rem 1.1rem;
}
button:hover { filter: brightness(1.1); }
+43
View File
@@ -0,0 +1,43 @@
{{define "claim"}}<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Your llmgw key</title><link rel="stylesheet" href="/_static/base.css">
</head><body><div class="wrap">
<header>
<h1>Your API key</h1>
<p class="sub">llmgw · {{.BaseURL}}</p>
</header>
{{if .Secret}}
<p>This is shown once. Nothing can produce it again — only its hash was kept.</p>
<pre>{{.Secret}}</pre>
<p>Store it in a password manager now, then close this page.</p>
<h2>Using it</h2>
<pre>export LLMGW_API_KEY={{.Secret}}
curl {{.BaseURL}}/v1/chat/completions \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "MODEL", "messages": [{"role":"user","content":"hello"}]}'</pre>
<pre>from openai import OpenAI
client = OpenAI(base_url="{{.BaseURL}}/v1", api_key=os.environ["LLMGW_API_KEY"])</pre>
<p class="note"><code>GET {{.BaseURL}}/v1/models</code> lists what this key may use.
<code>GET {{.BaseURL}}/me</code> shows its limits and what it has spent.</p>
{{else if .Gone}}
<p>{{.Message}}</p>
<p class="note">A link works once and then expires. Ask whoever sent it for a new one.</p>
{{else}}
<p>A key was issued for you. Revealing it uses up this link, and the key is
shown only this once.</p>
<form method="post">
<button type="submit">Reveal my key</button>
</form>
<p class="note">Expires {{.ExpiresAt}}.</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
</div></body></html>{{end}}
+4
View File
@@ -1412,6 +1412,10 @@ The launch path is now built too:
caller send its whole allowance at the end of one window and again at the
start of the next, so the observed rate doubles exactly when the system is
least able to take it.
- [x] Onboarding: one-time claim links (the key is sealed with the link's own
token, which is never stored), `GET /me` for a user's own limits and
spend, and `docs/onboarding.md`. Sending the key itself was the weak link
— a key pasted into a chat window stays there forever.
- [x] Model allowlists and key→band mapping. With auth on, the band comes from
the key and the header is ignored — it was trusted only while there was
no identity to consult, which let any client promote itself.
+133
View File
@@ -308,3 +308,136 @@ func TestWithoutAuthTheGatewayStillServes(t *testing.T) {
t.Errorf("an unauthenticated gateway refused a request: %d %s", code, msg)
}
}
func TestOnboardingAUserEndToEnd(t *testing.T) {
// The whole flow an operator actually performs: issue a key as a one-time
// link, hand the link over, and have the person collect it and start using
// the gateway.
path, _ := provision(t)
// The operator's side.
var link string
withStore(t, path, func(s *store.SQLite) {
secret, k, err := store.NewKey("key-new", "ayse-laptop", "proj")
if err != nil {
t.Fatal(err)
}
k.CreatedAt = time.Now()
if err := s.CreateKey(context.Background(), k); err != nil {
t.Fatal(err)
}
token, err := store.NewClaimToken()
if err != nil {
t.Fatal(err)
}
if err := s.CreateClaim(context.Background(), k.ID, token, secret, store.ClaimTTL); err != nil {
t.Fatal(err)
}
link = "/claim/" + token
})
h := start(t, 2, func(c *config.Server) {
c.Auth = config.Auth{Enabled: true, Store: path, CacheTTL: 50 * time.Millisecond}
})
page := func(method string) (int, string) {
req, err := http.NewRequestWithContext(context.Background(), method, h.router.URL+link, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// Something unfurls the link in a chat client. It must not burn it.
if code, body := page(http.MethodGet); code != http.StatusOK || strings.Contains(body, "sk-llmgw-") {
t.Fatalf("a preview fetch revealed or destroyed the key: %d", code)
}
// The person opens it and reveals.
code, body := page(http.MethodPost)
if code != http.StatusOK {
t.Fatalf("revealing failed: %d", code)
}
key := extractKey(t, body)
// And the key works.
if code, msg := h.call(t, key, testModel); code != http.StatusOK {
t.Fatalf("the collected key does not work: %d %s", code, msg)
}
// The link is spent.
if code, _ := page(http.MethodPost); code != http.StatusGone {
t.Errorf("the link worked a second time: %d", code)
}
}
func extractKey(t *testing.T, body string) string {
t.Helper()
i := strings.Index(body, store.KeyPrefix)
if i < 0 {
t.Fatal("no key on the page")
}
rest := body[i:]
end := strings.IndexAny(rest, "<\n ")
if end < 0 {
t.Fatal("the key is not delimited")
}
return rest[:end]
}
func TestTheSelfDescriptionEndpointAnswersWithoutAnOperator(t *testing.T) {
// So a user can see their own limits and spend rather than asking someone
// with access to the dashboard.
path, mint := provision(t)
key := mint(t, "curious")
withStore(t, path, func(s *store.SQLite) {
rpm := 42
if err := s.PutLimits(context.Background(), store.Limits{
Scope: store.ScopeKey, ScopeID: "key-curious",
RPM: &rpm, AllowedModels: []string{testModel},
}); err != nil {
t.Fatal(err)
}
})
h := start(t, 2, func(c *config.Server) {
c.Auth = config.Auth{Enabled: true, Store: path, CacheTTL: 50 * time.Millisecond}
})
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, h.router.URL+"/me", nil)
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var me struct {
Authenticated bool `json:"authenticated"`
KeyID string `json:"key_id"`
Key string `json:"key"`
Limits struct {
RPM int `json:"requests_per_minute"`
AllowedModels []string `json:"allowed_models"`
} `json:"limits"`
}
if err := json.NewDecoder(resp.Body).Decode(&me); err != nil {
t.Fatal(err)
}
if !me.Authenticated || me.KeyID != "key-curious" {
t.Errorf("/me returned %+v", me)
}
if me.Limits.RPM != 42 || len(me.Limits.AllowedModels) != 1 {
t.Errorf("limits came back as %+v", me.Limits)
}
// It describes the key; it must not repeat it.
if strings.Contains(me.Key, "…") == false || len(me.Key) > 24 {
t.Errorf("/me returned more of the key than a display prefix: %q", me.Key)
}
}