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
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,28 @@ jobs:
slug: Cloudstic/cli
files: unit.out,e2e_coverage.out
token: ${{ secrets.CODECOV_TOKEN }}

verify-platform-build-paths:
name: Verify platform build paths (${{ matrix.name }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- name: macOS
os: macos-latest
run: go test ./internal/secretref ./cmd/cloudstic
- name: Windows
os: windows-latest
run: go test ./internal/secretref ./cmd/cloudstic
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run platform-specific verification
run: ${{ matrix.run }}

build:
name: Build
runs-on: ubuntu-latest
Expand Down
29 changes: 26 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,34 @@ jobs:
go-version-file: go.mod
- run: go test -race -count=1 ./...

verify-platform-build-paths:
name: Verify platform build paths (${{ matrix.name }})
needs: test
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- name: macOS
os: macos-latest
run: go test ./internal/secretref ./cmd/cloudstic
- name: Windows
os: windows-latest
run: go test ./internal/secretref ./cmd/cloudstic
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run platform-specific verification
run: ${{ matrix.run }}

release:
name: Release
needs: test
needs:
- test
- verify-platform-build-paths
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -34,8 +59,6 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify macOS build paths
run: go test ./internal/secretref ./cmd/cloudstic
- name: Install Syft
uses: anchore/sbom-action/download-syft@v0
- uses: goreleaser/goreleaser-action@v6
Expand Down
1 change: 1 addition & 0 deletions internal/secretref/secretref.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func NewDefaultResolver() *Resolver {
return NewResolver(map[string]Backend{
"env": NewEnvBackend(nil),
"keychain": NewKeychainBackend(),
"wincred": NewWincredBackend(),
})
}

Expand Down
1 change: 1 addition & 0 deletions internal/secretref/secretref_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func TestParse(t *testing.T) {
}{
{name: "env", in: "env://CLOUDSTIC_PASSWORD", scheme: "env", path: "CLOUDSTIC_PASSWORD"},
{name: "mixed case scheme", in: "KeyChain://service/account", scheme: "keychain", path: "service/account"},
{name: "wincred", in: "WinCred://cloudstic/store/prod/password", scheme: "wincred", path: "cloudstic/store/prod/password"},
{name: "empty", in: "", wantErr: true},
{name: "missing separator", in: "env:CLOUDSTIC_PASSWORD", wantErr: true},
{name: "empty path", in: "env://", wantErr: true},
Expand Down
63 changes: 63 additions & 0 deletions internal/secretref/wincred_backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package secretref

import (
"context"
"errors"
"fmt"
"strings"
)

var (
errWincredNotFound = errors.New("windows credential not found")
errWincredUnavailable = errors.New("windows credential backend unavailable")
)

type wincredLookupFunc func(ctx context.Context, target string) (string, error)

// WincredBackend resolves wincred://target references.
type WincredBackend struct {
lookup wincredLookupFunc
}

// NewWincredBackend creates a Windows Credential Manager backend for the
// current platform.
func NewWincredBackend() *WincredBackend {
return &WincredBackend{lookup: defaultWincredLookup}
}

func newWincredBackendWithLookup(lookup wincredLookupFunc) *WincredBackend {
if lookup == nil {
lookup = defaultWincredLookup
}
return &WincredBackend{lookup: lookup}
}

func parseWincredTarget(path string) (string, error) {
target := strings.TrimSpace(path)
target = strings.TrimPrefix(target, "/")
if target == "" {
return "", errors.New("expected wincred://<target>")
}
return target, nil
}

func (b *WincredBackend) Resolve(ctx context.Context, ref Ref) (string, error) {
target, err := parseWincredTarget(ref.Path)
if err != nil {
return "", errorf(KindInvalidRef, ref.Raw, err.Error(), nil)
}

value, err := b.lookup(ctx, target)
if err != nil {
switch {
case errors.Is(err, errWincredNotFound):
return "", errorf(KindNotFound, ref.Raw, fmt.Sprintf("windows credential %q not found", target), err)
case errors.Is(err, errWincredUnavailable):
return "", errorf(KindBackendUnavailable, ref.Raw, err.Error(), err)
default:
return "", errorf(KindBackendUnavailable, ref.Raw, err.Error(), err)
}
}

return value, nil
}
90 changes: 90 additions & 0 deletions internal/secretref/wincred_backend_integration_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//go:build windows

package secretref

import (
"context"
"errors"
"fmt"
"testing"
"time"
"unsafe"

"golang.org/x/sys/windows"
)

const credPersistSession = 1

var (
procCredWriteW = advapi32DLL.NewProc("CredWriteW")
procCredDeleteW = advapi32DLL.NewProc("CredDeleteW")
)

func TestWincredBackendIntegration(t *testing.T) {
target := fmt.Sprintf("cloudstic-test-%d", time.Now().UnixNano())
secret := "cloudstic-test-secret"

if err := writeTestGenericCredential(target, secret); err != nil {
if errors.Is(err, windows.ERROR_NO_SUCH_LOGON_SESSION) {
t.Skipf("Credential Manager unavailable in this logon session: %v", err)
}
t.Fatalf("write test credential: %v", err)
}
t.Cleanup(func() {
_ = deleteTestGenericCredential(target)
})

b := NewWincredBackend()
got, err := b.Resolve(context.Background(), Ref{
Raw: "wincred://" + target,
Scheme: "wincred",
Path: target,
})
if err != nil {
var refErr *Error
if errors.As(err, &refErr) && refErr.Kind == KindBackendUnavailable {
t.Skipf("wincred backend unavailable: %v", err)
}
t.Fatalf("Resolve: %v", err)
}
if got != secret {
t.Fatalf("Resolve: got %q want %q", got, secret)
}
}

func writeTestGenericCredential(target, secret string) error {
targetPtr, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
blob := []byte(secret)
cred := windowsCredential{
Type: credTypeGeneric,
TargetName: targetPtr,
CredentialBlobSize: uint32(len(blob)),
Persist: credPersistSession,
}
if len(blob) > 0 {
cred.CredentialBlob = &blob[0]
}
r1, _, callErr := procCredWriteW.Call(uintptr(unsafe.Pointer(&cred)), 0)
if r1 == 0 {
return callErr
}
return nil
}

func deleteTestGenericCredential(target string) error {
targetPtr, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
r1, _, callErr := procCredDeleteW.Call(uintptr(unsafe.Pointer(targetPtr)), uintptr(credTypeGeneric), 0)
if r1 == 0 {
if errors.Is(callErr, windows.ERROR_NOT_FOUND) {
return nil
}
return callErr
}
return nil
}
12 changes: 12 additions & 0 deletions internal/secretref/wincred_backend_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !windows

package secretref

import (
"context"
"fmt"
)

func defaultWincredLookup(_ context.Context, _ string) (string, error) {
return "", fmt.Errorf("%w: windows credential backend is only available on Windows", errWincredUnavailable)
}
107 changes: 107 additions & 0 deletions internal/secretref/wincred_backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package secretref

import (
"context"
"errors"
"testing"
)

func TestParseWincredTarget(t *testing.T) {
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "basic", path: "cloudstic/store/prod/password", want: "cloudstic/store/prod/password"},
{name: "leading slash", path: "/cloudstic/store/prod/password", want: "cloudstic/store/prod/password"},
{name: "trim spaces", path: " cloudstic/store/prod/password ", want: "cloudstic/store/prod/password"},
{name: "empty", path: "", wantErr: true},
{name: "slash only", path: "/", wantErr: true},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseWincredTarget(tc.path)
if tc.wantErr {
if err == nil {
t.Fatalf("parseWincredTarget(%q): expected error", tc.path)
}
return
}
if err != nil {
t.Fatalf("parseWincredTarget(%q): %v", tc.path, err)
}
if got != tc.want {
t.Fatalf("parseWincredTarget(%q): got %q want %q", tc.path, got, tc.want)
}
})
}
}

