Skip to content

Commit 178e7c5

Browse files
committed
add age-based local vault
0 parents  commit 178e7c5

File tree

12 files changed

+884
-0
lines changed

12 files changed

+884
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.age
2+
*.txt
3+
playground/*

cmd/main.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"path/filepath"
7+
8+
"github.com/jahvon/vault"
9+
)
10+
11+
func main() {
12+
dir := "/Users/jahvon/workspaces/github.com/jahvon/vault/playground"
13+
fmt.Printf("Testing vault in: %s\n", dir)
14+
15+
fmt.Println("\n=== Test 1: Create New Vault ===")
16+
vault1, err := vault.New(
17+
"test",
18+
vault.WithProvider(vault.ProviderTypeLocal),
19+
vault.WithRecipients("age1nmkk0tv7ntg5yld0uhxc9f05p0d6zwxcaftxcjvwy82djuuzg96skmuzlk"),
20+
vault.WithLocalPath(dir),
21+
vault.WithLocalIdentityFromFile("/Users/jahvon/workspaces/github.com/jahvon/vault/playground/key.txt"),
22+
)
23+
if err != nil {
24+
log.Fatal("Failed to create vault:", err)
25+
}
26+
defer vault1.Close()
27+
28+
fmt.Printf("Created vault with ID: %s\n", vault1.ID())
29+
30+
fmt.Println("\n=== Test 2: Set and Get Secrets ===")
31+
32+
err = vault1.SetSecret("api-key", vault.NewSecretValue([]byte("my-secret-api-key")))
33+
if err != nil {
34+
log.Fatal("Failed to set secret:", err)
35+
}
36+
fmt.Println("✓ Set api-key")
37+
38+
err = vault1.SetSecret("db-password", vault.NewSecretValue([]byte("super-secret-password")))
39+
if err != nil {
40+
log.Fatal("Failed to set db-password:", err)
41+
}
42+
fmt.Println("✓ Set db-password")
43+
44+
secret, err := vault1.GetSecret("api-key")
45+
if err != nil {
46+
log.Fatal("Failed to get secret:", err)
47+
}
48+
fmt.Printf("✓ Retrieved api-key: %s (masked: %s)\n", secret.PlainTextString(), secret.String())
49+
50+
fmt.Println("\n=== Test 3: List Secrets ===")
51+
secrets, err := vault1.ListSecrets()
52+
if err != nil {
53+
log.Fatal("Failed to list secrets:", err)
54+
}
55+
56+
fmt.Printf("Found %d secrets:\n", len(secrets))
57+
for _, key := range secrets {
58+
fmt.Printf(" - %s\n", key)
59+
}
60+
61+
fmt.Println("\n=== Test 4: Verify Encrypted File ===")
62+
vaultFiles, err := filepath.Glob(filepath.Join(dir, "*.age"))
63+
if err != nil {
64+
log.Fatal("Failed to find vault files:", err)
65+
}
66+
67+
if len(vaultFiles) == 0 {
68+
log.Fatal("No .age vault files found!")
69+
}
70+
71+
fmt.Printf("✓ Found encrypted vault file: %s\n", vaultFiles[0])
72+
}

config.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package vault
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
)
10+
11+
type ProviderType string
12+
13+
const (
14+
ProviderTypeLocal ProviderType = "local"
15+
ProviderTypeExternal ProviderType = "external"
16+
)
17+
18+
type Config struct {
19+
ID string `json:"id"`
20+
Type ProviderType `json:"type"`
21+
Local *LocalConfig `json:"local,omitempty"`
22+
External *ExternalConfig `json:"external,omitempty"`
23+
}
24+
25+
func (c *Config) Validate() error {
26+
if c.ID == "" {
27+
return fmt.Errorf("vault ID is required")
28+
}
29+
30+
switch c.Type {
31+
case ProviderTypeLocal:
32+
if c.Local == nil {
33+
return fmt.Errorf("local configuration required for local vault")
34+
}
35+
return c.Local.Validate()
36+
case ProviderTypeExternal:
37+
if c.External == nil {
38+
return fmt.Errorf("external configuration required for external vault")
39+
}
40+
return c.External.Validate()
41+
default:
42+
return fmt.Errorf("unsupported vault type: %s", c.Type)
43+
}
44+
}
45+
46+
// SaveConfigJSON saves the vault configuration to a file in JSON format
47+
func SaveConfigJSON(config Config, path string) error {
48+
data, err := json.MarshalIndent(config, "", " ")
49+
if err != nil {
50+
return fmt.Errorf("failed to marshal config: %w", err)
51+
}
52+
53+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
54+
return fmt.Errorf("failed to create config directory: %w", err)
55+
}
56+
57+
if err := os.WriteFile(path, data, 0600); err != nil {
58+
return fmt.Errorf("failed to write config file: %w", err)
59+
}
60+
61+
return nil
62+
}
63+
64+
// LoadConfigJSON loads the vault configuration from a file in JSON format
65+
func LoadConfigJSON(path string) (Config, error) {
66+
data, err := os.ReadFile(path)
67+
if err != nil {
68+
return Config{}, fmt.Errorf("failed to read config file: %w", err)
69+
}
70+
71+
var config Config
72+
if err := json.Unmarshal(data, &config); err != nil {
73+
return Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
74+
}
75+
76+
return config, nil
77+
}
78+
79+
// IdentitySource represents a source for the local vault identity keys
80+
type IdentitySource struct {
81+
// Type of identity source
82+
// Must be one of: "env", "file", "ssh-agent"
83+
Type string `json:"type"`
84+
// Path to the identity file (for "file" type)
85+
Path string `json:"fullPath,omitempty"`
86+
// Environment variable name (for "env" type)
87+
Name string `json:"name,omitempty"`
88+
}
89+
90+
// LocalConfig contains local (age-based) vault configuration
91+
type LocalConfig struct {
92+
// Storage location for the vault file
93+
StoragePath string `json:"storage_path"`
94+
95+
// Identity sources for decryption (in order of preference)
96+
IdentitySources []IdentitySource `json:"identity_sources,omitempty"`
97+
98+
// Recipients who can decrypt secrets
99+
Recipients []string `json:"recipients,omitempty"`
100+
}
101+
102+
func (c *LocalConfig) Validate() error {
103+
if c.StoragePath == "" {
104+
return fmt.Errorf("storage fullPath is required for local vault")
105+
}
106+
return nil
107+
}
108+
109+
// CommandSet defines the command templates for external vault operations
110+
type CommandSet struct {
111+
Get string `json:"get"`
112+
Set string `json:"set"`
113+
Delete string `json:"delete"`
114+
List string `json:"list"`
115+
Exists string `json:"exists,omitempty"`
116+
}
117+
118+
// ExternalConfig contains external (cli command-based) vault configuration
119+
type ExternalConfig struct {
120+
// Command templates for operations
121+
Commands CommandSet `json:"commands"`
122+
123+
// Environment variables for commands
124+
Environment map[string]string `json:"environment,omitempty"`
125+
126+
// Timeout for command execution
127+
Timeout time.Duration `json:"timeout,omitempty"`
128+
129+
// WorkingDir for command execution
130+
WorkingDir string `json:"working_dir,omitempty"`
131+
}
132+
133+
func (c *ExternalConfig) Validate() error {
134+
if c.Commands.Get == "" || c.Commands.Set == "" {
135+
return fmt.Errorf("get and set commands are required for external vault")
136+
}
137+
return nil
138+
}

errors.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package vault
2+
3+
import (
4+
"errors"
5+
)
6+
7+
var (
8+
ErrSecretNotFound = errors.New("secret not found")
9+
)

external.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package vault
2+
3+
type ExternalVaultProvider struct {
4+
}
5+
6+
func (v *ExternalVaultProvider) ID() string {
7+
panic("not implemented yet")
8+
}
9+
10+
func (v *ExternalVaultProvider) GetSecret(_ string) (Secret, error) {
11+
panic("not implemented yet")
12+
}
13+
14+
func (v *ExternalVaultProvider) SetSecret(key string, value Secret) error {
15+
panic("not implemented yet")
16+
}
17+
18+
func (v *ExternalVaultProvider) DeleteSecret(key string) error {
19+
panic("not implemented yet")
20+
}
21+
22+
func (v *ExternalVaultProvider) ListSecrets() ([]string, error) {
23+
panic("not implemented yet")
24+
}
25+
26+
func (v *ExternalVaultProvider) HasSecret(key string) (bool, error) {
27+
panic("not implemented yet")
28+
}
29+
30+
func (v *ExternalVaultProvider) Close() error {
31+
return nil
32+
}

go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module github.com/jahvon/vault
2+
3+
go 1.24
4+
5+
require (
6+
filippo.io/age v1.2.1
7+
golang.org/x/crypto v0.24.0 // indirect
8+
)
9+
10+
require golang.org/x/sys v0.21.0 // indirect

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
2+
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
3+
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
4+
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
5+
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
6+
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
7+
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
8+
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

identity.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package vault
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"filippo.io/age"
9+
)
10+
11+
var (
12+
DefaultVaultKeyEnv = "AGE_VAULT_KEY"
13+
)
14+
15+
type IdentityResolver struct {
16+
sources []IdentitySource
17+
}
18+
19+
func NewIdentityResolver(sources []IdentitySource) *IdentityResolver {
20+
if len(sources) == 0 {
21+
sources = []IdentitySource{
22+
{Type: "env", Name: DefaultVaultKeyEnv},
23+
}
24+
}
25+
return &IdentityResolver{sources: sources}
26+
}
27+
28+
func (r *IdentityResolver) ResolveIdentities() ([]age.Identity, error) {
29+
var identities []age.Identity
30+
31+
for _, source := range r.sources {
32+
switch source.Type {
33+
case "env":
34+
if id := r.fromEnvironment(source.Name); id != nil {
35+
identities = append(identities, id)
36+
}
37+
case "file":
38+
if id, err := r.fromFile(source.Path); err != nil {
39+
return nil, fmt.Errorf("failed to read identity from file %s: %w", source.Path, err)
40+
} else if id != nil {
41+
identities = append(identities, id)
42+
}
43+
}
44+
}
45+
46+
if len(identities) == 0 {
47+
return nil, fmt.Errorf("no valid identities found")
48+
}
49+
50+
return identities, nil
51+
}
52+
53+
func (r *IdentityResolver) fromEnvironment(envVar string) age.Identity {
54+
if envVar == "" {
55+
envVar = DefaultVaultKeyEnv
56+
}
57+
58+
keyStr := os.Getenv(envVar)
59+
if keyStr == "" {
60+
return nil
61+
}
62+
63+
identity, err := age.ParseX25519Identity(keyStr)
64+
if err != nil {
65+
return nil
66+
}
67+
68+
return identity
69+
}
70+
71+
func (r *IdentityResolver) fromFile(path string) (age.Identity, error) {
72+
if path == "" {
73+
return nil, fmt.Errorf("identity file path cannot be empty")
74+
}
75+
76+
expandedPath := expandPath(path)
77+
keyBytes, err := os.ReadFile(expandedPath)
78+
if err != nil {
79+
return nil, fmt.Errorf("failed to read identity file %s: %w", expandedPath, err)
80+
}
81+
82+
identity, err := age.ParseX25519Identity(strings.TrimSpace(string(keyBytes)))
83+
if err != nil {
84+
return nil, fmt.Errorf("invalid identity in file %s: %w", expandedPath, err)
85+
}
86+
87+
return identity, nil
88+
}
89+
90+
func (v *LocalVault) addRecipientToState(publicKey string) error {
91+
_, err := age.ParseX25519Recipient(publicKey)
92+
if err != nil {
93+
return fmt.Errorf("invalid recipient key: %w", err)
94+
}
95+
96+
// for _, existing := range v.state.Recipients {
97+
// if existing == publicKey {
98+
// return fmt.Errorf("recipient already exists")
99+
// }
100+
// }
101+
102+
v.state.Recipients = append(v.state.Recipients, publicKey)
103+
return nil
104+
}
105+
106+
func (v *LocalVault) parseRecipients() error {
107+
v.recipients = make([]age.Recipient, 0, len(v.state.Recipients))
108+
109+
for _, recipientStr := range v.state.Recipients {
110+
recipient, err := age.ParseX25519Recipient(recipientStr)
111+
if err != nil {
112+
return fmt.Errorf("invalid recipient %s: %w", recipientStr, err)
113+
}
114+
v.recipients = append(v.recipients, recipient)
115+
}
116+
117+
return nil
118+
}

0 commit comments

Comments
 (0)