auth

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 29, 2026 License: MPL-2.0 Imports: 31 Imported by: 0

Documentation

Overview

Package auth implements the `txco auth …` CLI surface — keygen, dev enrollment, signed whoami, key rotation, and revocation. It's the client-side counterpart to chassis/server/admin's auth endpoints.

All persistent state (keys + meta files) lives under TXCO_HOME, defaulting to ~/.config/txco/.

Index

Constants

View Source
const (
	// SourceFile is the default: a private key on disk at KeyPath
	// (or at $TXCO_HOME/keys/<name>.ed25519 when KeyPath is empty).
	SourceFile = "file"
	// SourceSSHAgent: the key lives in ssh-agent and is identified
	// by PublicKeyB64. The local box never holds the private key.
	SourceSSHAgent = "ssh-agent"
)

Source constants for Meta.KeySource. Kept as a small enum rather than an open string so a typo in a config-handler somewhere can't silently fall through to "file" (the default for old metas).

View Source
const ActiveNone = "none"

ActiveNone is the sentinel written to $TXCO_HOME/active by `txco auth logout`. When the resolved profile is this string, callers MUST NOT try to sign — they send the request unsigned and let the chassis decide (e.g. auth-mode=both accepts it).

View Source
const DefaultProfile = "local"

DefaultProfile is the historical default name. When no $TXCO_HOME/active file exists, this is what commands fall back to — so existing developers running `txco auth bootstrap-local` then `txco apply` keep working with zero migration.

View Source
const DefaultTenantSlug = "default"

DefaultTenantSlug is the slug that migration 0008 seeds and that ResolveTenant falls back to. Exported so other callers (e.g. server tests) can reference the same constant without hard-coding the string.

Variables

This section is empty.

Functions

func Dispatch

func Dispatch(args []string, stdout, stderr io.Writer) int

Dispatch routes `txco auth <subcommand> ...`. Status code is what the parent CLI returns from os.Exit.

func GenerateKey

func GenerateKey() (ed25519.PublicKey, ed25519.PrivateKey, error)

GenerateKey produces a fresh ed25519 keypair. Wraps the stdlib so callers don't all need to import crypto/ed25519 directly.

func HomePath

func HomePath() (string, error)

HomePath returns the directory where keys + meta files live. Honors TXCO_HOME; otherwise $XDG_CONFIG_HOME/txco; otherwise ~/.config/txco. The directory is created with 0700 mode on first call so sibling files (private keys) can default to 0600 without surprise.

func HomePathPretty

func HomePathPretty() string

HomePathPretty returns the home directory in user-readable form for printing in help text, flag descriptions, and prompts. It's the resolved path with $HOME contracted to "~", so users see "~/.config/txco/keys" rather than "$TXCO_HOME/keys" (which most people read as a placeholder) or "/Users/mattmankins/.config/txco/keys" (which differs per machine and is ugly in shared docs).

Unlike HomePath, this does NOT create the directory — it's a pure path-prediction function safe to call from flag-definition time. Cached because help text is constructed many times.

func KeyPath

func KeyPath(name string) (string, error)

KeyPath returns the path to a named ed25519 key under TXCO_HOME. The keys/ subdirectory is created on demand; the key file itself isn't touched. Names should be bare (no `.ed25519` suffix); a name like "local" maps to `$TXCO_HOME/keys/local.ed25519`.

func LoadPrivateKey

func LoadPrivateKey(path string) (ed25519.PrivateKey, error)

LoadPrivateKey reads an Ed25519 private key from path. Accepts both PKCS#8 PEM (the legacy txco format) and OpenSSH PEM (the new default, and the format `ssh-keygen` produces). Encrypted keys trigger a passphrase prompt on TTY; non-TTY callers get a typed signer.PassphraseMissingError so they can surface a clear error.

Delegates to the signer package so there's exactly one parser shared with FileKeySigner — no risk of the two diverging.

func LoadPrivateKeyWithPassphrase

func LoadPrivateKeyWithPassphrase(path string, passphrase []byte) (ed25519.PrivateKey, error)

LoadPrivateKeyWithPassphrase decrypts an encrypted Ed25519 key using the supplied passphrase. For non-interactive callers (CI, scripts) that already have the passphrase in hand.

func LoadSignerForActiveProfile

func LoadSignerForActiveProfile(flag string) (signer.Signer, error)

LoadSignerForActiveProfile is the profile-aware sign-time entry. Walks the precedence chain (flag → TXCO_PROFILE → $TXCO_HOME/active → DefaultProfile) and returns a Signer for the resolved profile.

