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