194 lines
4.8 KiB
Go
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)
|
|
}
|