Returns (nil, nil) — note: nil signer, nil error — for two cases:

  • Resolved profile is ActiveNone (`txco auth logout`). The caller should send the request unsigned.
  • The resolved profile has no meta file. Same idea: nothing configured, send unsigned.

Any other error (unreadable meta, bad backend, etc.) bubbles up.

func LoadSignerForMetaPath

func LoadSignerForMetaPath(metaPath string) (signer.Signer, error)

LoadSignerForMetaPath is the explicit-path variant of LoadSignerForName — used when the caller already has an absolute meta path (e.g. derived from $TXCO_PRIVATE_KEY_PATH + ".meta.json").

Same nil-signer-no-error semantics for the "meta file doesn't exist" case.

func LoadSignerForName

func LoadSignerForName(name string) (signer.Signer, error)

LoadSignerForName resolves a signer.Signer for the meta file at $TXCO_HOME/keys/<name>.meta.json. Dispatches on meta.KeySource: file-backed metas yield a FileKeySigner; ssh-agent metas yield an AgentSigner. Legacy metas (no KeySource field) are treated as file-backed at the canonical $TXCO_HOME/keys/<name>.ed25519 path.

Returns (nil, nil) — note: nil error, nil signer — when no meta file exists for that name (the common "no signing key configured" case). All other failure modes return a typed error so callers can surface a clear message rather than silently fall through.

func MetaPath

func MetaPath(name string) (string, error)

MetaPath returns the sibling meta-file path for a named key. The meta file lives next to the private key so a single rm cleans up both halves.

func PrintCLIError

func PrintCLIError(stderr io.Writer, message string)

PrintCLIError writes a terminal error line in the canonical CLI shape: red text when stderr is a TTY, plain otherwise, followed by one blank line so the next shell prompt isn't glued to the message.

No trailing `\t`. An earlier convention used `\n\n\t` to indent the shell prompt one tab, but on zsh the missing trailing newline triggers the PROMPT_SP indicator (a reverse-`%` mark) — visually noisier than the indent was worth. The blank line alone does the job.

Pass a pre-formatted message; callers do their own fmt.Sprintf if they need to interpolate, or use PrintCLIErrorf below.

func PrintCLIErrorf

func PrintCLIErrorf(stderr io.Writer, format string, args ...any)

PrintCLIErrorf is the fmt.Sprintf-style sibling of PrintCLIError. Matches the format-string shape callers are used to from `fmt.Fprintf(stderr, "verb: %v\n\n\t", err)`.

func ProfileChassisURL

func ProfileChassisURL(profileFlag string) string

ProfileChassisURL returns the chassis_url recorded in the resolved signing profile's meta (what `txco auth bootstrap-local` / `accept` bound it to), or "" when logged out, no profile/meta exists, or no URL was recorded. Lets endpoint-less commands fall back to the profile's own chassis instead of a blind localhost default.

func PublicKeyB64

func PublicKeyB64(pub ed25519.PublicKey) string

PublicKeyB64 returns the base64-encoded (standard, padded) form of the public key. This is what `/auth/dev/enroll` expects in the `public_key_b64` field.

func ReadActiveProfile

func ReadActiveProfile() (string, error)

ReadActiveProfile returns the contents of $TXCO_HOME/active, or DefaultProfile when the file is missing. Returns ActiveNone when the file explicitly says "none" (the logout state).

Whitespace + trailing newline are trimmed so an editor that helpfully adds a final newline doesn't break selection.

func ResolveProfile

func ResolveProfile(flag string) (string, error)

ResolveProfile walks the precedence chain (flag → env → active file → default) and returns the profile name to use. Empty flag + empty env means "let the persisted state decide"; an explicit flag short-circuits everything.

The TXCO_PRIVATE_KEY_PATH escape hatch is NOT handled here — it's resolved at a higher layer in target.go::loadSigner. This function is purely about the profile-name pick.

func ResolveTenant

func ResolveTenant(flag, profileFlag string) string

ResolveTenant picks the tenant slug for an outbound admin request. Precedence (highest first):

  1. explicit --tenant flag value
  2. TXCO_TENANT environment variable
  3. the active profile's Meta.DefaultTenant
  4. the literal "default" tenant (seeded by migration 0008)

An empty value at any rung falls through to the next; the bottom rung is never empty so the CLI never silently regresses to the legacy flat routes once phase 4 retires them.

profileFlag is the same --profile value used by signer resolution, so the "active profile's default" rung consults the same identity as the outbound signature. Errors loading the meta are non-fatal: the helper just continues to the next rung.

Lives in the auth subpackage because it depends on ResolveProfile, MetaPath, and LoadMeta — none of which the upper cli package can import without a cycle. Upper-package call sites use this helper via auth.ResolveTenant.

