Pulse/internal/bootstrap/token_store.go
2026-03-18 16:06:30 +00:00

181 lines
4.9 KiB
Go

package bootstrap
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/crypto"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
const (
TokenFilename = ".bootstrap_token"
persistedFormatVersion = 2
)
type persistedToken struct {
Version int `json:"version"`
TokenCiphertext string `json:"token_ciphertext"`
TokenHash string `json:"token_hash"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
func ResolvePath(dataPath string) (string, error) {
clean := strings.TrimSpace(dataPath)
if clean == "" {
return "", errors.New("data path required for bootstrap token")
}
return filepath.Join(clean, TokenFilename), nil
}
func Load(dataPath string) (token string, path string, migrated bool, err error) {
path, err = ResolvePath(dataPath)
if err != nil {
return "", "", false, err
}
data, err := os.ReadFile(path)
if err != nil {
return "", path, false, err
}
token, migrated, err = loadFromBytes(strings.TrimSpace(dataPath), path, data)
if err != nil {
return "", path, migrated, err
}
return token, path, migrated, nil
}
func LoadOrCreate(dataPath string, generate func() (string, error)) (token string, created bool, path string, migrated bool, err error) {
clean := strings.TrimSpace(dataPath)
if clean == "" {
return "", false, "", false, errors.New("data path required for bootstrap token")
}
if err := os.MkdirAll(clean, 0o700); err != nil {
return "", false, "", false, fmt.Errorf("ensure data path: %w", err)
}
token, path, migrated, err = Load(clean)
if err == nil {
return token, false, path, migrated, nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", false, path, migrated, err
}
token, err = generate()
if err != nil {
return "", false, path, false, fmt.Errorf("generate bootstrap token: %w", err)
}
path, err = Persist(clean, token, time.Now().UTC())
if err != nil {
return "", false, path, false, err
}
return token, true, path, false, nil
}
func Persist(dataPath, token string, createdAt time.Time) (string, error) {
clean := strings.TrimSpace(dataPath)
token = strings.TrimSpace(token)
if clean == "" {
return "", errors.New("data path required for bootstrap token")
}
if token == "" {
return "", errors.New("bootstrap token file is empty")
}
if err := os.MkdirAll(clean, 0o700); err != nil {
return "", fmt.Errorf("ensure data path: %w", err)
}
path, err := ResolvePath(clean)
if err != nil {
return "", err
}
manager, err := crypto.NewCryptoManagerAt(clean)
if err != nil {
return path, fmt.Errorf("prepare bootstrap token crypto: %w", err)
}
ciphertext, err := manager.EncryptString(token)
if err != nil {
return path, fmt.Errorf("encrypt bootstrap token: %w", err)
}
record := persistedToken{
Version: persistedFormatVersion,
TokenCiphertext: ciphertext,
TokenHash: internalauth.HashAPIToken(token),
CreatedAt: createdAt.UTC(),
}
data, err := json.Marshal(record)
if err != nil {
return path, fmt.Errorf("marshal bootstrap token: %w", err)
}
tmpFile := path + ".tmp"
if err := os.WriteFile(tmpFile, data, 0o600); err != nil {
return path, fmt.Errorf("persist bootstrap token: %w", err)
}
if err := os.Rename(tmpFile, path); err != nil {
_ = os.Remove(tmpFile)
return path, fmt.Errorf("persist bootstrap token: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return path, fmt.Errorf("harden bootstrap token file: %w", err)
}
return path, nil
}
func loadFromBytes(dataPath, path string, data []byte) (token string, migrated bool, err error) {
trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
return "", false, errors.New("bootstrap token file is empty")
}
if strings.HasPrefix(trimmed, "{") {
token, err = loadEncryptedRecord(dataPath, trimmed)
return token, false, err
}
if _, err := Persist(dataPath, trimmed, time.Now().UTC()); err != nil {
return "", false, err
}
return trimmed, true, nil
}
func loadEncryptedRecord(dataPath, raw string) (string, error) {
var record persistedToken
if err := json.Unmarshal([]byte(raw), &record); err != nil {
return "", fmt.Errorf("read existing bootstrap token: %w", err)
}
if strings.TrimSpace(record.TokenCiphertext) == "" {
return "", errors.New("bootstrap token file is empty")
}
manager, err := crypto.NewCryptoManagerAt(dataPath)
if err != nil {
return "", fmt.Errorf("prepare bootstrap token crypto: %w", err)
}
token, err := manager.DecryptString(record.TokenCiphertext)
if err != nil {
return "", fmt.Errorf("decrypt bootstrap token: %w", err)
}
token = strings.TrimSpace(token)
if token == "" {
return "", errors.New("bootstrap token file is empty")
}
if expected := strings.TrimSpace(record.TokenHash); expected != "" && expected != internalauth.HashAPIToken(token) {
return "", errors.New("bootstrap token hash mismatch")
}
return token, nil
}