diff --git a/docs/onboarding.md b/docs/onboarding.md new file mode 100644 index 0000000..67c42ea --- /dev/null +++ b/docs/onboarding.md @@ -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 --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. diff --git a/go.mod b/go.mod index 02868d9..ee7bfe6 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c830678..0419189 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cli/keys.go b/internal/cli/keys.go index 03e2e7a..1401422 100644 --- a/internal/cli/keys.go +++ b/internal/cli/keys.go @@ -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 ", 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{ diff --git a/internal/router/handlers.go b/internal/router/handlers.go index f74e0d3..62b2516 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -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) +} diff --git a/internal/store/claims.go b/internal/store/claims.go new file mode 100644 index 0000000..fbd37d3 --- /dev/null +++ b/internal/store/claims.go @@ -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 +} diff --git a/internal/store/claims_test.go b/internal/store/claims_test.go new file mode 100644 index 0000000..3bf12fc --- /dev/null +++ b/internal/store/claims_test.go @@ -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) + } +} diff --git a/internal/store/migrations/sqlite/0002_claims.sql b/internal/store/migrations/sqlite/0002_claims.sql new file mode 100644 index 0000000..27b7e07 --- /dev/null +++ b/internal/store/migrations/sqlite/0002_claims.sql @@ -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); diff --git a/internal/store/seal.go b/internal/store/seal.go new file mode 100644 index 0000000..5340e3c --- /dev/null +++ b/internal/store/seal.go @@ -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) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 00a6ae0..4445a5a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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) diff --git a/internal/web/claim.go b/internal/web/claim.go new file mode 100644 index 0000000..32ed0fe --- /dev/null +++ b/internal/web/claim.go @@ -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) +} diff --git a/internal/web/claim_test.go b/internal/web/claim_test.go new file mode 100644 index 0000000..f829939 --- /dev/null +++ b/internal/web/claim_test.go @@ -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, "