verstak-desktop/internal/core/secrets/store.go

502 lines
13 KiB
Go

// Package secrets provides encrypted local storage for secret values.
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
keySize = 32
nonceSize = 12
masterSaltSize = 16
masterPBKDF2Iterations = 200000
masterVerifierPlaintext = "verstak-secret-store:v1"
masterMetadataVersion = 1
recordsDirName = "records"
masterMetadataFileName = "metadata.json"
ScopeGlobal = "global"
ScopeWorkspace = "workspace"
)
// Store encrypts secret records before writing them to disk.
type Store struct {
mu sync.RWMutex
root string
key []byte
}
type SecretScope struct {
Kind string `json:"kind"`
WorkspaceRootPath string `json:"workspaceRootPath,omitempty"`
}
type SecretRecord struct {
ID string `json:"id"`
Title string `json:"title"`
Value string `json:"value,omitempty"`
Scope SecretScope `json:"scope"`
Username string `json:"username,omitempty"`
UpdatedAt string `json:"updatedAt"`
}
type encryptedRecord struct {
Version int `json:"version"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
UpdatedAt string `json:"updatedAt"`
}
type plaintextRecord struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Value string `json:"value"`
Scope SecretScope `json:"scope"`
Username string `json:"username,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
type masterMetadata struct {
Version int `json:"version"`
Salt []byte `json:"salt"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
CreatedAt string `json:"createdAt"`
}
type VaultSession struct {
mu sync.RWMutex
root string
store *Store
}
// NewStore creates an encrypted secret store rooted at root.
func NewStore(root string, key []byte) (*Store, error) {
if root == "" {
return nil, fmt.Errorf("secret store root is empty")
}
if len(key) != keySize {
return nil, fmt.Errorf("secret store key must be %d bytes", keySize)
}
copiedKey := make([]byte, keySize)
copy(copiedKey, key)
return &Store{
root: root,
key: copiedKey,
}, nil
}
// Write encrypts and stores a secret value by ID.
func (s *Store) Write(id, value string) error {
return s.WriteRecord(SecretRecord{
ID: id,
Title: id,
Value: value,
Scope: SecretScope{Kind: ScopeGlobal},
})
}
func (s *Store) WriteRecord(record SecretRecord) error {
record.ID = strings.TrimSpace(record.ID)
record.Title = strings.TrimSpace(record.Title)
record.Scope.Kind = strings.TrimSpace(record.Scope.Kind)
record.Scope.WorkspaceRootPath = cleanWorkspaceRoot(record.Scope.WorkspaceRootPath)
if err := validateRecord(record); err != nil {
return err
}
record.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
plaintext, err := json.Marshal(plaintextRecord{
ID: record.ID,
Title: record.Title,
Value: record.Value,
Scope: record.Scope,
Username: record.Username,
UpdatedAt: record.UpdatedAt,
})
if err != nil {
return fmt.Errorf("marshal secret: %w", err)
}
nonce := make([]byte, nonceSize)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return fmt.Errorf("generate nonce: %w", err)
}
aead, err := s.aead()
if err != nil {
return err
}
encrypted := encryptedRecord{
Version: 1,
Nonce: nonce,
Ciphertext: aead.Seal(nil, nonce, plaintext, nil),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
data, err := json.MarshalIndent(encrypted, "", " ")
if err != nil {
return fmt.Errorf("marshal encrypted secret: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
return atomicWrite0600(s.pathForID(record.ID), data)
}
// Read decrypts and returns a secret value by ID.
func (s *Store) Read(id string) (string, error) {
record, err := s.ReadRecord(id)
if err != nil {
return "", err
}
return record.Value, nil
}
func (s *Store) ReadRecord(id string) (SecretRecord, error) {
if err := validateID(id); err != nil {
return SecretRecord{}, err
}
s.mu.RLock()
data, err := os.ReadFile(s.pathForID(id))
s.mu.RUnlock()
if err != nil {
return SecretRecord{}, fmt.Errorf("read secret %q: %w", id, err)
}
decoded, err := s.decryptRecord(id, data)
if err != nil {
return SecretRecord{}, err
}
return decoded, nil
}
func (s *Store) ListRecords() ([]SecretRecord, error) {
s.mu.RLock()
entries, err := os.ReadDir(s.root)
s.mu.RUnlock()
if err != nil {
if os.IsNotExist(err) {
return []SecretRecord{}, nil
}
return nil, fmt.Errorf("list secrets: %w", err)
}
records := make([]SecretRecord, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
path := filepath.Join(s.root, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read secret list item %s: %w", entry.Name(), err)
}
record, err := s.decryptRecord("", data)
if err != nil {
return nil, fmt.Errorf("decrypt secret list item %s: %w", entry.Name(), err)
}
record.Value = ""
records = append(records, record)
}
return records, nil
}
func (s *Store) decryptRecord(expectedID string, data []byte) (SecretRecord, error) {
var record encryptedRecord
if err := json.Unmarshal(data, &record); err != nil {
return SecretRecord{}, fmt.Errorf("decode encrypted secret %q: %w", expectedID, err)
}
if record.Version != 1 {
return SecretRecord{}, fmt.Errorf("unsupported secret version %d", record.Version)
}
aead, err := s.aead()
if err != nil {
return SecretRecord{}, err
}
plaintext, err := aead.Open(nil, record.Nonce, record.Ciphertext, nil)
if err != nil {
return SecretRecord{}, fmt.Errorf("decrypt secret %q: %w", expectedID, err)
}
var decoded plaintextRecord
if err := json.Unmarshal(plaintext, &decoded); err != nil {
return SecretRecord{}, fmt.Errorf("decode secret %q: %w", expectedID, err)
}
if expectedID != "" && decoded.ID != expectedID {
return SecretRecord{}, fmt.Errorf("secret %q contains mismatched id", expectedID)
}
return SecretRecord{
ID: decoded.ID,
Title: decoded.Title,
Value: decoded.Value,
Scope: decoded.Scope,
Username: decoded.Username,
UpdatedAt: decoded.UpdatedAt,
}, nil
}
func (s *Store) aead() (cipher.AEAD, error) {
block, err := aes.NewCipher(s.key)
if err != nil {
return nil, fmt.Errorf("create secret cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create secret gcm: %w", err)
}
return aead, nil
}
func (s *Store) pathForID(id string) string {
sum := sha256.Sum256([]byte(id))
return filepath.Join(s.root, hex.EncodeToString(sum[:])+".json")
}
func validateRecord(record SecretRecord) error {
if err := validateID(record.ID); err != nil {
return err
}
if record.Title == "" {
return fmt.Errorf("secret title is empty")
}
switch record.Scope.Kind {
case ScopeGlobal:
if record.Scope.WorkspaceRootPath != "" {
return fmt.Errorf("global secret must not have workspace root path")
}
case ScopeWorkspace:
if record.Scope.WorkspaceRootPath == "" {
return fmt.Errorf("workspace secret requires workspace root path")
}
if strings.ContainsAny(record.Scope.WorkspaceRootPath, `\`) {
return fmt.Errorf("workspace root path contains path separators")
}
default:
return fmt.Errorf("unsupported secret scope %q", record.Scope.Kind)
}
return nil
}
func cleanWorkspaceRoot(value string) string {
return strings.Trim(strings.TrimSpace(value), "/")
}
func validateID(id string) error {
if id == "" {
return fmt.Errorf("secret id is empty")
}
if len(id) > 256 {
return fmt.Errorf("secret id is too long")
}
if id == "." || id == ".." {
return fmt.Errorf("secret id %q is a path traversal reference", id)
}
if strings.ContainsAny(id, `/\`) {
return fmt.Errorf("secret id %q contains path separators", id)
}
if filepath.Clean(id) != id {
return fmt.Errorf("secret id %q contains path traversal", id)
}
return nil
}
func atomicWrite0600(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create secret store dir: %w", err)
}
tmpFile := filepath.Join(dir, fmt.Sprintf(".tmp.%d", time.Now().UnixNano()))
if err := os.WriteFile(tmpFile, data, 0o600); err != nil {
return fmt.Errorf("write secret temp file: %w", err)
}
if err := os.Rename(tmpFile, path); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("commit secret file: %w", err)
}
return nil
}
func NewVaultSession(root string) *VaultSession {
return &VaultSession{root: root}
}
func (s *VaultSession) Unlocked() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.store != nil
}
func (s *VaultSession) Store() (*Store, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.store == nil {
return nil, fmt.Errorf("secret store locked")
}
return s.store, nil
}
func (s *VaultSession) Unlock(masterPassword string) (*Store, error) {
if strings.TrimSpace(masterPassword) == "" {
return nil, fmt.Errorf("master password is empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.store != nil {
return s.store, nil
}
metadata, err := readMasterMetadata(s.root)
if err != nil {
return nil, err
}
if metadata == nil {
created, key, err := createMasterMetadata(masterPassword)
if err != nil {
return nil, err
}
if err := writeMasterMetadata(s.root, *created); err != nil {
return nil, err
}
store, err := NewStore(filepath.Join(s.root, recordsDirName), key)
if err != nil {
return nil, err
}
s.store = store
return store, nil
}
key := deriveMasterKey(masterPassword, metadata.Salt)
if err := verifyMasterMetadata(*metadata, key); err != nil {
return nil, err
}
store, err := NewStore(filepath.Join(s.root, recordsDirName), key)
if err != nil {
return nil, err
}
s.store = store
return store, nil
}
func readMasterMetadata(root string) (*masterMetadata, error) {
data, err := os.ReadFile(filepath.Join(root, masterMetadataFileName))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read secret metadata: %w", err)
}
var metadata masterMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return nil, fmt.Errorf("decode secret metadata: %w", err)
}
if metadata.Version != masterMetadataVersion {
return nil, fmt.Errorf("unsupported secret metadata version %d", metadata.Version)
}
return &metadata, nil
}
func createMasterMetadata(masterPassword string) (*masterMetadata, []byte, error) {
salt := make([]byte, masterSaltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, nil, fmt.Errorf("generate secret salt: %w", err)
}
key := deriveMasterKey(masterPassword, salt)
nonce := make([]byte, nonceSize)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, nil, fmt.Errorf("generate verifier nonce: %w", err)
}
aead, err := newAEAD(key)
if err != nil {
return nil, nil, err
}
return &masterMetadata{
Version: masterMetadataVersion,
Salt: salt,
Nonce: nonce,
Ciphertext: aead.Seal(nil, nonce, []byte(masterVerifierPlaintext), nil),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}, key, nil
}
func verifyMasterMetadata(metadata masterMetadata, key []byte) error {
aead, err := newAEAD(key)
if err != nil {
return err
}
plaintext, err := aead.Open(nil, metadata.Nonce, metadata.Ciphertext, nil)
if err != nil {
return fmt.Errorf("invalid master password")
}
if !hmac.Equal(plaintext, []byte(masterVerifierPlaintext)) {
return fmt.Errorf("invalid master password")
}
return nil
}
func writeMasterMetadata(root string, metadata masterMetadata) error {
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("marshal secret metadata: %w", err)
}
return atomicWrite0600(filepath.Join(root, masterMetadataFileName), data)
}
func deriveMasterKey(masterPassword string, salt []byte) []byte {
return pbkdf2SHA256([]byte(masterPassword), salt, masterPBKDF2Iterations, keySize)
}
func newAEAD(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create secret cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create secret gcm: %w", err)
}
return aead, nil
}
func pbkdf2SHA256(password, salt []byte, iterations, size int) []byte {
if iterations <= 0 || size <= 0 {
return nil
}
hashLen := sha256.Size
blocks := (size + hashLen - 1) / hashLen
output := make([]byte, 0, blocks*hashLen)
for block := 1; block <= blocks; block++ {
mac := hmac.New(sha256.New, password)
mac.Write(salt)
mac.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)})
u := mac.Sum(nil)
t := make([]byte, len(u))
copy(t, u)
for i := 1; i < iterations; i++ {
mac = hmac.New(sha256.New, password)
mac.Write(u)
u = mac.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
output = append(output, t...)
}
return output[:size]
}