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") +}