gitmote

gitmote  ·  history  ·  branches

feat: auth — PAT selector/verifier + per-repo ACL on the read path

3740b62eed67e47c757a12e8fcc4fdbf5b584e33
Atanas Minev <atmin@protonmail.com> · 2026-07-02 17:47

diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 21a675d..d66e9c5 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -14,6 +14,7 @@ import (
"syscall"
"time"
+ "github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/githttp"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
@@ -126,7 +127,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, fu
}
cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
- handler, err := githttp.New(repo.New(md, objs, cacheRoot), logger)
+ handler, err := githttp.New(repo.New(md, objs, cacheRoot), auth.NewGuard(md), logger)
if err != nil {
_ = md.Close()
return nil, noop, err
diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md
index 8651622..4c9a2be 100644
--- a/docs/architecture/storage.md
+++ b/docs/architecture/storage.md
@@ -83,7 +83,8 @@ CREATE TABLE users (
CREATE TABLE tokens ( -- HTTP personal access tokens
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
- hash TEXT NOT NULL, -- hash of the PAT, never the raw token
+ selector TEXT NOT NULL UNIQUE, -- public lookup key (not secret)
+ verifier TEXT NOT NULL, -- SHA-256 of the token's secret half, never the raw token
label TEXT,
created_at TEXT NOT NULL,
last_used TEXT
@@ -107,3 +108,10 @@ CREATE TABLE acls (
`HEAD` is not a row — it derives from `repos.default_branch`. Every `refs` row
holds a concrete object id; symbolic refs beyond `HEAD` are not stored.
+
+A PAT is a `selector.secret` pair. The **selector** is a non-secret, unique,
+indexed lookup key; only the **verifier** — `SHA-256` of the **secret** half —
+is stored. Verification looks the row up by selector, then compares the verifier
+in **constant time**, so neither a timing side-channel on the lookup nor a
+database leak yields a usable token. The raw `selector.secret` is shown to the
+user exactly once at mint time and never persisted.
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
new file mode 100644
index 0000000..9b435df
--- /dev/null
+++ b/internal/auth/auth_test.go
@@ -0,0 +1,192 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+func openMeta(t *testing.T) *meta.Metadata {
+ t.Helper()
+ m, err := meta.Open(context.Background(), meta.Config{
+ LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
+ })
+ if err != nil {
+ t.Fatalf("meta.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = m.Close() })
+ return m
+}
+
+func TestMintRoundTrips(t *testing.T) {
+ raw, selector, verifier, err := Mint()
+ if err != nil {
+ t.Fatalf("Mint: %v", err)
+ }
+ gotSel, gotSecret, ok := split(raw)
+ if !ok {
+ t.Fatalf("split(%q) failed", raw)
+ }
+ if gotSel != selector {
+ t.Errorf("selector = %q, want %q", gotSel, selector)
+ }
+ if hashSecret(gotSecret) != verifier {
+ t.Error("hashSecret(secret) != verifier")
+ }
+ // Two mints never collide.
+ raw2, _, _, _ := Mint()
+ if raw2 == raw {
+ t.Error("two Mint calls produced the same token")
+ }
+}
+
+func TestSplitRejectsMalformed(t *testing.T) {
+ for _, raw := range []string{"", "nope", "gmt_", "gmt_onlyselector", "gmt_.secret", "gmt_sel.", "sel.secret"} {
+ if _, _, ok := split(raw); ok {
+ t.Errorf("split(%q) = ok, want rejected", raw)
+ }
+ }
+}
+
+// seedToken creates a user + token and returns the raw token and user id.
+func seedToken(t *testing.T, m *meta.Metadata) (string, int64) {
+ t.Helper()
+ ctx := context.Background()
+ u, err := m.CreateUser(ctx, "atmin")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ raw, selector, verifier, err := Mint()
+ if err != nil {
+ t.Fatalf("Mint: %v", err)
+ }
+ if _, err := m.CreateToken(ctx, u.ID, selector, verifier, "test"); err != nil {
+ t.Fatalf("CreateToken: %v", err)
+ }
+ return raw, u.ID
+}
+
+// bearer builds a request carrying repoName and an optional bearer token.
+func bearer(token string) *http.Request {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ if token != "" {
+ r.Header.Set("Authorization", "Bearer "+token)
+ }
+ return r
+}
+
+func TestAuthorize(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+ g := NewGuard(m)
+
+ repo, err := m.CreateRepo(ctx, "atmin/repo", "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ raw, userID := seedToken(t, m)
+
+ // No token / malformed / unknown / wrong secret → ErrUnauthorized.
+ sel, _, _ := split(raw)
+ unauth := map[string]*http.Request{
+ "no token": bearer(""),
+ "malformed": bearer("not-a-token"),
+ "unknown": bearer("gmt_ffffffffffffffffffffffffffffffff.1234"),
+ "wrong secret": bearer("gmt_" + sel + ".wrongsecret"),
+ }
+ for name, r := range unauth {
+ if _, err := g.Authorize(r, "atmin/repo", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
+ t.Errorf("%s: Authorize = %v, want ErrUnauthorized", name, err)
+ }
+ }
+
+ // Valid token, no ACL → ErrForbidden.
+ if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); !errors.Is(err, ErrForbidden) {
+ t.Errorf("no ACL: Authorize = %v, want ErrForbidden", err)
+ }
+
+ // Valid token, unknown repo → meta.ErrNotFound.
+ if _, err := g.Authorize(bearer(raw), "no/such", meta.PermRead); !errors.Is(err, meta.ErrNotFound) {
+ t.Errorf("unknown repo: Authorize = %v, want meta.ErrNotFound", err)
+ }
+
+ // Grant read: read is allowed, write is not.
+ if err := m.SetACL(ctx, repo.ID, userID, meta.PermRead); err != nil {
+ t.Fatalf("SetACL: %v", err)
+ }
+ user, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead)
+ if err != nil {
+ t.Fatalf("read with read ACL: %v", err)
+ }
+ if user.ID != userID {
+ t.Errorf("authorized user = %d, want %d", user.ID, userID)
+ }
+ if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermWrite); !errors.Is(err, ErrForbidden) {
+ t.Errorf("write with read ACL: Authorize = %v, want ErrForbidden", err)
+ }
+
+ // Admin covers read.
+ if err := m.SetACL(ctx, repo.ID, userID, meta.PermAdmin); err != nil {
+ t.Fatalf("SetACL admin: %v", err)
+ }
+ if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); err != nil {
+ t.Errorf("read with admin ACL: %v", err)
+ }
+
+ // A successful authorization stamps last_used.
+ toks, _ := m.ListTokens(ctx, userID)
+ if len(toks) != 1 || toks[0].LastUsed == nil {
+ t.Errorf("last_used not stamped after Authorize: %+v", toks)
+ }
+}
+
+func TestBasicAuthCredentials(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+ g := NewGuard(m)
+ repo, _ := m.CreateRepo(ctx, "atmin/repo", "main")
+ raw, userID := seedToken(t, m)
+ if err := m.SetACL(ctx, repo.ID, userID, meta.PermRead); err != nil {
+ t.Fatalf("SetACL: %v", err)
+ }
+
+ // Token as Basic password (git's default) and as Basic username (token-only)
+ // both authenticate.
+ for _, creds := range []struct {
+ user, pass string
+ }{
+ {"git", raw}, // password carries the token
+ {raw, ""}, // username carries the token
+ } {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.SetBasicAuth(creds.user, creds.pass)
+ if _, err := g.Authorize(r, "atmin/repo", meta.PermRead); err != nil {
+ t.Errorf("Basic(%q,%q): Authorize = %v, want ok", creds.user, creds.pass, err)
+ }
+ }
+}
+
+func TestAllows(t *testing.T) {
+ cases := []struct {
+ granted, required meta.Perm
+ want bool
+ }{
+ {meta.PermRead, meta.PermRead, true},
+ {meta.PermWrite, meta.PermRead, true},
+ {meta.PermAdmin, meta.PermWrite, true},
+ {meta.PermRead, meta.PermWrite, false},
+ {meta.PermWrite, meta.PermAdmin, false},
+ {"", meta.PermRead, false},
+ {meta.PermRead, "", false},
+ }
+ for _, c := range cases {
+ if got := allows(c.granted, c.required); got != c.want {
+ t.Errorf("allows(%q, %q) = %v, want %v", c.granted, c.required, got, c.want)
+ }
+ }
+}
diff --git a/internal/auth/guard.go b/internal/auth/guard.go
new file mode 100644
index 0000000..def4988
--- /dev/null
+++ b/internal/auth/guard.go
@@ -0,0 +1,126 @@
+package auth
+
+import (
+ "context"
+ "crypto/subtle"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+// Guard authenticates PATs and authorizes requests per-repo against the ACL
+// table. It is the request guard the transport layer consults before serving.
+type Guard struct {
+ md *meta.Metadata
+}
+
+// NewGuard returns a Guard backed by the metadata layer.
+func NewGuard(md *meta.Metadata) *Guard { return &Guard{md: md} }
+
+// Authorize verifies the request's token and checks the resulting user holds at
+// least perm on repoName. The returned error selects the response:
+//
+// - ErrUnauthorized — no/invalid token: 401 with a Basic challenge;
+// - ErrForbidden — authenticated but lacking the permission: 403;
+// - meta.ErrNotFound — the repo does not exist: 404;
+// - any other error — an internal failure: 500.
+//
+// It returns the authenticated user on success.
+func (g *Guard) Authorize(r *http.Request, repoName string, perm meta.Perm) (*meta.User, error) {
+ ctx := r.Context()
+
+ user, err := g.authenticate(ctx, r)
+ if err != nil {
+ return nil, err
+ }
+
+ repo, err := g.md.GetRepo(ctx, repoName)
+ if err != nil {
+ return nil, err // meta.ErrNotFound flows through to a 404
+ }
+
+ granted, err := g.md.GetACL(ctx, repo.ID, user.ID)
+ if errors.Is(err, meta.ErrNotFound) {
+ return nil, ErrForbidden // no ACL row means no access
+ }
+ if err != nil {
+ return nil, err
+ }
+ if !allows(granted, perm) {
+ return nil, ErrForbidden
+ }
+ return user, nil
+}
+
+// authenticate resolves the request's PAT to its owner, or ErrUnauthorized. All
+// failures collapse to ErrUnauthorized so a client cannot distinguish "unknown
+// token" from "wrong secret".
+func (g *Guard) authenticate(ctx context.Context, r *http.Request) (*meta.User, error) {
+ raw, ok := tokenFromRequest(r)
+ if !ok {
+ return nil, ErrUnauthorized
+ }
+ selector, secret, ok := split(raw)
+ if !ok {
+ return nil, ErrUnauthorized
+ }
+ ta, err := g.md.TokenBySelector(ctx, selector)
+ if errors.Is(err, meta.ErrNotFound) {
+ return nil, ErrUnauthorized
+ }
+ if err != nil {
+ return nil, err
+ }
+ if subtle.ConstantTimeCompare([]byte(ta.Verifier), []byte(hashSecret(secret))) != 1 {
+ return nil, ErrUnauthorized
+ }
+ if err := g.md.TouchToken(ctx, ta.TokenID); err != nil {
+ return nil, err
+ }
+ return &ta.User, nil
+}
+
+// tokenFromRequest extracts a PAT from the Authorization header. It accepts a
+// Bearer token (API clients) and Basic credentials (git's native HTTP auth,
+// where the PAT is sent as the password — or the username when the password is
+// empty).
+func tokenFromRequest(r *http.Request) (string, bool) {
+ h := r.Header.Get("Authorization")
+ switch {
+ case h == "":
+ return "", false
+ case strings.HasPrefix(h, "Bearer "):
+ if tok := strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")); tok != "" {
+ return tok, true
+ }
+ return "", false
+ default:
+ user, pass, ok := r.BasicAuth()
+ if !ok {
+ return "", false
+ }
+ if pass != "" {
+ return pass, true
+ }
+ if user != "" {
+ return user, true
+ }
+ return "", false
+ }
+}
+
+// permRank orders permissions read < write < admin; a missing/unknown level is
+// rank 0 (no access).
+var permRank = map[meta.Perm]int{
+ meta.PermRead: 1,
+ meta.PermWrite: 2,
+ meta.PermAdmin: 3,
+}
+
+// allows reports whether the granted permission covers the required one.
+func allows(granted, required meta.Perm) bool {
+ req := permRank[required]
+ return req > 0 && permRank[granted] >= req
+}
diff --git a/internal/auth/token.go b/internal/auth/token.go
new file mode 100644
index 0000000..51dc165
--- /dev/null
+++ b/internal/auth/token.go
@@ -0,0 +1,82 @@
+// Package auth authenticates smart-HTTP requests with personal access tokens
+// and authorizes them per-repo against the ACL table, per
+// docs/architecture/auth.md.
+//
+// A PAT is a `selector.secret` pair (see docs/architecture/storage.md): the
+// selector is a non-secret lookup key and only the SHA-256 of the secret — the
+// verifier — is stored. Verification looks the row up by selector, then
+// compares the verifier in constant time, so neither a timing side-channel nor
+// a database leak yields a usable token.
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "strings"
+)
+
+// Sentinels distinguishing the two rejection modes, so the transport layer can
+// answer with the auth challenge git expects.
+var (
+ // ErrUnauthorized is a missing or invalid token — answer 401 + challenge.
+ ErrUnauthorized = errors.New("auth: unauthorized")
+ // ErrForbidden is a valid token lacking the required permission — answer 403.
+ ErrForbidden = errors.New("auth: forbidden")
+)
+
+const (
+ // tokenPrefix marks gitmote PATs (like GitHub's ghp_), so a leaked token is
+ // recognizable and secret-scanners can match it.
+ tokenPrefix = "gmt_"
+ // selectorBytes / secretBytes size the two halves; 128-bit selector for a
+ // collision-free lookup key, 256-bit secret for the verifier.
+ selectorBytes = 16
+ secretBytes = 32
+)
+
+// Mint generates a new personal access token. It returns the raw token — shown
+// to the user exactly once — and the selector and verifier to persist via
+// meta.CreateToken.
+func Mint() (raw, selector, verifier string, err error) {
+ sel, err := randHex(selectorBytes)
+ if err != nil {
+ return "", "", "", err
+ }
+ secret, err := randHex(secretBytes)
+ if err != nil {
+ return "", "", "", err
+ }
+ raw = tokenPrefix + sel + "." + secret
+ return raw, sel, hashSecret(secret), nil
+}
+
+// split parses a raw token into its selector and secret halves. It reports
+// ok=false for anything not shaped like a gitmote PAT.
+func split(raw string) (selector, secret string, ok bool) {
+ rest, found := strings.CutPrefix(raw, tokenPrefix)
+ if !found {
+ return "", "", false
+ }
+ selector, secret, found = strings.Cut(rest, ".")
+ if !found || selector == "" || secret == "" {
+ return "", "", false
+ }
+ return selector, secret, true
+}
+
+// hashSecret is the verifier derivation: SHA-256 of the secret half. The secret
+// is a 256-bit random string, so a plain hash (not a slow KDF) is sufficient.
+func hashSecret(secret string) string {
+ sum := sha256.Sum256([]byte(secret))
+ return hex.EncodeToString(sum[:])
+}
+
+func randHex(n int) (string, error) {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
diff --git a/internal/githttp/backend.go b/internal/githttp/backend.go
index 3d66148..205232a 100644
--- a/internal/githttp/backend.go
+++ b/internal/githttp/backend.go
@@ -15,22 +15,32 @@ import (
"os/exec"
"strings"
+ "github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
)
+// Authorizer guards a request before it is served: it authenticates the PAT and
+// checks the user holds perm on repoName, returning auth's error sentinels
+// (ErrUnauthorized / ErrForbidden), meta.ErrNotFound, or an internal error.
+type Authorizer interface {
+ Authorize(r *http.Request, repoName string, perm meta.Perm) (*meta.User, error)
+}
+
// Handler serves the read-path smart-HTTP endpoints for any repo, dispatching
// on the git URL suffix. Mount it at "/"; more specific routes (e.g. /healthz)
// still win under http.ServeMux.
type Handler struct {
mz *repo.Materializer
+ authz Authorizer
gitPath string
logger *slog.Logger
}
-// New returns a read-path handler backed by mz. It fails if the `git`
-// executable is not on PATH, since the whole design delegates to it.
-func New(mz *repo.Materializer, logger *slog.Logger) (*Handler, error) {
+// New returns a read-path handler backed by mz and guarded by authz. It fails
+// if the `git` executable is not on PATH, since the whole design delegates to
+// it.
+func New(mz *repo.Materializer, authz Authorizer, logger *slog.Logger) (*Handler, error) {
gitPath, err := exec.LookPath("git")
if err != nil {
return nil, fmt.Errorf("githttp: git not found on PATH: %w", err)
@@ -38,7 +48,7 @@ func New(mz *repo.Materializer, logger *slog.Logger) (*Handler, error) {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
- return &Handler{mz: mz, gitPath: gitPath, logger: logger}, nil
+ return &Handler{mz: mz, authz: authz, gitPath: gitPath, logger: logger}, nil
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -62,6 +72,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
+ // Authorize the read before doing any work. receive-pack is not served
+ // here, so read is the only permission this handler ever requires.
+ if _, err := h.authz.Authorize(r, repoName, meta.PermRead); err != nil {
+ h.writeAuthError(w, r, repoName, err)
+ return
+ }
+
if _, err := h.mz.Materialize(r.Context(), repoName); err != nil {
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
@@ -87,6 +104,24 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
backend.ServeHTTP(w, r)
}
+// writeAuthError maps an Authorize error to the HTTP response git expects: a
+// 401 carries the Basic challenge so git (and its credential helpers) retry
+// with credentials; a 403 is a hard stop; a missing repo is a 404.
+func (h *Handler) writeAuthError(w http.ResponseWriter, r *http.Request, repoName string, err error) {
+ switch {
+ case errors.Is(err, auth.ErrForbidden):
+ http.Error(w, "forbidden", http.StatusForbidden)
+ case errors.Is(err, auth.ErrUnauthorized):
+ w.Header().Set("WWW-Authenticate", `Basic realm="gitmote"`)
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ case errors.Is(err, meta.ErrNotFound):
+ http.NotFound(w, r)
+ default:
+ h.logger.Error("authorize failed", "repo", repoName, "error", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ }
+}
+
// parseReadPath splits a git smart-HTTP read URL into the repo name and the
// endpoint suffix ("info/refs" or "git-upload-pack"). The repo name — which may
// contain slashes ("atmin/dotfiles") — must be non-empty.
diff --git a/internal/githttp/backend_test.go b/internal/githttp/backend_test.go
index b3a7c4c..0e0f9ce 100644
--- a/internal/githttp/backend_test.go
+++ b/internal/githttp/backend_test.go
@@ -12,11 +12,14 @@ import (
"strings"
"testing"
+ "github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
"github.com/atmin/gitmote/internal/store"
)
+const repoName = "atmin/dotfiles"
+
// git runs a git command hermetically and returns its trimmed combined output,
// failing the test on a nonzero exit.
func git(t *testing.T, dir string, args ...string) string {
@@ -38,7 +41,7 @@ func git(t *testing.T, dir string, args ...string) string {
// loadObjects mirrors a source repo's loose/packed objects into the store under
// the repo's prefix — the durable objects a read hydrates from.
-func loadObjects(t *testing.T, s store.Store, src, repoName string) {
+func loadObjects(t *testing.T, s store.Store, src, name string) {
t.Helper()
objRoot := filepath.Join(src, ".git", "objects")
err := filepath.WalkDir(objRoot, func(p string, d os.DirEntry, err error) error {
@@ -53,15 +56,15 @@ func loadObjects(t *testing.T, s store.Store, src, repoName string) {
if err != nil {
return err
}
- return s.Put(context.Background(), repoName+"/objects/"+filepath.ToSlash(rel), bytes.NewReader(data))
+ return s.Put(context.Background(), name+"/objects/"+filepath.ToSlash(rel), bytes.NewReader(data))
})
if err != nil {
t.Fatalf("load objects: %v", err)
}
}
-// newServer wires meta + mem store + materializer behind a read-path handler on
-// an httptest server, returning the server and its backing pieces.
+// newServer wires meta + mem store + materializer behind a guarded read-path
+// handler on an httptest server.
func newServer(t *testing.T) (*httptest.Server, *meta.Metadata, store.Store) {
t.Helper()
m, err := meta.Open(context.Background(), meta.Config{
@@ -73,7 +76,7 @@ func newServer(t *testing.T) (*httptest.Server, *meta.Metadata, store.Store) {
t.Cleanup(func() { _ = m.Close() })
s := store.NewMem()
- h, err := New(repo.New(m, s, t.TempDir()), slog.New(slog.DiscardHandler))
+ h, err := New(repo.New(m, s, t.TempDir()), auth.NewGuard(m), slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("githttp.New: %v", err)
}
@@ -82,14 +85,11 @@ func newServer(t *testing.T) (*httptest.Server, *meta.Metadata, store.Store) {
return srv, m, s
}
-func TestCloneAndFetch(t *testing.T) {
+// seedRepo builds a one-commit source repo, loads its objects, and records the
+// repo + main ref, returning (repoID, headSHA).
+func seedRepo(t *testing.T, m *meta.Metadata, s store.Store, src string) (int64, string) {
+ t.Helper()
ctx := context.Background()
- srv, m, s := newServer(t)
- const repoName = "atmin/dotfiles"
-
- // Seed: a source repo with one commit → objects into the store, ref into
- // meta.
- src := t.TempDir()
git(t, src, "init", "-b", "main", ".")
if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
@@ -98,18 +98,57 @@ func TestCloneAndFetch(t *testing.T) {
git(t, src, "commit", "-m", "first")
loadObjects(t, s, src, repoName)
- head1 := git(t, src, "rev-parse", "HEAD")
+ head := git(t, src, "rev-parse", "HEAD")
r, err := m.CreateRepo(ctx, repoName, "main")
if err != nil {
t.Fatalf("CreateRepo: %v", err)
}
- if err := m.CASRef(ctx, r.ID, "refs/heads/main", meta.ZeroSHA, head1); err != nil {
+ if err := m.CASRef(ctx, r.ID, "refs/heads/main", meta.ZeroSHA, head); err != nil {
t.Fatalf("CASRef: %v", err)
}
+ return r.ID, head
+}
+
+// mintUser creates a user with a fresh token and returns the raw token. When
+// perm is non-empty it also grants that permission on repoID.
+func mintUser(t *testing.T, m *meta.Metadata, repoID int64, handle string, perm meta.Perm) string {
+ t.Helper()
+ ctx := context.Background()
+ u, err := m.CreateUser(ctx, handle)
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ raw, selector, verifier, err := auth.Mint()
+ if err != nil {
+ t.Fatalf("Mint: %v", err)
+ }
+ if _, err := m.CreateToken(ctx, u.ID, selector, verifier, "test"); err != nil {
+ t.Fatalf("CreateToken: %v", err)
+ }
+ if perm != "" {
+ if err := m.SetACL(ctx, repoID, u.ID, perm); err != nil {
+ t.Fatalf("SetACL: %v", err)
+ }
+ }
+ return raw
+}
+
+// authedURL embeds Basic credentials (git's native HTTP auth) into a base URL.
+func authedURL(base, raw string) string {
+ return strings.Replace(base, "://", "://git:"+raw+"@", 1)
+}
+
+func TestCloneAndFetch(t *testing.T) {
+ ctx := context.Background()
+ srv, m, s := newServer(t)
+
+ src := t.TempDir()
+ repoID, head1 := seedRepo(t, m, s, src)
+ raw := mintUser(t, m, repoID, "atmin", meta.PermRead)
- // Golden path: a real clone over HTTP.
+ // Golden path: a real clone over HTTP with a read token.
dst := filepath.Join(t.TempDir(), "clone")
- git(t, "", "clone", srv.URL+"/"+repoName, dst)
+ git(t, "", "clone", authedURL(srv.URL, raw)+"/"+repoName, dst)
if got := git(t, dst, "rev-parse", "HEAD"); got != head1 {
t.Errorf("cloned HEAD = %q, want %q", got, head1)
@@ -117,41 +156,99 @@ func TestCloneAndFetch(t *testing.T) {
if data, err := os.ReadFile(filepath.Join(dst, "README.md")); err != nil || string(data) != "hello\n" {
t.Errorf("cloned README = %q (err %v), want \"hello\\n\"", data, err)
}
- git(t, dst, "fsck", "--strict") // fails the test on any corruption/missing object
+ git(t, dst, "fsck", "--strict")
- // Seed an update: a second commit on the server side.
+ // Seed a second commit server-side.
if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil {
t.Fatal(err)
}
git(t, src, "commit", "-am", "second")
loadObjects(t, s, src, repoName)
head2 := git(t, src, "rev-parse", "HEAD")
+ r, _ := m.GetRepo(ctx, repoName)
if err := m.CASRef(ctx, r.ID, "refs/heads/main", head1, head2); err != nil {
t.Fatalf("CASRef advance: %v", err)
}
- // fetch pulls the new commit.
- git(t, dst, "fetch", "origin")
- if got := git(t, dst, "rev-parse", "origin/main"); got != head2 {
- t.Errorf("origin/main after fetch = %q, want %q", got, head2)
+ // An incremental fetch pulls the new commit (explicit URL carries creds).
+ git(t, dst, "fetch", authedURL(srv.URL, raw)+"/"+repoName, "main")
+ if got := git(t, dst, "rev-parse", "FETCH_HEAD"); got != head2 {
+ t.Errorf("FETCH_HEAD after fetch = %q, want %q", got, head2)
}
}
-func TestCloneUnknownRepoFails(t *testing.T) {
- srv, _, _ := newServer(t)
- // A clone of a repo with no metadata row must not succeed.
- dst := filepath.Join(t.TempDir(), "clone")
- cmd := exec.Command("git", "clone", srv.URL+"/no/such", dst)
- cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
- if out, err := cmd.CombinedOutput(); err == nil {
- t.Fatalf("clone of unknown repo succeeded, want failure\n%s", out)
+// infoRefs GETs the upload-pack advertisement with an optional bearer token and
+// returns the status code.
+func infoRefs(t *testing.T, srvURL, token string) int {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, srvURL+"/"+repoName+"/info/refs?service=git-upload-pack", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET info/refs: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusUnauthorized && resp.Header.Get("WWW-Authenticate") == "" {
+ t.Error("401 without WWW-Authenticate challenge")
+ }
+ return resp.StatusCode
+}
+
+func TestReadAuthorization(t *testing.T) {
+ srv, m, s := newServer(t)
+ repoID, _ := seedRepo(t, m, s, t.TempDir())
+
+ reader := mintUser(t, m, repoID, "reader", meta.PermRead)
+ noACL := mintUser(t, m, repoID, "stranger", "")
+
+ cases := []struct {
+ name string
+ token string
+ want int
+ }{
+ {"no token", "", http.StatusUnauthorized},
+ {"malformed token", "not-a-token", http.StatusUnauthorized},
+ {"unknown token", "gmt_00000000000000000000000000000000.1111", http.StatusUnauthorized},
+ {"valid token without ACL", noACL, http.StatusForbidden},
+ {"valid read token", reader, http.StatusOK},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := infoRefs(t, srv.URL, c.token); got != c.want {
+ t.Errorf("status = %d, want %d", got, c.want)
+ }
+ })
+ }
+}
+
+func TestWrongSecretRejected(t *testing.T) {
+ srv, m, s := newServer(t)
+ repoID, _ := seedRepo(t, m, s, t.TempDir())
+ raw := mintUser(t, m, repoID, "reader", meta.PermRead)
+
+ // Same selector, tampered secret: the constant-time verifier compare fails.
+ selector, _, _ := strings.Cut(strings.TrimPrefix(raw, "gmt_"), ".")
+ forged := "gmt_" + selector + ".deadbeefdeadbeef"
+ if got := infoRefs(t, srv.URL, forged); got != http.StatusUnauthorized {
+ t.Errorf("forged-secret status = %d, want 401", got)
}
}
func TestReceivePackNotServed(t *testing.T) {
- srv, _, _ := newServer(t)
- // The write service advertisement is not part of the read path (task 07).
- resp, err := http.Get(srv.URL + "/atmin/dotfiles/info/refs?service=git-receive-pack")
+ srv, m, s := newServer(t)
+ repoID, _ := seedRepo(t, m, s, t.TempDir())
+ raw := mintUser(t, m, repoID, "reader", meta.PermRead)
+
+ // The write service advertisement is not part of the read path (task 07) —
+ // 404 even for an authorized reader.
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/"+repoName+"/info/refs?service=git-receive-pack", nil)
+ req.Header.Set("Authorization", "Bearer "+raw)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET: %v", err)
}
@@ -177,10 +274,10 @@ func TestParseReadPath(t *testing.T) {
{"/", "", "", false},
}
for _, c := range cases {
- repoName, endpoint, ok := parseReadPath(c.path)
- if repoName != c.wantRepo || endpoint != c.wantEndpoint || ok != c.wantOK {
+ gotRepo, gotEndpoint, ok := parseReadPath(c.path)
+ if gotRepo != c.wantRepo || gotEndpoint != c.wantEndpoint || ok != c.wantOK {
t.Errorf("parseReadPath(%q) = (%q, %q, %v), want (%q, %q, %v)",
- c.path, repoName, endpoint, ok, c.wantRepo, c.wantEndpoint, c.wantOK)
+ c.path, gotRepo, gotEndpoint, ok, c.wantRepo, c.wantEndpoint, c.wantOK)
}
}
}
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
index 72ad0d3..b32d147 100644
--- a/internal/meta/meta.go
+++ b/internal/meta/meta.go
@@ -8,10 +8,8 @@ package meta
import (
"context"
- "crypto/sha256"
"database/sql"
_ "embed"
- "encoding/hex"
"errors"
"log/slog"
"time"
@@ -72,14 +70,6 @@ func (m *Metadata) Close() error { return m.db.Close() }
// Sync blocks until the replica has caught up; a no-op without replication.
func (m *Metadata) Sync(ctx context.Context) error { return m.db.Sync(ctx) }
-// HashToken maps a raw personal access token to its stored representation.
-// Tokens are high-entropy random strings, so a single SHA-256 is sufficient —
-// it makes the column indexable for lookup while never storing the raw token.
-func HashToken(raw string) string {
- sum := sha256.Sum256([]byte(raw))
- return hex.EncodeToString(sum[:])
-}
-
// now is the timestamp format for the TEXT *_at columns: RFC 3339, UTC.
func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }
diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go
index 483d9e4..35a82bd 100644
--- a/internal/meta/meta_test.go
+++ b/internal/meta/meta_test.go
@@ -89,7 +89,7 @@ func TestRepoCRUD(t *testing.T) {
}
}
-func TestUserAndTokenAuth(t *testing.T) {
+func TestTokenStorage(t *testing.T) {
ctx := context.Background()
m := open(t)
@@ -98,43 +98,50 @@ func TestUserAndTokenAuth(t *testing.T) {
t.Fatalf("CreateUser: %v", err)
}
- const raw = "pat_supersecret_value"
- tok, err := m.CreateToken(ctx, u.ID, raw, "laptop")
+ const (
+ selector = "0123456789abcdef"
+ verifier = "deadbeef" // stands in for SHA-256(secret); opaque to meta
+ )
+ tok, err := m.CreateToken(ctx, u.ID, selector, verifier, "laptop")
if err != nil {
t.Fatalf("CreateToken: %v", err)
}
- // The raw token is never stored.
- var stored string
- if err := m.db.QueryRowContext(ctx, `SELECT hash FROM tokens WHERE id = ?`, tok.ID).Scan(&stored); err != nil {
- t.Fatalf("read hash: %v", err)
- }
- if stored == raw {
- t.Fatal("raw token stored in tokens.hash")
+ // Lookup by selector returns the verifier + owner, and does not touch
+ // last_used.
+ ta, err := m.TokenBySelector(ctx, selector)
+ if err != nil {
+ t.Fatalf("TokenBySelector: %v", err)
}
- if stored != HashToken(raw) {
- t.Errorf("stored hash = %q, want %q", stored, HashToken(raw))
+ if ta.TokenID != tok.ID || ta.Verifier != verifier || ta.User.ID != u.ID || ta.User.Handle != "atmin" {
+ t.Errorf("TokenBySelector = %+v, want token %d verifier %q user %d atmin", ta, tok.ID, verifier, u.ID)
}
- // Authentication resolves the owner and stamps last_used.
- got, err := m.AuthenticateToken(ctx, raw)
+ before, err := m.ListTokens(ctx, u.ID)
if err != nil {
- t.Fatalf("AuthenticateToken: %v", err)
+ t.Fatalf("ListTokens: %v", err)
}
- if got.ID != u.ID || got.Handle != "atmin" {
- t.Errorf("AuthenticateToken = %+v, want user %d atmin", got, u.ID)
+ if len(before) != 1 || before[0].Label != "laptop" || before[0].LastUsed != nil {
+ t.Errorf("ListTokens = %+v, want one 'laptop' token with last_used unset", before)
}
- toks, err := m.ListTokens(ctx, u.ID)
- if err != nil {
- t.Fatalf("ListTokens: %v", err)
+
+ // TouchToken stamps last_used.
+ if err := m.TouchToken(ctx, tok.ID); err != nil {
+ t.Fatalf("TouchToken: %v", err)
}
- if len(toks) != 1 || toks[0].Label != "laptop" || toks[0].LastUsed == nil {
- t.Errorf("ListTokens = %+v, want one 'laptop' token with last_used set", toks)
+ after, _ := m.ListTokens(ctx, u.ID)
+ if after[0].LastUsed == nil {
+ t.Error("last_used still unset after TouchToken")
+ }
+
+ // An unknown selector is ErrNotFound.
+ if _, err := m.TokenBySelector(ctx, "nope"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("TokenBySelector(unknown) = %v, want ErrNotFound", err)
}
- // A wrong token authenticates no one.
- if _, err := m.AuthenticateToken(ctx, "pat_wrong"); !errors.Is(err, ErrNotFound) {
- t.Errorf("AuthenticateToken(wrong) = %v, want ErrNotFound", err)
+ // selector is UNIQUE.
+ if _, err := m.CreateToken(ctx, u.ID, selector, "other", "dup"); err == nil {
+ t.Error("CreateToken(duplicate selector) succeeded, want error")
}
}
diff --git a/internal/meta/schema.sql b/internal/meta/schema.sql
index 32b5530..3a8a6d9 100644
--- a/internal/meta/schema.sql
+++ b/internal/meta/schema.sql
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS tokens ( -- HTTP personal access tokens
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
- hash TEXT NOT NULL, -- hash of the PAT, never the raw token
+ selector TEXT NOT NULL UNIQUE, -- public lookup key (not secret)
+ verifier TEXT NOT NULL, -- SHA-256 of the token's secret half, never the raw token
label TEXT,
created_at TEXT NOT NULL,
last_used TEXT
diff --git a/internal/meta/users.go b/internal/meta/users.go
index 485ff19..8878e8d 100644
--- a/internal/meta/users.go
+++ b/internal/meta/users.go
@@ -13,8 +13,8 @@ type User struct {
CreatedAt time.Time
}
-// Token is a stored personal access token. The raw token is never persisted —
-// only Hash (see HashToken) — so it is absent here too.
+// Token is a stored personal access token's metadata. Neither the raw token nor
+// its verifier is exposed here — this is what the UI lists.
type Token struct {
ID int64
UserID int64
@@ -23,6 +23,14 @@ type Token struct {
LastUsed *time.Time
}
+// TokenAuth is a token's stored verifier plus its owner, returned to the auth
+// layer for constant-time verification (see internal/auth).
+type TokenAuth struct {
+ TokenID int64
+ Verifier string
+ User User
+}
+
// CreateUser inserts a user.
func (m *Metadata) CreateUser(ctx context.Context, handle string) (*User, error) {
created := now()
@@ -57,13 +65,14 @@ func (m *Metadata) GetUser(ctx context.Context, handle string) (*User, error) {
return &u, nil
}
-// CreateToken stores a personal access token for a user, keeping only the hash
-// of raw. The caller keeps raw; it cannot be recovered from the database.
-func (m *Metadata) CreateToken(ctx context.Context, userID int64, raw, label string) (*Token, error) {
+// CreateToken stores a personal access token for a user. selector is the token's
+// public lookup key; verifier is the hash of its secret half (see internal/auth
+// for the token format). Neither the raw token nor the secret is persisted.
+func (m *Metadata) CreateToken(ctx context.Context, userID int64, selector, verifier, label string) (*Token, error) {
created := now()
res, err := m.db.ExecContext(ctx,
- `INSERT INTO tokens (user_id, hash, label, created_at) VALUES (?, ?, ?, ?)`,
- userID, HashToken(raw), label, created)
+ `INSERT INTO tokens (user_id, selector, verifier, label, created_at) VALUES (?, ?, ?, ?, ?)`,
+ userID, selector, verifier, label, created)
if err != nil {
return nil, err
}
@@ -74,36 +83,35 @@ func (m *Metadata) CreateToken(ctx context.Context, userID int64, raw, label str
return &Token{ID: id, UserID: userID, Label: label, CreatedAt: parseTime(created)}, nil
}
-// AuthenticateToken resolves a raw token to its owning user and stamps the
-// token's last_used. It returns ErrNotFound when no token matches — callers
-// must not distinguish "unknown token" from any other auth failure to the
-// client.
-func (m *Metadata) AuthenticateToken(ctx context.Context, raw string) (*User, error) {
- hash := HashToken(raw)
+// TokenBySelector returns the verifier and owner of the token with the given
+// selector, or ErrNotFound. The auth layer compares the verifier in constant
+// time; this method deliberately does not touch last_used (see TouchToken).
+func (m *Metadata) TokenBySelector(ctx context.Context, selector string) (*TokenAuth, error) {
var (
- u User
- userID int64
- ts string
+ ta TokenAuth
+ ts string
)
err := m.db.QueryRowContext(ctx,
- `SELECT u.id, u.handle, u.created_at
+ `SELECT t.id, t.verifier, u.id, u.handle, u.created_at
FROM tokens t JOIN users u ON u.id = t.user_id
- WHERE t.hash = ?`, hash).
- Scan(&userID, &u.Handle, &ts)
+ WHERE t.selector = ?`, selector).
+ Scan(&ta.TokenID, &ta.Verifier, &ta.User.ID, &ta.User.Handle, &ts)
if isNoRows(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
- u.ID = userID
- u.CreatedAt = parseTime(ts)
+ ta.User.CreatedAt = parseTime(ts)
+ return &ta, nil
+}
- if _, err := m.db.ExecContext(ctx,
- `UPDATE tokens SET last_used = ? WHERE hash = ?`, now(), hash); err != nil {
- return nil, err
- }
- return &u, nil
+// TouchToken stamps a token's last_used with the current time. Called after a
+// successful verification.
+func (m *Metadata) TouchToken(ctx context.Context, tokenID int64) error {
+ _, err := m.db.ExecContext(ctx,
+ `UPDATE tokens SET last_used = ? WHERE id = ?`, now(), tokenID)
+ return err
}
// ListTokens returns a user's tokens (metadata only, never the hash) ordered by
diff --git a/tasks/06-auth-pat-acl.md b/tasks/06-auth-pat-acl.md
deleted file mode 100644
index ef75065..0000000
--- a/tasks/06-auth-pat-acl.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# 06 — Auth: PATs + per-repo ACLs
-
-Depends on: 03, 05.
-
-## Spec
-
-Authenticate smart-HTTP with bearer **personal access tokens** (hashed in
-`tokens`) and authorize **per-repo** from `acls` on every request, per
-[auth.md](../docs/architecture/auth.md).
-
-## Current
-
-The read path (task 05) is open to anyone.
-
-## Change
-
-- `internal/auth` — PAT mint (return the raw token once, store only its hash) +
- verify with constant-time compare; update `last_used`.
-- A request guard resolving `Authorization: Bearer …` → user, and an
- `authorize(repo, user, perm)` helper reading `acls`.
-- Apply `read` authorization to the task-05 routes. `401` for missing/invalid
- token, `403` for insufficient permission, using git's expected auth challenge
- so `git` and credential helpers behave.
-
-## Verify
-
-- Golden: clone with a valid `read` token succeeds.
-- Rejections (not optional): no token → 401; unknown/bad token → 401; valid
- token lacking `read` on the repo → 403.
-- Non-breaking: the read path now requires auth by design; read tests are
- updated to seed a token + ACL. No prior external consumers exist to break
- (design stage).
diff --git a/tasks/README.md b/tasks/README.md
index d723584..2ffac7b 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -6,7 +6,6 @@ lands** (commits are the record — this list never becomes a changelog).
Ordered by dependency; each task lands in a non-breaking, tested state.
-- [06-auth-pat-acl.md](06-auth-pat-acl.md) — Bearer PATs + per-repo ACL enforcement.
- [07-write-path-cas.md](07-write-path-cas.md) — `receive-pack` + pre-receive RPC + object PUT + ref CAS; `git push` works.
- [08-bootstrap.md](08-bootstrap.md) — First admin, token, repo from an empty bucket.
- [09-management-web-ui.md](09-management-web-ui.md) — Authenticated UI: repos, tokens, ACLs.