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:
1 parent
cd3233ffe8
commit
3ed211b2f4
16 files changed
+1164
-23
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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}}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user