Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/toad/internal/credential/credential.go

Raw
// Package credential owns deployment tokens as runtime state.
// Values are shown once at issue time and stored only as hashes, so infrastructure
// configuration, OpenTofu state, and Git never carry a usable credential.
package credential

import (
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"sync"
	"time"

	"bugabinga.net/toad/internal/store"
)

var (
	ErrUnknownToken = errors.New("unknown token")
	name            = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
	identifier      = regexp.MustCompile(`^[0-9a-f]{16}$`)
)

// Token is the stored form. The secret itself is never persisted.
type Token struct {
	ID        string    `json:"id"`
	Label     string    `json:"label,omitempty"`
	Hash      string    `json:"hash"`
	CreatedAt time.Time `json:"createdAt"`
}

// Public is the reportable form, safe for logs and listings.
type Public struct {
	ID        string    `json:"id"`
	Label     string    `json:"label,omitempty"`
	CreatedAt time.Time `json:"createdAt"`
}

type Store struct {
	Root string
	Now  func() time.Time

	mu sync.Mutex
}

func New(dataDir string) *Store {
	return &Store{Root: filepath.Join(dataDir, "credentials"), Now: func() time.Time { return time.Now().UTC() }}
}

// Issue returns the only copy of the token value that will ever exist.
func (s *Store) Issue(service, label string) (string, Public, error) {
	if err := validate(service, label); err != nil {
		return "", Public{}, err
	}
	id, secret, err := generate()
	if err != nil {
		return "", Public{}, err
	}
	digest := sha256.Sum256([]byte(secret))
	token := Token{ID: id, Label: label, Hash: hex.EncodeToString(digest[:]), CreatedAt: s.now()}

	s.mu.Lock()
	defer s.mu.Unlock()
	tokens, err := s.read(service)
	if err != nil {
		return "", Public{}, err
	}
	if err := s.write(service, append(tokens, token)); err != nil {
		return "", Public{}, err
	}
	return id + "." + secret, public(token), nil
}

func (s *Store) List(service string) ([]Public, error) {
	if !name.MatchString(service) {
		return nil, fmt.Errorf("invalid service %q", service)
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	tokens, err := s.read(service)
	if err != nil {
		return nil, err
	}
	listed := make([]Public, 0, len(tokens))
	for _, token := range tokens {
		listed = append(listed, public(token))
	}
	return listed, nil
}

func (s *Store) Revoke(service, id string) error {
	if !name.MatchString(service) {
		return fmt.Errorf("invalid service %q", service)
	}
	if !identifier.MatchString(id) {
		return ErrUnknownToken
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	tokens, err := s.read(service)
	if err != nil {
		return err
	}
	kept := make([]Token, 0, len(tokens))
	for _, token := range tokens {
		if token.ID != id {
			kept = append(kept, token)
		}
	}
	if len(kept) == len(tokens) {
		return ErrUnknownToken
	}
	return s.write(service, kept)
}

// Verify reports whether presented authorizes exactly this service.
func (s *Store) Verify(service, presented string) bool {
	if !name.MatchString(service) {
		return false
	}
	id, secret, found := strings.Cut(presented, ".")
	if !found || !identifier.MatchString(id) || secret == "" {
		return false
	}
	s.mu.Lock()
	tokens, err := s.read(service)
	s.mu.Unlock()
	if err != nil {
		return false
	}
	digest := sha256.Sum256([]byte(secret))
	expected := hex.EncodeToString(digest[:])
	authorized := false
	for _, token := range tokens {
		// Compare every candidate so a match does not shorten the loop.
		if token.ID == id && subtle.ConstantTimeCompare([]byte(token.Hash), []byte(expected)) == 1 {
			authorized = true
		}
	}
	return authorized
}

func (s *Store) path(service string) string { return filepath.Join(s.Root, service+".json") }

func (s *Store) read(service string) ([]Token, error) {
	data, err := os.ReadFile(s.path(service))
	if os.IsNotExist(err) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	var document struct {
		Tokens []Token `json:"tokens"`
	}
	if err := json.Unmarshal(data, &document); err != nil {
		return nil, fmt.Errorf("credentials for %s: %w", service, err)
	}
	return document.Tokens, nil
}

func (s *Store) write(service string, tokens []Token) error {
	if err := os.MkdirAll(s.Root, 0o750); err != nil {
		return err
	}
	data, err := json.MarshalIndent(struct {
		Tokens []Token `json:"tokens"`
	}{Tokens: tokens}, "", "  ")
	if err != nil {
		return err
	}
	return store.WriteFileAtomic(s.path(service), append(data, '\n'))
}

func (s *Store) now() time.Time {
	if s.Now == nil {
		return time.Now().UTC()
	}
	return s.Now().UTC()
}

func validate(service, label string) error {
	if !name.MatchString(service) {
		return fmt.Errorf("invalid service %q", service)
	}
	if len(label) > 64 || strings.ContainsAny(label, "\n\r\t") {
		return fmt.Errorf("invalid label %q", label)
	}
	return nil
}

func generate() (string, string, error) {
	var raw [8 + 32]byte
	if _, err := rand.Read(raw[:]); err != nil {
		return "", "", err
	}
	return hex.EncodeToString(raw[:8]), base64.RawURLEncoding.EncodeToString(raw[8:]), nil
}

func public(token Token) Public {
	return Public{ID: token.ID, Label: token.Label, CreatedAt: token.CreatedAt}
}