func SaveMeta

func SaveMeta(path string, m Meta) error

SaveMeta writes meta to path with mode 0600. Overwrite is allowed — `enroll` rewrites this when re-enrolling against a new chassis.

func SavePrivateKey

func SavePrivateKey(path string, priv ed25519.PrivateKey) error

SavePrivateKey writes priv to path in OpenSSH PEM format (`OPENSSH PRIVATE KEY`) AND a matching `<path>.pub` sidecar in authorized_keys format. This is the same on-disk shape `ssh-keygen -t ed25519 -f <path>` produces, which means:

  • Users can inspect / move / passphrase-encrypt the file with standard SSH tooling (`ssh-keygen -p -f <path>`).
  • The file is interchangeable with `~/.ssh/id_ed25519` and the signer package's FileKeySigner reads it transparently.
  • Other SSH tools (`ssh-add`, `ssh-copy-id`, `ssh -i <path>`) all work on the file because it has the canonical `.pub` sidecar they look for.

Existing developer keys written by older txco versions (PKCS#8 `PRIVATE KEY` blocks) are NOT rewritten — the reader handles both formats, so they keep working until the user rotates voluntarily.

File mode is 0600 for the private key, 0644 for the public sidecar. Refuses to overwrite an existing file — rotation is explicit (delete old, then save new) so a stray `auth init` can never silently clobber a working key.

func SavePrivateKeyWithComment

func SavePrivateKeyWithComment(path string, priv ed25519.PrivateKey, comment string) error

SavePrivateKeyWithComment is the labeled variant of SavePrivateKey. `comment` is written as the third whitespace-separated field of the `.pub` sidecar (the same place `ssh-keygen -C "matt@laptop"` puts it). Empty comment yields a bare two-field line, exactly what `ssh-keygen` without `-C` produces.

func WriteActiveProfile

func WriteActiveProfile(name string) error

WriteActiveProfile persists name as the active profile. Use ActiveNone to mark "logged out" (callers won't try to sign). Atomic-ish: write to a temp file then rename, so a crash mid- write can't leave a half-written active pointer that breaks every command.

Types

type EnrollmentChoices

type EnrollmentChoices struct {
	SSHAgent   bool   // --ssh-agent: force agent backend
	NoSSHAgent bool   // --no-ssh-agent: forbid agent backend even when available
	SSHKey     string // --ssh-key <path>: use this existing on-disk key
	NewKey     bool   // --new-key: generate a fresh key under $TXCO_HOME
	Name       string // --name: destination basename for new keys / meta file
	Label      string // --label: used to suggest a renamed key on collision (ssh-keygen-style prompt)
}

EnrollmentChoices is the set of flag values that drive resolveEnrollmentKey. Held as a struct so each command can fill it from its own FlagSet without a long function signature, and so new flags can land without rippling through callers.

type EnrollmentKey

type EnrollmentKey struct {
	PublicKey   ed25519.PublicKey // raw 32 bytes — goes to /auth/dev/enroll
	KeySource   string            // SourceFile | SourceSSHAgent
	KeyPath     string            // file backend: absolute path; "" otherwise
	Fingerprint string            // SHA256:… ssh-keygen format for display

	// CommentSuggestion is a human-readable hint to default the
	// --label flag from when the user didn't pass one. For
	// ssh-agent keys this is the agent's comment for the key
	// (typically "user@host"); for ssh-key files it's the comment
	// embedded in the OpenSSH PEM (also "user@host" for keys
	// produced by ssh-keygen). Empty when no comment is available.
	CommentSuggestion string
	// contains filtered or unexported fields
}

EnrollmentKey is the resolved choice: which public key to send to the chassis, which backend will sign with it, and how the meta file should be filled out.

func (*EnrollmentKey) CleanupOnFailure

func (e *EnrollmentKey) CleanupOnFailure()

CleanupOnFailure removes any freshly-generated artifact if enrolment fails after we'd already written the key. Mirrors the pre-pluggable cleanup that bootstrap.go did inline; safe to call unconditionally.

func (*EnrollmentKey) MetaName

func (e *EnrollmentKey) MetaName() string

MetaName returns the name the caller should use for SaveMeta / MetaPath. Same as the input --name when no rename happened; reflects the user's choice from the collision-rename prompt when generateFreshKey had to ask. Returns "" for ssh-agent / existing- file backends where the caller's --name is authoritative.

func (*EnrollmentKey) PersistFreshKey

func (e *EnrollmentKey) PersistFreshKey(comment string) error

PersistFreshKey writes a freshly-generated key to disk. Caller invokes this AFTER the chassis has accepted the public half, so a failed enrolment doesn't leave a stray key file around. No-op when the resolver didn't generate a key (ssh-agent / existing- file flows have nothing to persist).

comment goes into the .pub sidecar's third field, matching what `ssh-keygen -C "<comment>"` would produce. Callers typically pass the user's --label so a teammate later running `ssh-keygen -lf <path>.pub` sees a familiar identity string.

type ExistingEnrolmentKind

type ExistingEnrolmentKind int

ExistingEnrolmentKind classifies the disposition of an on-disk meta at the bootstrap-local target name + URL. Bootstrap dispatches on this value to decide whether to silently succeed, prompt for recovery, or bail with a network error.

const (
	// EnrolmentNone means there's no usable meta — first-time setup
	// or a previous removal. Caller proceeds with normal bootstrap.
	EnrolmentNone ExistingEnrolmentKind = iota
	// EnrolmentValid means the existing key authenticates against
	// the target chassis right now. Caller exits 0 — the user is
	// already set up, no further action needed.
	EnrolmentValid
	// EnrolmentRejected means the meta is for the same chassis but
	// the key was refused (401/403). Likely a rebuilt chassis or
	// revoked key. Caller offers recovery.
	EnrolmentRejected
	// EnrolmentOtherChassis means the meta is for a different
	// chassis URL than the one bootstrap is targeting. Caller
	// offers recovery without trying the key.
	EnrolmentOtherChassis
	// EnrolmentUnreachable means we couldn't reach the chassis to
	// verify the key (network error, 5xx). Caller bails with the
	// underlying error rather than guessing.
	EnrolmentUnreachable
)

type Meta

type Meta struct {
	ActorID    string    `json:"actor_id"`
	KeyID      string    `json:"key_id"`
	ChassisURL string    `json:"chassis_url"`
	Label      string    `json:"label,omitempty"`
	EnrolledAt time.Time `json:"enrolled_at"`

	// KeySource selects the signing backend. Empty → SourceFile for
	// back-compat with pre-pluggable meta files (the old format
	// implicitly meant "file at the canonical location").
	KeySource string `json:"key_source,omitempty"`

	// PublicKeyB64 is the raw 32-byte ed25519 public key, base64
	// (std + padded). Populated for ssh-agent keys (the matcher
	// uses it) and for new file-backed keys (lets `txco auth
	// whoami` print a fingerprint without re-reading the key).
	// Optional for legacy meta files; falls back to deriving from
	// the on-disk key when needed.
	PublicKeyB64 string `json:"public_key_b64,omitempty"`

	// KeyPath is the absolute path to the private key file when
	// KeySource is SourceFile. Empty means "use the default
	// $TXCO_HOME/keys/<name>.ed25519" (back-compat). Useful for
	// pointing at ~/.ssh/id_ed25519 directly.
	KeyPath string `json:"key_path,omitempty"`

	// DefaultTenant is the chassis tenant slug this profile most
	// recently enrolled or accepted into. Used as the bottom rung of
	// the --tenant resolution precedence (flag → TXCO_TENANT env →
	// this field → "default"). Empty means "no preference; fall
	// through." Populated by bootstrap-local and accept once phase-3
	// memberships land; for back-compat with pre-tenant meta files
	// the loader treats empty as "default".
	DefaultTenant string `json:"default_tenant,omitempty"`
}

Meta is the JSON record persisted alongside a private key (or, for ssh-agent keys, in lieu of one). It remembers what the server told us at enrollment time so we don't have to re-derive `key_id` from the public key on every call, AND records how to find the matching signing material at request time.

func LoadMeta

func LoadMeta(path string) (*Meta, error)

LoadMeta reads a meta file. Returns (nil, os.ErrNotExist) — wrap with errors.Is to test for "no meta yet".

func (*Meta) EffectiveKeySource

func (m *Meta) EffectiveKeySource() string

EffectiveKeySource returns m.KeySource, defaulting to SourceFile for legacy meta files that predate the pluggable-backend change. Callers should use this instead of reading the field directly so "" doesn't appear as a "no backend" sentinel anywhere.

type ProfileInfo

type ProfileInfo struct {
	Name   string
	Active bool
	Meta   *Meta
}

ProfileInfo is the summary `txco auth profiles` renders. The meta is loaded on demand by ListProfiles for each candidate, so callers get fully-populated rows in one shot.

func ListProfiles

func ListProfiles() ([]ProfileInfo, error)

ListProfiles enumerates every <name>.meta.json under $TXCO_HOME/keys/ and tags whichever one is currently active. Sort order: active first, then alphabetical — so `txco auth profiles` immediately shows what's in play.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL