From 6c428f669adbc990727e227503898a4e9212852a Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Fri, 2 Oct 2026 13:31:45 +0200 Subject: [PATCH 1/4] Document authentication cutover and preserve unmapped ownership --- docs/operations/auth-migration.md | 143 ++++++++++++++++++ .../database/auth_migration_test.go | 65 ++++++++ 2 files changed, 208 insertions(+) create mode 100644 docs/operations/auth-migration.md create mode 100644 internal/dispatcher/database/auth_migration_test.go diff --git a/docs/operations/auth-migration.md b/docs/operations/auth-migration.md new file mode 100644 index 00000000..f523bbc4 --- /dev/null +++ b/docs/operations/auth-migration.md @@ -0,0 +1,143 @@ +# Authentication cutover for an existing dispatcher + +Current Debuglet rejects UUID cookies and UUID bearer tokens. There is no +transitional mode that accepts them alongside real credentials. A UUID or matching +email/display name cannot claim an account, executor or measurement. Keep +`server.local_development = false` on staging and production, including a dispatcher +behind a reverse proxy. The explicit loopback development profile is not a hosted +authentication mode. + +Use [browser accounts and CLI access](authentication.md) for provider setup and +ordinary sign-in. This procedure concerns existing state; it does not establish +that a particular deployment has completed the cutover. + +| Existing state | Result after upgrade | +| --- | --- | +| Account with a GitHub identity | The same provider subject reaches the same canonical account and its resources. | +| Account with an account key or recovery code | Its existing credential remains the login/recovery path; successful provider linking preserves its account ID. | +| User row from UUID-only authentication, with no verified identity or credential | The row and recorded ownership remain; access is withheld. A new provider login creates a separate account. | +| Run without recorded ownership | No account adopts it automatically. | +| Old UUID cookie | Refused; the user must sign in using a supported method. | +| Valid server-issued session | Preserved by the identity schema upgrade, subject to its original expiry and revocation state. | + +## Back up and rehearse offline + +Select a verified package with the current authentication checks. Retain its +version, checksum, configuration and an auth-enforcing rollback package. Do not +use a historical UUID-authentication binary as the rollback target. Read the +[database upgrade rules](configuration.md#upgrading-a-database) first: dispatcher +schemas below 3 require a destructive migration and need a separate export and +operator decision. Do not add `-accept-data-loss` to this procedure. + +Run the following on the dispatcher host. Substitute the actual service account, +configuration and state paths; the backup root must be private. These commands +assume the managed service is the only database writer. Stop other writers too. + +```sh +sudo systemctl stop debuglet-dispatcher +sudo systemctl is-active debuglet-dispatcher # must report inactive +sudo -u debuglet sh +set -eu +umask 077 +state_dir=/var/lib/debuglet/dispatcher +config_file=/etc/debuglet/dispatcher/dispatcher.toml +snapshot=/srv/debuglet-backups/before-auth-cutover +test ! -e "$snapshot" +mkdir -m 700 "$snapshot" +cp -a "$state_dir" "$snapshot/state" +cp "$config_file" "$snapshot/dispatcher.toml" +``` + +Back up referenced TLS material and provider secrets through the deployment's +secret-backup procedure too. Copy the complete stopped database directory, +including any SQLite companions. `dbl backup` covers its documented foreground +TEST layouts, not a complete hosted OAuth deployment. + +Create a separate rehearsal copy, leaving the snapshot untouched: + +```sh +rehearsal=/srv/debuglet-backups/auth-rehearsal +test ! -e "$rehearsal" +mkdir -m 700 "$rehearsal" +cp -a "$snapshot/state" "$rehearsal/state" +cp "$snapshot/dispatcher.toml" "$rehearsal/dispatcher.toml" +``` + +Edit the rehearsal configuration's `database.path` to name the copied database. +Verify that it does not name the live file. As the database owner, run the selected +new package's commands below; neither command starts a listener: + +```sh +if debuglet-dispatcher -config "$rehearsal/dispatcher.toml" -check-database; then + : # Already current. +else + test "$?" -eq 3 # Only the non-destructive upgrade-required status may proceed. +fi +debuglet-dispatcher -config "$rehearsal/dispatcher.toml" -upgrade-database +debuglet-dispatcher -config "$rehearsal/dispatcher.toml" -check-database +``` + +Using a read-only SQLite connection, compare user IDs, UUIDs and `debuglet_users` +ownership against the untouched snapshot. For schema 6 or later, also compare +`transaction_users`; for schema 13 or later, compare recorded executor ownership. +For schema 9 or later, check that each existing GitHub subject still maps to its +original user. Record accounts lacking either credentials or a provider mapping; +an unchanged row count alone does not prove that they can sign in. Keep this +inventory private. + +## Cut over and verify + +Keep the service stopped and public ingress in maintenance while installing the +tested package/configuration. Upgrade the live database explicitly, then start +the service: + +```sh +debuglet-dispatcher -config "$config_file" -upgrade-database +debuglet-dispatcher -config "$config_file" -check-database +exit +sudo systemctl start debuglet-dispatcher +sudo systemctl is-active debuglet-dispatcher +``` + +Tell users that old UUID cookies no longer authenticate and that new browser/CLI +sign-in is required. There is no legacy session table to migrate from UUID-only +authentication; do not turn UUIDs into session secrets. Existing genuine sessions +are different and need not be invalidated merely because the schema changed. + +Before reopening public ingress, use two designated test accounts to check that +each sees its original account ID, executors and measurements, and cannot read +the other's private resources. Confirm unauthenticated requests to protected +routes fail and the local bootstrap login is unavailable. Verify the configured +provider callback and a CLI browser-approved login. A new blank account with a +similar display name is not a successful migration. + +## Unmapped accounts and recovery + +Keep unmapped accounts and their ownership records intact. Ask the operator to +independently verify ownership through the deployment's support process; a UUID, +email address or knowledge of a measurement is not proof. There is currently no +product command for an administrator to claim or attach these historical +accounts. Until a reviewed recovery procedure exists, their access stays withheld. +Do not rewrite ownership rows, merge accounts by display name, or enable the local +development bypass to get past the problem. + +For an account that already has a recovery code, use the separate +[account recovery procedure](../cli.md#recover-a-lost-account-key). It preserves +that account's ownership while replacing its credentials and revoking sessions. +It cannot recover an unmapped account that never had such a code. + +## Rollback without restoring UUID authentication + +Stop the service and keep ingress closed. Preserve the failed state for diagnosis; +restore the untouched snapshot into a fresh private directory using +`cp -a "$snapshot/state" /path/to/fresh-rollback-state`. Point a copied configuration +at that directory. With the chosen auth-enforcing rollback package, run +`debuglet-dispatcher -config /path/to/rollback.toml -check-database`; if it requires +an upgrade, rehearse its documented explicit upgrade on another snapshot copy. +Never run an older binary against an unsupported newer schema. + +Restart only after the same account-isolation and sign-in checks pass. If the only +binary compatible with the old snapshot accepts UUID authentication, leave the +service in maintenance and restore forward using an auth-enforcing package. +Retain the snapshot, package identities and verification results until recovery is +confirmed. Do not reopen an insecure historical version to restore availability. diff --git a/internal/dispatcher/database/auth_migration_test.go b/internal/dispatcher/database/auth_migration_test.go new file mode 100644 index 00000000..d693c480 --- /dev/null +++ b/internal/dispatcher/database/auth_migration_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package database_test + +import ( + "database/sql" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/netsec-ethz/debuglet/internal/dispatcher/database" + "github.com/netsec-ethz/debuglet/internal/sqlitedb" +) + +func TestAuthMigrationPreservesUnmappedOwnershipWithoutCreatingCredentials(t *testing.T) { + ctx, db := cbOpen(t, 5) + q := database.New(db) + var owners [2]struct { + id int64 + uuid uuid.UUID + run uuid.UUID + } + for i := range owners { + owner := &owners[i] + owner.uuid = uuid.New() + if err := db.QueryRowContext(ctx, "INSERT INTO users (uuid,name) VALUES (?,?) RETURNING id", owner.uuid, "same display name").Scan(&owner.id); err != nil { + t.Fatal(err) + } + owner.run = cbCreate(t, ctx, q, "", "").Uuid + if err := q.InsertDebugletUser(ctx, database.InsertDebugletUserParams{DebUuid: owner.run, UserUuid: owner.uuid}); err != nil { + t.Fatal(err) + } + } + unowned := cbCreate(t, ctx, q, "", "") + if _, err := sqlitedb.Migrate(ctx, db, database.MigrationFS(), sqlitedb.Latest); err != nil { + t.Fatal(err) + } + for _, owner := range owners { + user, err := q.GetUserByUUID(ctx, owner.uuid) + if err != nil || user.ID != owner.id || user.Uuid != owner.uuid || user.Name != "same display name" || user.Role != "user" { + t.Fatalf("legacy account changed: %+v: %v", user, err) + } + got, err := q.GetDebugletOwnerUUID(ctx, owner.run) + if err != nil || got != owner.uuid { + t.Fatalf("run owner = %s, want %s: %v", got, owner.uuid, err) + } + runs, err := q.ListDebugletsByUserUUID(ctx, database.ListDebugletsByUserUUIDParams{Uuid: owner.uuid, Limit: 10}) + if err != nil || len(runs) != 1 || runs[0].Uuid != owner.run { + t.Fatalf("legacy ownership was lost or joined by display name: %+v: %v", runs, err) + } + } + if _, err := q.GetDebugletOwnerUUID(ctx, unowned.Uuid); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("unowned run acquired an owner: %v", err) + } + for _, table := range []string{"user_credentials", "sessions", "oauth_identities"} { + var count int + if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("migration invented %d %s rows for unmapped accounts", count, table) + } + } +} From ef235caa7f8829e197f21a18c2c7a169a8237cdc Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Fri, 2 Oct 2026 13:36:28 +0200 Subject: [PATCH 2/4] Create the private backup root before authentication cutover --- docs/operations/auth-migration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/operations/auth-migration.md b/docs/operations/auth-migration.md index f523bbc4..03c7d570 100644 --- a/docs/operations/auth-migration.md +++ b/docs/operations/auth-migration.md @@ -30,12 +30,13 @@ schemas below 3 require a destructive migration and need a separate export and operator decision. Do not add `-accept-data-loss` to this procedure. Run the following on the dispatcher host. Substitute the actual service account, -configuration and state paths; the backup root must be private. These commands +group, configuration and state paths; the backup root must be private. These commands assume the managed service is the only database writer. Stop other writers too. ```sh sudo systemctl stop debuglet-dispatcher sudo systemctl is-active debuglet-dispatcher # must report inactive +sudo install -d -m 700 -o debuglet -g debuglet /srv/debuglet-backups sudo -u debuglet sh set -eu umask 077 From 6259e3508d98c12714fd7d1b7694f69259719026 Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Fri, 2 Oct 2026 13:51:56 +0200 Subject: [PATCH 3/4] Add Linux Secret Service storage for CLI credentials --- cmd/dbl/browser_login.go | 11 +- cmd/dbl/browser_login_test.go | 91 ++++----- cmd/dbl/cli.go | 11 +- cmd/dbl/cli_test.go | 3 +- cmd/dbl/credential_store_test.go | 146 +++++++++++++++ cmd/dbl/doctor.go | 2 +- cmd/dbl/export.go | 2 +- cmd/dbl/login.go | 72 +++++-- cmd/dbl/login_test.go | 4 +- cmd/dbl/logs.go | 2 +- cmd/dbl/query.go | 8 +- cmd/dbl/recovery.go | 2 +- cmd/dbl/rendezvous.go | 2 +- cmd/dbl/run.go | 2 +- cmd/dbl/whoami.go | 2 +- docs/cli.md | 38 +++- docs/operations/authentication.md | 31 ++- internal/connections/credentials.go | 59 ++++-- internal/connections/credentials_test.go | 12 +- internal/connections/secret_service.go | 197 ++++++++++++++++++++ internal/connections/secret_service_test.go | 193 +++++++++++++++++++ 21 files changed, 775 insertions(+), 115 deletions(-) create mode 100644 cmd/dbl/credential_store_test.go create mode 100644 internal/connections/secret_service.go create mode 100644 internal/connections/secret_service_test.go diff --git a/cmd/dbl/browser_login.go b/cmd/dbl/browser_login.go index c6b4b56b..57838af3 100644 --- a/cmd/dbl/browser_login.go +++ b/cmd/dbl/browser_login.go @@ -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]) } @@ -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": diff --git a/cmd/dbl/browser_login_test.go b/cmd/dbl/browser_login_test.go index 1518044c..504ed464 100644 --- a/cmd/dbl/browser_login_test.go +++ b/cmd/dbl/browser_login_test.go @@ -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) + } + }) } } @@ -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") } @@ -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") } diff --git a/cmd/dbl/cli.go b/cmd/dbl/cli.go index cf8eb449..77ddf6cf 100644 --- a/cmd/dbl/cli.go +++ b/cmd/dbl/cli.go @@ -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) @@ -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, diff --git a/cmd/dbl/cli_test.go b/cmd/dbl/cli_test.go index e4ec4363..9877f40e 100644 --- a/cmd/dbl/cli_test.go +++ b/cmd/dbl/cli_test.go @@ -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) diff --git a/cmd/dbl/credential_store_test.go b/cmd/dbl/credential_store_test.go new file mode 100644 index 00000000..a329d640 --- /dev/null +++ b/cmd/dbl/credential_store_test.go @@ -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) + } +} diff --git a/cmd/dbl/doctor.go b/cmd/dbl/doctor.go index 383939d8..59989c16 100644 --- a/cmd/dbl/doctor.go +++ b/cmd/dbl/doctor.go @@ -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 diff --git a/cmd/dbl/export.go b/cmd/dbl/export.go index 50b722ca..f8f4ac25 100644 --- a/cmd/dbl/export.go +++ b/cmd/dbl/export.go @@ -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 } diff --git a/cmd/dbl/login.go b/cmd/dbl/login.go index 376fdfd8..b2404097 100644 --- a/cmd/dbl/login.go +++ b/cmd/dbl/login.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/netsec-ethz/debuglet/internal/connections" "github.com/netsec-ethz/debuglet/pkg/client" @@ -17,8 +18,9 @@ import ( const loginUsage = `Usage: dbl [--dispatcher NAME] login [--account-key-file FILE] [--register NAME] - [--recovery-file FILE] + [--recovery-file FILE] [--credential-store auto|system|file] dbl [--dispatcher NAME] login --browser [--no-browser] [--scopes LIST] + [--credential-store auto|system|file] dbl [--dispatcher NAME] logout Obtain a session for the selected dispatcher and store it for that connection. @@ -37,9 +39,13 @@ yet: the account key to --account-key-file (default account-key-PROFILE.txt beside the connections file) and the recovery code to --recovery-file (default recovery-PROFILE.txt). Keep both; the dispatcher cannot show either again. -Only the session is stored, in credentials.json beside the connections file, -mode 0600, and it is only ever sent to the endpoint it was issued for. logout -revokes the session at the dispatcher and forgets it locally. +Only the session is stored. --credential-store auto (default) uses Linux Secret +Service when secret-tool and a desktop D-Bus session are available; otherwise it +uses credentials.json beside the connections file, mode 0600. An available but +locked or failing system store is an error, not a silent fallback. Use system to +require Secret Service, or file explicitly on headless hosts. Tokens are only +sent to the issuing endpoint. logout revokes the session and removes its local +file or system-store entry. ` // accountKeyEnv names the environment variable an account key may be supplied @@ -56,6 +62,7 @@ func loginCommand(ctx context.Context, args []string, options globalOptions, std recoveryFile := fs.String("recovery-file", "", "file the new account's recovery code is written to") browser := fs.Bool("browser", false, "approve this CLI in a browser") noBrowser := fs.Bool("no-browser", false, "print the browser approval URL without opening it") + storageMode := fs.String("credential-store", "auto", "credential storage: auto, system or file") scopes := fs.String("scopes", "account:read,executors:read,measurements:read,measurements:write", "comma-separated API scopes") if code, ok := parseCommandFlags(fs, args, loginUsage, stdout, stderr); !ok { return code @@ -76,6 +83,10 @@ func loginCommand(ctx context.Context, args []string, options globalOptions, std return usageError("dbl login", loginUsage, stderr, "browser approval cannot be combined with account-key login or registration") } + storage, err := connections.ResolveCredentialStorage(*storageMode) + if err != nil { + return usageError("dbl login", loginUsage, stderr, "%v", err) + } c, profile, code, ok := connectProfileWithoutCredential("dbl login", options, stderr) if !ok { return code @@ -91,7 +102,7 @@ func loginCommand(ctx context.Context, args []string, options globalOptions, std return reportFailure(ctx, "dbl login: read credential store", stderr, err) } if *browser || *noBrowser || scopesRequested || (*register == "" && *keyFile == "" && strings.TrimSpace(os.Getenv(accountKeyEnv)) == "" && remoteLoginEndpoint(profile.Endpoint)) { - return browserLoginCommand(ctx, c, profile, options, strings.Split(*scopes, ","), *noBrowser, stdout, stderr) + return browserLoginCommand(ctx, c, profile, options, strings.Split(*scopes, ","), *noBrowser, storage, stdout, stderr) } accountKey := "" @@ -148,27 +159,52 @@ func loginCommand(ctx context.Context, args []string, options globalOptions, std if err != nil { return reportFailure(ctx, "dbl login", stderr, loginHint(err)) } - if err := connections.SaveCredential(options.ConfigPath, profile.Name, connections.Credential{ + if err := saveLoginCredential(ctx, c, options.ConfigPath, profile.Name, connections.Credential{ Endpoint: profile.Endpoint, Token: session.Token, ExpiresAt: session.ExpiresAt, - }); err != nil { + }, storage, stderr); err != nil { return reportFailure(ctx, "dbl login: store credential", stderr, err) } - // Nothing printed here is a credential: the session token and the account - // key stay in the owner-only credential file. + // Neither the session token nor the account key is printed. return emitReported(ctx, "dbl login", options.Output, stdout, stderr, map[string]any{ - "dispatcher": profile.Name, - "endpoint": profile.Endpoint, - "account": session.Name, - "role": session.Role, - "expires_at": session.ExpiresAt, + "dispatcher": profile.Name, + "endpoint": profile.Endpoint, + "account": session.Name, + "role": session.Role, + "expires_at": session.ExpiresAt, + "credential_store": storage, }, func(w io.Writer) error { - _, err := fmt.Fprintf(w, "Logged in to %s (%s) as %s.\nTry: dbl executor list\n", profile.Name, profile.Endpoint, session.Name) + _, err := fmt.Fprintf(w, "Logged in to %s (%s) as %s.\nCredential store: %s.\nTry: dbl executor list\n", profile.Name, profile.Endpoint, session.Name, credentialStorageLabel(storage)) return err }) } +func saveLoginCredential(ctx context.Context, c *client.Client, path, name string, credential connections.Credential, storage string, stderr io.Writer) error { + result, err := connections.SaveCredentialWithStorage(ctx, path, name, credential, storage) + if err != nil { + if authenticated, clientErr := c.WithCredential(credential.Token); clientErr == nil { + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if revokeErr := authenticated.Logout(cleanup); revokeErr != nil && !alreadyUnauthenticated(revokeErr) { + return fmt.Errorf("%w; automatic revocation failed; revoke the new credential from the console Credentials page", err) + } + } + return err + } + if result.Warning != "" { + fmt.Fprintln(stderr, result.Warning) + } + return nil +} + +func credentialStorageLabel(storage string) string { + if storage == "system" { + return "Linux Secret Service" + } + return "owner-only file (mode 0600)" +} + func logoutCommand(ctx context.Context, args []string, options globalOptions, stdout, stderr io.Writer) int { fs := newCommandFlagSet("logout") if code, ok := parseCommandFlags(fs, args, loginUsage, stdout, stderr); !ok { @@ -185,10 +221,12 @@ func logoutCommand(ctx context.Context, args []string, options globalOptions, st return usageError("dbl logout", loginUsage, stderr, "logout ends the session of a saved connection; select one with --dispatcher NAME") } - credential, err := connections.CredentialFor(options.ConfigPath, profile.Name, profile.Endpoint) + credential, err := connections.CredentialFor(ctx, options.ConfigPath, profile.Name, profile.Endpoint) mismatched := errors.Is(err, connections.ErrCredentialEndpointMismatch) if err != nil && !mismatched { - return reportFailure(ctx, "dbl logout", stderr, err) + cleanupErr := connections.RemoveCredential(options.ConfigPath, profile.Name) + return reportFailure(ctx, "dbl logout", stderr, + fmt.Errorf("credential could not be read for remote revocation; revoke it from the console Credentials page: %w", errors.Join(err, cleanupErr))) } c, err := newClient(profile.Endpoint, client.Options{RequestTimeout: options.Timeout, Credential: credential.Token}) if err != nil { diff --git a/cmd/dbl/login_test.go b/cmd/dbl/login_test.go index 0fd256d0..fcc6cdbb 100644 --- a/cmd/dbl/login_test.go +++ b/cmd/dbl/login_test.go @@ -72,7 +72,7 @@ func TestLoginRegisterOutput(t *testing.T) { t.Fatalf("credential file is not owner-only: %v", err) } } - credential, err := connections.CredentialFor(config, "saved", fx.endpoint()) + credential, err := connections.CredentialFor(t.Context(), config, "saved", fx.endpoint()) if err != nil || credential.Token != token { t.Fatalf("session was not stored: %v", err) } @@ -148,7 +148,7 @@ func TestLoginReplacesCredentialAfterProfileEndpointChanges(t *testing.T) { if loginCalls != 1 { t.Fatalf("login requests=%d, want 1", loginCalls) } - credential, err := connections.CredentialFor(config, "saved", fx.endpoint()) + credential, err := connections.CredentialFor(t.Context(), config, "saved", fx.endpoint()) if err != nil || credential.Token != "new-session" { t.Fatalf("new endpoint credential was not stored: credential=%+v err=%v", credential, err) } diff --git a/cmd/dbl/logs.go b/cmd/dbl/logs.go index 94a0b814..5ae491ea 100644 --- a/cmd/dbl/logs.go +++ b/cmd/dbl/logs.go @@ -143,7 +143,7 @@ func logsCommand(ctx context.Context, args []string, options globalOptions, stdo if !ok { return code } - c, code, ok := connect("dbl logs", options, false, stderr) + c, code, ok := connect(ctx, "dbl logs", options, false, stderr) if !ok { return code } diff --git a/cmd/dbl/query.go b/cmd/dbl/query.go index d8e4b059..e5eafc58 100644 --- a/cmd/dbl/query.go +++ b/cmd/dbl/query.go @@ -70,7 +70,7 @@ func nodesCommand(ctx context.Context, args []string, options globalOptions, std if err := filter.Validate(); err != nil { return usageError("dbl nodes", nodesUsage, stderr, "%v", err) } - c, code, ok := connect("dbl nodes", options, false, stderr) + c, code, ok := connect(ctx, "dbl nodes", options, false, stderr) if !ok { return code } @@ -193,7 +193,7 @@ func statusCommand(ctx context.Context, args []string, options globalOptions, st if msg != "" { return usageError("dbl status", statusUsage, stderr, "%s", msg) } - c, code, ok := connect("dbl status", options, false, stderr) + c, code, ok := connect(ctx, "dbl status", options, false, stderr) if !ok { return code } @@ -227,7 +227,7 @@ func cancelCommand(ctx context.Context, args []string, options globalOptions, st if msg != "" { return usageError("dbl cancel", cancelUsage, stderr, "%s", msg) } - c, code, ok := connect("dbl cancel", options, false, stderr) + c, code, ok := connect(ctx, "dbl cancel", options, false, stderr) if !ok { return code } @@ -321,7 +321,7 @@ func versionCommand(ctx context.Context, args []string, options globalOptions, s } v := localVersion() if server { - c, code, ok := connect("dbl version", options, false, stderr) + c, code, ok := connect(ctx, "dbl version", options, false, stderr) if !ok { return code } diff --git a/cmd/dbl/recovery.go b/cmd/dbl/recovery.go index 6b9c48aa..fc105040 100644 --- a/cmd/dbl/recovery.go +++ b/cmd/dbl/recovery.go @@ -27,7 +27,7 @@ func recoveryCommand(ctx context.Context, args []string, options globalOptions, if msg != "" { return usageError("dbl recovery", recoveryUsage, stderr, "%s", msg) } - c, code, ok := connect("dbl recovery", options, false, stderr) + c, code, ok := connect(ctx, "dbl recovery", options, false, stderr) if !ok { return code } diff --git a/cmd/dbl/rendezvous.go b/cmd/dbl/rendezvous.go index edf91820..c55a116e 100644 --- a/cmd/dbl/rendezvous.go +++ b/cmd/dbl/rendezvous.go @@ -74,7 +74,7 @@ func rendezvousCommand(ctx context.Context, args []string, options globalOptions if err != nil { return reportFailure(ctx, "dbl rendezvous: client program", stderr, err) } - c, code, ok := connect("dbl rendezvous", options, remote, stderr) + c, code, ok := connect(ctx, "dbl rendezvous", options, remote, stderr) if !ok { return code } diff --git a/cmd/dbl/run.go b/cmd/dbl/run.go index d617c4a9..c3f70d93 100644 --- a/cmd/dbl/run.go +++ b/cmd/dbl/run.go @@ -278,7 +278,7 @@ func executeSubmission(ctx context.Context, o runOptions, options globalOptions, if addresses == nil { addresses = []string{} } - c, code, ok := connect(command, options, o.allowRemoteTEST, stderr) + c, code, ok := connect(ctx, command, options, o.allowRemoteTEST, stderr) if !ok { return code } diff --git a/cmd/dbl/whoami.go b/cmd/dbl/whoami.go index a020e9c4..fd7fb72c 100644 --- a/cmd/dbl/whoami.go +++ b/cmd/dbl/whoami.go @@ -32,7 +32,7 @@ func whoamiCommand(ctx context.Context, args []string, options globalOptions, st if err != nil { return reportFailure(ctx, "dbl whoami", stderr, err) } - credential, err := connections.CredentialFor(options.ConfigPath, profile.Name, profile.Endpoint) + credential, err := connections.CredentialFor(ctx, options.ConfigPath, profile.Name, profile.Endpoint) if err != nil { return reportFailure(ctx, "dbl whoami", stderr, err) } diff --git a/docs/cli.md b/docs/cli.md index dafba8fa..6c7dc8c8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,12 +62,44 @@ dbl --dispatcher research nodes ``` Registration prints the locations of the account key and recovery code, not -their contents. Keep private backups of both. `credentials.json` holds the -session and must remain mode `0600`; do not fix a permission error by making -it world-readable. `dbl --dispatcher research logout` revokes the current +their contents. Keep private backups of both. `credentials.json` holds either +the session or its system-store reference and must remain mode `0600`; do not +fix a permission error by making it world-readable. `dbl --dispatcher research logout` revokes the current session when reachable and forgets it locally. It does not delete the account or its account-key file. +### Choose credential storage + +`dbl login --credential-store auto` is the default. On Linux desktops with +`secret-tool` and a D-Bus session, it saves the issued credential in Secret +Service (for example, GNOME Keyring). Install your distribution's +`libsecret-tools` package to provide `secret-tool`. A locked or failing keyring +is an error: unlock it and retry, or explicitly choose file storage. + +Without that desktop facility, `auto` uses the existing owner-only `0600` file. +The login result always names the selected store; JSON output contains +`credential_store: "system"` or `"file"`. Use `--credential-store system` to +require Secret Service, or choose the headless/container path explicitly: + +```sh +dbl --dispatcher research login --no-browser --credential-store file +``` + +System storage keeps only an opaque reference in `credentials.json`. The +secret is bound to the profile name, config directory, dispatcher URL and +account: moving the profile to another path requires a fresh login. Existing +file-backed logins remain readable. Logging in again with a different store +replaces the selected profile's credential; it does not revoke previously +issued credentials. Use the console **Credentials** page to retire old access. + +`dbl logout` revokes the current credential and removes its local entry. If +system cleanup fails, use your desktop keyring manager to remove the affected +**Debuglet CLI** entry and revoke it from the console. A saved system credential +cannot be read on a headless host without its original Secret Service session; +use a fresh browser-approved login with file storage there. Native macOS and +Windows secret stores are not supported; supported native packages remain +Linux amd64. + ### Recover a lost account key The CLI has no recovery command. Use its saved recovery code with diff --git a/docs/operations/authentication.md b/docs/operations/authentication.md index 86bf06b2..7d2d0616 100644 --- a/docs/operations/authentication.md +++ b/docs/operations/authentication.md @@ -100,13 +100,40 @@ dbl --dispatcher research whoami --credential dbl --dispatcher research logout ``` -The CLI stores the credential in the existing owner-only `0600` profile file; OS -keychain integration is not implemented. The console's **Credentials** page shows +The CLI defaults to Linux Secret Service when `secret-tool` and a desktop D-Bus +session are available, otherwise to an owner-only `0600` file. Use +`--credential-store system` to require the keyring or `--credential-store file` +on headless hosts. A failing or locked keyring never silently downgrades to file +storage. See [credential storage](../cli.md#choose-credential-storage) and the +[upgrade guide](auth-migration.md) for existing dispatchers. + +The console's **Credentials** page shows active browser/API credentials and can revoke one or all of them immediately. Manually created API secrets are shown once. If an approved polling response is lost, revoke the resulting entry in the console and start again; approval is consumed once, not replayed to recover a secret. +## Respond to a copied credential + +From an uncompromised browser session, open **Credentials** and revoke the +affected entry. If the exposed credential is unknown, revoke all credentials, +sign in again and issue only the permissions each client needs. Confirm that +the old credential is refused; never paste its value into an incident report. + +If a provider account is compromised, recover it through that provider and use +an uncompromised linked method where available. Disabling a provider does not +revoke existing Debuglet credentials. For an exposed legacy account key, follow +[account recovery](../cli.md#recover-a-lost-account-key); recovery replaces the +key and recovery code and revokes existing sessions. + +Record the account, credential identifier, times, actions and observed outcome +in the deployment's private incident records. Revocation prevents future +authenticated requests; it does not cancel already admitted measurements. +Handle active work separately and verify its observed outcome before claiming +traffic has stopped. + +## Authentication request limits + Authentication issuance and polling have bounded per-client rate limits and a maximum of 4,096 pending device transactions. These limits complement reverse proxy resource limits; they are not a general denial-of-service defense. If a diff --git a/internal/connections/credentials.go b/internal/connections/credentials.go index 40493d65..0f77d977 100644 --- a/internal/connections/credentials.go +++ b/internal/connections/credentials.go @@ -1,6 +1,8 @@ package connections import ( + "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -37,7 +39,9 @@ const maxCredentialFile = 1 << 20 // rather than permanent access to the account. type Credential struct { Endpoint string `json:"endpoint"` - Token string `json:"token"` + Token string `json:"token,omitempty"` + Storage string `json:"storage,omitempty"` + SecretID string `json:"secret_id,omitempty"` ExpiresAt int64 `json:"expires_at,omitempty"` AccountID string `json:"account_id,omitempty"` } @@ -95,16 +99,24 @@ func LoadCredentials(path string) (CredentialStore, error) { if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { return CredentialStore{}, errors.New("trailing credential file JSON") } - if store.SchemaVersion != 1 { + if store.SchemaVersion != 1 && store.SchemaVersion != 2 { return CredentialStore{}, errors.New("unsupported credential file schema") } if store.Credentials == nil { store.Credentials = map[string]Credential{} } - for name := range store.Credentials { + for name, credential := range store.Credentials { if err := ValidateName(name); err != nil { return CredentialStore{}, err } + if credential.Storage == "system" { + id, err := hex.DecodeString(credential.SecretID) + if store.SchemaVersion != 2 || err != nil || len(id) != 32 || credential.Token != "" || credential.Endpoint == "" { + return CredentialStore{}, errors.New("invalid system credential metadata") + } + } else if credential.Storage != "" || credential.SecretID != "" { + return CredentialStore{}, errors.New("unsupported credential storage") + } } return store, nil } @@ -115,6 +127,12 @@ func writeCredentials(path string, store CredentialStore) error { if err != nil { return err } + store.SchemaVersion = 1 + for _, credential := range store.Credentials { + if credential.Storage == "system" { + store.SchemaVersion = 2 + } + } data, err := json.MarshalIndent(store, "", " ") if err != nil { return err @@ -128,18 +146,8 @@ func writeCredentials(path string, store CredentialStore) error { // SaveCredential records the credential of one profile, replacing its own. func SaveCredential(path, name string, credential Credential) error { - if err := ValidateName(name); err != nil { - return err - } - if credential.Endpoint == "" || credential.Token == "" { - return errors.New("a stored credential needs both its endpoint and its token") - } - store, err := LoadCredentials(path) - if err != nil { - return err - } - store.Credentials[name] = credential - return writeCredentials(path, store) + _, err := SaveCredentialWithStorage(context.Background(), path, name, credential, "file") + return err } // RemoveCredential forgets the credential of one profile. Forgetting one that @@ -152,11 +160,20 @@ func RemoveCredential(path, name string) error { if err != nil { return err } - if _, found := store.Credentials[name]; !found { + credential, found := store.Credentials[name] + if !found { return nil } delete(store.Credentials, name) - return writeCredentials(path, store) + if err := writeCredentials(path, store); err != nil { + return err + } + if credential.Storage == "system" { + if _, err := secretTool(context.Background(), "clear", credential.SecretID, nil); err != nil { + return errors.New("local profile cleared, but system secret cleanup failed; remove the Debuglet CLI entry from your desktop keyring and revoke it from the console Credentials page") + } + } + return nil } // CredentialFor returns the credential to present at endpoint for one saved @@ -168,7 +185,7 @@ func RemoveCredential(path, name string) error { // operator named directly, so nothing is looked up and the client // configuration directory is not even located: commands that need no saved // connection keep working where there is no such directory. -func CredentialFor(path, name, endpoint string) (Credential, error) { +func CredentialFor(ctx context.Context, path, name, endpoint string) (Credential, error) { if name == "" { return Credential{}, nil } @@ -183,5 +200,11 @@ func CredentialFor(path, name, endpoint string) (Credential, error) { if credential.Endpoint != endpoint { return Credential{}, fmt.Errorf("%w: the stored credential for %q was issued for another dispatcher endpoint; run dbl login", ErrCredentialEndpointMismatch, name) } + if credential.Storage == "system" { + credential.Token, err = loadSystemCredential(ctx, path, name, credential) + if err != nil { + return Credential{}, err + } + } return credential, nil } diff --git a/internal/connections/credentials_test.go b/internal/connections/credentials_test.go index 95b5b252..cbf262f2 100644 --- a/internal/connections/credentials_test.go +++ b/internal/connections/credentials_test.go @@ -80,7 +80,7 @@ func TestCredentialFileReadableByOthersIsRefused(t *testing.T) { func TestCredentialGoesOnlyToTheEndpointItWasIssuedFor(t *testing.T) { config, _ := savedCredential(t) - credential, err := CredentialFor(config, "local", credTestEndpoint) + credential, err := CredentialFor(t.Context(), config, "local", credTestEndpoint) if err != nil { t.Fatalf("CredentialFor: %v", err) } @@ -90,14 +90,14 @@ func TestCredentialGoesOnlyToTheEndpointItWasIssuedFor(t *testing.T) { // The same profile pointed at another dispatcher: the token stays put and // the caller is told why. - if _, err := CredentialFor(config, "local", credTestOther); err == nil { + if _, err := CredentialFor(t.Context(), config, "local", credTestOther); err == nil { t.Fatal("a credential was offered to another endpoint") } else if !strings.Contains(err.Error(), "another dispatcher endpoint") { t.Fatalf("the refusal is not actionable: %v", err) } // Another profile has its own credential or none; it never inherits one. - other, err := CredentialFor(config, "other", credTestEndpoint) + other, err := CredentialFor(t.Context(), config, "other", credTestEndpoint) if err != nil { t.Fatalf("CredentialFor(other): %v", err) } @@ -107,7 +107,7 @@ func TestCredentialGoesOnlyToTheEndpointItWasIssuedFor(t *testing.T) { // An endpoint the operator named directly belongs to no saved connection, // so it presents no credential at all. - unnamed, err := CredentialFor(config, "", credTestEndpoint) + unnamed, err := CredentialFor(t.Context(), config, "", credTestEndpoint) if err != nil { t.Fatalf("CredentialFor(no name): %v", err) } @@ -128,7 +128,7 @@ func TestCredentialForWithoutASavedConnectionReadsNothing(t *testing.T) { } // The default path is deliberately used: an unnamed lookup must not even // resolve it. - credential, err := CredentialFor("", "", credTestEndpoint) + credential, err := CredentialFor(t.Context(), "", "", credTestEndpoint) if err != nil { t.Fatalf("CredentialFor without a saved connection: %v", err) } @@ -136,7 +136,7 @@ func TestCredentialForWithoutASavedConnectionReadsNothing(t *testing.T) { t.Fatalf("an unnamed lookup produced %+v", credential) } // A named lookup does need the directory and says so. - if _, err := CredentialFor("", "local", credTestEndpoint); err == nil { + if _, err := CredentialFor(t.Context(), "", "local", credTestEndpoint); err == nil { t.Fatal("a named lookup resolved without a configuration directory") } } diff --git a/internal/connections/secret_service.go b/internal/connections/secret_service.go new file mode 100644 index 00000000..4b597145 --- /dev/null +++ b/internal/connections/secret_service.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package connections + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +// ResolveCredentialStorage selects the requested backend before login issues a +// credential. Auto falls back only when the desktop facility is absent. +func ResolveCredentialStorage(mode string) (string, error) { + switch mode { + case "file": + return mode, nil + case "auto", "system": + default: + return "", errors.New("credential store must be auto, system or file") + } + _, err := secretToolPath() + if err == nil { + return "system", nil + } + if mode == "system" { + return "", err + } + return "file", nil +} + +func secretToolPath() (string, error) { + if runtime.GOOS == "linux" && os.Getenv("DBUS_SESSION_BUS_ADDRESS") != "" { + if path, err := exec.LookPath("secret-tool"); err == nil { + return path, nil + } + } + return "", errors.New("system credential storage requires Linux, secret-tool and a desktop D-Bus session; use --credential-store file on a headless host") +} + +// CredentialStorageResult describes a committed local login. Failure to remove +// an older secret is reported separately, since the replacement is already saved. +type CredentialStorageResult struct { + Storage string + Warning string +} + +// SaveCredentialWithStorage saves a credential using the selected backend. +func SaveCredentialWithStorage(ctx context.Context, path, name string, credential Credential, storage string) (CredentialStorageResult, error) { + result := CredentialStorageResult{Storage: storage} + if err := ValidateName(name); err != nil { + return result, err + } + if credential.Endpoint == "" || credential.Token == "" { + return result, errors.New("a stored credential needs both its endpoint and its token") + } + if storage != "file" && storage != "system" { + return result, errors.New("resolve the credential store before saving") + } + if err := ctx.Err(); err != nil { + return result, err + } + store, err := LoadCredentials(path) + if err != nil { + return result, err + } + previous := store.Credentials[name] + credential.Storage, credential.SecretID = "", "" + if storage == "system" { + binding, err := credentialBinding(path, name, credential) + if err != nil { + return result, err + } + payload, _ := json.Marshal(systemCredential{Token: credential.Token, Binding: binding}) + // secret-tool's stdin input is bounded to 8192 bytes. + if len(payload) >= 8192 { + return result, errors.New("credential is too large for the system store") + } + var id [32]byte + if _, err := rand.Read(id[:]); err != nil { + return result, err + } + credential.Storage, credential.SecretID = "system", hex.EncodeToString(id[:]) + if _, err := secretTool(ctx, "store", credential.SecretID, payload); err != nil { + // A timed-out service may have committed before its reply was lost. + _, _ = secretTool(context.Background(), "clear", credential.SecretID, nil) + return result, err + } + credential.Token = "" + } + store.Credentials[name] = credential + err = ctx.Err() + if err == nil { + err = writeCredentials(path, store) + } + if err != nil { + // Directory sync can fail after the atomic rename committed. Preserve + // the secret if the published metadata already refers to it. + saved, readErr := LoadCredentials(path) + if readErr == nil && saved.Credentials[name] == credential { + result.Warning = "Credential saved, but filesystem durability could not be confirmed. " + } else { + if credential.Storage == "system" { + if _, cleanupErr := secretTool(context.Background(), "clear", credential.SecretID, nil); cleanupErr != nil { + return result, fmt.Errorf("%w; remove the unused Debuglet CLI entry from your desktop keyring", err) + } + } + return result, err + } + } + if previous.Storage == "system" { + if _, err := secretTool(context.Background(), "clear", previous.SecretID, nil); err != nil { + result.Warning += "Login saved, but an older system secret could not be removed. Remove the old Debuglet CLI entry from your desktop keyring." + } + } + return result, nil +} + +type systemCredential struct { + Token string `json:"token"` + Binding string `json:"binding"` +} + +func credentialBinding(path, name string, credential Credential) (string, error) { + file, err := CredentialPath(path) + if err != nil { + return "", err + } + file, err = filepath.Abs(file) + if err != nil { + return "", err + } + data, _ := json.Marshal([]string{file, name, credential.Endpoint, credential.AccountID}) + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]), nil +} + +func loadSystemCredential(ctx context.Context, path, name string, credential Credential) (string, error) { + data, err := secretTool(ctx, "lookup", credential.SecretID, nil) + if err != nil { + return "", err + } + binding, err := credentialBinding(path, name, credential) + if err != nil { + return "", err + } + var secret systemCredential + if json.Unmarshal(data, &secret) != nil || secret.Token == "" || secret.Binding != binding { + return "", errors.New("system credential does not match this profile; run dbl login again") + } + return secret.Token, nil +} + +func secretTool(ctx context.Context, operation, id string, input []byte) ([]byte, error) { + path, err := secretToolPath() + if err != nil { + return nil, err + } + args := []string{operation} + if operation == "store" { + args = append(args, "--label=Debuglet CLI") + } + args = append(args, "application", "org.debuglet.cli", "credential", id) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, path, args...) + cmd.WaitDelay = time.Second + cmd.Stdin = bytes.NewReader(input) + cmd.Stderr = io.Discard + output := boundedSecretOutput{} + cmd.Stdout = &output + if err := cmd.Run(); err != nil { + // Utility diagnostics may contain secrets; do not expose them. + return nil, errors.New("system credential store operation failed; unlock your desktop keyring and retry, or explicitly log in with --credential-store file") + } + return output.buffer.Bytes(), nil +} + +type boundedSecretOutput struct{ buffer bytes.Buffer } + +func (b *boundedSecretOutput) Write(p []byte) (int, error) { + if b.buffer.Len()+len(p) > 8192 { + return 0, errors.New("system credential response is too large") + } + return b.buffer.Write(p) +} diff --git a/internal/connections/secret_service_test.go b/internal/connections/secret_service_test.go new file mode 100644 index 00000000..2f2a83cc --- /dev/null +++ b/internal/connections/secret_service_test.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ETH Zurich + +package connections + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func fixtureSecretTool(t *testing.T) string { + t.Helper() + if runtime.GOOS != "linux" { + t.Skip("Secret Service integration is supported on Linux") + } + dir := t.TempDir() + tool := `#!/bin/sh +set -eu +for id do :; done +printf '%s\n' "$@" >> "$SECRET_TOOL_FIXTURE/args" +if [ "${SECRET_TOOL_FAIL:-}" = "$1" ]; then + printf 'fixture-secret-must-not-leak' >&2 + exit 1 +fi +case "$1" in + store) + cat > "$SECRET_TOOL_FIXTURE/$id" + if [ -n "${SECRET_TOOL_BLOCK_METADATA:-}" ]; then mkdir "$SECRET_TOOL_BLOCK_METADATA"; fi + ;; + lookup) cat "$SECRET_TOOL_FIXTURE/$id" ;; + clear) rm -f "$SECRET_TOOL_FIXTURE/$id" ;; +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("SECRET_TOOL_FIXTURE", dir) + return dir +} + +func TestCredentialStorageSelection(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + for _, mode := range []string{"auto", "file"} { + storage, err := ResolveCredentialStorage(mode) + if err != nil || storage != "file" { + t.Fatalf("headless %s: %s %v", mode, storage, err) + } + } + for _, mode := range []string{"system", "unknown"} { + if _, err := ResolveCredentialStorage(mode); err == nil { + t.Fatalf("accepted unavailable store %q", mode) + } + } + fixtureSecretTool(t) + if storage, err := ResolveCredentialStorage("auto"); err != nil || storage != "system" { + t.Fatalf("desktop auto: %s %v", storage, err) + } +} + +func TestSystemCredentialLifecycleAndIsolation(t *testing.T) { + dir := fixtureSecretTool(t) + config := credTestConfig(t) + credential := Credential{Endpoint: credTestEndpoint, Token: credTestToken, AccountID: "alice"} + if _, err := SaveCredentialWithStorage(t.Context(), config, "local", credential, "system"); err != nil { + t.Fatal(err) + } + store, err := LoadCredentials(config) + if err != nil || store.SchemaVersion != 2 || store.Credentials["local"].Token != "" { + t.Fatalf("system metadata: %+v %v", store, err) + } + file, _ := CredentialPath(config) + data, _ := os.ReadFile(file) + args, _ := os.ReadFile(filepath.Join(dir, "args")) + if strings.Contains(string(data)+string(args), credTestToken) || strings.Contains(string(args), credTestEndpoint) { + t.Fatal("credential or endpoint leaked to file/utility arguments") + } + got, err := CredentialFor(t.Context(), config, "local", credTestEndpoint) + if err != nil || got.Token != credTestToken { + t.Fatalf("saved system credential: %v", err) + } + if _, err := CredentialFor(t.Context(), config, "local", credTestOther); err == nil { + t.Fatal("system credential offered to another dispatcher") + } + store.Credentials["copied"] = store.Credentials["local"] + if err := writeCredentials(config, store); err != nil { + t.Fatal(err) + } + if _, err := CredentialFor(t.Context(), config, "copied", credTestEndpoint); err == nil { + t.Fatal("system credential reference transferred across profiles") + } + delete(store.Credentials, "copied") + if err := writeCredentials(config, store); err != nil { + t.Fatal(err) + } + oldID := store.Credentials["local"].SecretID + credential.Token = "replacement-token" + if result, err := SaveCredentialWithStorage(t.Context(), config, "local", credential, "system"); err != nil || result.Warning != "" { + t.Fatalf("replacement: %+v %v", result, err) + } + if _, err := os.Stat(filepath.Join(dir, oldID)); !os.IsNotExist(err) { + t.Fatal("old system secret retained") + } + if err := RemoveCredential(config, "local"); err != nil { + t.Fatal(err) + } + got, err = CredentialFor(t.Context(), config, "local", credTestEndpoint) + if err != nil || got.Token != "" { + t.Fatal("logout retained a credential") + } + entries, _ := os.ReadDir(dir) + for _, entry := range entries { + if len(entry.Name()) == 64 { + t.Fatal("logout left a system secret") + } + } +} + +func TestSystemCredentialFailuresPreserveSavedLogin(t *testing.T) { + dir := fixtureSecretTool(t) + config, _ := savedCredential(t) + t.Setenv("SECRET_TOOL_FAIL", "store") + if _, err := SaveCredentialWithStorage(t.Context(), config, "local", Credential{Endpoint: credTestEndpoint, Token: "new-token"}, "system"); err == nil || strings.Contains(err.Error(), "fixture-secret") { + t.Fatalf("failed store diagnostics: %v", err) + } + got, err := CredentialFor(t.Context(), config, "local", credTestEndpoint) + if err != nil || got.Token != credTestToken { + t.Fatal("failed system write changed previous login") + } + t.Setenv("SECRET_TOOL_FAIL", "") + if _, err := SaveCredentialWithStorage(t.Context(), config, "local", Credential{Endpoint: credTestEndpoint, Token: "new-token"}, "system"); err != nil { + t.Fatal(err) + } + t.Setenv("SECRET_TOOL_FAIL", "lookup") + if _, err := CredentialFor(t.Context(), config, "local", credTestEndpoint); err == nil { + t.Fatal("unavailable keyring silently fell back") + } + t.Setenv("SECRET_TOOL_FAIL", "clear") + result, err := SaveCredentialWithStorage(t.Context(), config, "local", Credential{Endpoint: credTestEndpoint, Token: "file-token"}, "file") + if err != nil || result.Warning == "" { + t.Fatalf("cleanup failure invalidated committed login: %+v %v", result, err) + } + got, err = CredentialFor(t.Context(), config, "local", credTestEndpoint) + if err != nil || got.Token != "file-token" { + t.Fatal("cleanup failure left new login unreadable") + } + args, _ := os.ReadFile(filepath.Join(dir, "args")) + if strings.Contains(string(args), "new-token") { + t.Fatal("secret leaked to command arguments") + } +} + +func TestSystemCredentialCleansUpWhenMetadataCannotBeWritten(t *testing.T) { + dir := fixtureSecretTool(t) + config := filepath.Join(t.TempDir(), "config.json") + file, _ := CredentialPath(config) + t.Setenv("SECRET_TOOL_BLOCK_METADATA", file) + if _, err := SaveCredentialWithStorage(t.Context(), config, "local", Credential{Endpoint: credTestEndpoint, Token: credTestToken}, "system"); err == nil { + t.Fatal("metadata failure accepted") + } + entries, _ := os.ReadDir(dir) + for _, entry := range entries { + if len(entry.Name()) == 64 { + t.Fatal("failed metadata write orphaned a system secret") + } + } +} + +func TestSystemCredentialLookupCancellationAndOversizeOutput(t *testing.T) { + dir := fixtureSecretTool(t) + config := credTestConfig(t) + if _, err := SaveCredentialWithStorage(t.Context(), config, "local", Credential{Endpoint: credTestEndpoint, Token: credTestToken}, "system"); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := CredentialFor(ctx, config, "local", credTestEndpoint); err == nil { + t.Fatal("cancelled credential lookup succeeded") + } + store, _ := LoadCredentials(config) + item := filepath.Join(dir, store.Credentials["local"].SecretID) + if err := os.WriteFile(item, []byte(strings.Repeat("x", 16384)), 0600); err != nil { + t.Fatal(err) + } + if _, err := CredentialFor(t.Context(), config, "local", credTestEndpoint); err == nil { + t.Fatal("oversized system response accepted") + } +} From 2c43970e5ecf3e9699dc20685abf5fe24c1ba8b1 Mon Sep 17 00:00:00 2001 From: TheodorAdrienIsaak Mattli Date: Fri, 2 Oct 2026 14:00:49 +0200 Subject: [PATCH 4/4] Document credential-store downgrade preparation --- docs/cli.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/cli.md b/docs/cli.md index 6c7dc8c8..89d48fbb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -92,6 +92,11 @@ file-backed logins remain readable. Logging in again with a different store replaces the selected profile's credential; it does not revoke previously issued credentials. Use the console **Credentials** page to retire old access. +Before downgrading the CLI, use the current version to log out or log in with +`--credential-store file` for every system-backed profile. Older CLIs reject a +credential file containing system-store references, including its file-backed +profiles; conversion of the last system-backed profile restores the older format. + `dbl logout` revokes the current credential and removes its local entry. If system cleanup fails, use your desktop keyring manager to remove the affected **Debuglet CLI** entry and revoke it from the console. A saved system credential