gitmote

gitmote / internal / auth / token.go  ·  history  ·  branches

2717 bytes · raw

// 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
}