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
57 changes: 57 additions & 0 deletions client/setec/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
78 changes: 78 additions & 0 deletions client/setec/keyring.go
Original file line number Diff line number Diff line change
@@ -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
}
82 changes: 82 additions & 0 deletions client/setec/keyring_test.go
Original file line number Diff line number Diff line change
@@ -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")
}