Skip to content
Merged
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
2 changes: 1 addition & 1 deletion cmd/cloudstic/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,5 @@ func (r *runner) promptSecret(label string) (string, error) {
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
return strings.TrimRight(string(b), "\r\n"), nil
}
20 changes: 17 additions & 3 deletions cmd/cloudstic/secret_store_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"strings"
)

var execCommandContext = exec.CommandContext

func saveSecretToNativeStore(ctx context.Context, service, account, value string) error {
cmd := execCommandContext(ctx, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", value)
cmd := execCommandContext(ctx, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w")
cmd.Stdin = strings.NewReader(value + "\n" + value + "\n")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
Expand All @@ -27,9 +30,20 @@ func saveSecretToNativeStore(ctx context.Context, service, account, value string
}

func nativeSecretExists(ctx context.Context, service, account string) (bool, error) {
cmd := execCommandContext(ctx, "security", "find-generic-password", "-s", service, "-a", account, "-w")
cmd := execCommandContext(ctx, "security", "find-generic-password", "-s", service, "-a", account)
cmd.Stdout = io.Discard
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return false, nil
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 44 {
return false, nil
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return false, fmt.Errorf("check secret in macOS keychain failed: %s", msg)
}
return true, nil
}
26 changes: 23 additions & 3 deletions cmd/cloudstic/secret_store_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestSaveSecretToNativeStore_Success(t *testing.T) {
if gotName != "security" {
t.Fatalf("command name=%q want security", gotName)
}
wantArgs := []string{"add-generic-password", "-U", "-s", "cloudstic/store/prod", "-a", "password", "-w", "super-secret"}
wantArgs := []string{"add-generic-password", "-U", "-s", "cloudstic/store/prod", "-a", "password", "-w"}
if !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("command args=%v want=%v", gotArgs, wantArgs)
}
Expand Down Expand Up @@ -77,7 +77,7 @@ func TestNativeSecretExists_Success(t *testing.T) {
if gotName != "security" {
t.Fatalf("command name=%q want security", gotName)
}
wantArgs := []string{"find-generic-password", "-s", "cloudstic/store/prod", "-a", "password", "-w"}
wantArgs := []string{"find-generic-password", "-s", "cloudstic/store/prod", "-a", "password"}
if !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("command args=%v want=%v", gotArgs, wantArgs)
}
Expand All @@ -88,7 +88,7 @@ func TestNativeSecretExists_NotFound(t *testing.T) {
defer func() { execCommandContext = orig }()

execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "exit 1")
return exec.CommandContext(ctx, "sh", "-c", "exit 44")
}

exists, err := nativeSecretExists(context.Background(), "svc", "acct")
Expand All @@ -99,3 +99,23 @@ func TestNativeSecretExists_NotFound(t *testing.T) {
t.Fatal("expected exists=false")
}
}

func TestNativeSecretExists_OtherError(t *testing.T) {
orig := execCommandContext
defer func() { execCommandContext = orig }()

execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "echo boom 1>&2; exit 2")
}

