Files
EveryPublish/server/internal/credentials/store.go
T

194 lines
4.8 KiB
Go

package credentials
// Package credentials stores platform secrets outside the SQL account model.
// Files are encrypted with AES-256-GCM and keyed by workspace/account IDs so a
// leaked account row or browser event never contains cookies.
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"sync"
)
var errInvalidID = errors.New("workspace and account IDs must be positive")
// Store is a process-safe encrypted credential store.
type Store struct {
root string
key []byte
mu sync.Mutex
}
// Open creates or loads a 32-byte master key in root. The key file is never
// returned by an API and is permission-restricted to the current user.
func Open(root string) (*Store, error) {
if root == "" {
return nil, errors.New("credential directory is empty")
}
if err := os.MkdirAll(root, 0o700); err != nil {
return nil, err
}
keyPath := filepath.Join(root, "master.key")
key, err := os.ReadFile(keyPath)
if errors.Is(err, os.ErrNotExist) {
key = make([]byte, 32)
if _, err = io.ReadFull(rand.Reader, key); err != nil {
return nil, err
}
if err = writePrivate(keyPath, key); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
if len(key) != 32 {
return nil, errors.New("credential master key must be 32 bytes")
}
return &Store{root: root, key: append([]byte(nil), key...)}, nil
}
func (s *Store) path(workspaceID, accountID uint64) (string, error) {
if workspaceID == 0 || accountID == 0 {
return "", errInvalidID
}
dir := filepath.Join(s.root, strconv.FormatUint(workspaceID, 10))
return filepath.Join(dir, strconv.FormatUint(accountID, 10)+".enc"), nil
}
// Save encrypts value and atomically replaces the account file.
func (s *Store) Save(workspaceID, accountID uint64, value []byte) error {
path, err := s.path(workspaceID, accountID)
if err != nil {
return err
}
block, err := aes.NewCipher(s.key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return err
}
ciphertext := gcm.Seal(nil, nonce, value, nil)
payload := struct {
Nonce string `json:"nonce"`
Data string `json:"data"`
}{base64.RawStdEncoding.EncodeToString(nonce), base64.RawStdEncoding.EncodeToString(ciphertext)}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".credential-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err = tmp.Chmod(0o600); err == nil {
_, err = tmp.Write(raw)
}
if closeErr := tmp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(tmpName, path)
}
// Load decrypts credentials for one workspace/account pair.
func (s *Store) Load(workspaceID, accountID uint64) ([]byte, error) {
path, err := s.path(workspaceID, accountID)
if err != nil {
return nil, err
}
s.mu.Lock()
raw, err := os.ReadFile(path)
s.mu.Unlock()
if err != nil {
return nil, err
}
var payload struct {
Nonce string `json:"nonce"`
Data string `json:"data"`
}
if err = json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("credential envelope: %w", err)
}
nonce, err := base64.RawStdEncoding.DecodeString(payload.Nonce)
if err != nil {
return nil, fmt.Errorf("credential nonce: %w", err)
}
ciphertext, err := base64.RawStdEncoding.DecodeString(payload.Data)
if err != nil {
return nil, fmt.Errorf("credential data: %w", err)
}
block, err := aes.NewCipher(s.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(nonce) != gcm.NonceSize() {
return nil, errors.New("credential nonce has invalid size")
}
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, errors.New("credential authentication failed")
}
return plain, nil
}
// Delete removes credentials. Missing files are treated as success.
func (s *Store) Delete(workspaceID, accountID uint64) error {
path, err := s.path(workspaceID, accountID)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if err = os.Remove(path); errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
func writePrivate(path string, value []byte) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".master-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err = tmp.Chmod(0o600); err == nil {
_, err = tmp.Write(value)
}
if closeErr := tmp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(tmpName, path)
}