Phase 5 begins. This is the persistence layer for the things the cluster cannot reconstruct — who may call, what they are allowed, what they used. Everything else llmgw knows is still derived from the fleet, which cannot be wrong about itself the way a database can (§6.5). SQLite behind an interface. The plan justified that by write volume, which was the wrong argument: the write path is batched anyway, and a 500-row transaction every 200ms is nothing for either engine. The real argument is the failure mode. Postgres is a second service, and a gateway that stops serving because its accounting database is unreachable is worse than one that buffers some accounting. Embedded means the store shares the gateway's fate instead of adding an independent way to be down. That reasoning stops holding at two instances, where budgets have to be counted somewhere shared — so the interface is here from the start, the SQL is portable, and migrations are per dialect with shared version numbers. Keys are stored as sha256 and nothing else, so a database leak hands over no working credential. The fast hash is deliberate: the input is 256 random bits with no dictionary to attack, so argon2 would buy nothing and charge tens of milliseconds per request. That assumption is written down next to the code that depends on it. Two things the schema now says that it did not: budgets are integer micro-units, because repeated addition of floats drifts; and an empty model allowlist forbids everything while an absent one permits everything, which the resolver has to keep distinct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
125 lines
3.8 KiB
Go
125 lines
3.8 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"math/big"
|
|
"strings"
|
|
)
|
|
|
|
// KeyPrefix is what every issued key starts with, so a leaked key is
|
|
// recognizable in a log or a repository scan.
|
|
const KeyPrefix = "sk-llmgw-"
|
|
|
|
// keyBytes is 256 bits of randomness. Enough that guessing is infeasible at any
|
|
// hash rate, which is the assumption the fast hash below rests on.
|
|
const keyBytes = 32
|
|
|
|
const base62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
|
|
// NewKey mints a key and returns both the secret, which the caller must show
|
|
// once and then forget, and the record to store.
|
|
//
|
|
// The two are deliberately returned together and separately: nothing in the
|
|
// record can reconstruct the secret, so the caller either shows it now or it is
|
|
// gone.
|
|
func NewKey(id, name, projectID string) (secret string, k Key, err error) {
|
|
raw := make([]byte, keyBytes)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", Key{}, err
|
|
}
|
|
secret = KeyPrefix + encodeBase62(raw)
|
|
|
|
sum := sha256.Sum256([]byte(secret))
|
|
return secret, Key{
|
|
ID: id,
|
|
Hash: sum[:],
|
|
Prefix: DisplayPrefix(secret),
|
|
Name: name,
|
|
ProjectID: projectID,
|
|
}, nil
|
|
}
|
|
|
|
// HashKey is what authentication computes from what a client presented.
|
|
//
|
|
// SHA-256 rather than bcrypt or argon2, on purpose. Slow password hashing
|
|
// exists because humans pick guessable passwords, and its cost buys time
|
|
// against an offline dictionary attack. A 256-bit random value has no
|
|
// dictionary: guessing it is infeasible at any hash rate. The slow hash would
|
|
// buy nothing here and charge tens of milliseconds on every request.
|
|
//
|
|
// This is the assumption to re-examine if the key format ever changes to
|
|
// something with less entropy.
|
|
func HashKey(secret string) []byte {
|
|
sum := sha256.Sum256([]byte(secret))
|
|
return sum[:]
|
|
}
|
|
|
|
// EqualHash compares two hashes without leaking where they differ.
|
|
//
|
|
// The lookup is by hash so timing tells an attacker little, but a key
|
|
// comparison is exactly the place not to economize.
|
|
func EqualHash(a, b []byte) bool { return subtle.ConstantTimeCompare(a, b) == 1 }
|
|
|
|
// DisplayPrefix is the part of a key safe to show: enough to tell two keys
|
|
// apart in a listing, far too little to use.
|
|
func DisplayPrefix(secret string) string {
|
|
body := strings.TrimPrefix(secret, KeyPrefix)
|
|
if len(body) > 4 {
|
|
body = body[:4]
|
|
}
|
|
return KeyPrefix + body + "…"
|
|
}
|
|
|
|
// ErrMalformedKey says the presented credential is not one of ours.
|
|
var ErrMalformedKey = errors.New("not an llmgw key")
|
|
|
|
// ParseKey checks the shape of a presented credential before it is hashed.
|
|
//
|
|
// Cheap rejection of everything that cannot possibly be a key — a bearer token
|
|
// from another system, a truncated copy/paste — so those never reach the store
|
|
// at all.
|
|
func ParseKey(presented string) error {
|
|
if !strings.HasPrefix(presented, KeyPrefix) {
|
|
return ErrMalformedKey
|
|
}
|
|
body := presented[len(KeyPrefix):]
|
|
if len(body) < 40 {
|
|
return ErrMalformedKey
|
|
}
|
|
for _, r := range body {
|
|
if !strings.ContainsRune(base62, r) {
|
|
return ErrMalformedKey
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// encodeBase62 renders bytes as a base62 string.
|
|
//
|
|
// Base62 rather than base64 so a key survives being pasted into a URL, a shell
|
|
// argument or a YAML file without quoting or escaping.
|
|
func encodeBase62(b []byte) string {
|
|
n := new(big.Int).SetBytes(b)
|
|
base := big.NewInt(62)
|
|
mod := new(big.Int)
|
|
|
|
var out []byte
|
|
for n.Sign() > 0 {
|
|
n.DivMod(n, base, mod)
|
|
out = append(out, base62[mod.Int64()])
|
|
}
|
|
// Leading zero bytes carry no digits, so pad to a fixed width; a key of
|
|
// varying length would leak a little about its value and look inconsistent.
|
|
for len(out) < 43 {
|
|
out = append(out, base62[0])
|
|
}
|
|
// Built least-significant first.
|
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
|
out[i], out[j] = out[j], out[i]
|
|
}
|
|
return string(out)
|
|
}
|