feat: expose scoped secret store API
This commit is contained in:
+335
-22
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user