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
11 changes: 4 additions & 7 deletions cmd/dbl/browser_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func remoteLoginEndpoint(endpoint string) bool {
return err != nil || net.ParseIP(u.Hostname()) == nil || !net.ParseIP(u.Hostname()).IsLoopback()
}

func browserLoginCommand(ctx context.Context, c *client.Client, profile connections.Profile, options globalOptions, scopes []string, noBrowser bool, stdout, stderr io.Writer) int {
func browserLoginCommand(ctx context.Context, c *client.Client, profile connections.Profile, options globalOptions, scopes []string, noBrowser bool, storage string, stdout, stderr io.Writer) int {
for i := range scopes {
scopes[i] = strings.TrimSpace(scopes[i])
}
Expand Down Expand Up @@ -93,15 +93,12 @@ func browserLoginCommand(ctx context.Context, c *client.Client, profile connecti
_ = authenticated.Logout(cleanup)
return reportFailure(ctx, "dbl login", stderr, errors.New("browser login cancelled; run dbl login again"))
}
if err = connections.SaveCredential(options.ConfigPath, profile.Name, connections.Credential{Endpoint: profile.Endpoint, Token: credential.Token, ExpiresAt: credential.ExpiresAt, AccountID: credential.ID}); err != nil {
cleanup, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cleanupCancel()
_ = authenticated.Logout(cleanup)
if err = saveLoginCredential(waiting, c, options.ConfigPath, profile.Name, connections.Credential{Endpoint: profile.Endpoint, Token: credential.Token, ExpiresAt: credential.ExpiresAt, AccountID: credential.ID}, storage, stderr); err != nil {
return reportFailure(ctx, "dbl login: store credential", stderr, err)
}
complete = true
return emitReported(ctx, "dbl login", options.Output, stdout, stderr, map[string]any{"dispatcher": profile.Name, "endpoint": profile.Endpoint, "account_id": credential.ID, "expires_at": credential.ExpiresAt, "scopes": credential.Scopes, "credential_store": "owner-only file"}, func(w io.Writer) error {
_, err := fmt.Fprintf(w, "Logged in to %s. Credential saved in the owner-only local credential file; expires %s.\n", profile.Name, time.Unix(credential.ExpiresAt, 0).UTC().Format(time.RFC3339))
return emitReported(ctx, "dbl login", options.Output, stdout, stderr, map[string]any{"dispatcher": profile.Name, "endpoint": profile.Endpoint, "account_id": credential.ID, "expires_at": credential.ExpiresAt, "scopes": credential.Scopes, "credential_store": storage}, func(w io.Writer) error {
_, err := fmt.Fprintf(w, "Logged in to %s. Credential store: %s; expires %s.\n", profile.Name, credentialStorageLabel(storage), time.Unix(credential.ExpiresAt, 0).UTC().Format(time.RFC3339))
return err
})
case "access_denied":
Expand Down
91 changes: 49 additions & 42 deletions cmd/dbl/browser_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,46 +17,53 @@ import (
)

func TestBrowserLoginHeadlessStoresOnlyLocalCredential(t *testing.T) {
const accountID = "c4d022cb-5bdf-47f9-ab0f-314bb8f0c7c2"
const deviceSecret = "dbd_selector.DEVICE-SECRET"
const accessSecret = "dbt_selector.ACCESS-SECRET"
config := filepath.Join(t.TempDir(), "config.json")
mux := http.NewServeMux()
var endpoint string
scopes := []string{"account:read", "executors:read", "measurements:read", "measurements:write"}
mux.HandleFunc("POST /auth/device/start", func(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, 201, client.DeviceLogin{DeviceCode: deviceSecret, UserCode: "ABCDE-F2345", VerificationURI: endpoint + "/device", Audience: endpoint, Scopes: scopes, ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), Interval: 5})
})
mux.HandleFunc("POST /auth/device/poll", func(w http.ResponseWriter, r *http.Request) {
var req struct {
DeviceCode string `json:"device_code"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.DeviceCode != deviceSecret {
t.Error("poll secret missing")
}
writeJSONResponse(w, 200, client.DeviceLoginPoll{State: "authorized", Credential: &client.APICredential{Token: accessSecret, CredentialID: "selector", Audience: endpoint, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour).Unix(), ID: accountID, Name: "Alice", Role: "user"}})
})
fx := newFixture(t, mux)
endpoint = fx.endpoint()
if err := connections.Save(config, connections.Profile{Name: "saved", Endpoint: endpoint}, true); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
code, out, errout := runCLI(ctx, "--config", config, "--output", outputJSON, "login", "--no-browser")
assertCode(t, code, exitOK, out, errout)
if !strings.Contains(errout, "ABCDE-F2345") || !strings.Contains(errout, endpoint+"/device") {
t.Fatal("headless instructions missing")
}
for _, secret := range []string{deviceSecret, accessSecret} {
if strings.Contains(out+errout, secret) {
t.Fatal("credential in CLI output")
}
}
saved, err := connections.CredentialFor(config, "saved", endpoint)
if err != nil || saved.Token != accessSecret || saved.AccountID != accountID {
t.Fatalf("saved credential %v", err)
for _, storage := range []string{"file", "system"} {
t.Run(storage, func(t *testing.T) {
if storage == "system" {
cliSecretToolFixture(t)
}
const accountID = "c4d022cb-5bdf-47f9-ab0f-314bb8f0c7c2"
const deviceSecret = "dbd_selector.DEVICE-SECRET"
const accessSecret = "dbt_selector.ACCESS-SECRET"
config := filepath.Join(t.TempDir(), "config.json")
mux := http.NewServeMux()
var endpoint string
scopes := []string{"account:read", "executors:read", "measurements:read", "measurements:write"}
mux.HandleFunc("POST /auth/device/start", func(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, 201, client.DeviceLogin{DeviceCode: deviceSecret, UserCode: "ABCDE-F2345", VerificationURI: endpoint + "/device", Audience: endpoint, Scopes: scopes, ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), Interval: 5})
})
mux.HandleFunc("POST /auth/device/poll", func(w http.ResponseWriter, r *http.Request) {
var req struct {
DeviceCode string `json:"device_code"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.DeviceCode != deviceSecret {
t.Error("poll secret missing")
}
writeJSONResponse(w, 200, client.DeviceLoginPoll{State: "authorized", Credential: &client.APICredential{Token: accessSecret, CredentialID: "selector", Audience: endpoint, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour).Unix(), ID: accountID, Name: "Alice", Role: "user"}})
})
fx := newFixture(t, mux)
endpoint = fx.endpoint()
if err := connections.Save(config, connections.Profile{Name: "saved", Endpoint: endpoint}, true); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
code, out, errout := runCLI(ctx, "--config", config, "--output", outputJSON, "login", "--no-browser", "--credential-store", storage)
assertCode(t, code, exitOK, out, errout)
if !strings.Contains(errout, "ABCDE-F2345") || !strings.Contains(errout, endpoint+"/device") {
t.Fatal("headless instructions missing")
}
for _, secret := range []string{deviceSecret, accessSecret} {
if strings.Contains(out+errout, secret) {
t.Fatal("credential in CLI output")
}
}
saved, err := connections.CredentialFor(t.Context(), config, "saved", endpoint)
if err != nil || saved.Token != accessSecret || saved.AccountID != accountID {
t.Fatalf("saved credential %v", err)
}
})
}
}

