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