exists, err := nativeSecretExists(context.Background(), "svc", "acct")
if err == nil {
t.Fatal("expected error")
}
if exists {
t.Fatal("expected exists=false")
}
if !strings.Contains(err.Error(), "check secret in macOS keychain failed") {
t.Fatalf("unexpected error: %v", err)
}
}
16 changes: 8 additions & 8 deletions cmd/cloudstic/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,20 +173,20 @@ func printUsage() {
{"-s3-endpoint <url>", "S3-compatible endpoint URL"},
{"-s3-access-key <key>", "S3 static access key"},
{"-s3-secret-key <key>", "S3 static secret key"},
{"-s3-access-key-secret <ref>", "Secret reference for S3 access key (env://, keychain://, wincred://, secret-service://)"},
{"-s3-secret-key-secret <ref>", "Secret reference for S3 secret key (env://, keychain://, wincred://, secret-service://)"},
{"-s3-access-key-secret <ref>", "Secret reference for S3 access key (env://, keychain://)"},
{"-s3-secret-key-secret <ref>", "Secret reference for S3 secret key (env://, keychain://)"},
{"-s3-access-key-env <var>", "Env var name for S3 access key"},
{"-s3-secret-key-env <var>", "Env var name for S3 secret key"},
{"-s3-profile-env <var>", "Env var name for AWS profile"},
{"-store-sftp-password <pass>", "SFTP password"},
{"-store-sftp-key <path>", "Path to SFTP private key"},
{"-store-sftp-password-secret <ref>", "Secret reference for SFTP password (env://, keychain://, wincred://, secret-service://)"},
{"-store-sftp-key-secret <ref>", "Secret reference for SFTP key path (env://, keychain://, wincred://, secret-service://)"},
{"-store-sftp-password-secret <ref>", "Secret reference for SFTP password (env://, keychain://)"},
{"-store-sftp-key-secret <ref>", "Secret reference for SFTP key path (env://, keychain://)"},
{"-store-sftp-password-env <var>", "Env var name for SFTP password"},
{"-store-sftp-key-env <var>", "Env var name for SFTP key path"},
{"-password-secret <ref>", "Secret reference for repository password (env://, keychain://, wincred://, secret-service://)"},
{"-encryption-key-secret <ref>", "Secret reference for platform key (env://, keychain://, wincred://, secret-service://)"},
{"-recovery-key-secret <ref>", "Secret reference for recovery key mnemonic (env://, keychain://, wincred://, secret-service://)"},
{"-password-secret <ref>", "Secret reference for repository password (env://, keychain://)"},
{"-encryption-key-secret <ref>", "Secret reference for platform key (env://, keychain://)"},
{"-recovery-key-secret <ref>", "Secret reference for recovery key mnemonic (env://, keychain://)"},
{"-password-env <var>", "Env var name for repository password"},
{"-encryption-key-env <var>", "Env var name for platform key (hex)"},
{"-recovery-key-env <var>", "Env var name for recovery key mnemonic"},
Expand All @@ -197,7 +197,7 @@ func printUsage() {
})
t.Note(" Create or update a store entry in profiles.yaml.",
" Prefer secret refs: -password-secret / -encryption-key-secret / -recovery-key-secret.",
" Legacy -*-env flags are still supported and auto-converted to env:// refs on write.",
" Legacy *-env flags are still supported and auto-converted to env:// refs on write.",
" KMS settings are stored directly (ARN is not a secret).",
)
t.Blank()
Expand Down
5 changes: 3 additions & 2 deletions docs/encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,9 @@ reference schemes:

- `env://VAR_NAME`
- `keychain://service/account` (macOS)
- `wincred://target` (Windows)
- `secret-service://collection/item` (Linux)

`wincred://...` (Windows) and `secret-service://...` (Linux) are planned but not
yet available in the default CLI resolver.

Examples:

Expand Down
5 changes: 3 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,8 +567,9 @@ Prefer `*_secret` fields and secret refs in `profiles.yaml`. Supported schemes:

- `env://VAR_NAME`
- `keychain://service/account` (macOS)
- `wincred://target` (Windows)
- `secret-service://collection/item` (Linux)

`wincred://...` (Windows) and `secret-service://...` (Linux) are planned but not
yet available in the default CLI resolver.

Legacy `*_env` fields are still read for backward compatibility, but new writes
should use `*_secret`.
Expand Down
2 changes: 1 addition & 1 deletion internal/secretref/keychain_backend_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ func defaultKeychainLookup(ctx context.Context, service, account string) (string
}
return "", fmt.Errorf("security find-generic-password failed: %s", msg)
}
return strings.TrimSpace(string(out)), nil
return strings.TrimRight(string(out), "\r\n"), nil
}
10 changes: 10 additions & 0 deletions internal/secretref/keychain_backend_integration_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ package secretref

import (
"context"
"errors"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
)
Expand All @@ -22,6 +24,10 @@ func TestKeychainBackend_Integration(t *testing.T) {

add := exec.Command("security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", secret)
if out, err := add.CombinedOutput(); err != nil {
msg := strings.ToLower(strings.TrimSpace(string(out)))
if strings.Contains(msg, "user interaction is not allowed") || strings.Contains(msg, "interaction not allowed") || strings.Contains(msg, "not allowed") {
t.Skipf("keychain unavailable in this session: %s", strings.TrimSpace(string(out)))
}
t.Fatalf("add-generic-password failed: %v\n%s", err, out)
}
t.Cleanup(func() {
Expand All @@ -36,6 +42,10 @@ func TestKeychainBackend_Integration(t *testing.T) {
Path: service + "/" + account,
})
if err != nil {
var refErr *Error
if errors.As(err, &refErr) && refErr.Kind == KindBackendUnavailable {
t.Skipf("keychain backend unavailable: %v", err)
}
t.Fatalf("Resolve: %v", err)
}
if got != secret {
Expand Down
2 changes: 1 addition & 1 deletion internal/secretref/secretref.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func NewResolver(backends map[string]Backend) *Resolver {
return r
}

// NewDefaultResolver builds the baseline resolver with env:// support.
// NewDefaultResolver builds the baseline resolver with env:// and keychain:// support.
func NewDefaultResolver() *Resolver {
return NewResolver(map[string]Backend{
"env": NewEnvBackend(nil),
Expand Down
Loading