Expand All @@ -82,7 +89,7 @@ func TestBrowserLoginInterruptionCancelsTransaction(t *testing.T) {
if err != nil {
t.Fatal(err)
}
code := browserLoginCommand(ctx, c, connections.Profile{Name: "saved", Endpoint: endpoint}, options, []string{"account:read", "executors:read", "measurements:read", "measurements:write"}, true, cancelLoginWriter{cancel}, &strings.Builder{})
code := browserLoginCommand(ctx, c, connections.Profile{Name: "saved", Endpoint: endpoint}, options, []string{"account:read", "executors:read", "measurements:read", "measurements:write"}, true, "file", cancelLoginWriter{cancel}, &strings.Builder{})
if code == exitOK {
t.Fatal("interrupted login succeeded")
}
Expand All @@ -91,7 +98,7 @@ func TestBrowserLoginInterruptionCancelsTransaction(t *testing.T) {
default:
t.Fatal("transaction not cancelled")
}
saved, err := connections.CredentialFor(config, "saved", endpoint)
saved, err := connections.CredentialFor(t.Context(), config, "saved", endpoint)
if err != nil || saved.Token != "" {
t.Fatal("interruption saved a credential")
}
Expand Down
11 changes: 5 additions & 6 deletions cmd/dbl/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,14 +252,14 @@ func reportFailure(ctx context.Context, name string, stderr io.Writer, err error
// whole-command timeout, which the command context already enforces, and
// client.New only validates the operator's endpoint, so its failure is a usage
// error rather than a transport one.
func connect(name string, options globalOptions, allowRemoteTEST bool, stderr io.Writer) (*client.Client, int, bool) {
c, _, code, ok := connectProfile(name, options, allowRemoteTEST, stderr)
func connect(ctx context.Context, name string, options globalOptions, allowRemoteTEST bool, stderr io.Writer) (*client.Client, int, bool) {
c, _, code, ok := connectProfile(ctx, name, options, allowRemoteTEST, stderr)
return c, code, ok
}

// connectProfile is connect plus the saved profile it selected, for the
// commands that have to record or forget that profile's credential.
func connectProfile(name string, options globalOptions, allowRemoteTEST bool, stderr io.Writer) (*client.Client, connections.Profile, int, bool) {
func connectProfile(ctx context.Context, name string, options globalOptions, allowRemoteTEST bool, stderr io.Writer) (*client.Client, connections.Profile, int, bool) {
profile, err := selectedProfile(options)
if err != nil {
fmt.Fprintf(stderr, "%s: %v\n", name, err)
Expand All @@ -271,10 +271,9 @@ func connectProfile(name string, options globalOptions, allowRemoteTEST bool, st
// connection, so nothing is read and the client configuration directory is
// never located: dbl version --server against an explicit endpoint works
// where there is no configuration directory at all.
credential, err := connections.CredentialFor(options.ConfigPath, profile.Name, profile.Endpoint)
credential, err := connections.CredentialFor(ctx, options.ConfigPath, profile.Name, profile.Endpoint)
if err != nil {
fmt.Fprintf(stderr, "%s: %v\n", name, err)
return nil, profile, exitFailure, false
return nil, profile, reportFailure(ctx, name, stderr, err), false
}
c, err := newClient(profile.Endpoint, client.Options{
RequestTimeout: options.Timeout,
Expand Down
3 changes: 2 additions & 1 deletion cmd/dbl/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ import (
"github.com/netsec-ethz/debuglet/pkg/client"
)

// Never consult the account's real saved dispatcher while testing defaults.
// Never consult the account's real saved dispatcher or desktop keyring while testing defaults.
func TestMain(m *testing.M) {
_ = os.Unsetenv("DBUS_SESSION_BUS_ADDRESS")
dir, err := os.MkdirTemp("", "dbl-client-tests-*")
if err != nil {
os.Exit(1)
Expand Down
146 changes: 146 additions & 0 deletions cmd/dbl/credential_store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 ETH Zurich

package main

import (
"context"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/netsec-ethz/debuglet/internal/connections"
"github.com/netsec-ethz/debuglet/pkg/client"
)

func cliSecretToolFixture(t *testing.T) string {
t.Helper()
if runtime.GOOS != "linux" {
t.Skip("Secret Service is supported on Linux")
}
dir := t.TempDir()
tool := `#!/bin/sh
set -eu
case "$1" in
store) cat > "$CLI_SECRET_FIXTURE/item" ;;
lookup) cat "$CLI_SECRET_FIXTURE/item" ;;
clear) rm -f "$CLI_SECRET_FIXTURE/item" ;;
esac
`
if err := os.WriteFile(filepath.Join(dir, "secret-tool"), []byte(tool), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/test-owned-session")
t.Setenv("CLI_SECRET_FIXTURE", dir)
return dir
}

func TestLoginSystemStorageAndLogout(t *testing.T) {
dir := cliSecretToolFixture(t)
config := filepath.Join(t.TempDir(), "config.json")
revoked := false
mux := http.NewServeMux()
mux.HandleFunc("POST /auth/login", func(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, 200, client.Session{Token: fixSecret, Name: "Alice", Role: "user"})
})
mux.HandleFunc("POST /auth/logout", func(w http.ResponseWriter, r *http.Request) {
revoked = r.Header.Get("Authorization") == "Bearer "+fixSecret
w.WriteHeader(204)
})
fx := newFixture(t, mux)
if err := connections.Save(config, connections.Profile{Name: "saved", Endpoint: fx.endpoint()}, true); err != nil {
t.Fatal(err)
}
code, out, errout := runCLI(t.Context(), "--config", config, "--output", "json", "login", "--credential-store", "system")
assertCode(t, code, exitOK, out, errout)
if oneJSONDocument(t, out)["credential_store"] != "system" || strings.Contains(out+errout, fixSecret) {
t.Fatal("login output omitted backend or exposed secret")
}
file, _ := connections.CredentialPath(config)
data, _ := os.ReadFile(file)
if strings.Contains(string(data), fixSecret) {
t.Fatal("system credential copied to disk metadata")
}
code, out, errout = runCLI(t.Context(), "--config", config, "logout")
assertCode(t, code, exitOK, out, errout)
if !revoked {
t.Fatal("logout did not revoke system credential")
}
if _, err := os.Stat(filepath.Join(dir, "item")); !os.IsNotExist(err) {
t.Fatal("logout did not remove system credential")
}
}

func TestLoginRevokesCredentialWhenPersistenceFails(t *testing.T) {
dir := cliSecretToolFixture(t)
if err := os.WriteFile(filepath.Join(dir, "secret-tool"), []byte("#!/bin/sh\nprintf 'secret-error' >&2\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
config := filepath.Join(t.TempDir(), "config.json")
revoked := false
mux := http.NewServeMux()
mux.HandleFunc("POST /auth/login", func(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, 200, client.Session{Token: fixSecret, Name: "Alice", Role: "user"})
})
mux.HandleFunc("POST /auth/logout", func(w http.ResponseWriter, r *http.Request) {
revoked = r.Header.Get("Authorization") == "Bearer "+fixSecret
w.WriteHeader(204)
})
fx := newFixture(t, mux)
if err := connections.Save(config, connections.Profile{Name: "saved", Endpoint: fx.endpoint()}, true); err != nil {
t.Fatal(err)
}
code, out, errout := runCLI(t.Context(), "--config", config, "login", "--credential-store", "auto")
if code == exitOK || !revoked || strings.Contains(out+errout, "secret-error") || strings.Contains(out+errout, fixSecret) {
t.Fatalf("failed persistence was not safely revoked: code=%d revoked=%v", code, revoked)
}
credential, err := connections.CredentialFor(t.Context(), config, "saved", fx.endpoint())
if err != nil || credential.Token != "" {
t.Fatal("failed system write fell back to file")
}
}

func TestLogoutForgetsUnavailableSystemCredential(t *testing.T) {
dir := cliSecretToolFixture(t)
config := filepath.Join(t.TempDir(), "config.json")
const endpoint = "http://127.0.0.1:1"
if err := connections.Save(config, connections.Profile{Name: "saved", Endpoint: endpoint}, true); err != nil {
t.Fatal(err)
}
if _, err := connections.SaveCredentialWithStorage(t.Context(), config, "saved", connections.Credential{Endpoint: endpoint, Token: fixSecret}, "system"); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(dir, "item")); err != nil {
t.Fatal(err)
}
code, out, errout := runCLI(t.Context(), "--config", config, "logout")
if code == exitOK || !strings.Contains(errout, "revoke it from the console") {
t.Fatalf("unconfirmed revocation reported as success: %d %s %s", code, out, errout)
}
credential, err := connections.CredentialFor(t.Context(), config, "saved", endpoint)
if err != nil || credential.Token != "" {
t.Fatal("logout left stale system metadata")
}
}

func TestConnectionKeyringCancellationUsesCommandExitCode(t *testing.T) {
cliSecretToolFixture(t)
config := filepath.Join(t.TempDir(), "config.json")
const endpoint = "http://127.0.0.1:1"
if err := connections.Save(config, connections.Profile{Name: "saved", Endpoint: endpoint}, true); err != nil {
t.Fatal(err)
}
if _, err := connections.SaveCredentialWithStorage(t.Context(), config, "saved", connections.Credential{Endpoint: endpoint, Token: fixSecret}, "system"); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, code, ok := connect(ctx, "dbl nodes", globalOptions{ConfigPath: config}, false, &strings.Builder{})
if ok || code != 130 {
t.Fatalf("cancelled keyring lookup returned %d, want 130", code)
}
}
2 changes: 1 addition & 1 deletion cmd/dbl/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func dispatcherCheck(ctx context.Context, options globalOptions) doctorCheck {
check := doctorCheck{"dispatcher_connection", "failure", "the selected dispatcher could not be reached", "check the selected endpoint, service availability and TLS trust"}
// Connection errors may contain configured URLs; the report uses only
// fixed diagnostics, not arbitrary server or credential-store text.
c, _, ok := connect("dbl doctor", options, false, io.Discard)
c, _, ok := connect(ctx, "dbl doctor", options, false, io.Discard)
if !ok {
check.Detail, check.Next = "the saved connection could not be loaded", "select a valid saved connection with dbl dispatcher use NAME, or pass --endpoint URL"
return check
Expand Down
2 changes: 1 addition & 1 deletion cmd/dbl/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func exportCommand(ctx context.Context, args []string, options globalOptions, st
if msg != "" {
return usageError("dbl export", exportUsage, stderr, "%s", msg)
}
c, code, ok := connect("dbl export", options, false, stderr)
c, code, ok := connect(ctx, "dbl export", options, false, stderr)
if !ok {
return code
}
Expand Down
Loading