From 3cf229aa7f02b7818e5fa0e69c6bface3a38f7d5 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Wed, 4 Mar 2026 17:30:24 -0800 Subject: [PATCH] client/setec: add a Keyring type Introduce a new Keyring type to the client library, representing a collection of all the available versions of a secret. The Keyring allows the caller to get any of the versions known explicitly (as Client.GetVersion) without an additional fetch from the service, or to get the ID and value of the "active" version explicitly. A Keyring also supports an Update operation which polls the server for changes, allowing the caller to observe additional versions and changes to the active version ID. - Add a new method GetKeyring to the Client to fetch a keyring. - Update tests and documentation. --- client/setec/client.go | 57 +++++++++++++++++++++++++ client/setec/keyring.go | 78 ++++++++++++++++++++++++++++++++++ client/setec/keyring_test.go | 82 ++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 client/setec/keyring.go create mode 100644 client/setec/keyring_test.go diff --git a/client/setec/client.go b/client/setec/client.go index 165792b..59a9c36 100644 --- a/client/setec/client.go +++ b/client/setec/client.go @@ -42,6 +42,42 @@ // // See also [Bootstrapping and Availability]. // +// ## Multiple Version Keyrings +// +// Some callers need to use multiple versions of the same key. A typical +// example is when a secret contains different versions of an encryption key, +// where the active version is used for encryption, and non-active versions are +// needed for decryption. +// +// To support this, the [Keyring] type allows a caller to fetch all available +// versions of a given secret, and to keep them up-to-date with the service as +// new values are added. To construct a [Keyring], call [Client.GetKeyring]: +// +// r, err := client.GetKeyring(ctx, "example-encryption-key") +// if err != nil { +// log.Fatalf("Initializing keyring: %v", err) +// } +// +// To retrieve the current active version (e.g., for encryption), use +// [Keyring.Active]: +// +// v, data := r.Active() +// +// The version is reported so the caller can record which version was observed. +// +// To retrieve a specified version (e.g., for decryption), use [Keyring.Get]: +// +// data, ok := r.Get(v) +// +// This reports whether the requested version exists, and if so the current +// value of the secret at that version. +// +// To update the keyring from the service, call [Keyring.Update: +// +// if err := r.Update(ctx); err != nil { +// log.Printf("Updating keyring failed: %v", err) +// } +// // # Other Operations // // Programs that need to create, update, or delete secrets and secret versions @@ -254,3 +290,24 @@ func (c Client) Delete(ctx context.Context, name string) error { }) return err } + +// GetKeyring fetches all available versions of the named secret, and +// returns a [Keyring] containing them. +func (c Client) GetKeyring(ctx context.Context, name string) (*Keyring, error) { + info, err := c.Info(ctx, name) + if err != nil { + return nil, err + } + out := &Keyring{ + name: name, + client: c, + versions: make(map[api.SecretVersion]*api.SecretValue), + active: info.ActiveVersion, + + // versions will be initialized by update below + } + if err := out.Update(ctx); err != nil { + return nil, err + } + return out, nil +} diff --git a/client/setec/keyring.go b/client/setec/keyring.go new file mode 100644 index 0000000..8b30da0 --- /dev/null +++ b/client/setec/keyring.go @@ -0,0 +1,78 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package setec + +import ( + "context" + "fmt" + "sync" + + "github.com/tailscale/setec/types/api" +) + +// A Keyring represents multiple versions of a single key, each of which can be +// accessed separately. +type Keyring struct { + // These fields are immutable after construction. + name string + client Client + + mu sync.Mutex // protectes the fields below + versions map[api.SecretVersion]*api.SecretValue + active api.SecretVersion +} + +// Active reports the version and value of the active secret in the keyring. +func (r *Keyring) Active() (api.SecretVersion, []byte) { + r.mu.Lock() + defer r.mu.Unlock() + v := r.versions[r.active] + return v.Version, v.Value +} + +// Get reports whether the keyring contains the specified version of the +// secret, and if so the value of that version. +func (r *Keyring) Get(version api.SecretVersion) ([]byte, bool) { + r.mu.Lock() + defer r.mu.Unlock() + v, ok := r.versions[version] + if !ok { + return nil, false + } + return v.Value, true +} + +// Update updates the contents of r in-place to match the values stored +// on the service. Additional versions will be fetched and added; however, +// versions deleted from the server will not be removed from the keyring. +func (r *Keyring) Update(ctx context.Context) error { + info, err := r.client.Info(ctx, r.name) + if err != nil { + return err + } + + // Buffer new versions so that we don't modify the keyring until we know the + // update has succeeded completely. + var added []*api.SecretValue + for _, v := range info.Versions { + if _, ok := r.Get(v); ok { + continue // we already have this one, don't fetch it again + } + sv, err := r.client.GetVersion(ctx, r.name, v) + if err != nil { + return fmt.Errorf("get %q version %d: %w", r.name, v, err) + } + added = append(added, sv) + } + + // Reaching here, we have all the new versions, and possibly a new active + // version as well. + r.mu.Lock() + defer r.mu.Unlock() + for _, av := range added { + r.versions[av.Version] = av + } + r.active = info.ActiveVersion + return nil +} diff --git a/client/setec/keyring_test.go b/client/setec/keyring_test.go new file mode 100644 index 0000000..7e90186 --- /dev/null +++ b/client/setec/keyring_test.go @@ -0,0 +1,82 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package setec_test + +import ( + "net/http/httptest" + "testing" + + "github.com/tailscale/setec/client/setec" + "github.com/tailscale/setec/setectest" + "github.com/tailscale/setec/types/api" +) + +func TestKeyring(t *testing.T) { + d := setectest.NewDB(t, nil) + v1 := d.MustPut(d.Superuser, "apple", "a1") + v2 := d.MustPut(d.Superuser, "apple", "a2") + v3 := d.MustPut(d.Superuser, "apple", "a3") + d.MustActivate(d.Superuser, "apple", v3) + + ts := setectest.NewServer(t, d, nil) + hs := httptest.NewServer(ts.Mux) + defer hs.Close() + + cli := setec.Client{Server: hs.URL, DoHTTP: hs.Client().Do} + + r, err := cli.GetKeyring(t.Context(), "apple") + if err != nil { + t.Fatalf("GetKeyring failed: %v", err) + } + + mustActive := func(wantV api.SecretVersion, want string) { + gotV, data := r.Active() + if gotV != wantV || string(data) != want { + t.Errorf("Active: got %v, %q; want %v, %q", gotV, data, wantV, want) + } + } + mustGet := func(v api.SecretVersion, want string) { + got, ok := r.Get(v) + if !ok || string(got) != want { + t.Errorf("Get(%v): got %q, %v; want %q, %v", v, got, ok, want, true) + } + } + mustNotSee := func(v api.SecretVersion) { + got, ok := r.Get(v) + if ok || string(got) != "" { + t.Errorf(`Get(%v): got %q, %v; want "", true`, v, got, ok) + } + } + + // Verify that the active version is the one we expect. + mustActive(v3, "a3") + + // Verify that we can fetch the other versions. + mustGet(v1, "a1") + mustGet(v2, "a2") + + // Verify that we cannot fetch a hitherto unseen version. + mustNotSee(4) + mustNotSee(999) + + // Add a new version. Until we do an update we should not see it yet. + v4 := d.MustPut(d.Superuser, "apple", "a4") + mustNotSee(v4) // yet + + // Now do an update, and verify that we see the new version. + if err := r.Update(t.Context()); err != nil { + t.Fatalf("Update failed: %v", err) + } + mustGet(v4, "a4") + + // Note, however, that the new version is not active yet. + mustActive(v3, "a3") + + // Activate the new version, update, and verify we see that change. + d.MustActivate(d.Superuser, "apple", v4) + if err := r.Update(t.Context()); err != nil { + t.Fatalf("Update failed: %v", err) + } + mustActive(v4, "a4") +}