Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,13 @@ func main() {
} else {
cfg.AuthDir = resolvedAuthDir
}
if repoRoot, ok := util.FindGitRepoRoot(cfg.AuthDir); ok {
if useGitStore {
log.Warnf("auth-dir %q is inside a git repository (%q); git-backed token storage is enabled, ensure the repo/remote is private and tokens are not exposed", cfg.AuthDir, repoRoot)
} else {
log.Warnf("auth-dir %q is inside a git repository (%q); do not commit token files, add it to .gitignore or set auth-dir outside the repo", cfg.AuthDir, repoRoot)
}
}
managementasset.SetCurrentConfig(cfg)

// Create login options to be used in authentication flows.
Expand Down
8 changes: 4 additions & 4 deletions internal/logging/request_logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[st
content := l.formatLogContent(url, method, requestHeaders, body, apiRequest, apiResponse, decompressedResponse, statusCode, responseHeaders, apiResponseErrors)

// Write to file
if err = os.WriteFile(filePath, []byte(content), 0644); err != nil {
if err = os.WriteFile(filePath, []byte(content), 0o600); err != nil {
return fmt.Errorf("failed to write log file: %w", err)
}

Expand Down Expand Up @@ -233,7 +233,7 @@ func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[
filePath := filepath.Join(l.logsDir, filename)

// Create and open file
file, err := os.Create(filePath)
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
Expand Down Expand Up @@ -270,7 +270,7 @@ func (l *FileRequestLogger) generateErrorFilename(url string) string {
// - error: An error if directory creation fails, nil otherwise
func (l *FileRequestLogger) ensureLogsDir() error {
if _, err := os.Stat(l.logsDir); os.IsNotExist(err) {
return os.MkdirAll(l.logsDir, 0755)
return os.MkdirAll(l.logsDir, 0o700)
}
return nil
}
Expand Down Expand Up @@ -618,7 +618,7 @@ func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[st
content.WriteString("\n")

content.WriteString("=== REQUEST BODY ===\n")
content.Write(body)
content.Write(util.MaskSensitiveJSON(body))
content.WriteString("\n\n")

return content.String()
Expand Down
94 changes: 94 additions & 0 deletions internal/securefile/atomic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package securefile

import (
"fmt"
"os"
"path/filepath"
"time"
)

// EnsurePrivateDir creates dirPath (and parents) with 0700 permissions.
func EnsurePrivateDir(dirPath string) error {
if dirPath == "" {
return fmt.Errorf("securefile: dir path is empty")
}
if err := os.MkdirAll(dirPath, 0o700); err != nil {
return err
}
// Best-effort permission hardening. Ignore errors (e.g., non-POSIX FS).
_ = os.Chmod(dirPath, 0o700)
return nil
}

// AtomicWriteFile writes data to path using a temp file + rename, and attempts to fsync.
// mode controls the final file permissions.
func AtomicWriteFile(path string, data []byte, mode os.FileMode) error {
if path == "" {
return fmt.Errorf("securefile: path is empty")
}
dir := filepath.Dir(path)
if err := EnsurePrivateDir(dir); err != nil {
return err
}

tmp, err := os.CreateTemp(dir, ".tmp.*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}()

if mode == 0 {
mode = 0o600
}
if err := tmp.Chmod(mode); err != nil {
// Best-effort: ignore chmod failure on some filesystems.
}

if _, err := tmp.Write(data); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}

if err := os.Rename(tmpName, path); err != nil {
return err
}

// Best-effort: ensure final mode.
_ = os.Chmod(path, mode)
return nil
}

// ReadFileRawLocked reads the file at path while holding an advisory lock on path+".lock".
func ReadFileRawLocked(path string) ([]byte, error) {
lockPath := path + ".lock"
var out []byte
err := WithLock(lockPath, 10*time.Second, func() error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
out = data
return nil
})
if err != nil {
return nil, err
}
return out, nil
}

// WriteFileRawLocked writes data to path using an advisory lock on path+".lock" and atomic replace.
func WriteFileRawLocked(path string, data []byte, mode os.FileMode) error {
lockPath := path + ".lock"
return WithLock(lockPath, 10*time.Second, func() error {
return AtomicWriteFile(path, data, mode)
})
}
201 changes: 201 additions & 0 deletions internal/securefile/authjson.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package securefile

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

const (
authEnvelopeVersion = 1
authEnvelopeAlgAES = "aes-256-gcm"
)

type authEnvelope struct {
V int `json:"v"`
Alg string `json:"alg"`
Nonce string `json:"nonce"`
Ct string `json:"ct"`
}

// deriveKey returns a 32-byte key derived from the provided secret. If the secret is base64
// for exactly 32 bytes, it is used directly; otherwise SHA-256(secret) is used.
func deriveKey(secret string) ([]byte, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return nil, fmt.Errorf("securefile: encryption secret is empty")
}
if decoded, err := base64.StdEncoding.DecodeString(secret); err == nil && len(decoded) == 32 {
return decoded, nil
}
sum := sha256.Sum256([]byte(secret))
return sum[:], nil
}

func encryptBytes(plaintext []byte, secret string) ([]byte, error) {
key, err := deriveKey(secret)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
ct := gcm.Seal(nil, nonce, plaintext, nil)
env := authEnvelope{
V: authEnvelopeVersion,
Alg: authEnvelopeAlgAES,
Nonce: base64.StdEncoding.EncodeToString(nonce),
Ct: base64.StdEncoding.EncodeToString(ct),
}
return json.Marshal(env)
}

func decryptBytes(envelopeBytes []byte, secret string) ([]byte, error) {
var env authEnvelope
if err := json.Unmarshal(envelopeBytes, &env); err != nil {
return nil, fmt.Errorf("securefile: invalid encrypted envelope: %w", err)
}
if env.V != authEnvelopeVersion || strings.TrimSpace(env.Alg) != authEnvelopeAlgAES {
return nil, fmt.Errorf("securefile: unsupported envelope (v=%d alg=%s)", env.V, env.Alg)
}
key, err := deriveKey(secret)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce, err := base64.StdEncoding.DecodeString(env.Nonce)
if err != nil {
return nil, fmt.Errorf("securefile: invalid nonce encoding: %w", err)
}
ct, err := base64.StdEncoding.DecodeString(env.Ct)
if err != nil {
return nil, fmt.Errorf("securefile: invalid ciphertext encoding: %w", err)
}
plaintext, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("securefile: decrypt failed: %w", err)
}
return plaintext, nil
}

func looksEncryptedEnvelope(raw []byte) bool {
var probe map[string]any
if err := json.Unmarshal(raw, &probe); err != nil {
return false
}
_, hasV := probe["v"]
_, hasAlg := probe["alg"]
_, hasCt := probe["ct"]
return hasV && hasAlg && hasCt
}

// DecodeAuthJSON returns decrypted JSON bytes if raw is an encrypted envelope; otherwise returns raw.
// It returns (plaintext, wasEncrypted, error).
func DecodeAuthJSON(raw []byte, settings AuthEncryptionSettings) ([]byte, bool, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" {
return raw, false, nil
}
if !looksEncryptedEnvelope(raw) {
return raw, false, nil
}
secret := ResolveAuthEncryptionSecret(settings.Secret)
if strings.TrimSpace(secret) == "" {
return nil, true, fmt.Errorf("securefile: auth file is encrypted but no encryption key is configured")
}
plaintext, err := decryptBytes(raw, secret)
if err != nil {
return nil, true, err
}
return plaintext, true, nil
}

func writeAuthJSONFileUnlocked(path string, jsonBytes []byte, settings AuthEncryptionSettings) error {
payload := jsonBytes
if settings.Enabled {
secret := ResolveAuthEncryptionSecret(settings.Secret)
if strings.TrimSpace(secret) == "" {
return fmt.Errorf("securefile: auth encryption enabled but no encryption key configured")
}
enc, err := encryptBytes(jsonBytes, secret)
if err != nil {
return err
}
payload = enc
}
if err := EnsurePrivateDir(filepath.Dir(path)); err != nil {
return err
}
return AtomicWriteFile(path, payload, 0o600)
}

// ReadAuthJSONFile reads path, locking path+".lock", and returns decrypted JSON when needed.
func ReadAuthJSONFile(path string) ([]byte, bool, error) {
settings := CurrentAuthEncryption()
lockPath := path + ".lock"
var (
out []byte
encrypted bool
readErr error
)
err := WithLock(lockPath, 10*time.Second, func() error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
plaintext, wasEncrypted, err := DecodeAuthJSON(raw, settings)
if err != nil {
return err
}
out = plaintext
encrypted = wasEncrypted
// Best-effort migration: if encryption is enabled and we read plaintext, re-save encrypted.
if settings.Enabled && !wasEncrypted && settings.AllowPlaintextFallback {
if err := writeAuthJSONFileUnlocked(path, plaintext, settings); err != nil {
// ignore; caller still gets plaintext content
}
}
return nil
})
if err != nil {
readErr = err
}
return out, encrypted, readErr
}

// WriteAuthJSONFile writes jsonBytes to path with 0600 perms, using lock + atomic write.
// If auth encryption is enabled, it stores an encrypted envelope.
func WriteAuthJSONFile(path string, jsonBytes []byte) error {
if path == "" {
return fmt.Errorf("securefile: path is empty")
}
settings := CurrentAuthEncryption()
lockPath := path + ".lock"
return WithLock(lockPath, 10*time.Second, func() error {
return writeAuthJSONFileUnlocked(path, jsonBytes, settings)
})
}
8 changes: 8 additions & 0 deletions internal/securefile/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package securefile

// LoadError captures best-effort load failures (parse/decrypt/read) when scanning auth stores.
type LoadError struct {
Path string `json:"path"`
ErrorType string `json:"error_type"`
Message string `json:"message"`
}
Loading