502 lines
13 KiB
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]
|
|
}
|