// Command hush-hush-cli is the client every hush-hush consumer speaks
// through - the writer's only interface to the service, and the same
// binary any consumer (CI job, deploy script) runs to fetch and decrypt a
// value locally. See CLAUDE.md and openspec/changes/secrets-object-store/
// for the design. The commands live in internal/cmd; this file only starts
// them.
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/alrayyes/hush-hush-cli/internal/cmd"
)
// version is stamped in at build time by goreleaser, from the tag.
var version = "dev"
// exitInterrupted is the shell convention (128 + SIGINT) for a run ended
// by Ctrl-C.
const exitInterrupted = 130
func main() {
os.Exit(run())
}
// run is main without the os.Exit, so the signal handler's stop runs.
// Ctrl-C (or SIGTERM) cancels the context every command's requests carry,
// so a slow or hung server can be given up on at once.
func run() int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err := cmd.NewRootCmd(version).ExecuteContext(ctx)
switch {
case err == nil:
return 0
case errors.Is(err, context.Canceled) && ctx.Err() != nil:
fmt.Fprintln(os.Stderr, "interrupted")
return exitInterrupted
default:
fmt.Fprintln(os.Stderr, err)
return 1
}
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// AuditLog returns every audit log entry matching filter, oldest first -
// paging and limit handling both live in internal/client (design.md).
func AuditLog(ctx context.Context, cfg Config, filter client.AuditLogFilter) ([]client.AuditLogEntry, error) {
cl, err := cfg.newClient()
if err != nil {
return nil, err
}
entries, err := cl.QueryAuditLog(ctx, filter)
if err != nil {
return nil, fmt.Errorf("query audit log: %w", err)
}
return entries, nil
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// AuthStatus reports whether the target server has an admin account
// bootstrapped yet - unauthenticated, unlike list/inject/update/delete.
func AuthStatus(ctx context.Context, cfg Config) (client.AuthStatus, error) {
cl, err := cfg.newClient()
if err != nil {
return client.AuthStatus{}, err
}
status, err := cl.AuthStatus(ctx)
if err != nil {
return client.AuthStatus{}, fmt.Errorf("auth status: %w", err)
}
return status, nil
}
package cli
import (
"errors"
"fmt"
"net/url"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// Config is the CLI's runtime configuration, shared by every command.
type Config struct {
// Server is the hush-hush server's base URL.
Server string
// Token is the write-path bearer token - required by inject, update,
// and delete, and also authorizes get (a write token reads anything).
Token string
// ConsumerToken is a read-only, consumer-scoped bearer token - used by
// get only, and only as a fallback when Token is empty (a write token
// always takes priority, since it authorizes reads too). Never read by
// inject/update/delete/list/audit-log: consumerBearerAuth never
// authorizes anything besides GetObject (design.md).
ConsumerToken string
// Caller is this CLI's self-presented identity for the audit log
// (api/openapi.yaml's X-Caller header) - optional.
Caller string
// RequireToken is set by inject/update/delete before calling
// Validate, and left false by get - Token itself can't say which
// commands need it, since an empty string is also what "not
// configured" looks like.
RequireToken bool
}
// Validate's own sentinels - fixed conditions, not messages built from
// per-call detail, which stays in the %w wrap around each at the call site.
var (
errServerRequired = errors.New("server: required")
errServerNotAbsolute = errors.New("not an absolute URL")
errTokenRequired = errors.New("no token configured (--token, HUSH_HUSH_TOKEN, or run `hush-hush-cli init`)")
)
// Validate catches a bad Server value, and a missing Token where
// RequireToken is set, at startup rather than at the first request - an
// empty or malformed base URL, or an empty token, otherwise only surface
// as a generic connection or 401 error from deep inside net/http.
func (c Config) Validate() error {
if c.Server == "" {
return errServerRequired
}
u, err := url.Parse(c.Server)
if err != nil {
return fmt.Errorf("server: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return fmt.Errorf("server %q: %w", c.Server, errServerNotAbsolute)
}
if c.RequireToken && c.Token == "" {
return errTokenRequired
}
return nil
}
func (c Config) newClient() (*client.Client, error) {
cl, err := client.New(c.Server, c.Token)
if err != nil {
return nil, fmt.Errorf("build client: %w", err)
}
cl.Caller = c.Caller
return cl, nil
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// ListConsumers returns every consumer directory entry whose name contains
// query ("" matches all).
func ListConsumers(ctx context.Context, cfg Config, query string) ([]client.Consumer, error) {
cl, err := cfg.newClient()
if err != nil {
return nil, err
}
consumers, err := cl.ListConsumers(ctx, query)
if err != nil {
return nil, fmt.Errorf("list consumers: %w", err)
}
return consumers, nil
}
// AddConsumer adds name to the directory with no secret referencing it yet.
func AddConsumer(ctx context.Context, cfg Config, name string) (client.Consumer, error) {
cl, err := cfg.newClient()
if err != nil {
return client.Consumer{}, err
}
consumer, err := cl.AddConsumer(ctx, name)
if err != nil {
return client.Consumer{}, fmt.Errorf("add consumer: %w", err)
}
return consumer, nil
}
// UpdateConsumer renames name and/or registers its age public key - nil
// leaves a field unchanged.
func UpdateConsumer(ctx context.Context, cfg Config, name string, newName, publicKey *string) (client.Consumer, error) {
cl, err := cfg.newClient()
if err != nil {
return client.Consumer{}, err
}
consumer, err := cl.UpdateConsumer(ctx, name, newName, publicKey)
if err != nil {
return client.Consumer{}, fmt.Errorf("update consumer: %w", err)
}
return consumer, nil
}
// DeleteConsumer removes name from the directory and every object's
// used_by list; no object is deleted.
func DeleteConsumer(ctx context.Context, cfg Config, name string) error {
cl, err := cfg.newClient()
if err != nil {
return err
}
if err := cl.DeleteConsumer(ctx, name); err != nil {
return fmt.Errorf("delete consumer: %w", err)
}
return nil
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// Delete permanently removes id - there's nothing to seal or unseal here,
// unlike every other command.
func Delete(ctx context.Context, cfg Config, id string, opts ...client.CallOption) error {
cl, err := cfg.newClient()
if err != nil {
return err
}
if err := cl.Delete(ctx, id, opts...); err != nil {
return fmt.Errorf("delete object: %w", err)
}
return nil
}
package cli
import (
"context"
"errors"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// ErrNothingToEdit is Edit called with no field to change, which the server
// would only refuse with a 400 of its own.
var ErrNothingToEdit = errors.New("nothing to edit: give a description, tags or a slug")
// Edit changes id's description, tags or name (WithDescription, WithTags,
// WithSlug) without reading or sealing a value, so fixing a typo needs no
// plaintext. A field left out stays as it is.
func Edit(ctx context.Context, cfg Config, id string, opts ...WriteOption) error {
w := newWriteOptions(opts)
if w.description == nil && w.slug == nil && w.tags == nil {
return ErrNothingToEdit
}
cl, err := cfg.newClient()
if err != nil {
return err
}
if _, err := cl.Patch(ctx, id, w.description, w.slug, w.tags, client.WithVariant(w.variantID), client.WithConsumer(w.consumer)); err != nil {
return fmt.Errorf("edit object: %w", err)
}
return nil
}
package cli
import (
"errors"
"fmt"
"time"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// ParseAuditLogFilter parses --since/--until as RFC3339 (design.md) before
// any request is made, so a malformed value fails locally rather than as
// a 400 from the server.
func ParseAuditLogFilter(objectID, actor, caller, since, until string, limit int) (client.AuditLogFilter, error) {
var filter client.AuditLogFilter
if objectID != "" {
filter.ObjectID = &objectID
}
if actor != "" {
filter.Token = &actor
}
if caller != "" {
filter.Caller = &caller
}
if since != "" {
t, err := time.Parse(time.RFC3339, since)
if err != nil {
return filter, fmt.Errorf("--since: %w", err)
}
filter.Since = &t
}
if until != "" {
t, err := time.Parse(time.RFC3339, until)
if err != nil {
return filter, fmt.Errorf("--until: %w", err)
}
filter.Until = &t
}
if limit > 0 {
filter.Limit = &limit
}
return filter, nil
}
// ErrTTLRequired is a sentinel: a fixed condition (no --ttl given), not a
// message built from per-call detail.
var ErrTTLRequired = errors.New("--ttl is required (a Go duration, e.g. 720h)")
// ParseTTL reads --ttl as a Go duration (720h, say). It's required: a
// consumer token with no expiry isn't offered.
func ParseTTL(ttl string) (time.Duration, error) {
if ttl == "" {
return 0, ErrTTLRequired
}
d, err := time.ParseDuration(ttl)
if err != nil {
return 0, fmt.Errorf("--ttl: %w", err)
}
return d, nil
}
package cli
import (
"context"
"errors"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
"github.com/alrayyes/hush-hush-cli/internal/seal"
)
// errNoReadCredentialMsg names both ways to authorize a read, since Get
// (unlike inject/update/delete) accepts either one - Validate's
// RequireToken check can't catch this ahead of time, since neither field
// is individually required, so this only ever fires after the server has
// already rejected the request (readError wraps client.ErrUnauthorized
// around it, unlike cli.go's errTokenRequired, which fires before any
// request is made).
const errNoReadCredentialMsg = "no token configured (--token/--consumer-token, " +
"HUSH_HUSH_TOKEN/HUSH_HUSH_CONSUMER_TOKEN, or run `hush-hush-cli init` for a write token)"
// Get fetches id's ciphertext and decrypts it locally with whichever of
// identities matches - the CLI never writes an assembled file or applies
// consumer-side file-shape logic (design.md).
//
// It builds its own client rather than using Config.newClient(): a write
// Token, when set, always takes priority over ConsumerToken - a write
// token already authorizes reads, and there's exactly one Authorization
// header per request, so the two are never sent together (design.md's
// "Decisions").
func Get(ctx context.Context, cfg Config, id string, identities []string, opts ...client.CallOption) ([]byte, error) {
credential := cfg.Token
if credential == "" {
credential = cfg.ConsumerToken
}
cl, err := client.New(cfg.Server, credential)
if err != nil {
return nil, fmt.Errorf("build client: %w", err)
}
cl.Caller = cfg.Caller
sealed, err := cl.Get(ctx, id, opts...)
if err != nil {
return nil, fmt.Errorf("fetch object: %w", readError(err, cfg))
}
plaintext, err := seal.Unseal(sealed, identities)
if err != nil {
return nil, fmt.Errorf("decrypt object: %w", err)
}
return plaintext, nil
}
// readError turns a bare client.ErrUnauthorized into one naming which
// credential was missing or rejected, per this change's cli-config delta
// ("Actionable error on a rejected read"). Any other error passes through
// unchanged.
func readError(err error, cfg Config) error {
if !errors.Is(err, client.ErrUnauthorized) {
return err
}
switch {
case cfg.Token == "" && cfg.ConsumerToken == "":
return fmt.Errorf("%s: %w", errNoReadCredentialMsg, client.ErrUnauthorized)
case cfg.Token != "":
return fmt.Errorf("write token rejected: %w", client.ErrUnauthorized)
default:
return fmt.Errorf("consumer token rejected: %w", client.ErrUnauthorized)
}
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// Health reports whether the target server is up - unauthenticated, like
// AuthStatus.
func Health(ctx context.Context, cfg Config) (client.Health, error) {
cl, err := cfg.newClient()
if err != nil {
return client.Health{}, err
}
health, err := cl.Health(ctx)
if err != nil {
return client.Health{}, fmt.Errorf("health: %w", err)
}
return health, nil
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
"github.com/alrayyes/hush-hush-cli/internal/seal"
)
// Inject seals value to recipients and creates a new object under id - the
// writer's process never handles a private key, only recipients' public
// keys (design.md). description is fixed at creation, the same as usedBy.
//
// recipients, given non-empty, is used exactly as given - an explicit
// --recipients always wins over directory resolution. Left empty, each of
// usedBy's consumers' registered public key is resolved from the server's
// consumer directory instead (issue #125), and the call fails naming the
// first consumer with no registered key rather than sealing to fewer
// recipients than requested.
func Inject(ctx context.Context, cfg Config, id string, value []byte, recipients []string, usedBy []string, description string, opts ...WriteOption) error {
w := newWriteOptions(opts)
cl, err := cfg.newClient()
if err != nil {
return err
}
recipients, err = resolveRecipients(ctx, cl, recipients, usedBy)
if err != nil {
return err
}
recipients, err = w.withOwnerKey(ctx, cl, recipients)
if err != nil {
return err
}
sealed, err := seal.Seal(value, recipients)
if err != nil {
return fmt.Errorf("seal value: %w", err)
}
if _, err := cl.Create(ctx, id, sealed, usedBy, description, w.tagsOrNil(), w.keepReadableCopy); err != nil {
return fmt.Errorf("create object: %w", err)
}
return nil
}
// resolveRecipients returns recipients unchanged when non-empty. Otherwise
// it resolves each of usedBy's consumers' registered public key from the
// server's consumer directory.
func resolveRecipients(ctx context.Context, cl *client.Client, recipients, usedBy []string) ([]string, error) {
if len(recipients) > 0 {
return recipients, nil
}
resolved := make([]string, 0, len(usedBy))
for _, name := range usedBy {
key, err := cl.ConsumerPublicKey(ctx, name)
if err != nil {
return nil, fmt.Errorf("resolve --used-by recipients: %w", err)
}
resolved = append(resolved, key)
}
return resolved, nil
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// List returns stored objects' metadata - only those whose used_by
// includes usedBy and that carry every one of tags, when either is given.
// There's nothing to seal or unseal
// here, unlike get/inject/update.
func List(ctx context.Context, cfg Config, usedBy string, tags []string) ([]client.ObjectMetadata, error) {
cl, err := cfg.newClient()
if err != nil {
return nil, err
}
objects, err := cl.List(ctx, usedBy, tags)
if err != nil {
return nil, fmt.Errorf("list objects: %w", err)
}
return objects, nil
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// WriteOption tweaks an Inject or Update call beyond its required
// arguments - variadic so adding one never touches existing callers.
type WriteOption func(*writeOptions)
type writeOptions struct {
// tags is nil when unset. On Update a non-nil empty slice clears the
// object's tags; on Inject empty and nil are the same.
tags *[]string
// usedBy is nil when unset. On Update a non-nil empty slice clears the
// object's consumers.
usedBy *[]string
// keepReadableCopy adds the owner's escrowed key as a recipient.
keepReadableCopy bool
// description and slug are Edit's: nil leaves the field alone.
description *string
slug *string
// variantID names one variant of a name that has several; empty means
// the only one.
variantID string
// consumer picks the variant used by this consumer; empty means none.
consumer string
}
func newWriteOptions(opts []WriteOption) writeOptions {
var w writeOptions
for _, o := range opts {
o(&w)
}
return w
}
// tagsOrNil is the tags to send on create: nothing, unless some were given.
func (w writeOptions) tagsOrNil() []string {
if w.tags == nil {
return nil
}
return *w.tags
}
// WithTags sets an object's tags: on Inject the initial ones, on Update a
// full replacement. Pass an empty, non-nil slice to Update to clear them.
func WithTags(tags []string) WriteOption {
return func(w *writeOptions) { w.tags = &tags }
}
// WithUsedBy replaces an object's consumers on Update. Pass an empty,
// non-nil slice to clear them. Inject takes its consumers as an argument.
func WithUsedBy(usedBy []string) WriteOption {
return func(w *writeOptions) { w.usedBy = &usedBy }
}
// WithVariant makes Update change one variant of a name that has several,
// by the UUID list --json shows.
func WithVariant(id string) WriteOption {
return func(w *writeOptions) { w.variantID = id }
}
// WithConsumer makes Update change the variant used by consumer, for a name
// that has several.
func WithConsumer(consumer string) WriteOption {
return func(w *writeOptions) { w.consumer = consumer }
}
// WithDescription makes Edit replace an object's description; an empty one
// clears it.
func WithDescription(description string) WriteOption {
return func(w *writeOptions) { w.description = &description }
}
// WithSlug makes Edit rename an object, every variant of the name included.
func WithSlug(slug string) WriteOption {
return func(w *writeOptions) { w.slug = &slug }
}
// WithKeepReadableCopy seals the value to the owner's escrowed identity
// key as well, so the owner can decrypt what they wrote. The server never
// does this itself.
func WithKeepReadableCopy() WriteOption {
return func(w *writeOptions) { w.keepReadableCopy = true }
}
// withOwnerKey appends the owner's escrowed public key to recipients when
// keepReadableCopy is set. It fails, rather than sealing without it, when
// the owner has none.
func (w writeOptions) withOwnerKey(ctx context.Context, cl *client.Client, recipients []string) ([]string, error) {
if !w.keepReadableCopy {
return recipients, nil
}
key, err := cl.OwnerPublicKey(ctx)
if err != nil {
return nil, fmt.Errorf("keep readable copy: %w", err)
}
return append(recipients, key), nil
}
package cli
import (
"context"
"fmt"
"time"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// CreateConsumerToken mints a new consumer read token - the caller sets
// cfg.RequireToken, the same as inject/update/delete: minting a
// credential that grants read access is itself a write-path operation.
func CreateConsumerToken(ctx context.Context, cfg Config, consumer, description string, ttl time.Duration) (client.ConsumerTokenWithValue, error) {
cl, err := cfg.newClient()
if err != nil {
return client.ConsumerTokenWithValue{}, err
}
token, err := cl.CreateConsumerToken(ctx, consumer, description, ttl)
if err != nil {
return client.ConsumerTokenWithValue{}, fmt.Errorf("create consumer token: %w", err)
}
return token, nil
}
// ListConsumerTokens returns every issued consumer token's metadata,
// never a raw value.
func ListConsumerTokens(ctx context.Context, cfg Config) ([]client.ConsumerToken, error) {
cl, err := cfg.newClient()
if err != nil {
return nil, err
}
tokens, err := cl.ListConsumerTokens(ctx)
if err != nil {
return nil, fmt.Errorf("list consumer tokens: %w", err)
}
return tokens, nil
}
// RotateConsumerToken replaces id's secret and expiry, keeping its
// consumer and description unchanged.
func RotateConsumerToken(ctx context.Context, cfg Config, id string, ttl time.Duration) (client.ConsumerTokenWithValue, error) {
cl, err := cfg.newClient()
if err != nil {
return client.ConsumerTokenWithValue{}, err
}
token, err := cl.RotateConsumerToken(ctx, id, ttl)
if err != nil {
return client.ConsumerTokenWithValue{}, fmt.Errorf("rotate consumer token: %w", err)
}
return token, nil
}
// RevokeConsumerToken invalidates id - revoking one that's already
// expired or doesn't exist isn't an error.
func RevokeConsumerToken(ctx context.Context, cfg Config, id string) error {
cl, err := cfg.newClient()
if err != nil {
return err
}
if err := cl.RevokeConsumerToken(ctx, id); err != nil {
return fmt.Errorf("revoke consumer token: %w", err)
}
return nil
}
// PurgeConsumerToken permanently removes id, once it's already revoked or
// past its expiry.
func PurgeConsumerToken(ctx context.Context, cfg Config, id string) error {
cl, err := cfg.newClient()
if err != nil {
return err
}
if err := cl.PurgeConsumerToken(ctx, id); err != nil {
return fmt.Errorf("purge consumer token: %w", err)
}
return nil
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
"github.com/alrayyes/hush-hush-cli/internal/seal"
)
// Update seals value to recipients and replaces id's stored value. Its
// used_by metadata stays unchanged (design.md) unless WithUsedBy replaces
// it, and WithTags replaces its tags - the writer's process never handles
// a private key here either, same as Inject.
//
// recipients, given non-empty, is used exactly as given. Left empty, the
// new consumers' registered public keys are resolved the way Inject does,
// and the call fails before touching the server's copy if one has none.
func Update(ctx context.Context, cfg Config, id string, value []byte, recipients []string, opts ...WriteOption) error {
w := newWriteOptions(opts)
cl, err := cfg.newClient()
if err != nil {
return err
}
var usedBy []string
if w.usedBy != nil {
usedBy = *w.usedBy
}
recipients, err = resolveRecipients(ctx, cl, recipients, usedBy)
if err != nil {
return err
}
recipients, err = w.withOwnerKey(ctx, cl, recipients)
if err != nil {
return err
}
sealed, err := seal.Seal(value, recipients)
if err != nil {
return fmt.Errorf("seal value: %w", err)
}
if _, err := cl.Update(ctx, id, sealed, w.tags, w.usedBy, w.keepReadableCopy, client.WithVariant(w.variantID), client.WithConsumer(w.consumer)); err != nil {
return fmt.Errorf("update object: %w", err)
}
return nil
}
package cli
import (
"context"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// UsedBy returns id's recorded consumers - unauthenticated, matching Get,
// not List: enumerating one object's own already-known-id lineage grants
// no capability List's full enumeration does.
func UsedBy(ctx context.Context, cfg Config, id string, opts ...client.CallOption) ([]string, error) {
cl, err := cfg.newClient()
if err != nil {
return nil, err
}
usedBy, err := cl.UsedBy(ctx, id, opts...)
if err != nil {
return nil, fmt.Errorf("used-by: %w", err)
}
return usedBy, nil
}
package cliconfig
import (
"bufio"
"fmt"
"io"
"os"
"path"
"strings"
"github.com/adrg/xdg"
)
// Path returns the YAML config file path for a named tool under
// XDG_CONFIG_HOME (e.g. "hush-hush-cli" -> ~/.config/hush-hush-cli/config.yaml),
// creating parent directories as needed. xdg.Reload picks up a
// XDG_CONFIG_HOME set after the process started - the adrg/xdg package
// otherwise resolves it once, at init.
func Path(tool string) (string, error) {
xdg.Reload()
p, err := xdg.ConfigFile(path.Join(tool, "config.yaml"))
if err != nil {
return "", fmt.Errorf("resolve config path: %w", err)
}
return p, nil
}
// Exists reports whether a config file already exists at configPath.
func Exists(configPath string) bool {
_, err := os.Stat(configPath)
return err == nil
}
// Confirm asks a yes/no question on out, reading the answer from sc, and
// defaults to yes on a bare Enter (or EOF) - cli.md's "offers to run init
// right there" is meant to be the path of least resistance, not another
// gate. sc is a *bufio.Scanner rather than a bare io.Reader so a caller
// that goes on to prompt for more input afterward (maybeOfferInit chaining
// into PromptConfig) can share one scanner across every read: a fresh
// bufio.Scanner wrapping the same underlying reader can silently swallow
// whatever the previous one had already buffered past the first line.
func Confirm(sc *bufio.Scanner, out io.Writer, question string) bool {
// A failed prompt write doesn't change the answer - Confirm reports a
// yes/no, not an I/O outcome - so it's ignored rather than plumbed
// through a signature every caller would then have to handle.
_, _ = fmt.Fprintf(out, "%s [Y/n] ", question)
if !sc.Scan() {
return true
}
switch strings.ToLower(strings.TrimSpace(sc.Text())) {
case "", "y", "yes":
return true
default:
return false
}
}
// ShouldWriteStarter decides whether the "no config file" nudge should
// write one, given:
//
// - configExists: a config file is already there - never overwrite it
// unasked.
// - anyRelevantEnvSet: the tool is already configured through the
// environment (a CI job, say) - no file needed, no nudge either.
// - yes: an explicit --yes flag - write unconditionally, no TTY needed.
// - interactive: stdin is a terminal a prompt can actually use.
// - confirmed: the answer to that prompt, meaningless when interactive
// is false.
func ShouldWriteStarter(configExists, anyRelevantEnvSet, yes, interactive, confirmed bool) bool {
if configExists || anyRelevantEnvSet {
return false
}
if yes {
return true
}
return interactive && confirmed
}
package cliconfig
import (
"bufio"
"fmt"
"io"
)
// OfferOptions is everything OfferInit needs from its caller, so none of it
// has to know about cobra, viper or a real terminal.
type OfferOptions struct {
// ResolvePath finds (and may create the directory for) the config file
// path. It's a callback so OfferInit can skip it entirely when the
// environment already configures the tool: resolving a path has a real
// side effect that can fail on a CI runner with no writable home.
ResolvePath func() (string, error)
In io.Reader
Out io.Writer
Err io.Writer
// FD and ReadPassword read a secret without echo.
FD int
ReadPassword PasswordReader
// Yes is the --yes flag. Interactive is whether stdin is a terminal a
// prompt can use.
Yes bool
Interactive bool
// Current seeds the prompts' defaults.
Current Values
}
// OfferInit is cli.md's "a run with no config file and no relevant
// environment variable set offers to run init right there": skipped once a
// config file exists or the environment already configures the tool, and
// never blocks a non-interactive run on a prompt nothing will answer. It
// reports whether it wrote a config file. A path that can't be resolved is
// advisory only: a run this environment doesn't configure still has to work.
func OfferInit(o OfferOptions) (bool, error) {
anyEnvSet := AnyEnvVarSet()
if anyEnvSet {
return false, nil
}
path, err := o.ResolvePath()
if err != nil {
return false, nil //nolint:nilerr // advisory only, see above
}
exists := Exists(path)
// One scanner for the confirm and the prompts after it: a second one
// over the same reader would lose whatever the first had buffered.
var (
sc *bufio.Scanner
confirmed bool
)
if !o.Yes && o.Interactive && !exists {
sc = bufio.NewScanner(o.In)
confirmed = Confirm(sc, o.Out, "No config file found. Set one up now?")
}
if !ShouldWriteStarter(exists, anyEnvSet, o.Yes, o.Interactive, confirmed) {
return false, printUnconfiguredNudge(o.Err, exists, o.Interactive)
}
if o.Yes {
return true, WriteStarter(path)
}
return true, WriteInteractive(path, sc, o.Out, o.FD, o.ReadPassword, o.Current)
}
// printUnconfiguredNudge is the stderr fallback for the one case
// ShouldWriteStarter leaves nothing written for: fully non-interactive and
// entirely unconfigured, where there was never a prompt to answer.
func printUnconfiguredNudge(w io.Writer, exists, interactive bool) error {
if exists || interactive {
return nil
}
if _, err := fmt.Fprintf(w,
"no config file and no HUSH_HUSH_* environment variables set - running on defaults (`hush-hush-cli init` writes a starter config)\n",
); err != nil {
return fmt.Errorf("write config nudge: %w", err)
}
return nil
}
package cliconfig
import (
"bufio"
"fmt"
"io"
"strings"
)
// PromptValue asks label (showing current as the default) on out, reading
// the answer from sc, and returns current unchanged on a bare Enter (or
// EOF) - the same "path of least resistance" shape as Confirm.
func PromptValue(sc *bufio.Scanner, out io.Writer, label, current string) (string, error) {
if _, err := fmt.Fprintf(out, "%s [%s]: ", label, current); err != nil {
return "", fmt.Errorf("write prompt: %w", err)
}
if !sc.Scan() {
return current, nil
}
if line := strings.TrimSpace(sc.Text()); line != "" {
return line, nil
}
return current, nil
}
// PasswordReader reads one line of masked input from a real terminal file
// descriptor - golang.org/x/term.ReadPassword's own signature, taken as a
// parameter so a caller not attached to a real TTY (a test) can fake one.
type PasswordReader func(fd int) ([]byte, error)
// PromptSecret asks label on out, then reads one masked line via
// readPassword against fd - production code passes term.ReadPassword and
// int(os.Stdin.Fd()); a test passes a fake reader and an arbitrary fd.
func PromptSecret(out io.Writer, label string, fd int, readPassword PasswordReader) (string, error) {
if _, err := fmt.Fprintf(out, "%s: ", label); err != nil {
return "", fmt.Errorf("write prompt: %w", err)
}
b, err := readPassword(fd)
if err != nil {
return "", fmt.Errorf("read %s: %w", label, err)
}
// term.ReadPassword doesn't echo the Enter that ended input, so the
// next line of output would otherwise run on straight after the
// masked prompt.
if _, err := fmt.Fprintln(out); err != nil {
return "", fmt.Errorf("write newline: %w", err)
}
return strings.TrimSpace(string(b)), nil
}
// PersistChoice is how init should persist a credential field the user
// just typed - rules/cli.md's "secrets get a command option, not just a
// value", offered as an explicit menu rather than assumed.
type PersistChoice int
const (
// PersistSkip means don't persist the value at all - it's used for
// this invocation only (or, from PromptConfig's own perspective,
// never entered in the first place).
PersistSkip PersistChoice = iota
// PersistKeyring means store the value in the OS credential store and
// write the generated "hush-hush-cli config keyring-get <field>" as
// the field's _command.
PersistKeyring
// PersistCommand means write a retrieval command the user supplied
// themselves as the field's _command.
PersistCommand
// PersistLiteral means write the value itself into the config file.
PersistLiteral
)
// PromptPersistence offers the four-way persistence choice for a
// credential field named by field (used only in the prompt text), reading
// from sc. An unrecognised or empty answer is PersistSkip - the same
// least-surprise default as Confirm and PromptValue: silence never opts
// a secret into being written to disk.
func PromptPersistence(sc *bufio.Scanner, out io.Writer, field string) (PersistChoice, string, error) {
_, err := fmt.Fprintf(out, "Save %s for next time?\n"+
" 1) OS keyring\n"+
" 2) a command that retrieves it\n"+
" 3) the literal value in the config file\n"+
" 4) don't save it\n"+
"choice [4]: ", field)
if err != nil {
return PersistSkip, "", fmt.Errorf("write prompt: %w", err)
}
if !sc.Scan() {
return PersistSkip, "", nil
}
switch strings.TrimSpace(sc.Text()) {
case "1":
return PersistKeyring, "", nil
case "2":
if _, err := fmt.Fprint(out, "command: "); err != nil {
return PersistSkip, "", fmt.Errorf("write prompt: %w", err)
}
if !sc.Scan() {
return PersistSkip, "", nil
}
return PersistCommand, strings.TrimSpace(sc.Text()), nil
case "3":
return PersistLiteral, "", nil
default:
return PersistSkip, "", nil
}
}
// Values is the plain (non-credential) connection settings PromptConfig
// shows as defaults, and the same three fields it returns unchanged.
type Values struct {
Server string
Caller string
Recipients string
}
// CredentialAnswer is what PromptConfig collected for one credential
// field: the typed value (empty if the prompt was left blank, in which
// case Choice is always PersistSkip and Extra is meaningless) and how the
// caller should persist it.
type CredentialAnswer struct {
Value string
Choice PersistChoice
// Extra is the user-supplied retrieval command when Choice is
// PersistCommand, empty otherwise.
Extra string
}
// PromptResult is everything an interactive init run collected.
type PromptResult struct {
Server string
Caller string
Recipients string
Token CredentialAnswer
Identity CredentialAnswer
}
// PromptConfig walks every connection setting in turn - server, token,
// caller, recipients, identity (design.md) - prompting on out and reading
// from sc, showing current's values as defaults for the three plain
// fields. It takes a *bufio.Scanner, the same as Confirm, rather than
// building its own from an io.Reader: a caller that prompts for something
// else first (maybeOfferInit's own Confirm) needs to hand PromptConfig
// that exact same scanner, not a fresh one over the same underlying
// reader - see Confirm's doc comment for why.
func PromptConfig(sc *bufio.Scanner, out io.Writer, fd int, readPassword PasswordReader, current Values) (PromptResult, error) {
server, err := PromptValue(sc, out, "Server URL", current.Server)
if err != nil {
return PromptResult{}, err
}
token, err := promptCredential(sc, out, "Token", fd, readPassword)
if err != nil {
return PromptResult{}, err
}
caller, err := PromptValue(sc, out, "Caller identity (optional)", current.Caller)
if err != nil {
return PromptResult{}, err
}
recipients, err := PromptValue(sc, out, "Recipients (comma-separated age public keys, optional)", current.Recipients)
if err != nil {
return PromptResult{}, err
}
identity, err := promptCredential(sc, out, "Identity (comma-separated age private keys)", fd, readPassword)
if err != nil {
return PromptResult{}, err
}
return PromptResult{
Server: server,
Caller: caller,
Recipients: recipients,
Token: token,
Identity: identity,
}, nil
}
// promptCredential reads one credential field's value, then - only when
// something was actually typed - offers the persistence choice. Leaving
// the value blank skips both the choice prompt and persistence itself:
// there's nothing to persist.
func promptCredential(sc *bufio.Scanner, out io.Writer, label string, fd int, readPassword PasswordReader) (CredentialAnswer, error) {
value, err := PromptSecret(out, label, fd, readPassword)
if err != nil {
return CredentialAnswer{}, err
}
if value == "" {
return CredentialAnswer{Choice: PersistSkip}, nil
}
choice, extra, err := PromptPersistence(sc, out, label)
if err != nil {
return CredentialAnswer{}, err
}
return CredentialAnswer{Value: value, Choice: choice, Extra: extra}, nil
}
package cliconfig
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/zalando/go-keyring"
)
// KeyringService names hush-hush-cli's own OS keyring entries - one
// "account" per credential field ("token", "identity").
const KeyringService = "hush-hush-cli"
// EnvVars are every HUSH_HUSH_* variable a command reads - the root
// command's persistent flags plus recipients/identity, which are bound
// per-subcommand rather than on root. Used only to decide whether the
// tool is already configured through the environment, not to read a
// value.
var EnvVars = []string{
"HUSH_HUSH_SERVER", "HUSH_HUSH_TOKEN", "HUSH_HUSH_TOKEN_COMMAND", "HUSH_HUSH_CALLER",
"HUSH_HUSH_RECIPIENTS", "HUSH_HUSH_IDENTITY",
"HUSH_HUSH_CONSUMER_TOKEN", "HUSH_HUSH_CONSUMER_TOKEN_COMMAND",
}
// StarterConfig is the blank template `init --yes` and a non-interactive
// init write.
const StarterConfig = `# hush-hush-cli config file. Flags and HUSH_HUSH_* environment variables
# both override these - see README.md#configuration.
server: http://localhost:8080
token: ""
# token_command runs a command and uses its trimmed stdout as the token
# instead - it wins over the literal value above if both are set.
# token_command: "pass show hush-hush/write-token"
# consumer_token is a read-only, consumer-scoped token - only used by
# get, and only when token above is empty. Mint one with
# "hush-hush-cli token create <consumer> --ttl <duration>".
consumer_token: ""
# consumer_token_command works the same as token_command, for consumer_token.
# consumer_token_command: "pass show hush-hush/consumer-token"
caller: ""
recipients: ""
identity: ""
# identity_command runs a command and uses its trimmed stdout as the
# identity instead - it wins over the literal value above if both are set.
# identity_command: "pass show hush-hush/identity-key"
`
// RenderedSecret is a credential field's config-file representation after
// its persistence choice has been applied: at most one of Literal/Command
// is non-empty (PersistSkip leaves both empty, matching an unanswered
// field).
type RenderedSecret struct {
Literal string
Command string
}
// PersistCredential turns one prompted credential answer into its
// config-file representation, storing the value in the OS keyring first
// when that's the chosen persistence.
func PersistCredential(answer CredentialAnswer, field string) (RenderedSecret, error) {
switch answer.Choice {
case PersistKeyring:
if err := keyring.Set(KeyringService, field, answer.Value); err != nil {
return RenderedSecret{}, fmt.Errorf("store %s in keyring: %w", field, err)
}
return RenderedSecret{Command: "hush-hush-cli config keyring-get " + field}, nil
case PersistCommand:
return RenderedSecret{Command: answer.Extra}, nil
case PersistLiteral:
return RenderedSecret{Literal: answer.Value}, nil
default: // PersistSkip
return RenderedSecret{}, nil
}
}
// Render builds the YAML an interactive init writes. Every value is
// double-quoted via strconv.Quote regardless of content - simpler and
// safer than deciding case by case which values need it, at the cost of
// looking less like StarterConfig's own hand-written, selectively-quoted
// style; that constant is untouched and still what --yes/no-TTY writes.
func Render(server string, token RenderedSecret, caller, recipients string, identity RenderedSecret) string {
var b strings.Builder
b.WriteString("# hush-hush-cli config file. Flags and HUSH_HUSH_* environment variables\n")
b.WriteString("# both override these - see README.md#configuration.\n")
fmt.Fprintf(&b, "server: %s\n", strconv.Quote(server))
fmt.Fprintf(&b, "token: %s\n", strconv.Quote(token.Literal))
b.WriteString("# token_command runs a command and uses its trimmed stdout as the token\n")
b.WriteString("# instead - it wins over the literal value above if both are set.\n")
if token.Command != "" {
fmt.Fprintf(&b, "token_command: %s\n", strconv.Quote(token.Command))
} else {
b.WriteString("# token_command: \"pass show hush-hush/write-token\"\n")
}
fmt.Fprintf(&b, "caller: %s\n", strconv.Quote(caller))
fmt.Fprintf(&b, "recipients: %s\n", strconv.Quote(recipients))
fmt.Fprintf(&b, "identity: %s\n", strconv.Quote(identity.Literal))
b.WriteString("# identity_command runs a command and uses its trimmed stdout as the\n")
b.WriteString("# identity instead - it wins over the literal value above if both are set.\n")
if identity.Command != "" {
fmt.Fprintf(&b, "identity_command: %s\n", strconv.Quote(identity.Command))
} else {
b.WriteString("# identity_command: \"pass show hush-hush/identity-key\"\n")
}
return b.String()
}
// AnyEnvVarSet reports whether any HUSH_HUSH_* variable a command reads is
// set, even to an empty value.
func AnyEnvVarSet() bool {
for _, name := range EnvVars {
if _, ok := os.LookupEnv(name); ok {
return true
}
}
return false
}
package cliconfig
import (
"fmt"
"os/exec"
"strings"
)
// ResolveSecret is the shared implementation behind every "<field>"/
// "<field>_command" pair (token/token_command, identity/identity_command):
// rules/cli.md's "secrets get a command option, not just a value". command
// wins over literal if both are set - whoever configured the command form
// did it on purpose.
func ResolveSecret(literal, command string) (string, error) {
if command == "" {
return literal, nil
}
out, err := runSecretCommand(command)
if err != nil {
return "", fmt.Errorf("run: %w", err)
}
return out, nil
}
// runSecretCommand runs cmdStr through the shell (so a pipeline like `pass
// show <path>` works unmodified) and returns its trimmed stdout - exactly
// one trailing newline, not every trailing space, so a secret that
// genuinely ends in whitespace survives.
func runSecretCommand(cmdStr string) (string, error) {
out, err := exec.Command("sh", "-c", cmdStr).Output() //nolint:gosec // cmdStr is operator-supplied config, not external input
if err != nil {
return "", fmt.Errorf("run: %w", err)
}
return strings.TrimSuffix(string(out), "\n"), nil
}
package cliconfig
import (
"bufio"
"fmt"
"io"
"os"
)
// WriteStarter writes the blank StarterConfig template to path, owner-only:
// the file can hold a literal credential once someone fills it in.
func WriteStarter(path string) error {
if err := os.WriteFile(path, []byte(StarterConfig), 0o600); err != nil {
return fmt.Errorf("write config file: %w", err)
}
return nil
}
// WriteInteractive is the value-by-value flow behind init and the
// pre-command nudge: prompt for every connection setting via PromptConfig,
// apply each credential field's chosen persistence, and write the result
// to path. sc and readPassword are parameters rather than built here so the
// nudge can hand over the exact scanner its own Confirm prompt just read
// from (see Confirm for why a fresh one would lose input), and a test can
// fake a TTY that doesn't exist in CI.
func WriteInteractive(path string, sc *bufio.Scanner, out io.Writer, fd int, readPassword PasswordReader, current Values) error {
result, err := PromptConfig(sc, out, fd, readPassword, current)
if err != nil {
return fmt.Errorf("prompt for config: %w", err)
}
token, err := PersistCredential(result.Token, "token")
if err != nil {
return fmt.Errorf("persist token: %w", err)
}
identity, err := PersistCredential(result.Identity, "identity")
if err != nil {
return fmt.Errorf("persist identity: %w", err)
}
content := Render(result.Server, token, result.Caller, result.Recipients, identity)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return fmt.Errorf("write config file: %w", err)
}
return nil
}
package client
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
hushhush "github.com/alrayyes/hush-hush-go/v4"
)
// auditLogPageMax is the server's own page-size cap (api/openapi.yaml's
// GET /audit-log limit parameter, max 500).
const auditLogPageMax = 500
// ErrNoOwnerKey is returned by OwnerPublicKey for an owner who hasn't
// completed a first registration, so has no escrowed identity key yet.
var ErrNoOwnerKey = errors.New("owner has no escrowed identity public key yet")
// Sentinel errors mapped from the server's documented status codes -
// callers match on these rather than inspecting a status code themselves.
var (
ErrUnauthorized = errors.New("missing or invalid bearer token")
ErrNotFound = errors.New("object not found")
ErrAlreadyExists = errors.New("object already exists")
// ErrSeveralVariants is the server's 409 for a name that holds more
// than one variant, addressed without saying which.
ErrSeveralVariants = errors.New("name has several variants; pass --id or --consumer to say which (ids are in list --json)")
)
// CallOption adjusts one call that addresses an existing object by name:
// Get, Update, Patch, Delete and UsedBy.
type CallOption func(*callConfig)
type callConfig struct {
variantID string
consumer string
}
// WithVariant addresses one variant of a name that has several, by the
// UUID list --json shows. Without it a name with one variant is addressed
// as always, and one with several is ErrSeveralVariants.
func WithVariant(id string) CallOption {
return func(c *callConfig) { c.variantID = id }
}
// WithConsumer addresses the one variant of a name whose consumers include
// consumer, for a caller who knows who reads the value and not its UUID.
// Giving it together with WithVariant is the server's 400.
func WithConsumer(consumer string) CallOption {
return func(c *callConfig) { c.consumer = consumer }
}
// objectOptions is the SDK's form of opts.
func objectOptions(opts []CallOption) []hushhush.ObjectOption {
var cfg callConfig
for _, opt := range opts {
opt(&cfg)
}
var out []hushhush.ObjectOption
if cfg.variantID != "" {
out = append(out, hushhush.WithVariantID(cfg.variantID))
}
if cfg.consumer != "" {
out = append(out, hushhush.WithConsumer(cfg.consumer))
}
return out
}
// ErrUnexpectedStatus anchors an unmapped status's error to something
// errors.Is can match, since the status code and server message vary per
// call and can't be a fixed sentinel on their own.
var ErrUnexpectedStatus = errors.New("unexpected status")
// ErrConsumerNoPublicKey is returned by ConsumerPublicKey for a name not in
// the consumer directory at all, or in it but with no key registered -
// either way there's nothing to seal to.
var ErrConsumerNoPublicKey = errors.New("consumer has no registered public key")
// ObjectMetadata is what a successful create, update, or list returns.
// Matches components.schemas.ObjectMetadata in api/openapi.yaml. JSON tags
// exist for List's --json output, the first place this type is ever
// marshaled. Slug, not ID: the server's own internal id is a separate,
// opaque value this API never exposes (alrayyes/Hush-Hush#404).
// ID is the variant's UUID: several objects can share a slug, and this is
// what tells them apart.
type ObjectMetadata struct {
ID string `json:"id"`
Slug string `json:"slug"`
UsedBy []string `json:"used_by,omitempty"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags"`
// CreatedAt through UpdatedBy are only set by List: the server leaves
// them out of a create or update response, and CreatedBy/UpdatedBy
// are also nil for an object that predates auditing.
CreatedAt *time.Time `json:"created_at,omitempty"`
CreatedBy *Actor `json:"created_by,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
UpdatedBy *Actor `json:"updated_by,omitempty"`
}
// Actor is who performed an audited write: Type is "session", "token" or
// "consumer_token", and ID that actor's own id.
type Actor struct {
ID string `json:"id"`
Type string `json:"type"`
}
// Client is a hush-hush API client. Token is the write-path bearer token;
// it's sent on every request, and reads simply ignore it server-side.
type Client struct {
Caller string
sdk *hushhush.Client
}
// New returns a Client backed by the hush-hush-go SDK.
func New(baseURL, token string) (*Client, error) {
sdk, err := hushhush.NewClient(baseURL, hushhush.WithAPIKey(token))
if err != nil {
return nil, fmt.Errorf("build hush-hush-go client: %w", err)
}
return &Client{sdk: sdk}, nil
}
// Create stores value under id, sealed to usedBy's recipients before this
// is ever called - the client itself does no sealing. description is fixed
// at creation, the same as usedBy. tags are optional labels; the server
// lower-cases and de-duplicates them.
func (c *Client) Create(ctx context.Context, id string, value []byte, usedBy []string, description string, tags []string, keepReadableCopy bool) (ObjectMetadata, error) {
req := hushhush.CreateObjectRequest{Slug: id, Value: value}
if keepReadableCopy {
req.KeepReadableCopy = &keepReadableCopy
}
if len(usedBy) > 0 {
req.UsedBy = &usedBy
}
if description != "" {
req.Description = &description
}
if len(tags) > 0 {
req.Tags = &tags
}
meta, err := c.sdk.CreateObject(ctx, req, c.Caller)
if err != nil {
return ObjectMetadata{}, mapError(err)
}
return toObjectMetadata(meta), nil
}
// Get fetches an object's stored ciphertext exactly as sealed.
func (c *Client) Get(ctx context.Context, id string, opts ...CallOption) ([]byte, error) {
value, err := c.sdk.GetObject(ctx, id, c.Caller, objectOptions(opts)...)
if err != nil {
return nil, mapError(err)
}
return value, nil
}
// Update replaces id's stored value, leaving its used_by metadata
// unchanged. A nil tags leaves the object's tags alone; a non-nil one
// replaces them, and an empty one clears them.
func (c *Client) Update(ctx context.Context, id string, value []byte, tags, usedBy *[]string, keepReadableCopy bool, opts ...CallOption) (ObjectMetadata, error) {
req := hushhush.UpdateObjectRequest{Value: value, Tags: tags, UsedBy: usedBy}
if keepReadableCopy {
req.KeepReadableCopy = &keepReadableCopy
}
meta, err := c.sdk.UpdateObject(ctx, id, req, c.Caller, objectOptions(opts)...)
if err != nil {
return ObjectMetadata{}, mapError(err)
}
return toObjectMetadata(meta), nil
}
// Patch changes id's description, tags or slug without touching its sealed
// value. A nil field stays as it is; a non-nil empty description or tags
// clears it. A new slug renames every variant of the name.
func (c *Client) Patch(ctx context.Context, id string, description, slug *string, tags *[]string, opts ...CallOption) (ObjectMetadata, error) {
meta, err := c.sdk.PatchObject(ctx, id, hushhush.PatchObjectRequest{Description: description, Slug: slug, Tags: tags}, c.Caller, objectOptions(opts)...)
if err != nil {
return ObjectMetadata{}, mapError(err)
}
return toObjectMetadata(meta), nil
}
// OwnerPublicKey returns the owner's escrowed identity public key, the age
// recipient to add before sealing when the owner wants a readable copy. It
// needs a token, and returns ErrNoOwnerKey when none is registered yet.
func (c *Client) OwnerPublicKey(ctx context.Context) (string, error) {
identity, err := c.sdk.GetOwnerIdentity(ctx)
if err != nil {
return "", mapError(err)
}
if identity.PublicKey == nil || *identity.PublicKey == "" {
return "", ErrNoOwnerKey
}
return *identity.PublicKey, nil
}
// Delete permanently removes id.
func (c *Client) Delete(ctx context.Context, id string, opts ...CallOption) error {
if err := c.sdk.DeleteObject(ctx, id, c.Caller, objectOptions(opts)...); err != nil {
return mapError(err)
}
return nil
}
// List returns every stored object's metadata, sorted by id - never the
// value. Requires a credential, unlike Get: enumerating every object is a
// capability none of the other, id-scoped reads grant on their own.
func (c *Client) List(ctx context.Context, usedBy string, tags []string) ([]ObjectMetadata, error) {
metas, err := c.sdk.ListObjectsFiltered(ctx, hushhush.ListObjectsFilter{UsedBy: usedBy, Tags: tags})
if err != nil {
return nil, mapError(err)
}
result := make([]ObjectMetadata, len(metas))
for i, m := range metas {
result[i] = toObjectMetadata(&m)
}
return result, nil
}
// UsedBy returns id's recorded consumers - unauthenticated, matching Get's
// auth boundary rather than List's, which needs a credential.
func (c *Client) UsedBy(ctx context.Context, id string, opts ...CallOption) ([]string, error) {
usedBy, err := c.sdk.GetObjectUsedBy(ctx, id, objectOptions(opts)...)
if err != nil {
return nil, mapError(err)
}
return usedBy.UsedBy, nil
}
// ConsumerPublicKey resolves name's registered age public key from the
// server's consumer directory - the lookup inject uses to seal to a
// --used-by consumer with no explicit --recipients override.
func (c *Client) ConsumerPublicKey(ctx context.Context, name string) (string, error) {
key, err := c.sdk.GetConsumerPublicKey(ctx, name)
if err != nil {
return "", mapError(err)
}
if key == nil {
return "", fmt.Errorf("%w: %s", ErrConsumerNoPublicKey, name)
}
return *key, nil
}
// AuthStatus reports whether hush-hush has an admin account bootstrapped
// yet. Matches hushhush.AuthStatus.
type AuthStatus struct {
Bootstrapped bool `json:"bootstrapped"`
}
// AuthStatus asks whether the target server has an admin account
// bootstrapped yet - unauthenticated, matching the SDK's own AuthStatus
// doc comment: no credential is required to call it.
func (c *Client) AuthStatus(ctx context.Context) (AuthStatus, error) {
status, err := c.sdk.AuthStatus(ctx)
if err != nil {
return AuthStatus{}, mapError(err)
}
return AuthStatus{Bootstrapped: status.Bootstrapped}, nil
}
// AuditLogEntry is one row of hush-hush's audit trail. JSON tags match
// api/openapi.yaml's AuditLogEntry shape exactly, since audit-log's
// --format json output encodes this type directly (design.md).
type AuditLogEntry struct {
ID int64 `json:"id"`
Action string `json:"action"`
ObjectID string `json:"object_id"`
Timestamp time.Time `json:"timestamp"`
Caller *string `json:"caller,omitempty"`
IP string `json:"ip"`
ActorType *string `json:"actor_type,omitempty"`
ActorID *string `json:"actor_id,omitempty"`
}
// AuditLogFilter narrows QueryAuditLog; filters combine with AND. Token
// maps onto the server's verified actor filter (a token id, or the admin
// account's own actor id) - named for the CLI's own --token flag rather
// than the SDK's "actor" vocabulary. Limit caps how many entries in total
// QueryAuditLog returns across every page it requests, not a single
// page's size; nil means fetch everything the server has.
type AuditLogFilter struct {
ObjectID *string
Caller *string
Token *string
Since *time.Time
Until *time.Time
Limit *int
}
// QueryAuditLog returns every entry matching filter, oldest first. It
// pages through the server's own limit/after cursor rather than
// truncating one unpaginated response (design.md's paging decision): the
// first request's limit comes from filter.Limit capped at 500, and
// further pages are requested with after set to the previous page's last
// entry's id until filter.Limit entries have been collected or a short
// page (fewer than requested) signals nothing is left.
func (c *Client) QueryAuditLog(ctx context.Context, filter AuditLogFilter) ([]AuditLogEntry, error) {
pageLimit := int32(auditLogPageMax)
if filter.Limit != nil && *filter.Limit > 0 && *filter.Limit < auditLogPageMax {
pageLimit = int32(*filter.Limit)
}
var (
result []AuditLogEntry
after *int64
)
for {
page, err := c.sdk.QueryAuditLog(ctx, hushhush.AuditLogFilter{
ObjectID: filter.ObjectID,
Caller: filter.Caller,
Actor: filter.Token,
From: filter.Since,
To: filter.Until,
After: after,
Limit: &pageLimit,
})
if err != nil {
return nil, mapError(err)
}
for _, e := range page {
result = append(result, toAuditLogEntry(e))
if filter.Limit != nil && len(result) >= *filter.Limit {
return result, nil
}
}
if len(page) < int(pageLimit) {
return result, nil
}
last := page[len(page)-1].Id
after = &last
}
}
func toAuditLogEntry(e hushhush.AuditLogEntry) AuditLogEntry {
entry := AuditLogEntry{
ID: e.Id,
Action: string(e.Action),
ObjectID: e.ObjectId,
Timestamp: e.Timestamp,
Caller: e.Caller,
IP: e.Ip,
ActorID: e.ActorId,
}
if e.ActorType != nil {
actorType := string(*e.ActorType)
entry.ActorType = &actorType
}
return entry
}
func toObjectMetadata(m *hushhush.ObjectMetadata) ObjectMetadata {
meta := ObjectMetadata{ID: m.Id.String(), Slug: m.Slug, Tags: m.Tags}
if meta.Tags == nil {
meta.Tags = []string{}
}
if m.UsedBy != nil {
meta.UsedBy = *m.UsedBy
}
if m.Description != nil {
meta.Description = *m.Description
}
meta.CreatedAt, meta.UpdatedAt = m.CreatedAt, m.UpdatedAt
// hush-hush-go doesn't alias its Actor type, so the fields are read
// off each pointer directly.
if m.CreatedBy != nil {
meta.CreatedBy = &Actor{ID: m.CreatedBy.Id, Type: m.CreatedBy.Type}
}
if m.UpdatedBy != nil {
meta.UpdatedBy = &Actor{ID: m.UpdatedBy.Id, Type: m.UpdatedBy.Type}
}
return meta
}
// mapError translates the SDK's *hushhush.APIError into this package's
// sentinels, so callers keep matching on client.Err* regardless of which
// transport sits underneath.
func mapError(err error) error {
var apiErr *hushhush.APIError
if !errors.As(err, &apiErr) {
return fmt.Errorf("call hush-hush server: %w", err)
}
switch apiErr.StatusCode {
case http.StatusUnauthorized:
return ErrUnauthorized
case http.StatusNotFound:
return ErrNotFound
case http.StatusConflict:
if strings.Contains(apiErr.Message, "several variants") {
return ErrSeveralVariants
}
// The sentinel stays matchable, but a conflict the server explains
// shows its own reason rather than reading as a duplicate.
if apiErr.Message == "" || strings.EqualFold(apiErr.Message, ErrAlreadyExists.Error()) {
return ErrAlreadyExists
}
return fmt.Errorf("%w: %s", ErrAlreadyExists, apiErr.Message)
default:
if apiErr.Message != "" {
return fmt.Errorf("%w %d: %s", ErrUnexpectedStatus, apiErr.StatusCode, apiErr.Message)
}
return fmt.Errorf("%w %d", ErrUnexpectedStatus, apiErr.StatusCode)
}
}
// Health is the server's liveness answer. Matches hushhush.Health.
type Health struct {
Status string `json:"status"`
Environment *string `json:"environment,omitempty"`
}
// Health asks whether the target server is up - unauthenticated, like
// AuthStatus.
func (c *Client) Health(ctx context.Context) (Health, error) {
health, err := c.sdk.Health(ctx)
if err != nil {
return Health{}, mapError(err)
}
return Health{Status: health.Status, Environment: health.Environment}, nil
}
package client
import (
"context"
"errors"
"time"
hushhush "github.com/alrayyes/hush-hush-go/v4"
)
// ErrConsumerTokenNotFound is returned by RotateConsumerToken and
// PurgeConsumerToken for an unknown, already revoked, or already expired
// token id - mapError's own ErrNotFound carries the wrong noun ("object
// not found") for this call.
var ErrConsumerTokenNotFound = errors.New("consumer token not found")
// ErrConsumerTokenActive is returned by PurgeConsumerToken for a token
// that's neither revoked nor expired yet - RevokeConsumerToken has to run
// first, matching the SDK's own PurgeConsumerToken doc comment.
var ErrConsumerTokenActive = errors.New("consumer token is still active - revoke it first")
// ConsumerToken is one issued consumer read token's metadata - never its
// raw value, which by design exists nowhere to return once
// CreateConsumerToken or RotateConsumerToken's caller has already seen it.
type ConsumerToken struct {
ID string `json:"id"`
Consumer string `json:"consumer"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Revoked bool `json:"revoked"`
// Status is "active", "expired" or "revoked", and AllowedActions what
// may still be done to the token. Both come from the server; against
// an older one that doesn't send them, they're derived locally.
Status string `json:"status"`
AllowedActions []string `json:"allowed_actions"`
}
// ConsumerTokenWithValue is CreateConsumerToken and RotateConsumerToken's
// return value - ConsumerToken's own metadata plus the raw token, shown
// here once and never again.
type ConsumerTokenWithValue struct {
ConsumerToken
Value string `json:"value"`
}
// CreateConsumerToken mints a new read token scoped to consumer, valid
// for ttl starting now. Requires a write token - minting a credential
// that grants read access is itself a write-path operation
// (alrayyes/hush-hush#467).
func (c *Client) CreateConsumerToken(ctx context.Context, consumer, description string, ttl time.Duration) (ConsumerTokenWithValue, error) {
resp, err := c.sdk.CreateConsumerToken(ctx, hushhush.CreateConsumerTokenRequest{
Consumer: consumer,
Description: description,
TtlSeconds: int64(ttl.Seconds()),
})
if err != nil {
return ConsumerTokenWithValue{}, mapError(err)
}
return toConsumerTokenWithValue(resp), nil
}
// ListConsumerTokens returns every issued consumer token's metadata,
// never a raw value. Requires a write token.
func (c *Client) ListConsumerTokens(ctx context.Context) ([]ConsumerToken, error) {
tokens, err := c.sdk.ListConsumerTokens(ctx)
if err != nil {
return nil, mapError(err)
}
result := make([]ConsumerToken, len(tokens))
for i, t := range tokens {
result[i] = toConsumerToken(t)
}
return result, nil
}
// RotateConsumerToken replaces id's secret and expiry, keeping its
// consumer and description unchanged - the old secret stops
// authenticating immediately. Requires a write token.
func (c *Client) RotateConsumerToken(ctx context.Context, id string, ttl time.Duration) (ConsumerTokenWithValue, error) {
resp, err := c.sdk.RotateConsumerToken(ctx, id, hushhush.RotateConsumerTokenRequest{TtlSeconds: int64(ttl.Seconds())})
if err != nil {
return ConsumerTokenWithValue{}, mapConsumerTokenIDError(err)
}
return toConsumerTokenWithValue(resp), nil
}
// RevokeConsumerToken invalidates id - revoking one that's already
// expired or doesn't exist isn't an error, matching the SDK's own
// RevokeConsumerToken doc comment. Requires a write token.
func (c *Client) RevokeConsumerToken(ctx context.Context, id string) error {
if err := c.sdk.RevokeConsumerToken(ctx, id); err != nil {
return mapError(err)
}
return nil
}
// PurgeConsumerToken permanently removes id, once it's already revoked or
// past its expiry - RevokeConsumerToken's soft-delete stays the only way
// to invalidate a still-active token. Requires a write token.
func (c *Client) PurgeConsumerToken(ctx context.Context, id string) error {
if err := c.sdk.PurgeConsumerToken(ctx, id); err != nil {
return mapConsumerTokenIDError(err)
}
return nil
}
func toConsumerToken(t hushhush.ConsumerTokenMetadata) ConsumerToken {
token := ConsumerToken{
ID: t.Id,
Consumer: t.Consumer,
Description: t.Description,
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
Revoked: t.Revoked,
}
if t.Status != nil {
token.Status = string(*t.Status)
}
if t.AllowedActions != nil {
token.AllowedActions = *t.AllowedActions
}
return withDerivedState(token)
}
// withDerivedState fills Status and AllowedActions in from Revoked and
// ExpiresAt when the server sent neither.
func withDerivedState(t ConsumerToken) ConsumerToken {
if t.Status == "" {
switch {
case t.Revoked:
t.Status = "revoked"
case !time.Now().Before(t.ExpiresAt):
t.Status = "expired"
default:
t.Status = "active"
}
}
if t.AllowedActions == nil {
if t.Status == "active" {
t.AllowedActions = []string{"rotate", "revoke"}
} else {
t.AllowedActions = []string{"purge"}
}
}
return t
}
func toConsumerTokenWithValue(t *hushhush.ConsumerTokenWithValue) ConsumerTokenWithValue {
token := toConsumerToken(hushhush.ConsumerTokenMetadata{
AllowedActions: t.AllowedActions,
Consumer: t.Consumer,
CreatedAt: t.CreatedAt,
Description: t.Description,
ExpiresAt: t.ExpiresAt,
Id: t.Id,
LastUsedAt: t.LastUsedAt,
Revoked: t.Revoked,
Status: t.Status,
})
return ConsumerTokenWithValue{token, t.Value}
}
// mapConsumerTokenIDError translates mapError's generic, object-flavored
// sentinels into ones with the right noun for a token id - RotateConsumerToken
// and PurgeConsumerToken are the only calls id-scoped enough to need it.
func mapConsumerTokenIDError(err error) error {
mapped := mapError(err)
switch {
case errors.Is(mapped, ErrNotFound):
return ErrConsumerTokenNotFound
case errors.Is(mapped, ErrAlreadyExists):
return ErrConsumerTokenActive
default:
return mapped
}
}
package client
import (
"context"
"errors"
hushhush "github.com/alrayyes/hush-hush-go/v4"
)
// consumersPageMax is the server's own page-size cap (api/openapi.yaml's
// GET /consumers page_size parameter, max 100).
const consumersPageMax = 100
// ErrConsumerNotFound is returned by UpdateConsumer and DeleteConsumer for
// a name not in the directory - mapError's own ErrNotFound carries the
// wrong noun ("object not found") for these calls.
var ErrConsumerNotFound = errors.New("consumer not found")
// ErrConsumerAlreadyExists is returned by AddConsumer for a name already
// in the directory.
var ErrConsumerAlreadyExists = errors.New("consumer already exists")
// Consumer is one consumer directory entry. Matches
// components.schemas.ConsumerEntry in api/openapi.yaml.
type Consumer struct {
Name string `json:"name"`
SecretCount int32 `json:"secret_count"`
PublicKey string `json:"public_key,omitempty"`
}
// ListConsumers returns every directory entry whose name contains query
// (case-insensitive; "" matches all), following the server's pagination so
// the caller always gets the whole set. Requires a write token.
func (c *Client) ListConsumers(ctx context.Context, query string) ([]Consumer, error) {
var (
all []Consumer
pageSize = int32(consumersPageMax)
)
for page := int32(1); ; page++ {
filter := hushhush.ConsumerFilter{Page: &page, PageSize: &pageSize}
if query != "" {
filter.Q = &query
}
res, err := c.sdk.ListConsumers(ctx, filter)
if err != nil {
return nil, mapConsumerError(err)
}
if res.Page == nil {
return all, nil
}
for _, e := range res.Page.Consumers {
all = append(all, toConsumer(e))
}
if len(res.Page.Consumers) == 0 || len(all) >= int(res.Page.Total) {
return all, nil
}
}
}
// AddConsumer adds name to the directory with no secret referencing it
// yet. Requires a write token.
func (c *Client) AddConsumer(ctx context.Context, name string) (Consumer, error) {
e, err := c.sdk.AddConsumer(ctx, hushhush.AddConsumerRequest{Name: name})
if err != nil {
return Consumer{}, mapConsumerError(err)
}
return toConsumer(*e), nil
}
// UpdateConsumer renames name and/or registers its age public key - nil
// leaves a field unchanged. Requires a write token.
func (c *Client) UpdateConsumer(ctx context.Context, name string, newName, publicKey *string) (Consumer, error) {
e, err := c.sdk.UpdateConsumer(ctx, name, hushhush.UpdateConsumerRequest{Name: newName, PublicKey: publicKey})
if err != nil {
return Consumer{}, mapConsumerError(err)
}
return toConsumer(*e), nil
}
// DeleteConsumer strips name from every object's used_by list and the
// directory; no object is deleted. Requires a write token.
func (c *Client) DeleteConsumer(ctx context.Context, name string) error {
if err := c.sdk.DeleteConsumer(ctx, name); err != nil {
return mapConsumerError(err)
}
return nil
}
func toConsumer(e hushhush.ConsumerEntry) Consumer {
out := Consumer{Name: e.Name, SecretCount: e.SecretCount}
if e.PublicKey != nil {
out.PublicKey = *e.PublicKey
}
return out
}
// mapConsumerError swaps mapError's object-flavored sentinels for ones
// with the right noun for a consumer name.
func mapConsumerError(err error) error {
mapped := mapError(err)
switch {
case errors.Is(mapped, ErrNotFound):
return ErrConsumerNotFound
case errors.Is(mapped, ErrAlreadyExists):
return ErrConsumerAlreadyExists
default:
return mapped
}
}
package cmd
import (
"errors"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/alrayyes/hush-hush-cli/internal/render"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// errUnknownAuditLogFormat is a sentinel: a fixed condition (an
// unsupported --format value), not a message built from per-call detail -
// the value itself is per-call detail, wrapped in at the call site.
var errUnknownAuditLogFormat = errors.New("must be table or json")
// newAuditLogCmd makes exactly one request per invocation and exits -
// spec.md's "No live/follow mode" requirement, matching gh's own
// audit-log command shape.
func newAuditLogCmd(v *viper.Viper) *cobra.Command {
var (
objectID, actor, caller, since, until, format string
limit int
)
cmd := &cobra.Command{
Use: "audit-log",
Short: "Query the audit trail of create/read/update/delete calls",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if format != "table" && format != "json" {
return fmt.Errorf("--format %q: %w", format, errUnknownAuditLogFormat)
}
filter, err := cli.ParseAuditLogFilter(objectID, actor, caller, since, until, limit)
if err != nil {
return err //nolint:wrapcheck // the error already names the flag
}
cfg, err := config(v, false)
if err != nil {
return err
}
entries, err := cli.AuditLog(cmd.Context(), cfg, filter)
if err != nil {
return fmt.Errorf("audit-log: %w", err)
}
if format == "json" {
return render.AuditLogJSON(cmd.OutOrStdout(), entries)
}
return render.AuditLogTable(cmd.OutOrStdout(), entries)
},
}
cmd.Flags().StringVar(&objectID, "object", "", "restrict to entries for this object id")
cmd.Flags().StringVar(&actor, "actor", "",
"restrict to entries authenticated by this verified actor (a token id, or the admin account's own actor id)")
cmd.Flags().StringVar(&caller, "caller", "", "restrict to entries recorded with this caller identity")
cmd.Flags().StringVar(&since, "since", "", "restrict to entries at or after this RFC3339 time")
cmd.Flags().StringVar(&until, "until", "", "restrict to entries at or before this RFC3339 time")
cmd.Flags().StringVar(&format, "format", "table", "output format: table or json")
cmd.Flags().IntVar(&limit, "limit", 0, "maximum number of entries to print (0 means every matching entry)")
return cmd
}
package cmd
import (
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/cliconfig"
"github.com/spf13/cobra"
"github.com/zalando/go-keyring"
)
// newConfigCmd groups init's own generated _command subcommands under one
// hidden parent, rather than a bare top-level "keyring-get": nothing here
// is meant to be typed by hand, only shelled out to by a token_command/
// identity_command the interactive init wrote.
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Config-file internals (not for direct use)",
Hidden: true,
}
cmd.AddCommand(newKeyringGetCmd())
return cmd
}
// newKeyringGetCmd is what "hush-hush-cli config keyring-get <field>"
// runs - the command an interactive init generates for the OS-keyring
// persistence choice (rules/cli.md's "secrets get a command option, not
// just a value"). Reading it back through runSecretCommand rather than
// calling keyring.Get directly from config() keeps every credential field
// resolving through the exact same literal-or-command path regardless of
// where the command form points.
func newKeyringGetCmd() *cobra.Command {
return &cobra.Command{
Use: "keyring-get <field>",
Short: "Print a value previously stored in the OS keyring (not for direct use)",
Hidden: true,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
value, err := keyring.Get(cliconfig.KeyringService, args[0])
if err != nil {
return fmt.Errorf("keyring get %s: %w", args[0], err)
}
if _, err := fmt.Fprintln(cmd.OutOrStdout(), value); err != nil {
return fmt.Errorf("write keyring value: %w", err)
}
return nil
},
}
}
package cmd
import (
"errors"
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/alrayyes/hush-hush-cli/internal/client"
"github.com/alrayyes/hush-hush-cli/internal/render"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// errConsumerUpdateNothing is a sentinel: a fixed condition (neither flag
// given), not a message built from per-call detail.
var errConsumerUpdateNothing = errors.New("nothing to update: pass --name and/or --public-key")
// newConsumerCmd groups the consumer directory commands - all of them
// require a write token, the same as list: the directory enumerates names
// no caller necessarily holds the id of.
func newConsumerCmd(v *viper.Viper) *cobra.Command {
cmd := &cobra.Command{
Use: "consumer",
Short: "Manage the consumer directory",
}
cmd.AddCommand(newConsumerListCmd(v))
cmd.AddCommand(newConsumerAddCmd(v))
cmd.AddCommand(newConsumerUpdateCmd(v))
cmd.AddCommand(newConsumerDeleteCmd(v))
return cmd
}
func newConsumerListCmd(v *viper.Viper) *cobra.Command {
var (
query string
asJSON bool
)
cmd := &cobra.Command{
Use: "list",
Short: "List consumers with their secret count and public key",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config(v, true)
if err != nil {
return err
}
consumers, err := cli.ListConsumers(cmd.Context(), cfg, query)
if err != nil {
return fmt.Errorf("consumer list: %w", err)
}
if asJSON {
if consumers == nil {
consumers = []client.Consumer{}
}
return render.ConsumersJSON(cmd.OutOrStdout(), consumers)
}
return render.ConsumersTable(cmd.OutOrStdout(), consumers)
},
}
cmd.Flags().StringVar(&query, "query", "", "only consumers whose name contains this text (case-insensitive)")
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON array instead of a table")
return cmd
}
func newConsumerAddCmd(v *viper.Viper) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "add <name>",
Short: "Add a consumer no secret references yet",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config(v, true)
if err != nil {
return err
}
consumer, err := cli.AddConsumer(cmd.Context(), cfg, args[0])
if err != nil {
return fmt.Errorf("consumer add: %w", err)
}
return render.OneConsumer(cmd.OutOrStdout(), consumer, asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON object instead of a table")
return cmd
}
func newConsumerUpdateCmd(v *viper.Viper) *cobra.Command {
var (
newName string
publicKey string
asJSON bool
)
cmd := &cobra.Command{
Use: "update <name>",
Short: "Rename a consumer and/or register its age public key",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var namePtr, keyPtr *string
if cmd.Flags().Changed("name") {
namePtr = &newName
}
if cmd.Flags().Changed("public-key") {
keyPtr = &publicKey
}
if namePtr == nil && keyPtr == nil {
return errConsumerUpdateNothing
}
cfg, err := config(v, true)
if err != nil {
return err
}
consumer, err := cli.UpdateConsumer(cmd.Context(), cfg, args[0], namePtr, keyPtr)
if err != nil {
return fmt.Errorf("consumer update: %w", err)
}
return render.OneConsumer(cmd.OutOrStdout(), consumer, asJSON)
},
}
cmd.Flags().StringVar(&newName, "name", "", "the consumer's new name (merges into an existing one of that name)")
cmd.Flags().StringVar(&publicKey, "public-key", "", "the consumer's age public key (age1...)")
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON object instead of a table")
return cmd
}
func newConsumerDeleteCmd(v *viper.Viper) *cobra.Command {
return &cobra.Command{
Use: "delete <name>",
Short: "Remove a consumer from every secret that references it",
Long: "Strips the consumer from every secret's used_by list and the directory. " +
"No secret is deleted, even one left with an empty used_by list.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config(v, true)
if err != nil {
return err
}
if err := cli.DeleteConsumer(cmd.Context(), cfg, args[0]); err != nil {
return fmt.Errorf("consumer delete: %w", err)
}
return nil
},
}
}
package cmd
import (
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func newDeleteCmd(v *viper.Viper) *cobra.Command {
var variant variantFlags
cmd := &cobra.Command{
Use: "delete <id>",
Short: "Permanently remove an object",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := variant.validate(); err != nil {
return err
}
cfg, err := config(v, true)
if err != nil {
return err
}
if err := cli.Delete(cmd.Context(), cfg, args[0], variant.callOptions()...); err != nil {
return fmt.Errorf("delete: %w", err)
}
return nil
},
}
variant.register(cmd)
return cmd
}
package cmd
import (
"errors"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var errNothingToEdit = errors.New("nothing to edit: give at least one of --description, --tag, --clear-tags or --slug")
// newEditCmd changes an object's metadata only. Unlike update it reads no
// value from stdin and seals nothing.
func newEditCmd(v *viper.Viper) *cobra.Command {
var (
f variantFlags
description string
slug string
tags []string
clearTags bool
)
cmd := &cobra.Command{
Use: "edit <id>",
Short: "Change an object's description, tags or name without touching its value",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := f.validate(); err != nil {
return err
}
changed := cmd.Flags().Changed
switch {
case clearTags && changed("tag"):
return errTagAndClearTags
case !changed("description") && !changed("slug") && !changed("tag") && !clearTags:
return errNothingToEdit
}
cfg, err := config(v, true)
if err != nil {
return err
}
return cli.Edit(cmd.Context(), cfg, args[0], editOptions(cmd, f, description, slug, tags, clearTags)...)
},
}
f.register(cmd)
cmd.Flags().StringVar(&description, "description", "", "replace the free-text label (empty clears it)")
cmd.Flags().StringVar(&slug, "slug", "", "rename the object; every variant of the name moves")
cmd.Flags().StringSliceVar(&tags, "tag", nil, tagFlagUsage+"; replaces the object's tags")
cmd.Flags().BoolVar(&clearTags, "clear-tags", false, "remove every tag from the object")
return cmd
}
// editOptions turns the flags the user actually gave into Edit's options; a
// flag left out is not sent, so the server leaves that field alone.
func editOptions(cmd *cobra.Command, f variantFlags, description, slug string, tags []string, clearTags bool) []cli.WriteOption {
changed := cmd.Flags().Changed
var opts []cli.WriteOption
if changed("description") {
opts = append(opts, cli.WithDescription(description))
}
if changed("slug") {
opts = append(opts, cli.WithSlug(slug))
}
switch {
case clearTags:
opts = append(opts, cli.WithTags([]string{}))
case changed("tag"):
opts = append(opts, cli.WithTags(tags))
}
if f.id != "" {
opts = append(opts, cli.WithVariant(f.id))
}
if f.consumer != "" {
opts = append(opts, cli.WithConsumer(f.consumer))
}
return opts
}
package cmd
import (
"errors"
"fmt"
"strings"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/alrayyes/hush-hush-cli/internal/cliconfig"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// errNoIdentity is a sentinel: a fixed condition (no decrypting key was
// configured at all), not a message built from per-call detail.
var errNoIdentity = errors.New("no identity configured (--identity, HUSH_HUSH_IDENTITY, or run `hush-hush-cli init`)")
// newGetCmd writes the decrypted value to stdout, and nothing else - no
// assembled file, no consumer-side file-shape logic (the cli spec's
// "get returns exactly one decrypted value to stdout per call").
func newGetCmd(v *viper.Viper) *cobra.Command {
var variant variantFlags
cmd := &cobra.Command{
Use: "get <id>",
Short: "Fetch and decrypt one value to stdout",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := variant.validate(); err != nil {
return err
}
identities, err := cliconfig.ResolveSecret(v.GetString("identity"), v.GetString("identity_command"))
if err != nil {
return fmt.Errorf("identity_command: %w", err)
}
if identities == "" {
return errNoIdentity
}
cfg, err := config(v, false)
if err != nil {
return err
}
plaintext, err := cli.Get(cmd.Context(), cfg, args[0], strings.Split(identities, ","), variant.callOptions()...)
if err != nil {
return fmt.Errorf("get: %w", err)
}
_, err = cmd.OutOrStdout().Write(plaintext)
if err != nil {
return fmt.Errorf("write value to stdout: %w", err)
}
return nil
},
}
variant.register(cmd)
cmd.Flags().String("identity", "", "comma-separated age private keys")
cmd.Flags().String("identity-command", "", "command whose trimmed stdout is the identity instead (wins over --identity if both are set)")
_ = v.BindPFlag("identity", cmd.Flags().Lookup("identity"))
_ = v.BindPFlag("identity_command", cmd.Flags().Lookup("identity-command"))
return cmd
}
package cmd
import (
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/alrayyes/hush-hush-cli/internal/render"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// newHealthCmd is unauthenticated like status: a probe or deploy script
// with no token still gets an answer. A server that is down is an error
// (non-zero exit), unlike status's unbootstrapped case, so a script can
// wait on it.
func newHealthCmd(v *viper.Viper) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "health",
Short: "Report whether the target server is up",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config(v, false)
if err != nil {
return err
}
health, err := cli.Health(cmd.Context(), cfg)
if err != nil {
return fmt.Errorf("health: %w", err)
}
if asJSON {
return render.HealthJSON(cmd.OutOrStdout(), health)
}
return render.HealthText(cmd.OutOrStdout(), health)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON object instead of plain lines")
return cmd
}
package cmd
import (
"errors"
"fmt"
"io"
"strings"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// errNoRecipients is a sentinel rather than a plain fmt.Errorf: it's a
// fixed condition (recipients weren't configured at all), not a message
// built from per-call detail.
var errNoRecipients = errors.New("no recipients configured (--recipients, HUSH_HUSH_RECIPIENTS, or run `hush-hush-cli init`)")
// errNoRecipientsOrUsedBy is inject's own version of errNoRecipients:
// unlike update, inject can still resolve recipients from --used-by's
// registered consumer keys, so the message names that path too.
var errNoRecipientsOrUsedBy = errors.New("no recipients configured (--recipients, HUSH_HUSH_RECIPIENTS, --used-by naming a consumer with a registered public key, or run `hush-hush-cli init`)")
// errTagAndClearTags is a sentinel: a fixed condition (both flags given).
var errTagAndClearTags = errors.New("--tag and --clear-tags can't be combined")
// errUsedByAndClearUsedBy is a sentinel: a fixed condition (both flags given).
var errUsedByAndClearUsedBy = errors.New("--used-by and --clear-used-by can't be combined")
// keepReadableCopyUsage is shared by inject and update's --keep-readable-copy.
const keepReadableCopyUsage = "also seal to your own escrowed identity key, so you can decrypt it yourself"
// injectOptions are inject's tags plus, when asked for, the owner's copy.
func injectOptions(tags []string, keepCopy bool) []cli.WriteOption {
opts := []cli.WriteOption{cli.WithTags(tags)}
if keepCopy {
opts = append(opts, cli.WithKeepReadableCopy())
}
return opts
}
// tagFlagUsage is shared by inject and update's --tag.
const tagFlagUsage = "label for grouping secrets (repeatable, or comma-separated; 1-32 chars of a-z 0-9 . _ / -, max 10)"
// newInjectCmd reads the plaintext value from stdin rather than a flag -
// a flag value ends up in shell history and process listings, exactly
// what injecting a secret should avoid.
func newInjectCmd(v *viper.Viper) *cobra.Command {
var (
usedBy []string
tags []string
description string
keepCopy bool
)
cmd := &cobra.Command{
Use: "inject <id>",
Short: "Seal a value (read from stdin) and create a new object",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Rebound here rather than at construction: inject and update
// share the --recipients/HUSH_HUSH_RECIPIENTS name, and binding
// both flag objects to the one viper key at construction time
// leaves viper pointing at whichever command was registered
// last on root, silently ignoring the other's flag.
_ = v.BindPFlag("recipients", cmd.Flags().Lookup("recipients"))
recipients := v.GetString("recipients")
if recipients == "" && len(usedBy) == 0 {
return errNoRecipientsOrUsedBy
}
var recipientList []string
if recipients != "" {
recipientList = strings.Split(recipients, ",")
}
value, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return fmt.Errorf("read value from stdin: %w", err)
}
cfg, err := config(v, true)
if err != nil {
return err
}
return cli.Inject(cmd.Context(), cfg, args[0], value, recipientList, usedBy, description, injectOptions(tags, keepCopy)...)
},
}
cmd.Flags().StringSliceVar(&usedBy, "used-by", nil, "consumers of this secret (repeatable, or comma-separated)")
cmd.Flags().String("recipients", "", "comma-separated age recipient public keys")
cmd.Flags().StringSliceVar(&tags, "tag", nil, tagFlagUsage)
cmd.Flags().BoolVar(&keepCopy, "keep-readable-copy", false, keepReadableCopyUsage)
cmd.Flags().StringVar(&description, "description", "", "free-text label for this object, fixed at creation")
return cmd
}
package cmd
import (
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/alrayyes/hush-hush-cli/internal/render"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// newListCmd requires a token, unlike get: enumerating every stored object
// is a capability none of the other, id-scoped reads grant on their own,
// matching hush-hush's own GET /objects.
func newListCmd(v *viper.Viper) *cobra.Command {
var (
asJSON bool
usedBy string
tags []string
)
cmd := &cobra.Command{
Use: "list",
Short: "List stored objects' metadata",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config(v, true)
if err != nil {
return err
}
objects, err := cli.List(cmd.Context(), cfg, usedBy, tags)
if err != nil {
return fmt.Errorf("list: %w", err)
}
if asJSON {
return render.ObjectsJSON(cmd.OutOrStdout(), objects)
}
return render.ObjectsTable(cmd.OutOrStdout(), objects)
},
}
cmd.Flags().StringVar(&usedBy, "used-by", "", "only objects whose used_by includes this consumer")
cmd.Flags().StringSliceVar(&tags, "tag", nil, "only objects carrying this tag (repeatable, or comma-separated; all must match)")
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON array instead of a table")
return cmd
}
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
// newManCmd generates one man page per command into dir, from root's own
// already-defined tree - the same Use/Short/Long/flags --help already
// reads from, so the page can't drift out of sync with the command it
// documents. Hidden: this is a build-time step
// (.goreleaser.yml's before.hooks runs `go run ./cmd/hush-hush-cli man`),
// not something an end user reaches for.
//
// root is captured by reference, not value: newManCmd runs before
// newRootCmd's own AddCommand calls finish, but RunE only runs later, by
// which point root's full command tree exists.
func newManCmd(root *cobra.Command) *cobra.Command {
return &cobra.Command{
Use: "man <dir>",
Short: "Generate man pages into dir",
Hidden: true,
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
// GenManTree writes into dir directly, no mkdir of its own.
if err := os.MkdirAll(args[0], 0o750); err != nil {
return fmt.Errorf("create %s: %w", args[0], err)
}
header := &doc.GenManHeader{
Title: "HUSH-HUSH-CLI",
Section: "1",
}
if err := doc.GenManTree(root, header, args[0]); err != nil {
return fmt.Errorf("generate man pages: %w", err)
}
return nil
},
}
}
package cmd
import (
"bufio"
"errors"
"fmt"
"os"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/alrayyes/hush-hush-cli/internal/cliconfig"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"
)
// errConfigAlreadyExists is a sentinel rather than a plain fmt.Errorf: a
// fixed condition (a file is already there), not a message built from
// per-call detail - the path itself is per-call detail, so it's wrapped
// in rather than folded into the message.
var errConfigAlreadyExists = errors.New("config file already exists (use --force to overwrite)")
// Option configures NewRootCmd.
type Option func(*options)
type options struct {
configPath string
}
// pathResolver is the config-file path lookup: the fixed path WithConfigPath
// gave, else the one under XDG_CONFIG_HOME.
func (o options) pathResolver() func() (string, error) {
if o.configPath == "" {
return configFilePath
}
return func() (string, error) { return o.configPath, nil }
}
// WithConfigPath reads and writes the config file at path instead of the one
// under XDG_CONFIG_HOME. A test that uses it needs no t.Setenv, so it can
// run in parallel.
func WithConfigPath(path string) Option {
return func(o *options) { o.configPath = path }
}
// NewRootCmd wires the persistent, env-overridable connection config
// (server URL, bearer token, caller identity) shared by every subcommand.
// HUSH_HUSH_SERVER, HUSH_HUSH_TOKEN, and HUSH_HUSH_CALLER override their
// matching flags - a CI job supplies these through its own secret storage,
// with no bespoke wrapper or Action (the cli spec's "runs unmodified
// inside CI" requirement). A config file at configPath() sits below both:
// rules/cli.md's flags > environment > config file > defaults.
func NewRootCmd(version string, opts ...Option) *cobra.Command {
root, _ := newRootCmd(version, opts...)
return root
}
// newRootCmd is NewRootCmd plus the viper instance every subcommand reads,
// for the internal tests that call config() directly.
func newRootCmd(version string, opts ...Option) (*cobra.Command, *viper.Viper) {
var o options
for _, opt := range opts {
opt(&o)
}
resolvePath := o.pathResolver()
// One viper per command tree, not the package-level one: two roots (two
// tests, say) must never see each other's flags, env or config file.
v := viper.New()
root := &cobra.Command{
Use: "hush-hush-cli",
Short: "Client for the hush-hush secrets object store",
Version: version,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
// man generates pages by walking the command tree, not by
// connecting to a server - the same reason init is exempt.
// config/keyring-get is what a keyring-persisted _command
// shells out to - it must never itself trigger the nudge, or
// a token_command pointing at it would recurse into prompting
// for setup every time something resolves the token.
switch cmd.Name() {
case "init", "man", "config", "keyring-get":
return nil
default:
return maybeOfferInit(v, resolvePath, cmd)
}
},
}
root.PersistentFlags().String("server", "http://localhost:8080", "hush-hush server URL")
root.PersistentFlags().String("token", "", "write-path bearer token")
root.PersistentFlags().String("token-command", "", "command whose trimmed stdout is the write-path bearer token (wins over --token if both are set)")
root.PersistentFlags().String("consumer-token", "", "read-only, consumer-scoped bearer token - used by get only, as a fallback when --token isn't set")
root.PersistentFlags().String("consumer-token-command", "", "command whose trimmed stdout is the consumer token instead (wins over --consumer-token if both are set)")
root.PersistentFlags().String("caller", "", "self-presented identity recorded in the audit log")
root.PersistentFlags().BoolP("yes", "y", false, "write a starter config with no prompt, if none exists")
bindConnectionFlags(root, v)
v.SetEnvPrefix("hush_hush")
v.AutomaticEnv()
if path, err := resolvePath(); err == nil {
v.SetConfigFile(path)
v.SetConfigType("yaml")
_ = v.ReadInConfig() // no config file yet is not an error
}
root.AddCommand(newInitCmd(v, resolvePath))
root.AddCommand(newInjectCmd(v))
root.AddCommand(newGetCmd(v))
root.AddCommand(newUpdateCmd(v))
root.AddCommand(newEditCmd(v))
root.AddCommand(newListCmd(v))
root.AddCommand(newDeleteCmd(v))
root.AddCommand(newUsedByCmd(v))
root.AddCommand(newAuditLogCmd(v))
root.AddCommand(newTokenCmd(v))
root.AddCommand(newConsumerCmd(v))
root.AddCommand(newStatusCmd(v))
root.AddCommand(newHealthCmd(v))
root.AddCommand(newConfigCmd())
root.AddCommand(newManCmd(root))
return root, v
}
// bindConnectionFlags binds the persistent connection flags to their config
// keys, so a flag, an environment variable and a config-file key all name
// the same setting.
func bindConnectionFlags(root *cobra.Command, v *viper.Viper) {
for _, name := range []string{"server", "token", "caller"} {
_ = v.BindPFlag(name, root.PersistentFlags().Lookup(name))
}
_ = v.BindPFlag("token_command", root.PersistentFlags().Lookup("token-command"))
_ = v.BindPFlag("consumer_token", root.PersistentFlags().Lookup("consumer-token"))
_ = v.BindPFlag("consumer_token_command", root.PersistentFlags().Lookup("consumer-token-command"))
}
// config resolves the CLI's connection settings, running --token-command/
// HUSH_HUSH_TOKEN_COMMAND if set - rules/cli.md's "secrets get a command
// option, not just a value", so a token can come from `pass`, an
// age-encrypted file, or a keyring CLI instead of sitting in the config
// file as plaintext. The command wins over a literal --token/token if both
// are set: whoever configured the command form did it on purpose.
//
// consumer_token/consumer_token_command resolve the same way, for get's
// fallback read-only credential (internal/cli.Get, design.md).
//
// requireToken is set by inject/update/delete and left false by get: a
// missing token then fails Validate() here, before any request reaches
// the server, rather than surfacing as a bare 401 from deep inside the
// SDK. get accepts either token being empty - it falls back to whichever
// of Token/ConsumerToken is set, or neither, itself.
func config(v *viper.Viper, requireToken bool) (cli.Config, error) {
token, err := cliconfig.ResolveSecret(v.GetString("token"), v.GetString("token_command"))
if err != nil {
return cli.Config{}, fmt.Errorf("token_command: %w", err)
}
consumerToken, err := cliconfig.ResolveSecret(v.GetString("consumer_token"), v.GetString("consumer_token_command"))
if err != nil {
return cli.Config{}, fmt.Errorf("consumer_token_command: %w", err)
}
cfg := cli.Config{
Server: v.GetString("server"),
Token: token,
ConsumerToken: consumerToken,
Caller: v.GetString("caller"),
RequireToken: requireToken,
}
if err := cfg.Validate(); err != nil {
return cli.Config{}, fmt.Errorf("invalid config: %w", err)
}
return cfg, nil
}
func configFilePath() (string, error) {
path, err := cliconfig.Path("hush-hush-cli")
if err != nil {
return "", fmt.Errorf("resolve hush-hush-cli config path: %w", err)
}
return path, nil
}
// newInitCmd writes a starter config file populated with the same
// defaults the tool would otherwise fall back to, ready to edit
// (rules/cli.md).
func newInitCmd(v *viper.Viper, resolvePath func() (string, error)) *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "init",
Short: "Write a starter config file",
RunE: func(cmd *cobra.Command, _ []string) error {
path, err := resolvePath()
if err != nil {
return err
}
if cliconfig.Exists(path) && !force {
return fmt.Errorf("%s: %w", path, errConfigAlreadyExists)
}
yes, _ := cmd.Flags().GetBool("yes")
if !yes && term.IsTerminal(int(os.Stdin.Fd())) {
return runInteractiveInit(v, cmd, path, bufio.NewScanner(cmd.InOrStdin()), term.ReadPassword)
}
return writeStarterConfig(cmd, path)
},
}
cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing config file")
return cmd
}
func writeStarterConfig(cmd *cobra.Command, path string) error {
if err := cliconfig.WriteStarter(path); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return reportWrote(cmd, path)
}
// runInteractiveInit is the value-by-value flow behind init's own RunE and
// the pre-command nudge (maybeOfferInit): the prompting, persistence and
// writing live in cliconfig.WriteInteractive, seeded here with whatever
// viper already resolved. sc and readPassword are threaded through for the
// reasons its doc comment gives.
func runInteractiveInit(v *viper.Viper, cmd *cobra.Command, path string, sc *bufio.Scanner, readPassword cliconfig.PasswordReader) error {
if err := cliconfig.WriteInteractive(path, sc, cmd.OutOrStdout(), int(os.Stdin.Fd()), readPassword, currentValues(v)); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return reportWrote(cmd, path)
}
// currentValues seeds the prompts' defaults with whatever viper resolved.
func currentValues(v *viper.Viper) cliconfig.Values {
return cliconfig.Values{
Server: v.GetString("server"),
Caller: v.GetString("caller"),
Recipients: v.GetString("recipients"),
}
}
func reportWrote(cmd *cobra.Command, path string) error {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "wrote %s\n", path); err != nil {
return fmt.Errorf("write init confirmation: %w", err)
}
return nil
}
// maybeOfferInit wires cliconfig.OfferInit to this process's real terminal
// and viper, then reloads the config it may just have written. The
// decision logic lives in cliconfig, where the confirmed-interactively
// branch is testable: go test's own stdin is never a TTY, so only the
// term.IsTerminal call below can't be exercised through root.Execute().
func maybeOfferInit(v *viper.Viper, resolvePath func() (string, error), cmd *cobra.Command) error {
yes, _ := cmd.Flags().GetBool("yes")
var path string
wrote, err := cliconfig.OfferInit(cliconfig.OfferOptions{
ResolvePath: func() (string, error) {
var err error
path, err = resolvePath()
return path, err
},
In: cmd.InOrStdin(),
Out: cmd.OutOrStdout(),
Err: cmd.ErrOrStderr(),
FD: int(os.Stdin.Fd()),
ReadPassword: term.ReadPassword,
Yes: yes,
Interactive: term.IsTerminal(int(os.Stdin.Fd())),
Current: currentValues(v),
})
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
if !wrote {
return nil
}
if err := reportWrote(cmd, path); err != nil {
return err
}
if err := v.ReadInConfig(); err != nil {
return fmt.Errorf("read newly written config: %w", err)
}
return nil
}
package cmd
import (
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/alrayyes/hush-hush-cli/internal/render"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// newStatusCmd is unauthenticated, matching internal/cli.AuthStatus - it
// works with no token configured at all, unlike list/inject/update/delete.
// An unbootstrapped server is a normal result to print, not an error: exit
// 0 either way (#107's own acceptance criteria).
func newStatusCmd(v *viper.Viper) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "status",
Short: "Report whether the target server has an admin account bootstrapped yet",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config(v, false)
if err != nil {
return err
}
status, err := cli.AuthStatus(cmd.Context(), cfg)
if err != nil {
return fmt.Errorf("status: %w", err)
}
if asJSON {
return render.StatusJSON(cmd.OutOrStdout(), status)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "bootstrapped: %t\n", status.Bootstrapped)
if err != nil {
return fmt.Errorf("write status: %w", err)
}
return nil
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON object instead of a plain line")
return cmd
}
package cmd
import (
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/alrayyes/hush-hush-cli/internal/render"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// newTokenCmd groups every consumer read token lifecycle command - all of
// them require a write token, the same as inject/update/delete: minting
// or managing a credential that grants read access is itself a
// write-path operation (alrayyes/hush-hush#467).
func newTokenCmd(v *viper.Viper) *cobra.Command {
cmd := &cobra.Command{
Use: "token",
Short: "Mint and manage consumer read tokens",
}
cmd.AddCommand(newTokenCreateCmd(v))
cmd.AddCommand(newTokenListCmd(v))
cmd.AddCommand(newTokenRotateCmd(v))
cmd.AddCommand(newTokenRevokeCmd(v))
cmd.AddCommand(newTokenPurgeCmd(v))
return cmd
}
func newTokenCreateCmd(v *viper.Viper) *cobra.Command {
var (
description string
ttl string
asJSON bool
)
cmd := &cobra.Command{
Use: "create <consumer>",
Short: "Mint a new consumer read token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
d, err := cli.ParseTTL(ttl)
if err != nil {
return err //nolint:wrapcheck // the error already names the flag
}
cfg, err := config(v, true)
if err != nil {
return err
}
token, err := cli.CreateConsumerToken(cmd.Context(), cfg, args[0], description, d)
if err != nil {
return fmt.Errorf("token create: %w", err)
}
return render.ConsumerTokenWithValue(cmd.OutOrStdout(), token, asJSON)
},
}
cmd.Flags().StringVar(&description, "description", "", "free-text label for the token")
cmd.Flags().StringVar(&ttl, "ttl", "", "how long the token stays valid, starting now (a Go duration, e.g. 720h)")
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON object instead of a table")
return cmd
}
func newTokenListCmd(v *viper.Viper) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "list",
Short: "List every issued consumer token's metadata",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config(v, true)
if err != nil {
return err
}
tokens, err := cli.ListConsumerTokens(cmd.Context(), cfg)
if err != nil {
return fmt.Errorf("token list: %w", err)
}
if asJSON {
return render.ConsumerTokensJSON(cmd.OutOrStdout(), tokens)
}
return render.ConsumerTokensTable(cmd.OutOrStdout(), tokens)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON array instead of a table")
return cmd
}
func newTokenRotateCmd(v *viper.Viper) *cobra.Command {
var (
ttl string
asJSON bool
)
cmd := &cobra.Command{
Use: "rotate <id>",
Short: "Replace a consumer token's secret and expiry",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
d, err := cli.ParseTTL(ttl)
if err != nil {
return err //nolint:wrapcheck // the error already names the flag
}
cfg, err := config(v, true)
if err != nil {
return err
}
token, err := cli.RotateConsumerToken(cmd.Context(), cfg, args[0], d)
if err != nil {
return fmt.Errorf("token rotate: %w", err)
}
return render.ConsumerTokenWithValue(cmd.OutOrStdout(), token, asJSON)
},
}
cmd.Flags().StringVar(&ttl, "ttl", "", "how long the rotated token stays valid, starting now (a Go duration, e.g. 720h)")
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON object instead of a table")
return cmd
}
func newTokenRevokeCmd(v *viper.Viper) *cobra.Command {
return &cobra.Command{
Use: "revoke <id>",
Short: "Invalidate a consumer token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config(v, true)
if err != nil {
return err
}
if err := cli.RevokeConsumerToken(cmd.Context(), cfg, args[0]); err != nil {
return fmt.Errorf("token revoke: %w", err)
}
return nil
},
}
}
func newTokenPurgeCmd(v *viper.Viper) *cobra.Command {
return &cobra.Command{
Use: "purge <id>",
Short: "Permanently remove an already-revoked or expired consumer token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config(v, true)
if err != nil {
return err
}
if err := cli.PurgeConsumerToken(cmd.Context(), cfg, args[0]); err != nil {
return fmt.Errorf("token purge: %w", err)
}
return nil
},
}
}
package cmd
import (
"fmt"
"io"
"strings"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// newUpdateCmd reads the new plaintext value from stdin, same reasoning as
// newInjectCmd: a flag value ends up in shell history and process listings.
func newUpdateCmd(v *viper.Viper) *cobra.Command {
var f updateFlags
cmd := &cobra.Command{
Use: "update <id>",
Short: "Seal a new value (read from stdin) and replace an object's stored value",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := f.variant.validate(); err != nil {
return err
}
// See newInjectCmd: rebound here, not at construction, since
// inject and update share the --recipients/HUSH_HUSH_RECIPIENTS
// name and only one command's flag object can hold the viper
// key at a time.
_ = v.BindPFlag("recipients", cmd.Flags().Lookup("recipients"))
recipients, err := f.recipients(v, cmd)
if err != nil {
return err
}
value, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return fmt.Errorf("read value from stdin: %w", err)
}
cfg, err := config(v, true)
if err != nil {
return err
}
return cli.Update(cmd.Context(), cfg, args[0], value, recipients, f.options(cmd)...)
},
}
f.variant.register(cmd)
cmd.Flags().String("recipients", "", "comma-separated age recipient public keys")
cmd.Flags().StringSliceVar(&f.tags, "tag", nil, tagFlagUsage+"; replaces the object's tags")
cmd.Flags().BoolVar(&f.clearTags, "clear-tags", false, "remove every tag from the object")
cmd.Flags().StringSliceVar(&f.usedBy, "used-by", nil, "replace the object's consumers (repeatable, or comma-separated); seals to their registered keys unless --recipients is given")
cmd.Flags().BoolVar(&f.keepCopy, "keep-readable-copy", false, keepReadableCopyUsage)
cmd.Flags().BoolVar(&f.clearUsedBy, "clear-used-by", false, "remove every consumer from the object")
return cmd
}
// updateFlags holds update's tag and consumer flags.
type updateFlags struct {
tags []string
clearTags bool
usedBy []string
clearUsedBy bool
keepCopy bool
variant variantFlags
}
// recipients validates the flag combination and returns the explicit
// recipients, nil when they should be resolved from --used-by instead.
func (f updateFlags) recipients(v *viper.Viper, cmd *cobra.Command) ([]string, error) {
usedByGiven := cmd.Flags().Changed("used-by")
switch {
case f.clearTags && cmd.Flags().Changed("tag"):
return nil, errTagAndClearTags
case f.clearUsedBy && usedByGiven:
return nil, errUsedByAndClearUsedBy
}
recipients := v.GetString("recipients")
switch {
case recipients != "":
return strings.Split(recipients, ","), nil
case len(f.usedBy) > 0:
return nil, nil
case usedByGiven:
return nil, errNoRecipientsOrUsedBy
default:
return nil, errNoRecipients
}
}
func (f updateFlags) options(cmd *cobra.Command) []cli.WriteOption {
var opts []cli.WriteOption
switch {
case f.clearTags:
opts = append(opts, cli.WithTags([]string{}))
case cmd.Flags().Changed("tag"):
opts = append(opts, cli.WithTags(f.tags))
}
switch {
case f.clearUsedBy:
opts = append(opts, cli.WithUsedBy([]string{}))
case cmd.Flags().Changed("used-by"):
opts = append(opts, cli.WithUsedBy(f.usedBy))
}
if f.keepCopy {
opts = append(opts, cli.WithKeepReadableCopy())
}
if f.variant.id != "" {
opts = append(opts, cli.WithVariant(f.variant.id))
}
if f.variant.consumer != "" {
opts = append(opts, cli.WithConsumer(f.variant.consumer))
}
return opts
}
package cmd
import (
"fmt"
"github.com/alrayyes/hush-hush-cli/internal/cli"
"github.com/alrayyes/hush-hush-cli/internal/render"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// newUsedByCmd is unauthenticated, matching newGetCmd: fetching one
// already-known object's recorded consumers needs no token, unlike list's
// full enumeration.
func newUsedByCmd(v *viper.Viper) *cobra.Command {
var (
asJSON bool
variant variantFlags
)
cmd := &cobra.Command{
Use: "used-by <id>",
Short: "Print an object's recorded consumers",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := variant.validate(); err != nil {
return err
}
cfg, err := config(v, false)
if err != nil {
return err
}
usedBy, err := cli.UsedBy(cmd.Context(), cfg, args[0], variant.callOptions()...)
if err != nil {
return fmt.Errorf("used-by: %w", err)
}
if asJSON {
return render.UsedByJSON(cmd.OutOrStdout(), usedBy)
}
return render.UsedByLines(cmd.OutOrStdout(), usedBy)
},
}
variant.register(cmd)
cmd.Flags().BoolVar(&asJSON, "json", false, "print the raw JSON array instead of one consumer per line")
return cmd
}
package cmd
import (
"errors"
"github.com/alrayyes/hush-hush-cli/internal/client"
"github.com/spf13/cobra"
)
var errIDAndConsumer = errors.New("--id and --consumer both pick a variant; give one")
// variantFlags is the pair of ways get, update, delete and used-by pick one
// variant of a name that has several.
type variantFlags struct {
id string
consumer string
}
func (f *variantFlags) register(cmd *cobra.Command) {
cmd.Flags().StringVar(&f.id, "id", "", "UUID of one variant, for a name that has several (see list --json)")
cmd.Flags().StringVar(&f.consumer, "consumer", "", "pick the variant used by this consumer, for a name that has several")
}
// validate refuses both selectors at once, before any request: the server
// would answer 400.
func (f variantFlags) validate() error {
if f.id != "" && f.consumer != "" {
return errIDAndConsumer
}
return nil
}
func (f variantFlags) callOptions() []client.CallOption {
return []client.CallOption{client.WithVariant(f.id), client.WithConsumer(f.consumer)}
}
package render
import (
"encoding/json"
"fmt"
"io"
"text/tabwriter"
"time"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// AuditLogJSON prints audit entries as one JSON array, `[]` for none.
func AuditLogJSON(out io.Writer, entries []client.AuditLogEntry) error {
if entries == nil {
entries = []client.AuditLogEntry{}
}
if err := json.NewEncoder(out).Encode(entries); err != nil {
return fmt.Errorf("write audit-log as json: %w", err)
}
return nil
}
// AuditLogTable prints audit entries as an aligned table, "-" for a missing caller.
func AuditLogTable(out io.Writer, entries []client.AuditLogEntry) error {
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "TIMESTAMP\tACTION\tOBJECT ID\tCALLER\tIP"); err != nil {
return fmt.Errorf("write audit-log header: %w", err)
}
for _, e := range entries {
caller := "-"
if e.Caller != nil {
caller = *e.Caller
}
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
e.Timestamp.Local().Format(time.DateTime), e.Action, e.ObjectID, caller, e.IP); err != nil {
return fmt.Errorf("write audit-log row: %w", err)
}
}
if err := w.Flush(); err != nil {
return fmt.Errorf("flush audit-log table: %w", err)
}
return nil
}
package render
import (
"encoding/json"
"fmt"
"io"
"text/tabwriter"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// OneConsumer prints a single consumer as a one-row table, or raw JSON.
func OneConsumer(out io.Writer, consumer client.Consumer, asJSON bool) error {
if asJSON {
if err := json.NewEncoder(out).Encode(consumer); err != nil {
return fmt.Errorf("write consumer as json: %w", err)
}
return nil
}
return ConsumersTable(out, []client.Consumer{consumer})
}
// ConsumersJSON prints consumers as one JSON array.
func ConsumersJSON(out io.Writer, consumers []client.Consumer) error {
if err := json.NewEncoder(out).Encode(consumers); err != nil {
return fmt.Errorf("write consumers as json: %w", err)
}
return nil
}
// ConsumersTable prints consumers as an aligned table.
func ConsumersTable(out io.Writer, consumers []client.Consumer) error {
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "NAME\tSECRETS\tPUBLIC KEY"); err != nil {
return fmt.Errorf("write consumer header: %w", err)
}
for _, c := range consumers {
if _, err := fmt.Fprintf(w, "%s\t%d\t%s\n", c.Name, c.SecretCount, c.PublicKey); err != nil {
return fmt.Errorf("write consumer row: %w", err)
}
}
if err := w.Flush(); err != nil {
return fmt.Errorf("flush consumer table: %w", err)
}
return nil
}
package render
import (
"encoding/json"
"fmt"
"io"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// HealthJSON prints the health answer as JSON.
func HealthJSON(out io.Writer, health client.Health) error {
if err := json.NewEncoder(out).Encode(health); err != nil {
return fmt.Errorf("write health as json: %w", err)
}
return nil
}
// HealthText prints the health answer as plain lines, leaving out an
// environment label the server doesn't set.
func HealthText(out io.Writer, health client.Health) error {
if _, err := fmt.Fprintf(out, "status: %s\n", health.Status); err != nil {
return fmt.Errorf("write health: %w", err)
}
if health.Environment != nil {
if _, err := fmt.Fprintf(out, "environment: %s\n", *health.Environment); err != nil {
return fmt.Errorf("write health: %w", err)
}
}
return nil
}
package render
import (
"encoding/json"
"fmt"
"io"
"strings"
"text/tabwriter"
"time"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// ObjectsJSON prints objects as one JSON array, `[]` for none.
func ObjectsJSON(out io.Writer, objects []client.ObjectMetadata) error {
if objects == nil {
objects = []client.ObjectMetadata{}
}
if err := json.NewEncoder(out).Encode(objects); err != nil {
return fmt.Errorf("write list as json: %w", err)
}
return nil
}
// ObjectsTable prints objects as an aligned table, "-" for a time the server left out.
func ObjectsTable(out io.Writer, objects []client.ObjectMetadata) error {
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "ID\tUSED BY\tTAGS\tCREATED\tUPDATED\tDESCRIPTION"); err != nil {
return fmt.Errorf("write list header: %w", err)
}
for _, obj := range objects {
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", obj.Slug, strings.Join(obj.UsedBy, ","), strings.Join(obj.Tags, ","), formatTime(obj.CreatedAt), formatTime(obj.UpdatedAt), obj.Description); err != nil {
return fmt.Errorf("write list row: %w", err)
}
}
if err := w.Flush(); err != nil {
return fmt.Errorf("flush list table: %w", err)
}
return nil
}
// formatTime renders t in local time, or "-" when the server sent none.
func formatTime(t *time.Time) string {
if t == nil {
return "-"
}
return t.Local().Format(time.DateTime)
}
package render
import (
"encoding/json"
"fmt"
"io"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// StatusJSON prints the auth status as JSON.
func StatusJSON(out io.Writer, status client.AuthStatus) error {
if err := json.NewEncoder(out).Encode(status); err != nil {
return fmt.Errorf("write status as json: %w", err)
}
return nil
}
package render
import (
"encoding/json"
"fmt"
"io"
"text/tabwriter"
"time"
"github.com/alrayyes/hush-hush-cli/internal/client"
)
// ConsumerTokenWithValue prints a freshly minted or rotated token, value included, as a one-row table or raw JSON.
func ConsumerTokenWithValue(out io.Writer, token client.ConsumerTokenWithValue, asJSON bool) error {
if asJSON {
if err := json.NewEncoder(out).Encode(token); err != nil {
return fmt.Errorf("write consumer token as json: %w", err)
}
return nil
}
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "ID\tCONSUMER\tDESCRIPTION\tEXPIRES\tTOKEN"); err != nil {
return fmt.Errorf("write consumer token header: %w", err)
}
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
token.ID, token.Consumer, token.Description, token.ExpiresAt.Local().Format(time.DateTime), token.Value); err != nil {
return fmt.Errorf("write consumer token row: %w", err)
}
if err := w.Flush(); err != nil {
return fmt.Errorf("flush consumer token table: %w", err)
}
return nil
}
// ConsumerTokensJSON prints tokens as one JSON array, `[]` for none.
func ConsumerTokensJSON(out io.Writer, tokens []client.ConsumerToken) error {
if tokens == nil {
tokens = []client.ConsumerToken{}
}
if err := json.NewEncoder(out).Encode(tokens); err != nil {
return fmt.Errorf("write consumer tokens as json: %w", err)
}
return nil
}
// ConsumerTokensTable prints tokens as an aligned table with their status.
func ConsumerTokensTable(out io.Writer, tokens []client.ConsumerToken) error {
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "ID\tCONSUMER\tDESCRIPTION\tEXPIRES\tSTATUS"); err != nil {
return fmt.Errorf("write token list header: %w", err)
}
for _, t := range tokens {
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
t.ID, t.Consumer, t.Description, t.ExpiresAt.Local().Format(time.DateTime), t.Status); err != nil {
return fmt.Errorf("write token list row: %w", err)
}
}
if err := w.Flush(); err != nil {
return fmt.Errorf("flush token list table: %w", err)
}
return nil
}
package render
import (
"encoding/json"
"fmt"
"io"
)
// UsedByJSON prints consumer names as one JSON array, `[]` for none.
func UsedByJSON(out io.Writer, usedBy []string) error {
if usedBy == nil {
usedBy = []string{}
}
if err := json.NewEncoder(out).Encode(usedBy); err != nil {
return fmt.Errorf("write used-by as json: %w", err)
}
return nil
}
// UsedByLines prints one consumer name per line.
func UsedByLines(out io.Writer, usedBy []string) error {
for _, consumer := range usedBy {
if _, err := fmt.Fprintln(out, consumer); err != nil {
return fmt.Errorf("write used-by line: %w", err)
}
}
return nil
}
// Package seal wraps age encryption for the writer's side of hush-hush:
// value in, ciphertext out, no server or CLI process ever holding a
// private key (openspec/changes/secrets-object-store/design.md).
package seal
import (
"bytes"
"fmt"
"filippo.io/age"
)
// Seal encrypts value to every one of recipientKeys independently -
// multi-recipient sealing, so a single object can serve several
// consumers, each decrypting with their own private key, rather than one
// shared key distributed to all of them.
func Seal(value []byte, recipientKeys []string) ([]byte, error) {
recipients := make([]age.Recipient, len(recipientKeys))
for i, key := range recipientKeys {
r, err := age.ParseX25519Recipient(key)
if err != nil {
return nil, fmt.Errorf("parse recipient %d: %w", i, err)
}
recipients[i] = r
}
var buf bytes.Buffer
w, err := age.Encrypt(&buf, recipients...)
if err != nil {
return nil, fmt.Errorf("start encryption: %w", err)
}
if _, err := w.Write(value); err != nil {
return nil, fmt.Errorf("write plaintext: %w", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("finish encryption: %w", err)
}
return buf.Bytes(), nil
}
package seal
import (
"bytes"
"fmt"
"io"
"filippo.io/age"
)
// Unseal decrypts sealed with whichever of identityKeys matches one of
// the recipients it was sealed to. age.Decrypt itself fails outright when
// none match, rather than producing corrupted output - Unseal only adds
// context to that failure, never papering over it.
func Unseal(sealed []byte, identityKeys []string) ([]byte, error) {
identities := make([]age.Identity, len(identityKeys))
for i, key := range identityKeys {
id, err := age.ParseX25519Identity(key)
if err != nil {
return nil, fmt.Errorf("parse identity %d: %w", i, err)
}
identities[i] = id
}
r, err := age.Decrypt(bytes.NewReader(sealed), identities...)
if err != nil {
return nil, fmt.Errorf("decrypt value: %w", err)
}
plaintext, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read decrypted value: %w", err)
}
return plaintext, nil
}
// Package testserver is a small in-memory, stateful stand-in for the
// hush-hush server's HTTP API - internal/client's actual transport
// contract (api/openapi.yaml's /objects endpoints), not hush-hush's own
// internal/api implementation, which stays in that repo. It exists so
// internal/cli and internal/cmd's tests can exercise a real
// create/get/update/delete/list round trip - including auth, 404, and 409
// semantics against live state - without a second repo's server in the
// loop (openspec/changes/split-cli-into-own-repo/design.md: a Prism
// spec-mock can't reproduce that state, so this fake carries it itself).
package testserver
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
)
// ErrNotFound is returned by Store.GetObject, Store.UpdateObject, and
// Store.DeleteObject for an unknown id.
var ErrNotFound = errors.New("object not found")
// ErrAlreadyExists is returned by Store.CreateObject for an id already
// stored.
var ErrAlreadyExists = errors.New("object already exists")
// ErrInvalidFilter is returned by Store.QueryAuditLog for a filter value
// the real server would reject with a 400 - matching object_id's own
// pattern (api/openapi.yaml's ObjectId schema).
var ErrInvalidFilter = errors.New("invalid filter")
// ErrConsumerAlreadyExists is returned by Store.AddConsumer for a name
// already in the consumer directory.
var ErrConsumerAlreadyExists = errors.New("consumer already exists")
// ErrConsumerNotFound is returned by Store.UpdateConsumer, when renaming an
// unknown name, and by Store.DeleteConsumer for an unknown consumer.
var ErrConsumerNotFound = errors.New("consumer not found")
// ErrConsumerTokenNotFound is returned by Store.RotateConsumerToken and
// Store.PurgeConsumerToken for an unknown, already revoked, or already
// expired token id.
var ErrConsumerTokenNotFound = errors.New("consumer token not found")
// ErrConsumerTokenActive is returned by Store.PurgeConsumerToken for a
// token that's neither revoked nor expired yet.
var ErrConsumerTokenActive = errors.New("consumer token is still active")
var objectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
// ErrSeveralVariants is returned for a name holding more than one variant
// when a request didn't say which, matching the real server's 409.
var ErrSeveralVariants = errors.New("name has several variants; pass id to say which")
// Object is a stored object's current state. ID is its UUID, the one thing
// that tells apart variants sharing a name.
type Object struct {
ID string
Value []byte
UsedBy []string
Description string
Tags []string
CreatedAt time.Time
UpdatedAt time.Time
}
// Store is an in-memory object store plus write-token issuance, backing a
// Server started by New. Safe for concurrent use.
type Store struct {
mu sync.Mutex
objects map[string]Object
// extra holds the variants AddVariant adds beyond the one in objects, so
// the rest of the fake keeps its one-object-per-name shape. They take
// part in list, get, used-by, update and delete, not in the consumer
// rename, delete and secret-count walks.
extra map[string][]Object
tokens map[string]time.Time
auditLog []AuditLogEntry
auditSeq int64
bootstrapped bool
environment string
// consumers holds the consumer directory: name -> registered age public
// key, nil if none registered. Presence as a key is what makes a name
// a directory entry, whether it got there via AddConsumer or via
// appearing in some object's used_by list at creation.
consumers map[string]*string
// consumerTokens holds every issued consumer read token's full state,
// keyed by id (alrayyes/hush-hush#446's consumerBearerAuth - a
// consumer token authorizes GetObject only, only for an object whose
// used_by includes its bound consumer; alrayyes/hush-hush#467 added
// minting/listing/rotating/revoking/purging one via a write bearer
// token instead of a cookie session).
consumerTokens map[string]*consumerToken
// consumerTokenValues maps a raw token value to the id that issued
// it - the lookup handleGetObject's own auth check uses, and the one
// RotateConsumerToken has to keep in sync when a token's value
// changes.
consumerTokenValues map[string]string
// ownerPublicKey is the owner's escrowed identity public key, served
// by GET /auth/identity. Empty means the owner hasn't registered one.
ownerPublicKey string
}
// consumerToken is one issued consumer read token's full state - the
// metadata every response shape (ConsumerToken, ConsumerTokenWithValue)
// is built from.
type consumerToken struct {
ID string
Consumer string
Description string
CreatedAt time.Time
ExpiresAt time.Time
LastUsedAt *time.Time
Revoked bool
Value string
}
// ConsumerToken is one issued consumer read token's metadata - the shape
// GET /consumer-tokens returns, and CreateConsumerToken/RotateConsumerToken
// embed alongside the raw value - never a raw value on its own
// (api/openapi.yaml's ConsumerTokenMetadata).
type ConsumerToken struct {
ID string `json:"id"`
Consumer string `json:"consumer"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Revoked bool `json:"revoked"`
// Status and AllowedActions mirror hush-hush's own server-computed
// fields (hush-hush-go v4.2.4+).
Status string `json:"status"`
AllowedActions []string `json:"allowed_actions"`
}
// ConsumerTokenWithValue is POST /consumer-tokens and
// POST /consumer-tokens/{id}/rotate's own response shape - the same
// metadata as ConsumerToken plus the raw token, shown here once
// (api/openapi.yaml's ConsumerTokenWithValue).
type ConsumerTokenWithValue struct {
ConsumerToken
Value string `json:"value"`
}
func toConsumerTokenMetadata(t *consumerToken) ConsumerToken {
status, actions := "active", []string{"rotate", "revoke"}
switch {
case t.Revoked:
status, actions = "revoked", []string{"purge"}
case !time.Now().Before(t.ExpiresAt):
status, actions = "expired", []string{"purge"}
}
return ConsumerToken{
Status: status,
AllowedActions: actions,
ID: t.ID,
Consumer: t.Consumer,
Description: t.Description,
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
Revoked: t.Revoked,
}
}
func toConsumerTokenWithValue(t *consumerToken) ConsumerTokenWithValue {
return ConsumerTokenWithValue{ConsumerToken: toConsumerTokenMetadata(t), Value: t.Value}
}
// newStore defaults bootstrapped to true - the shape every other test in
// this package already assumes (a normal, already-set-up server); a test
// exercising the unbootstrapped case calls SetBootstrapped(false) itself.
func newStore() *Store {
return &Store{
objects: make(map[string]Object),
extra: make(map[string][]Object),
tokens: make(map[string]time.Time),
bootstrapped: true,
consumers: make(map[string]*string),
consumerTokens: make(map[string]*consumerToken),
consumerTokenValues: make(map[string]string),
}
}
// SetEnvironment sets the label GET /healthz reports as "environment"; the
// default is none, and the field is then omitted, as on the real server.
func (s *Store) SetEnvironment(label string) {
s.mu.Lock()
defer s.mu.Unlock()
s.environment = label
}
// SetBootstrapped overrides the value GET /auth/status reports.
func (s *Store) SetBootstrapped(bootstrapped bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.bootstrapped = bootstrapped
}
// AuthStatus reports whether an admin account has been created yet -
// matches hush-hush's own GET /auth/status (hush-hush-go#100).
func (s *Store) AuthStatus(_ context.Context) AuthStatus {
s.mu.Lock()
defer s.mu.Unlock()
return AuthStatus{Bootstrapped: s.bootstrapped}
}
// CreateObject stores value under id, or ErrAlreadyExists if id is taken.
// Every name in usedBy that's new to the consumer directory joins it with
// no registered key, matching ConsumerEntry's own doc comment: a directory
// entry is "a distinct consumer name recorded in some object's used_by
// list", not only one added explicitly via AddConsumer.
func (s *Store) CreateObject(ctx context.Context, id string, value []byte, usedBy []string, description string) error {
return s.createObject(ctx, id, value, usedBy, description, nil)
}
func (s *Store) createObject(_ context.Context, id string, value []byte, usedBy []string, description string, tags []string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.objects[id]; ok {
return ErrAlreadyExists
}
now := time.Now()
s.objects[id] = Object{ID: objectID(id), Value: value, UsedBy: usedBy, Description: description, Tags: tags, CreatedAt: now, UpdatedAt: now}
for _, name := range usedBy {
if _, ok := s.consumers[name]; !ok {
s.consumers[name] = nil
}
}
return nil
}
// GetObject returns id's current stored state, or ErrNotFound.
func (s *Store) GetObject(ctx context.Context, id string) (Object, error) {
return s.GetVariant(ctx, id, "")
}
// GetVariant returns one variant of id: the one with that UUID, or, with
// variantID empty, the only one. ErrSeveralVariants if the name has more
// and no id was given, ErrNotFound for an unknown name or an id that
// doesn't belong to it.
func (s *Store) GetVariant(ctx context.Context, id, variantID string) (Object, error) {
return s.getVariant(ctx, id, selector{id: variantID})
}
func (s *Store) getVariant(_ context.Context, id string, sel selector) (Object, error) {
s.mu.Lock()
defer s.mu.Unlock()
_, obj, err := s.findLocked(id, sel)
return obj, err
}
// AddVariant gives an existing name a further variant with its own UUID and
// value, returning that UUID. ErrNotFound if the name doesn't exist yet.
func (s *Store) AddVariant(_ context.Context, id string, value []byte, usedBy []string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.objects[id]; !ok {
return "", ErrNotFound
}
now := time.Now()
variant := Object{ID: uuid.NewString(), Value: value, UsedBy: usedBy, CreatedAt: now, UpdatedAt: now}
s.extra[id] = append(s.extra[id], variant)
return variant.ID, nil
}
// variantsLocked is every variant of id, the one in objects first.
func (s *Store) variantsLocked(id string) []Object {
primary, ok := s.objects[id]
if !ok {
return nil
}
return append([]Object{primary}, s.extra[id]...)
}
// storeVariantsLocked writes variants back as variantsLocked read them.
func (s *Store) storeVariantsLocked(id string, variants []Object) {
if len(variants) == 0 {
delete(s.objects, id)
delete(s.extra, id)
return
}
s.objects[id] = variants[0]
if len(variants) > 1 {
s.extra[id] = variants[1:]
} else {
delete(s.extra, id)
}
}
// selector says which variant of a name a request means: by UUID, by a
// consumer it names, or neither (the only one).
type selector struct {
id string
consumer string
}
// findLocked picks one variant of id: the selector's, or the only one when
// it is empty. It returns the variant's index in variantsLocked's order.
func (s *Store) findLocked(id string, sel selector) (int, Object, error) {
variants := s.variantsLocked(id)
if len(variants) == 0 {
return 0, Object{}, ErrNotFound
}
if sel == (selector{}) {
if len(variants) > 1 {
return 0, Object{}, ErrSeveralVariants
}
return 0, variants[0], nil
}
for i, v := range variants {
if (sel.id != "" && v.ID == sel.id) || (sel.consumer != "" && slices.Contains(v.UsedBy, sel.consumer)) {
return i, v, nil
}
}
return 0, Object{}, ErrNotFound
}
// UpdateObject replaces id's stored value, leaving used_by, description
// and tags unchanged, or ErrNotFound.
func (s *Store) UpdateObject(ctx context.Context, id string, value []byte) error {
_, err := s.updateVariant(ctx, id, selector{}, value, nil, nil)
return err
}
// updateVariant also replaces the variant's tags when tags is non-nil (an
// empty slice clears them), and likewise its used_by when usedBy is
// non-nil, matching PUT /objects/{slug}'s own semantics. It returns the
// variant as updated.
func (s *Store) updateVariant(_ context.Context, id string, sel selector, value []byte, tags, usedBy *[]string) (Object, error) {
s.mu.Lock()
defer s.mu.Unlock()
i, obj, err := s.findLocked(id, sel)
if err != nil {
return Object{}, err
}
obj.Value = value
obj.UpdatedAt = time.Now()
if tags != nil {
obj.Tags = *tags
}
if usedBy != nil {
obj.UsedBy = *usedBy
for _, name := range *usedBy {
if _, ok := s.consumers[name]; !ok {
s.consumers[name] = nil
}
}
}
variants := s.variantsLocked(id)
variants[i] = obj
s.storeVariantsLocked(id, variants)
return obj, nil
}
// patchVariant applies PATCH /objects/{slug}'s semantics: a nil field stays
// as it is, description and tags change only the addressed variant, and a
// new slug moves every variant of the name, or ErrAlreadyExists when that
// slug is taken. It returns the addressed variant as patched.
func (s *Store) patchVariant(_ context.Context, id string, sel selector, description, slug *string, tags *[]string) (Object, error) {
s.mu.Lock()
defer s.mu.Unlock()
i, obj, err := s.findLocked(id, sel)
if err != nil {
return Object{}, err
}
if slug != nil && *slug != id {
if _, taken := s.objects[*slug]; taken {
return Object{}, ErrAlreadyExists
}
}
if description != nil {
obj.Description = *description
}
if tags != nil {
obj.Tags = *tags
}
obj.UpdatedAt = time.Now()
variants := s.variantsLocked(id)
variants[i] = obj
if slug != nil && *slug != id {
s.storeVariantsLocked(id, nil)
id = *slug
}
s.storeVariantsLocked(id, variants)
return obj, nil
}
// SetObjectTags replaces id's tags as given - a test-setup shortcut that
// skips the HTTP layer's normalization, so pass already-valid tags.
func (s *Store) SetObjectTags(_ context.Context, id string, tags []string) error {
s.mu.Lock()
defer s.mu.Unlock()
obj, ok := s.objects[id]
if !ok {
return ErrNotFound
}
obj.Tags = tags
s.objects[id] = obj
return nil
}
// DeleteObject removes id, or ErrNotFound.
func (s *Store) DeleteObject(ctx context.Context, id string) error {
return s.deleteVariant(ctx, id, selector{})
}
func (s *Store) deleteVariant(_ context.Context, id string, sel selector) error {
s.mu.Lock()
defer s.mu.Unlock()
i, _, err := s.findLocked(id, sel)
if err != nil {
return err
}
variants := s.variantsLocked(id)
s.storeVariantsLocked(id, slices.Delete(variants, i, i+1))
return nil
}
// ConsumerEntry is one directory entry, mirroring hush-hush-go's own type
// of the same name (GET /consumers's paginated response shape, and
// AddConsumer/UpdateConsumer's own response).
type ConsumerEntry struct {
Name string `json:"name"`
PublicKey *string `json:"public_key,omitempty"`
SecretCount int32 `json:"secret_count"`
}
// ConsumersPage is GET /consumers's paginated response shape, returned
// whenever the request's filter has any field set.
type ConsumersPage struct {
Consumers []ConsumerEntry `json:"consumers"`
Total int32 `json:"total"`
}
// ConsumerFilter narrows Store.ListConsumers, mirroring GET /consumers's
// own query parameters.
type ConsumerFilter struct {
Q *string
Page *int32
PageSize *int32
}
// ConsumersResult is Store.ListConsumers's return value - exactly one
// field set, matching GET /consumers's own union response shape: Names for
// an empty filter, Page for a filter with any field set.
type ConsumersResult struct {
Names []string
Page *ConsumersPage
}
// AddConsumer adds name to the directory with no public key registered, or
// ErrConsumerAlreadyExists if it's already there.
func (s *Store) AddConsumer(_ context.Context, name string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.consumers[name]; ok {
return ErrConsumerAlreadyExists
}
s.consumers[name] = nil
return nil
}
// UpdateConsumer renames name to newName (if non-nil) and/or registers
// publicKey on the resulting name (if non-nil), applied in that order -
// matching hush-hush-go's own UpdateConsumer doc comment. Renaming an
// unknown name is ErrConsumerNotFound; registering a key alone on an
// unknown name upserts a directory entry for it instead. A rename whose
// target already has its own recorded objects merges under it, and a
// target's own key wins the merge if it already had one.
func (s *Store) UpdateConsumer(_ context.Context, name string, newName, publicKey *string) (ConsumerEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
key, ok := s.consumers[name]
if !ok {
if newName != nil {
return ConsumerEntry{}, ErrConsumerNotFound
}
s.consumers[name] = publicKey
return s.consumerEntryLocked(name), nil
}
target := name
if newName != nil {
target = *newName
if targetKey, targetOK := s.consumers[target]; targetOK && targetKey != nil {
key = targetKey
}
delete(s.consumers, name)
s.consumers[target] = key
for id, obj := range s.objects {
obj.UsedBy = renameUsedBy(obj.UsedBy, name, target)
s.objects[id] = obj
}
}
if publicKey != nil {
s.consumers[target] = publicKey
}
return s.consumerEntryLocked(target), nil
}
// DeleteConsumer strips name from every object's used_by list and removes
// it from the directory, or ErrConsumerNotFound if it's not in the
// directory at all.
func (s *Store) DeleteConsumer(_ context.Context, name string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.consumers[name]; !ok {
return ErrConsumerNotFound
}
delete(s.consumers, name)
for id, obj := range s.objects {
obj.UsedBy = removeUsedBy(obj.UsedBy, name)
s.objects[id] = obj
}
return nil
}
// ListConsumers returns the consumer directory, matching GET /consumers's
// own union response shape: an empty filter returns the plain, sorted name
// list; any filter field set switches to the paginated ConsumersPage shape
// (name substring match, 1-based page, page size capped at 100).
func (s *Store) ListConsumers(_ context.Context, filter ConsumerFilter) ConsumersResult {
s.mu.Lock()
defer s.mu.Unlock()
names := make([]string, 0, len(s.consumers))
for name := range s.consumers {
names = append(names, name)
}
slices.Sort(names)
if filter.Q == nil && filter.Page == nil && filter.PageSize == nil {
return ConsumersResult{Names: names}
}
matched := names
if filter.Q != nil {
q := strings.ToLower(*filter.Q)
filtered := make([]string, 0, len(names))
for _, name := range names {
if strings.Contains(strings.ToLower(name), q) {
filtered = append(filtered, name)
}
}
matched = filtered
}
total := int32(len(matched)) //nolint:gosec // matched is an in-memory test fixture, never near int32 max
page := int32(1)
if filter.Page != nil {
page = *filter.Page
}
pageSize := int32(20)
if filter.PageSize != nil {
pageSize = *filter.PageSize
}
if pageSize > 100 {
pageSize = 100
}
start := min(int((page-1)*pageSize), len(matched))
end := min(start+int(pageSize), len(matched))
entries := make([]ConsumerEntry, 0, end-start)
for _, name := range matched[start:end] {
entries = append(entries, s.consumerEntryLocked(name))
}
return ConsumersResult{Page: &ConsumersPage{Consumers: entries, Total: total}}
}
// consumerEntryLocked builds name's directory entry, including its
// computed secret count - callers must hold s.mu.
func (s *Store) consumerEntryLocked(name string) ConsumerEntry {
entry := ConsumerEntry{Name: name}
for _, obj := range s.objects {
if slices.Contains(obj.UsedBy, name) {
entry.SecretCount++
}
}
if key := s.consumers[name]; key != nil {
entry.PublicKey = key
}
return entry
}
// renameUsedBy replaces every occurrence of from in usedBy with to,
// deduplicating - a used_by list already naming both collapses to one.
func renameUsedBy(usedBy []string, from, to string) []string {
seen := make(map[string]bool, len(usedBy))
result := make([]string, 0, len(usedBy))
for _, name := range usedBy {
if name == from {
name = to
}
if seen[name] {
continue
}
seen[name] = true
result = append(result, name)
}
return result
}
// removeUsedBy returns usedBy with every occurrence of name stripped.
func removeUsedBy(usedBy []string, name string) []string {
result := make([]string, 0, len(usedBy))
for _, n := range usedBy {
if n != name {
result = append(result, n)
}
}
return result
}
// ListObjects returns every stored object's slug, used_by, and
// description - never the value - sorted by slug, optionally narrowed to
// objects whose used_by includes usedByFilter ("" means no filter).
// Matches hush-hush's own GET /objects (hush-hush#188/#189).
func (s *Store) ListObjects(ctx context.Context, usedByFilter string) ([]ObjectMetadata, error) {
return s.ListObjectsFiltered(ctx, usedByFilter, nil)
}
// ListObjectsFiltered is ListObjects narrowed further to objects carrying
// every one of tags, compared case-insensitively, matching GET /objects'
// repeated tag parameter.
func (s *Store) ListObjectsFiltered(_ context.Context, usedByFilter string, tags []string) ([]ObjectMetadata, error) {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]ObjectMetadata, 0, len(s.objects))
for id := range s.objects {
for _, obj := range s.variantsLocked(id) {
meta, ok := listedObject(id, obj, usedByFilter, tags)
if ok {
result = append(result, meta)
}
}
}
slices.SortFunc(result, func(a, b ObjectMetadata) int { return strings.Compare(a.Slug, b.Slug) })
return result, nil
}
// listedObject is obj's list entry, or false when the filters leave it out.
func listedObject(id string, obj Object, usedByFilter string, tags []string) (ObjectMetadata, bool) {
if usedByFilter != "" && !slices.Contains(obj.UsedBy, usedByFilter) {
return ObjectMetadata{}, false
}
if !hasAllTags(obj.Tags, tags) {
return ObjectMetadata{}, false
}
actor := &Actor{ID: "testserver", Type: "token"}
created, updated := obj.CreatedAt, obj.UpdatedAt
return ObjectMetadata{
ID: obj.ID, Slug: id, UsedBy: obj.UsedBy, Description: obj.Description, Tags: tagsOrEmpty(obj.Tags),
CreatedAt: &created, CreatedBy: actor, UpdatedAt: &updated, UpdatedBy: actor,
}, true
}
// AuditLogEntry mirrors hush-hush's GET /audit-log response shape
// (api/openapi.yaml's AuditLogEntry schema), oldest-first.
type AuditLogEntry struct {
ID int64 `json:"id"`
ObjectID string `json:"object_id"`
Action string `json:"action"`
Timestamp time.Time `json:"timestamp"`
Caller *string `json:"caller,omitempty"`
IP string `json:"ip"`
ActorType *string `json:"actor_type,omitempty"`
ActorID *string `json:"actor_id,omitempty"`
}
// AuditLogFilter narrows Store.QueryAuditLog - mirrors GET /audit-log's own
// query parameters (api/openapi.yaml). Filters combine with AND.
type AuditLogFilter struct {
ObjectID *string
Caller *string
Actor *string
From *time.Time
To *time.Time
After *int64
Limit *int32
}
// RecordAuditEntry appends entry to the audit log, assigning it the next
// strictly increasing id and, if Timestamp is zero, the current time.
// Tests seed entries this way rather than through create/get/update/delete
// themselves, so a filter/pagination test controls exactly what exists
// without needing a matching object round trip for every row.
func (s *Store) RecordAuditEntry(entry AuditLogEntry) AuditLogEntry {
s.mu.Lock()
defer s.mu.Unlock()
s.auditSeq++
entry.ID = s.auditSeq
if entry.Timestamp.IsZero() {
entry.Timestamp = time.Now()
}
s.auditLog = append(s.auditLog, entry)
return entry
}
// QueryAuditLog returns entries matching filter, oldest first, one page at
// a time - default page size 50, capped at 500, matching
// api/openapi.yaml's own defaults. Returns ErrInvalidFilter for an
// ObjectID that doesn't match the real server's id pattern.
func (s *Store) QueryAuditLog(_ context.Context, filter AuditLogFilter) ([]AuditLogEntry, error) {
if filter.ObjectID != nil && !objectIDPattern.MatchString(*filter.ObjectID) {
return nil, fmt.Errorf("%w: object_id %q", ErrInvalidFilter, *filter.ObjectID)
}
limit := int32(50)
if filter.Limit != nil {
limit = *filter.Limit
}
if limit < 1 || limit > 500 {
return nil, fmt.Errorf("%w: limit %d out of range [1, 500]", ErrInvalidFilter, limit)
}
s.mu.Lock()
defer s.mu.Unlock()
result := make([]AuditLogEntry, 0, limit)
for _, e := range s.auditLog {
if !auditLogEntryMatches(e, filter) {
continue
}
result = append(result, e)
if len(result) >= int(limit) {
break
}
}
return result, nil
}
// auditLogEntryMatches reports whether e satisfies every filter field
// that's set - filters combine with AND (api/openapi.yaml).
func auditLogEntryMatches(e AuditLogEntry, filter AuditLogFilter) bool {
if filter.ObjectID != nil && e.ObjectID != *filter.ObjectID {
return false
}
if filter.Caller != nil && (e.Caller == nil || *e.Caller != *filter.Caller) {
return false
}
if filter.Actor != nil && (e.ActorID == nil || *e.ActorID != *filter.Actor) {
return false
}
if filter.From != nil && e.Timestamp.Before(*filter.From) {
return false
}
if filter.To != nil && e.Timestamp.After(*filter.To) {
return false
}
if filter.After != nil && e.ID <= *filter.After {
return false
}
return true
}
// CreateWriteToken issues a fresh write token valid for ttl. The first
// return value mirrors hush-hush's own store.CreateWriteToken (an issued
// token's id); this fake has no separate use for it.
func (s *Store) CreateWriteToken(_ context.Context, name string, ttl time.Duration) (id, token string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
token = name + "-" + randomSuffix()
s.tokens[token] = time.Now().Add(ttl)
return "", token, nil
}
// CreateConsumerToken issues a fresh read token bound to consumer, valid
// for a year - a thin convenience over IssueConsumerToken for tests that
// only need a plain token value and don't care about description or ttl.
func (s *Store) CreateConsumerToken(consumer string) (token string) {
return s.IssueConsumerToken(consumer, "", 365*24*time.Hour).Value
}
// IssueConsumerToken mints a new consumer read token, mirroring
// hush-hush's POST /consumer-tokens (alrayyes/hush-hush#467).
func (s *Store) IssueConsumerToken(consumer, description string, ttl time.Duration) ConsumerTokenWithValue {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
rec := &consumerToken{
ID: randomSuffix(),
Consumer: consumer,
Description: description,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
Value: "consumer-" + consumer + "-" + randomSuffix(),
}
s.consumerTokens[rec.ID] = rec
s.consumerTokenValues[rec.Value] = rec.ID
return toConsumerTokenWithValue(rec)
}
// ListConsumerTokens returns every issued consumer token's metadata,
// sorted by id, mirroring hush-hush's GET /consumer-tokens.
func (s *Store) ListConsumerTokens() []ConsumerToken {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]ConsumerToken, 0, len(s.consumerTokens))
for _, rec := range s.consumerTokens {
result = append(result, toConsumerTokenMetadata(rec))
}
slices.SortFunc(result, func(a, b ConsumerToken) int { return strings.Compare(a.ID, b.ID) })
return result
}
// RotateConsumerToken replaces id's secret and expiry, keeping its
// consumer and description unchanged - ErrConsumerTokenNotFound for an
// unknown, already revoked, or already expired id, matching hush-hush-go's
// own RotateConsumerToken doc comment.
func (s *Store) RotateConsumerToken(id string, ttl time.Duration) (ConsumerTokenWithValue, error) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.consumerTokens[id]
if !ok || rec.Revoked || time.Now().After(rec.ExpiresAt) {
return ConsumerTokenWithValue{}, ErrConsumerTokenNotFound
}
delete(s.consumerTokenValues, rec.Value)
rec.Value = "consumer-" + rec.Consumer + "-" + randomSuffix()
rec.ExpiresAt = time.Now().Add(ttl)
s.consumerTokenValues[rec.Value] = id
return toConsumerTokenWithValue(rec), nil
}
// RevokeConsumerToken invalidates id - revoking an unknown, already
// revoked, or already expired id isn't an error, matching hush-hush-go's
// own RevokeConsumerToken doc comment.
func (s *Store) RevokeConsumerToken(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if rec, ok := s.consumerTokens[id]; ok {
rec.Revoked = true
}
}
// PurgeConsumerToken permanently removes id, once it's already revoked or
// past its expiry - ErrConsumerTokenNotFound for an unknown id,
// ErrConsumerTokenActive for one still active, matching hush-hush-go's own
// PurgeConsumerToken doc comment.
func (s *Store) PurgeConsumerToken(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.consumerTokens[id]
if !ok {
return ErrConsumerTokenNotFound
}
if !rec.Revoked && time.Now().Before(rec.ExpiresAt) {
return ErrConsumerTokenActive
}
delete(s.consumerTokens, id)
delete(s.consumerTokenValues, rec.Value)
return nil
}
// randomSuffix never errors in practice - crypto/rand.Read only fails if
// the OS entropy source itself is broken, not a condition a test fake
// needs to handle - so a read failure falls back to a fixed suffix rather
// than adding an error return every caller would have to check.
func randomSuffix() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "fallback"
}
return hex.EncodeToString(b)
}
func (s *Store) validateToken(token string) bool {
s.mu.Lock()
defer s.mu.Unlock()
expiry, ok := s.tokens[token]
return ok && time.Now().Before(expiry)
}
// consumerForToken reports the consumer name value is bound to, and
// whether it's currently a valid consumer token at all - false for an
// unknown, revoked, or expired value, the same as hush-hush's own
// consumerBearerAuth would reject each of those.
func (s *Store) consumerForToken(value string) (consumer string, ok bool) {
s.mu.Lock()
defer s.mu.Unlock()
id, ok := s.consumerTokenValues[value]
if !ok {
return "", false
}
rec := s.consumerTokens[id]
if rec.Revoked || time.Now().After(rec.ExpiresAt) {
return "", false
}
return rec.Consumer, true
}
// New starts an httptest.Server backed by a fresh Store and issues a
// write token valid against it - none of this repo's tests need a
// distinct one, matching hush-hush's own newTestServer convention.
func New(t *testing.T) (srv *httptest.Server, s *Store, token string) {
t.Helper()
s = newStore()
_, token, err := s.CreateWriteToken(t.Context(), "test", time.Hour)
if err != nil {
t.Fatalf("create write token: %v", err)
}
srv = httptest.NewServer(newMux(s))
t.Cleanup(srv.Close)
return srv, s, token
}
// AuthStatus mirrors hush-hush's GET /auth/status response shape
// (api/openapi.yaml's AuthStatus schema).
type AuthStatus struct {
Bootstrapped bool `json:"bootstrapped"`
}
// ObjectMetadata is a stored object's slug, used_by, and description, with
// no value - the shape create/update/list all return over the wire. Slug,
// not id: the server's own internal id is a separate, opaque value never
// exposed over this API (alrayyes/Hush-Hush#404).
type ObjectMetadata struct {
ID string `json:"id"`
Slug string `json:"slug"`
UsedBy []string `json:"used_by,omitempty"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags"`
// CreatedAt through UpdatedBy are only sent by GET /objects, as the
// real server does (hush-hush-go v4.2.4+). The fake has no audit log,
// so both actors are always the same stand-in.
CreatedAt *time.Time `json:"created_at,omitempty"`
CreatedBy *Actor `json:"created_by,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
UpdatedBy *Actor `json:"updated_by,omitempty"`
}
// Actor is who performed an audited write.
type Actor struct {
ID string `json:"id"`
Type string `json:"type"`
}
type createObjectRequest struct {
Slug string `json:"slug"`
Value []byte `json:"value"`
UsedBy []string `json:"used_by,omitempty"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type patchObjectRequest struct {
Description *string `json:"description,omitempty"`
Slug *string `json:"slug,omitempty"`
Tags *[]string `json:"tags,omitempty"`
}
// problem is the 400 message for a request the real server would refuse, or
// "" when it is fine.
func (r patchObjectRequest) problem() string {
switch {
case r.Description == nil && r.Slug == nil && r.Tags == nil:
return "at least one of description, slug or tags is required"
case r.Slug != nil && *r.Slug == "":
return "slug must not be empty"
default:
return ""
}
}
type updateObjectRequest struct {
Value []byte `json:"value"`
Tags *[]string `json:"tags,omitempty"`
UsedBy *[]string `json:"used_by,omitempty"`
}
// tagPattern and maxTags mirror api/openapi.yaml's Tags schema: 1 to 32
// characters from a-z 0-9 . _ / -, at most 10 per object.
var tagPattern = regexp.MustCompile(`^[a-z0-9._/-]{1,32}$`)
const maxTags = 10
var errInvalidTags = errors.New("invalid tags")
// normalizeTags lower-cases and de-duplicates tags, then validates them -
// the same conversions and limits the real server applies.
func normalizeTags(in []string) ([]string, error) {
out := make([]string, 0, len(in))
for _, tag := range in {
tag = strings.ToLower(tag)
if !tagPattern.MatchString(tag) {
return nil, fmt.Errorf("%w: tag %q must be 1-32 characters from a-z 0-9 . _ / -", errInvalidTags, tag)
}
if !slices.Contains(out, tag) {
out = append(out, tag)
}
}
if len(out) > maxTags {
return nil, fmt.Errorf("%w: at most %d tags per object", errInvalidTags, maxTags)
}
return out, nil
}
// normalizeOptionalTags is normalizeTags for a field a request may leave
// out: nil in, nil out.
func normalizeOptionalTags(in *[]string) (*[]string, error) {
if in == nil {
return nil, nil
}
out, err := normalizeTags(*in)
if err != nil {
return nil, err
}
return &out, nil
}
// tagsOrEmpty makes sure a response always carries the tags array, never
// null, as the real API does.
func tagsOrEmpty(tags []string) []string {
if tags == nil {
return []string{}
}
return tags
}
type errorBody struct {
Error string `json:"error"`
}
// newMux wires the /objects, /audit-log, and /auth/status endpoints
// internal/client actually calls (api/openapi.yaml) - not GET /healthz,
// which internal/client's Client exposes no method for.
func newMux(s *Store) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("POST /objects", requireWriteToken(s, handleCreateObject(s)))
mux.HandleFunc("GET /objects", requireWriteToken(s, handleListObjects(s)))
mux.HandleFunc("GET /objects/{id}", handleGetObject(s))
mux.HandleFunc("PUT /objects/{id}", requireWriteToken(s, handleUpdateObject(s)))
mux.HandleFunc("PATCH /objects/{id}", requireWriteToken(s, handlePatchObject(s)))
mux.HandleFunc("DELETE /objects/{id}", requireWriteToken(s, handleDeleteObject(s)))
mux.HandleFunc("GET /objects/{id}/used-by", handleGetObjectUsedBy(s))
mux.HandleFunc("GET /audit-log", handleQueryAuditLog(s))
mux.HandleFunc("GET /healthz", handleHealth(s))
mux.HandleFunc("GET /auth/status", handleAuthStatus(s))
mux.HandleFunc("GET /auth/identity", requireWriteToken(s, handleAuthIdentity(s)))
mux.HandleFunc("GET /consumers", requireWriteToken(s, handleListConsumers(s)))
mux.HandleFunc("POST /consumers", requireWriteToken(s, handleAddConsumer(s)))
// {name...}, not {name}: a consumer name is a repo or host slug and
// routinely contains a slash (e.g. "homelab/vps-docker"), which a
// single path segment can't capture.
mux.HandleFunc("PATCH /consumers/{name...}", requireWriteToken(s, handleUpdateConsumer(s)))
mux.HandleFunc("DELETE /consumers/{name...}", requireWriteToken(s, handleDeleteConsumer(s)))
mux.HandleFunc("POST /consumer-tokens", requireWriteToken(s, handleCreateConsumerToken(s)))
mux.HandleFunc("GET /consumer-tokens", requireWriteToken(s, handleListConsumerTokens(s)))
mux.HandleFunc("POST /consumer-tokens/{id}/rotate", requireWriteToken(s, handleRotateConsumerToken(s)))
mux.HandleFunc("DELETE /consumer-tokens/{id}/purge", requireWriteToken(s, handlePurgeConsumerToken(s)))
mux.HandleFunc("DELETE /consumer-tokens/{id}", requireWriteToken(s, handleRevokeConsumerToken(s)))
return mux
}
// SetOwnerPublicKey sets the escrowed identity public key GET
// /auth/identity returns. Unset, the response carries no public_key, as
// for an owner who hasn't completed a first registration.
func (s *Store) SetOwnerPublicKey(key string) {
s.mu.Lock()
defer s.mu.Unlock()
s.ownerPublicKey = key
}
// ownerIdentityResponse matches api/openapi.yaml's OwnerIdentity schema.
type ownerIdentityResponse struct {
PublicKey *string `json:"public_key,omitempty"`
}
func handleAuthIdentity(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
key := s.ownerPublicKey
s.mu.Unlock()
resp := ownerIdentityResponse{}
if key != "" {
resp.PublicKey = &key
}
writeJSON(w, http.StatusOK, resp)
}
}
// handleAuthStatus is unauthenticated, matching hush-hush-go's own
// AuthStatus doc comment ("no credential is required to call it").
// handleHealth matches hush-hush's GET /healthz: unauthenticated, always
// {"status":"ok"}, plus the operator's INSTANCE_LABEL when one is set.
func handleHealth(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
env := s.environment
s.mu.Unlock()
body := map[string]string{"status": "ok"}
if env != "" {
body["environment"] = env
}
writeJSON(w, http.StatusOK, body)
}
}
func handleAuthStatus(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.AuthStatus(r.Context()))
}
}
// requireWriteToken matches hush-hush's own handler: an unknown,
// malformed, or expired token are all the same 401 to the caller.
func requireWriteToken(s *Store, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || got == "" || !s.validateToken(got) {
writeError(w, http.StatusUnauthorized, "missing or invalid bearer token")
return
}
next(w, r)
}
}
func handleCreateObject(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req createObjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if req.Slug == "" || len(req.Value) == 0 {
writeError(w, http.StatusBadRequest, "slug and value are required")
return
}
tags, err := normalizeTags(req.Tags)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.createObject(r.Context(), req.Slug, req.Value, req.UsedBy, req.Description, tags); err != nil {
writeError(w, http.StatusConflict, "object already exists")
return
}
writeJSON(w, http.StatusCreated, ObjectMetadata{ID: objectID(req.Slug), Slug: req.Slug, UsedBy: req.UsedBy, Description: req.Description, Tags: tagsOrEmpty(tags)})
}
}
// handleGetObject requires a write token, or a consumer token whose bound
// consumer appears in the object's used_by list - matching hush-hush's
// current contract (alrayyes/hush-hush#446), not the earlier
// unauthenticated-by-design one. A consumer token presented for an object
// outside its scope gets the same 404 an unknown slug would, never a 403,
// so it can't be used to enumerate which other slugs exist - the same
// anti-enumeration rule api/openapi.yaml documents on the real endpoint.
func handleGetObject(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
var consumer string
switch {
case ok && got != "" && s.validateToken(got):
// A write token reads any object - no scope check.
case ok && got != "":
consumer, ok = s.consumerForToken(got)
if !ok {
writeError(w, http.StatusUnauthorized, "missing or invalid bearer token")
return
}
default:
writeError(w, http.StatusUnauthorized, "missing or invalid bearer token")
return
}
sel, ok := selectorFrom(w, r)
if !ok {
return
}
obj, err := s.getVariant(r.Context(), r.PathValue("id"), sel)
if err != nil {
writeVariantError(w, err)
return
}
if consumer != "" && !slices.Contains(obj.UsedBy, consumer) {
writeError(w, http.StatusNotFound, "unknown object")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(obj.Value)
}
}
// usedByResponse matches api/openapi.yaml's UsedBy schema.
type usedByResponse struct {
UsedBy []string `json:"used_by"`
}
// handleGetObjectUsedBy is unauthenticated, matching handleGetObject: an
// object's own recorded consumers need no more than its id to fetch.
func handleGetObjectUsedBy(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sel, ok := selectorFrom(w, r)
if !ok {
return
}
obj, err := s.getVariant(r.Context(), r.PathValue("id"), sel)
if err != nil {
writeVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, usedByResponse{UsedBy: obj.UsedBy})
}
}
func handleUpdateObject(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req updateObjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if len(req.Value) == 0 {
writeError(w, http.StatusBadRequest, "value is required")
return
}
var tags *[]string
if req.Tags != nil {
normalized, err := normalizeTags(*req.Tags)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
tags = &normalized
}
id := r.PathValue("id")
sel, ok := selectorFrom(w, r)
if !ok {
return
}
obj, err := s.updateVariant(r.Context(), id, sel, req.Value, tags, req.UsedBy)
if err != nil {
writeVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, ObjectMetadata{ID: obj.ID, Slug: id, UsedBy: obj.UsedBy, Description: obj.Description, Tags: tagsOrEmpty(obj.Tags)})
}
}
func handlePatchObject(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req patchObjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if msg := req.problem(); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
tags, err := normalizeOptionalTags(req.Tags)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id := r.PathValue("id")
sel, ok := selectorFrom(w, r)
if !ok {
return
}
obj, err := s.patchVariant(r.Context(), id, sel, req.Description, req.Slug, tags)
if errors.Is(err, ErrAlreadyExists) {
writeError(w, http.StatusConflict, "object already exists")
return
}
if err != nil {
writeVariantError(w, err)
return
}
slug := id
if req.Slug != nil {
slug = *req.Slug
}
writeJSON(w, http.StatusOK, ObjectMetadata{ID: obj.ID, Slug: slug, UsedBy: obj.UsedBy, Description: obj.Description, Tags: tagsOrEmpty(obj.Tags)})
}
}
// handleListObjects is gated by the same write token as create/update/
// delete, unlike handleGetObject - matching hush-hush's own design:
// enumerating every object is a capability none of the other, id-scoped
// reads grant on their own.
func handleListObjects(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
objects, err := s.ListObjectsFiltered(r.Context(), r.URL.Query().Get("used_by"), r.URL.Query()["tag"])
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, paged(w, r, objects))
}
}
func handleDeleteObject(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sel, ok := selectorFrom(w, r)
if !ok {
return
}
if err := s.deleteVariant(r.Context(), r.PathValue("id"), sel); err != nil {
writeVariantError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleListConsumers matches GET /consumers's own union response shape:
// the plain name array for an empty filter, ConsumersPage otherwise.
func handleListConsumers(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filter, err := parseConsumerFilter(r.URL.Query())
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
result := s.ListConsumers(r.Context(), filter)
if result.Page != nil {
writeJSON(w, http.StatusOK, result.Page)
return
}
writeJSON(w, http.StatusOK, result.Names)
}
}
func parseConsumerFilter(q url.Values) (ConsumerFilter, error) {
var filter ConsumerFilter
if v := q.Get("q"); v != "" {
filter.Q = &v
}
if v := q.Get("page"); v != "" {
page, err := strconv.ParseInt(v, 10, 32)
if err != nil {
return filter, fmt.Errorf("page: %w", err)
}
page32 := int32(page)
filter.Page = &page32
}
if v := q.Get("page_size"); v != "" {
pageSize, err := strconv.ParseInt(v, 10, 32)
if err != nil {
return filter, fmt.Errorf("page_size: %w", err)
}
pageSize32 := int32(pageSize)
filter.PageSize = &pageSize32
}
return filter, nil
}
type addConsumerRequest struct {
Name string `json:"name"`
}
func handleAddConsumer(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req addConsumerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := s.AddConsumer(r.Context(), req.Name); err != nil {
writeError(w, http.StatusConflict, "consumer already exists")
return
}
s.mu.Lock()
entry := s.consumerEntryLocked(req.Name)
s.mu.Unlock()
writeJSON(w, http.StatusCreated, entry)
}
}
type updateConsumerRequest struct {
Name *string `json:"name,omitempty"`
PublicKey *string `json:"public_key,omitempty"`
}
func handleUpdateConsumer(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req updateConsumerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
entry, err := s.UpdateConsumer(r.Context(), r.PathValue("name"), req.Name, req.PublicKey)
if err != nil {
writeError(w, http.StatusNotFound, "unknown consumer")
return
}
writeJSON(w, http.StatusOK, entry)
}
}
func handleDeleteConsumer(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := s.DeleteConsumer(r.Context(), r.PathValue("name")); err != nil {
writeError(w, http.StatusNotFound, "unknown consumer")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
type createConsumerTokenRequest struct {
Consumer string `json:"consumer"`
Description string `json:"description"`
TTLSeconds int64 `json:"ttl_seconds"`
}
func handleCreateConsumerToken(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req createConsumerTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Consumer == "" || req.TTLSeconds <= 0 {
writeError(w, http.StatusBadRequest, "consumer and a positive ttl_seconds are required")
return
}
token := s.IssueConsumerToken(req.Consumer, req.Description, time.Duration(req.TTLSeconds)*time.Second)
writeJSON(w, http.StatusCreated, token)
}
}
func handleListConsumerTokens(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, paged(w, r, s.ListConsumerTokens()))
}
}
type rotateConsumerTokenRequest struct {
TTLSeconds int64 `json:"ttl_seconds"`
}
func handleRotateConsumerToken(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req rotateConsumerTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TTLSeconds <= 0 {
writeError(w, http.StatusBadRequest, "a positive ttl_seconds is required")
return
}
token, err := s.RotateConsumerToken(r.PathValue("id"), time.Duration(req.TTLSeconds)*time.Second)
if err != nil {
writeError(w, http.StatusNotFound, "unknown, revoked, or expired consumer token")
return
}
writeJSON(w, http.StatusOK, token)
}
}
func handleRevokeConsumerToken(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.RevokeConsumerToken(r.PathValue("id"))
w.WriteHeader(http.StatusNoContent)
}
}
func handlePurgeConsumerToken(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch err := s.PurgeConsumerToken(r.PathValue("id")); {
case errors.Is(err, ErrConsumerTokenNotFound):
writeError(w, http.StatusNotFound, "unknown consumer token")
case errors.Is(err, ErrConsumerTokenActive):
writeError(w, http.StatusConflict, "consumer token is still active")
case err != nil:
writeError(w, http.StatusInternalServerError, err.Error())
default:
w.WriteHeader(http.StatusNoContent)
}
}
}
// handleQueryAuditLog is unauthenticated, matching hush-hush-go's own
// QueryAuditLog doc comment ("No credential is required").
func handleQueryAuditLog(s *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filter, err := parseAuditLogFilter(r.URL.Query())
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
entries, err := s.QueryAuditLog(r.Context(), filter)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, entries)
}
}
func parseAuditLogFilter(q url.Values) (AuditLogFilter, error) {
var filter AuditLogFilter
if v := q.Get("object_id"); v != "" {
filter.ObjectID = &v
}
if v := q.Get("caller"); v != "" {
filter.Caller = &v
}
if v := q.Get("actor"); v != "" {
filter.Actor = &v
}
if v := q.Get("from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return filter, fmt.Errorf("from: %w", err)
}
filter.From = &t
}
if v := q.Get("to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return filter, fmt.Errorf("to: %w", err)
}
filter.To = &t
}
if v := q.Get("after"); v != "" {
after, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return filter, fmt.Errorf("after: %w", err)
}
filter.After = &after
}
if v := q.Get("limit"); v != "" {
limit, err := strconv.ParseInt(v, 10, 32)
if err != nil {
return filter, fmt.Errorf("limit: %w", err)
}
limit32 := int32(limit)
filter.Limit = &limit32
}
return filter, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorBody{Error: msg})
}
// hasAllTags reports whether have carries every one of want, ignoring case.
func hasAllTags(have, want []string) bool {
for _, tag := range want {
if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, tag) }) {
return false
}
}
return true
}
const (
// defaultListPage is how many rows a list request with no `limit` gets,
// and maxListPage the most one request may ask for: the real server's
// own caps (alrayyes/Hush-Hush#649, #662).
defaultListPage = 50
maxListPage = 500
)
// paged returns the page of items that r's `limit` and `offset` select and
// sets X-Total-Count to the full count, the way the real server's list
// endpoints do. A request with no `limit` gets one default page, so a client
// that doesn't page silently sees only the first 50 rows.
func paged[T any](w http.ResponseWriter, r *http.Request, items []T) []T {
w.Header().Set("X-Total-Count", strconv.Itoa(len(items)))
limit, offset := defaultListPage, 0
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
limit = min(v, maxListPage)
}
if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v > 0 {
offset = v
}
start := min(offset, len(items))
end := min(start+limit, len(items))
return items[start:end]
}
// objectID derives a stable UUID from a slug. The fake keeps one variant per
// name, so the slug alone identifies it; the real server's id is random.
func objectID(slug string) string {
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(slug)).String()
}
// writeVariantError answers a lookup that failed: the 409 for a name with
// several variants and no id, the 404 for everything else.
func writeVariantError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrSeveralVariants) {
writeError(w, http.StatusConflict, err.Error())
return
}
writeError(w, http.StatusNotFound, "unknown object")
}
// selectorFrom reads ?id= and ?consumer=, answering 400 itself, as the real
// server does, when both are given.
func selectorFrom(w http.ResponseWriter, r *http.Request) (selector, bool) {
sel := selector{id: r.URL.Query().Get("id"), consumer: r.URL.Query().Get("consumer")}
if sel.id != "" && sel.consumer != "" {
writeError(w, http.StatusBadRequest, "pass id or consumer to pick a variant, not both")
return selector{}, false
}
return sel, true
}