func TestWincredBackendResolve(t *testing.T) {
b := newWincredBackendWithLookup(func(_ context.Context, target string) (string, error) {
if target != "cloudstic/store/prod/password" {
t.Fatalf("unexpected lookup target %q", target)
}
return "s3cr3t", nil
})

got, err := b.Resolve(context.Background(), Ref{Raw: "wincred://cloudstic/store/prod/password", Scheme: "wincred", Path: "cloudstic/store/prod/password"})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if got != "s3cr3t" {
t.Fatalf("Resolve: got %q want s3cr3t", got)
}
}

func TestWincredBackendResolveErrors(t *testing.T) {
tests := []struct {
name string
ref Ref
err error
kind ErrorKind
}{
{
name: "invalid path",
ref: Ref{Raw: "wincred://", Scheme: "wincred", Path: ""},
kind: KindInvalidRef,
},
{
name: "not found",
ref: Ref{Raw: "wincred://cloudstic/store/prod/password", Scheme: "wincred", Path: "cloudstic/store/prod/password"},
err: errWincredNotFound,
kind: KindNotFound,
},
{
name: "unavailable",
ref: Ref{Raw: "wincred://cloudstic/store/prod/password", Scheme: "wincred", Path: "cloudstic/store/prod/password"},
err: errWincredUnavailable,
kind: KindBackendUnavailable,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
b := newWincredBackendWithLookup(func(context.Context, string) (string, error) {
if tc.err == nil {
return "", nil
}
return "", tc.err
})

_, err := b.Resolve(context.Background(), tc.ref)
if err == nil {
t.Fatal("expected error")
}
var refErr *Error
if !errors.As(err, &refErr) {
t.Fatalf("expected *Error, got %T", err)
}
if refErr.Kind != tc.kind {
t.Fatalf("kind=%s want=%s", refErr.Kind, tc.kind)
}
})
}
}
Loading
Loading