feat: expose scoped secret store API

This commit is contained in:
2026-06-29 20:36:45 +08:00
parent 7a7b3c7a3e
commit 56c685212b
7 changed files with 808 additions and 22 deletions
+335 -22
View File
@@ -4,6 +4,7 @@ package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
@@ -18,8 +19,16 @@ import (
)
const (
keySize = 32
nonceSize = 12
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.
@@ -29,6 +38,20 @@ type Store struct {
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"`
@@ -37,8 +60,26 @@ type encryptedRecord struct {
}
type plaintextRecord struct {
ID string `json:"id"`
Value string `json:"value"`
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.
@@ -60,11 +101,32 @@ func NewStore(root string, key []byte) (*Store, error) {
// Write encrypts and stores a secret value by ID.
func (s *Store) Write(id, value string) error {
if err := validateID(id); err != nil {
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: id, Value: value})
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)
}
@@ -78,60 +140,113 @@ func (s *Store) Write(id, value string) error {
if err != nil {
return err
}
record := encryptedRecord{
encrypted := encryptedRecord{
Version: 1,
Nonce: nonce,
Ciphertext: aead.Seal(nil, nonce, plaintext, []byte(id)),
Ciphertext: aead.Seal(nil, nonce, plaintext, nil),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
data, err := json.MarshalIndent(record, "", " ")
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(id), data)
return atomicWrite0600(s.pathForID(record.ID), data)
}
// Read decrypts and returns a secret value by ID.
func (s *Store) Read(id string) (string, error) {
if err := validateID(id); err != nil {
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 "", fmt.Errorf("read secret %q: %w", id, err)
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 "", fmt.Errorf("decode encrypted secret %q: %w", id, err)
return SecretRecord{}, fmt.Errorf("decode encrypted secret %q: %w", expectedID, err)
}
if record.Version != 1 {
return "", fmt.Errorf("unsupported secret version %d", record.Version)
return SecretRecord{}, fmt.Errorf("unsupported secret version %d", record.Version)
}
aead, err := s.aead()
if err != nil {
return "", err
return SecretRecord{}, err
}
plaintext, err := aead.Open(nil, record.Nonce, record.Ciphertext, []byte(id))
plaintext, err := aead.Open(nil, record.Nonce, record.Ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decrypt secret %q: %w", id, err)
return SecretRecord{}, fmt.Errorf("decrypt secret %q: %w", expectedID, err)
}
var decoded plaintextRecord
if err := json.Unmarshal(plaintext, &decoded); err != nil {
return "", fmt.Errorf("decode secret %q: %w", id, err)
return SecretRecord{}, fmt.Errorf("decode secret %q: %w", expectedID, err)
}
if decoded.ID != id {
return "", fmt.Errorf("secret %q contains mismatched id", id)
if expectedID != "" && decoded.ID != expectedID {
return SecretRecord{}, fmt.Errorf("secret %q contains mismatched id", expectedID)
}
return decoded.Value, nil
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) {
@@ -151,6 +266,35 @@ func (s *Store) pathForID(id string) string {
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")
@@ -186,3 +330,172 @@ func atomicWrite0600(path string, data []byte) error {
}
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]
}
+119
View File
@@ -105,3 +105,122 @@ func TestStoreRejectsUnsafeIDs(t *testing.T) {
})
}
}
func TestStoreListsScopedRecordsWithoutPlaintextOnDisk(t *testing.T) {
root := t.TempDir()
store, err := NewStore(root, testKey(0x11))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
records := []SecretRecord{
{
ID: "global.server-token",
Title: "Server Token",
Value: "global-secret-value",
Scope: SecretScope{Kind: ScopeGlobal},
Username: "root",
UpdatedAt: "caller value must be replaced",
},
{
ID: "client-a.database",
Title: "Client A Database",
Value: "workspace-secret-value",
Scope: SecretScope{
Kind: ScopeWorkspace,
WorkspaceRootPath: "ClientA",
},
Username: "app",
},
}
for _, record := range records {
if err := store.WriteRecord(record); err != nil {
t.Fatalf("WriteRecord(%s): %v", record.ID, err)
}
}
list, err := store.ListRecords()
if err != nil {
t.Fatalf("ListRecords: %v", err)
}
if len(list) != 2 {
t.Fatalf("ListRecords len = %d, want 2: %+v", len(list), list)
}
if list[0].Value != "" || list[1].Value != "" {
t.Fatalf("ListRecords leaked secret values: %+v", list)
}
workspaceRecord, err := store.ReadRecord("client-a.database")
if err != nil {
t.Fatalf("ReadRecord: %v", err)
}
if workspaceRecord.Value != "workspace-secret-value" {
t.Fatalf("workspace secret value = %q", workspaceRecord.Value)
}
if workspaceRecord.Scope.Kind != ScopeWorkspace || workspaceRecord.Scope.WorkspaceRootPath != "ClientA" {
t.Fatalf("workspace scope = %+v", workspaceRecord.Scope)
}
if workspaceRecord.UpdatedAt == "" || workspaceRecord.UpdatedAt == "caller value must be replaced" {
t.Fatalf("UpdatedAt was not set by store: %+v", workspaceRecord)
}
raw := readAllSecretStoreBytes(t, root)
for _, plaintext := range []string{
"global-secret-value",
"workspace-secret-value",
"global.server-token",
"client-a.database",
"Client A Database",
"ClientA",
} {
if bytes.Contains(raw, []byte(plaintext)) {
t.Fatalf("secret store contains plaintext %q", plaintext)
}
}
}
func TestVaultSessionUnlocksWithMasterPasswordOnce(t *testing.T) {
root := t.TempDir()
session := NewVaultSession(root)
if session.Unlocked() {
t.Fatal("new session is unlocked")
}
if _, err := session.Store(); err == nil {
t.Fatal("Store before unlock succeeded")
}
store, err := session.Unlock("correct horse battery staple")
if err != nil {
t.Fatalf("Unlock first time: %v", err)
}
if !session.Unlocked() {
t.Fatal("session did not stay unlocked")
}
if err := store.Write("server.password", "s3cr3t-value"); err != nil {
t.Fatalf("Write: %v", err)
}
sameStore, err := session.Store()
if err != nil {
t.Fatalf("Store after unlock: %v", err)
}
if got, err := sameStore.Read("server.password"); err != nil || got != "s3cr3t-value" {
t.Fatalf("Read after unlock = %q, %v", got, err)
}
nextSession := NewVaultSession(root)
if _, err := nextSession.Unlock("wrong password"); err == nil {
t.Fatal("Unlock with wrong password succeeded")
}
if _, err := nextSession.Unlock("correct horse battery staple"); err != nil {
t.Fatalf("Unlock with correct password: %v", err)
}
raw := readAllSecretStoreBytes(t, root)
for _, plaintext := range []string{"correct horse battery staple", "s3cr3t-value"} {
if bytes.Contains(raw, []byte(plaintext)) {
t.Fatalf("vault session storage contains plaintext %q", plaintext)
}
}
}