This commit is contained in:
@@ -22,7 +22,9 @@ func encryptionKey() ([]byte, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func encryptPrivateKey(plaintext string) (string, error) {
|
||||
// encryptString encrypts a plaintext value with AES-256-GCM using the
|
||||
// shared KEY_ENCRYPTION_KEY, returning hex(nonce + ciphertext).
|
||||
func encryptString(plaintext string) (string, error) {
|
||||
key, err := encryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -43,7 +45,8 @@ func encryptPrivateKey(plaintext string) (string, error) {
|
||||
return hex.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func decryptPrivateKey(ciphertextHex string) (string, error) {
|
||||
// decryptString reverses encryptString.
|
||||
func decryptString(ciphertextHex string) (string, error) {
|
||||
key, err := encryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -70,3 +73,7 @@ func decryptPrivateKey(ciphertextHex string) (string, error) {
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) }
|
||||
|
||||
func decryptPrivateKey(ciphertextHex string) (string, error) { return decryptString(ciphertextHex) }
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// EnsureSecretIndexes creates the unique compound index on (group, key).
|
||||
func EnsureSecretIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ListSecretGroups returns a summary of every group with its key count and
|
||||
// most recent update time.
|
||||
func ListSecretGroups() ([]models.GroupSummary, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pipeline := mongo.Pipeline{
|
||||
{{Key: "$group", Value: bson.D{
|
||||
{Key: "_id", Value: "$group"},
|
||||
{Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}},
|
||||
{Key: "updated_at", Value: bson.D{{Key: "$max", Value: "$updated_at"}}},
|
||||
}}},
|
||||
{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
|
||||
}
|
||||
|
||||
cursor, err := db.Col("secrets").Aggregate(ctx, pipeline)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var rows []struct {
|
||||
Group string `bson:"_id"`
|
||||
KeyCount int `bson:"key_count"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
if err := cursor.All(ctx, &rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := make([]models.GroupSummary, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
groups = append(groups, models.GroupSummary{
|
||||
Group: r.Group,
|
||||
KeyCount: r.KeyCount,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetSecretGroup returns the keys within a group, sorted by key name, without
|
||||
// decrypted values.
|
||||
func GetSecretGroup(group string) ([]models.Secret, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group},
|
||||
options.Find().SetSort(bson.D{{Key: "key", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var docs []models.Secret
|
||||
if err := cursor.All(ctx, &docs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
// GetSecretGroupDecrypted returns a flat map of key → plaintext value for a
|
||||
// group. Used by the ESO read endpoint.
|
||||
func GetSecretGroupDecrypted(group string) (map[string]string, error) {
|
||||
docs, err := GetSecretGroup(group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(docs))
|
||||
for _, doc := range docs {
|
||||
val, err := decryptString(doc.EncryptedValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt %s/%s: %w", group, doc.Key, err)
|
||||
}
|
||||
result[doc.Key] = val
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RevealSecret returns the decrypted value of a single key.
|
||||
func RevealSecret(group, key string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var doc models.Secret
|
||||
err := db.Col("secrets").FindOne(ctx, bson.M{"group": group, "key": key}).Decode(&doc)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return "", fmt.Errorf("secret not found")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decryptString(doc.EncryptedValue)
|
||||
}
|
||||
|
||||
// UpsertSecrets encrypts and writes each key/value pair into the group.
|
||||
func UpsertSecrets(group string, values map[string]string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for key, val := range values {
|
||||
encrypted, err := encryptString(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt %s: %w", key, err)
|
||||
}
|
||||
_, err = db.Col("secrets").UpdateOne(ctx,
|
||||
bson.M{"group": group, "key": key},
|
||||
bson.M{"$set": bson.M{
|
||||
"encrypted_value": encrypted,
|
||||
"updated_at": time.Now(),
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortedKeys returns the map keys sorted — handy for stable audit messages.
|
||||
func SortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// DeleteSecret removes a single key from a group.
|
||||
func DeleteSecret(group, key string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"group": group, "key": key})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSecretGroup removes an entire group and all its keys.
|
||||
func DeleteSecretGroup(group string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"group": group})
|
||||
return err
|
||||
}
|
||||
@@ -3,7 +3,11 @@ package services
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -43,9 +47,59 @@ func GetSettings() (*models.Settings, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Secrets.ReadTokenSet = s.Secrets.ReadTokenHash != ""
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// RotateSecretsReadToken generates a new ESO read token, stores its SHA-256
|
||||
// hash, and returns the plaintext token exactly once.
|
||||
func RotateSecretsReadToken() (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
|
||||
_, err := db.Col("settings").UpdateOne(ctx,
|
||||
bson.M{},
|
||||
bson.M{"$set": bson.M{
|
||||
"secrets.read_token_hash": hashToken(token),
|
||||
"secrets.rotated_at": time.Now(),
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifySecretsReadToken reports whether the supplied token matches the stored
|
||||
// hash, using a constant-time comparison.
|
||||
func VerifySecretsReadToken(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
s, err := GetSettings()
|
||||
if err != nil || s.Secrets.ReadTokenHash == "" {
|
||||
return false
|
||||
}
|
||||
expected, err := hex.DecodeString(s.Secrets.ReadTokenHash)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := sha256.Sum256([]byte(token))
|
||||
return subtle.ConstantTimeCompare(expected, got[:]) == 1
|
||||
}
|
||||
|
||||
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
Reference in New Issue
Block a user