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
- func Dispatch(args []string, stdout, stderr io.Writer) int
- func GenerateKey() (ed25519.PublicKey, ed25519.PrivateKey, error)
- func HomePath() (string, error)
- func HomePathPretty() string
- func KeyPath(name string) (string, error)
- func LoadPrivateKey(path string) (ed25519.PrivateKey, error)
- func LoadPrivateKeyWithPassphrase(path string, passphrase []byte) (ed25519.PrivateKey, error)
- func LoadSignerForActiveProfile(flag string) (signer.Signer, error)
- func LoadSignerForMetaPath(metaPath string) (signer.Signer, error)
- func LoadSignerForName(name string) (signer.Signer, error)
- func MetaPath(name string) (string, error)
- func PrintCLIError(stderr io.Writer, message string)
- func PrintCLIErrorf(stderr io.Writer, format string, args ...any)
- func ProfileChassisURL(profileFlag string) string
- func PublicKeyB64(pub ed25519.PublicKey) string
- func ReadActiveProfile() (string, error)
- func ResolveProfile(flag string) (string, error)
- func ResolveTenant(flag, profileFlag string) string
- func SaveMeta(path string, m Meta) error
- func SavePrivateKey(path string, priv ed25519.PrivateKey) error
- func SavePrivateKeyWithComment(path string, priv ed25519.PrivateKey, comment string) error
- func WriteActiveProfile(name string) error
- type EnrollmentChoices
- type EnrollmentKey
- type ExistingEnrolmentKind
- type Meta
- type ProfileInfo
Constants ¶
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).
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).
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
ResolveTenant picks the tenant slug for an outbound admin request. Precedence (highest first):
- explicit --tenant flag value
- TXCO_TENANT environment variable
- the active profile's Meta.DefaultTenant
- 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 ¶
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 ¶
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 ¶
LoadMeta reads a meta file. Returns (nil, os.ErrNotExist) — wrap with errors.Is to test for "no meta yet".
func (*Meta) EffectiveKeySource ¶
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 ¶
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.