Documentation
¶
Overview ¶
Package auth provides CLI-side authentication infrastructure for communicating with the clawker control plane. The CLI is the trust orchestrator — it generates all key material and bind-mounts the public halves into the CP container.
Index ¶
- Constants
- Variables
- func AgentFullName(project ProjectSlug, agent AgentName) string
- func AgentFullNameFromCert(cert *x509.Certificate) (string, error)
- func BuildAgentAssertion(audience string, signingKey *ecdsa.PrivateKey) (string, error)
- func BuildAgentSAN(project ProjectSlug, agent AgentName) (*url.URL, error)
- func BuildContainerSAN(containerID string) (*url.URL, error)
- func BuildSignedAssertion(claims AssertionClaims, signingKey *ecdsa.PrivateKey) (string, error)
- func CACert() (*x509.Certificate, error)
- func ContainerIDFromCert(cert *x509.Certificate) (string, error)
- func EnsureAuthMaterial() error
- func EnsureHydraSecret() (string, error)
- func LoadClientCert() (tls.Certificate, error)
- func LoadSigningKey() (*ecdsa.PrivateKey, error)
- func ReadJWK() (json.RawMessage, error)
- func RotateAuthMaterial(forceSigningKey bool) error
- func ValidateAssertionClaims(claims AssertionClaims) error
- type AgentCert
- type AgentName
- type AssertionClaims
- type AuthFileStatus
- type ProjectSlug
Constants ¶
const AgentAssertionTTL = 24 * time.Hour
AgentAssertionTTL bounds how long a CLI-signed agent assertion stays valid at Hydra. Sized for typical container session length: a single assertion can refresh access tokens for a full working day before clawkerd's parent container would be expected to be torn down.
const AgentSANScheme = "urn:clawker:agent:"
AgentSANScheme is the URI SAN scheme that carries the AgentFullName ("clawker.<project>.<agent>" for project-scoped agents, or "clawker.<agent>" for global-scope agents). It lives in a URI SAN — not Subject.CommonName — because the composed AgentFullName can exceed x509's 64-byte CN limit once a long project slug + a long agent name (or a random docker.GenerateRandomName output) are concatenated. The cert's Subject.CommonName is the deterministic consts.ContainerClawkerd binary-identity literal instead.
CP-side gates (IdentityInterceptor, Register handler) read this SAN via AgentFullNameFromCert and compare it against the label-derived AgentFullName resolved from the peer IP's Docker container. The dialer's capturePeer also reads it as a diagnostic for the SessionConnected event payload (it does not gate trust on the value — that's the IdentityInterceptor's job on inbound RPCs).
Example: urn:clawker:agent:clawker.myapp.dev
const ContainerSANScheme = "urn:clawker:container:"
ContainerSANScheme is the URI SAN scheme used to bind a leaf cert to the docker container_id it was minted for. The Register handler reads this SAN at handler entry and rejects any cert presenting a container_id that doesn't match the cert's structural binding.
Example: urn:clawker:container:abc123def456...
Variables ¶
var ( ErrAgentSANMissing = errors.New("auth: cert has no urn:clawker:agent URI SAN") ErrAgentSANMalformed = errors.New("auth: urn:clawker:agent URI SAN has empty tail") ErrContainerSANMissing = errors.New("auth: cert has no urn:clawker:container URI SAN") ErrContainerSANMalformed = errors.New("auth: urn:clawker:container URI SAN has empty tail") )
Tri-state SAN sentinels. CP-side gates (IdentityInterceptor, Register handler) classify missing vs malformed into distinct structured-log events while presenting a uniform PermissionDenied over the wire.
Functions ¶
func AgentFullName ¶ added in v0.9.0
func AgentFullName(project ProjectSlug, agent AgentName) string
AgentFullName composes the agent identity string carried in the cert's urn:clawker:agent: URI SAN and reconstructed on demand from the registry row's (project, agent_name) columns for display. Three-segment for a project-scoped agent ("clawker.<project>.<agent>"), two-segment for a global-scope agent ("clawker.<agent>") to match docker.ContainerName naming.
Takes typed AgentName + ProjectSlug values for compile-time discipline — callers can't accidentally pass a raw string. Charset / form constraints are NOT enforced by the constructors; Docker rejects unusable resource names at create time and x509 URI SAN encoding handles whatever survives. Upstream input normalization lives in `cmdutil.ProjectSlugify`.
Lives in this package because it is purely a function of consts.NamePrefix and the (project, agent) tuple — every layer that needs to compose or verify the AgentFullName (cert minting, IdentityInterceptor cert-vs-label compare, display) reaches for this so the rule has a single home.
func AgentFullNameFromCert ¶ added in v0.9.0
func AgentFullNameFromCert(cert *x509.Certificate) (string, error)
AgentFullNameFromCert extracts the AgentFullName encoded as a URI SAN of the form urn:clawker:agent:<agent_full_name>. Returns three states the IdentityInterceptor needs to classify:
- ("name", nil) — SAN present and valid
- ("", ErrAgentSANMissing) — no urn:clawker:agent: SAN on the cert
- ("", ErrAgentSANMalformed) — scheme present but empty tail
The interceptor maps both error cases to a generic PermissionDenied over the wire (no leak about which check failed) but emits distinct structured-log events so operators can tell missing-binding from producer-side malformation.
func BuildAgentAssertion ¶
func BuildAgentAssertion(audience string, signingKey *ecdsa.PrivateKey) (string, error)
BuildAgentAssertion signs an RFC 7523 client_assertion identifying the calling clawkerd as the clawker-agent OAuth2 client at Hydra. The assertion is consumed by clawkerd at boot to obtain the access token it needs for AgentService.Connect; it is NOT used for per-agent identity (that comes from the mTLS cert thumbprint at Connect).
Same private key as the CLI client (`clawker-cli`) — distinct client_id + scope keeps the AuthZ surface clean even though the signing key is shared. See `RegisterAgentClient` for the Hydra-side counterpart.
func BuildAgentSAN ¶ added in v0.9.0
func BuildAgentSAN(project ProjectSlug, agent AgentName) (*url.URL, error)
BuildAgentSAN composes the URI SAN that carries the AgentFullName ("clawker.<project>.<agent>"). Takes typed ProjectSlug + AgentName so the AgentFullName-form rule is enforced once by AgentFullName and the helper trusts its inputs.
func BuildContainerSAN ¶
BuildContainerSAN composes the URI SAN for a given container_id. The returned *url.URL embeds in x509.Certificate.URIs.
Docker container IDs are 64-char hex strings (truncated forms in CLI output use prefixes of the same alphabet). We enforce hex-only here so a malformed ID (whitespace, slashes, control chars) cannot ride into the cert SAN — the Register handler reads this back via ContainerIDFromCert and uses it to look up a docker container, so an unvalidated value is a producer-side bug surface.
func BuildSignedAssertion ¶
func BuildSignedAssertion(claims AssertionClaims, signingKey *ecdsa.PrivateKey) (string, error)
BuildSignedAssertion creates a signed JWT assertion per RFC 7523 for use in private_key_jwt client authentication with Hydra. The assertion is signed with ES256 (ECDSA P-256). Returns the signed JWT string.
func CACert ¶
func CACert() (*x509.Certificate, error)
CACert reads the CLI CA certificate. The CLI uses this to verify server certs it signed.
func ContainerIDFromCert ¶
func ContainerIDFromCert(cert *x509.Certificate) (string, error)
ContainerIDFromCert extracts the container_id encoded as a URI SAN of the form urn:clawker:container:<id>. Returns ErrContainerSANMissing / ErrContainerSANMalformed for the two reject states; mirrors AgentFullNameFromCert.
func EnsureAuthMaterial ¶
func EnsureAuthMaterial() error
EnsureAuthMaterial checks for existing auth material and creates any that is missing. Idempotent — safe to call on every CLI invocation. Directories are created by the consts accessors.
The CLI is the root of trust. It generates:
- CA keypair — signs server and client certs, never enters containers
- ES256 signing keypair — for private_key_jwt auth with Hydra
- Server cert — signed by the CLI CA, bind-mounted into CP
- Client cert — signed by the CLI CA, used for mTLS to AdminService
func EnsureHydraSecret ¶
EnsureHydraSecret reads the persisted Hydra system secret from disk, or generates a new 32-byte random hex secret and writes it with 0600 permissions. The secret is generated once and reused across restarts.
Read errors are NOT collapsed into "regenerate" — a transient I/O fault that recovers between read and write would silently rotate the secret and invalidate every previously-issued Hydra token. Only os.ErrNotExist (first run) and an empty file (corruption fallback) trigger regeneration.
func LoadClientCert ¶
func LoadClientCert() (tls.Certificate, error)
LoadClientCert reads the CLI's mTLS client certificate and key as a tls.Certificate for use with grpc.WithTransportCredentials.
func LoadSigningKey ¶
func LoadSigningKey() (*ecdsa.PrivateKey, error)
LoadSigningKey reads the CLI's ES256 private key.
func ReadJWK ¶
func ReadJWK() (json.RawMessage, error)
ReadJWK reads the CLI's public signing key as raw JSON bytes.
func RotateAuthMaterial ¶
RotateAuthMaterial regenerates all auth material unconditionally. Unlike EnsureAuthMaterial which is idempotent (no-op if files exist), this deletes existing material and creates fresh keypairs.
The server cert is always regenerated because it depends on the CA. The signing key is regenerated only if forceSigningKey is true (it requires re-registering the CLI client with Hydra on next CP start).
func ValidateAssertionClaims ¶
func ValidateAssertionClaims(claims AssertionClaims) error
ValidateAssertionClaims checks that all required RFC 7523 claims are present. Returns an error describing the first missing or invalid claim.
Types ¶
type AgentCert ¶
AgentCert is the co-derived material produced by MintAgentCert: the PEM-encoded cert, its matching key, and the SHA-256 thumbprint over the cert DER. The three pieces are only meaningful as a unit — pairing a thumbprint with a different cert breaks the cert-swap defense at AgentService.Connect.
The String/GoString methods deliberately redact the contents so the struct (which carries the per-agent private key) can never leak via `%v`, `%+v`, `%#v`, or zerolog's interface logger. Callers that need the raw bytes must read the fields directly.
func MintAgentCert ¶
func MintAgentCert(caCertPath, caKeyPath string, project ProjectSlug, agent AgentName, containerID string) (*AgentCert, error)
MintAgentCert generates a per-agent mTLS leaf signed by the CLI CA at caCertPath/caKeyPath. The returned material is meant to be delivered to the agent container's writable layer via Docker's CopyToContainer API (see consts.BootstrapDir) and never persisted on the host.
Subject.CommonName is the deterministic consts.ContainerClawkerd literal so the CN length is fixed regardless of project / agent inputs. The per-agent AgentFullName ("clawker.<project>.<agent>" — composed via AgentFullName from the typed inputs) lives in a URI SAN (urn:clawker:agent:<agent_full_name>) instead, so a long project slug or a long random docker.GenerateRandomName output can't push the cert past x509's 64-byte CN limit. CP-side gates read the AgentFullName via AgentFullNameFromCert.
containerID is the docker container_id this cert is being minted for. MintAgentCert embeds it as a second URI SAN (urn:clawker:container: <id>) so the CP-side Register handler can read the binding directly off the peer cert at handler entry. A leaked cert presented for a different container_id is rejected because the SAN won't match the docker container the peer IP resolves to.
The 24h lifetime is intentional — thumbprint pinning at registry lookup time makes longer-lived certs safe, but a tight ceiling caps the blast radius if a leaf leaks. CP captures the thumbprint at Register handler entry from the live mTLS peer (cert.Raw → SHA-256) and writes it into the agentregistry row alongside container_id; subsequent Sessions presenting a different cert for the same container_id are rejected as untrusted.
Returns *AgentCert (nil on error) so a caller that ignores the error cannot accidentally log the redacted zero-value as a successful cert.
project + agent are typed (auth.ProjectSlug, auth.AgentName) for compile-time discipline — a raw-string caller produces a compile error. The types do not enforce charset / form; downstream layers (x509 URI SAN encoding, Docker resource names, IdentityInterceptor SAN-vs-label compare) catch malformed values at op time.
type AgentName ¶
type AgentName struct {
// contains filtered or unexported fields
}
AgentName is the user-typed short agent name (e.g. "dev"). It is distinct from a `string` purely for compile-time discipline — callers can't accidentally pass an arbitrary string where AgentName is expected. The constructor performs no runtime validation; bad input errors at the actual downstream consumer (Docker container/volume create, x509 URI SAN encoding, gRPC IdentityInterceptor's symmetric SAN-vs-label compare). Pre-validation at this layer would duplicate what those layers already enforce.
Input normalization for project/agent names derived from filesystem paths or user flags happens upstream in `cmdutil.ProjectSlugify` before the value crosses into this package.
func MustAgentName ¶
MustAgentName wraps a string that the caller has ALREADY checked for emptiness (e.g. composed in tests, or read back from a registry entry inserted through NewAgentName). Panics on empty input — invariant violation that must surface loudly rather than land a silently-zero identity downstream.
func NewAgentName ¶
NewAgentName wraps a string as an AgentName. Returns an error only when the input is empty — every other constraint is enforced at the consumer (Docker, x509, CP IdentityInterceptor). The error return stays on the signature so existing callers don't need to change shape; the only error today is "agent name required".
func (AgentName) IsZero ¶
IsZero reports whether this is the zero value (uninitialized AgentName{}). The constructors reject empty input, so a real AgentName is never zero — IsZero is for callers holding a value of unknown provenance.
func (AgentName) Less ¶ added in v0.9.0
Less reports whether a sorts before other in lexicographic order on the underlying short name. Lives on AgentName so the sort sites under internal/controlplane/agent/registry*.go don't reach for `.String() < .String()` (which would silently drop the type-safety the rest of this file gives). Treats the zero value as the smallest element so snapshot output stays deterministic.
type AssertionClaims ¶
type AssertionClaims struct {
// Issuer (iss) — must be the client_id.
Issuer string
// Subject (sub) — must be the client_id.
Subject string
// Audience (aud) — must be the Hydra token endpoint URL.
Audience string
// JWTID (jti) — cryptographically random unique ID.
JWTID string
// ExpiresIn is the duration until expiration (typically 30-60s).
ExpiresInSeconds int
}
AssertionClaims holds the claims for a client assertion JWT per RFC 7523.
type AuthFileStatus ¶
type AuthFileStatus struct {
Name string // human-readable name (e.g., "CA certificate")
Path string // filesystem path
Exists bool
Mode os.FileMode // only valid if Exists
ParseError error // non-nil if stat/read/parse failed (not os.ErrNotExist)
Expires time.Time // only valid for certificates
Expired bool // only valid for certificates
}
AuthFileStatus describes the state of a single auth material file.
func CheckAuthMaterial ¶
func CheckAuthMaterial() ([]AuthFileStatus, error)
CheckAuthMaterial inspects all auth material files and returns their status.
type ProjectSlug ¶
type ProjectSlug struct {
// contains filtered or unexported fields
}
ProjectSlug is the user-typed project slug (e.g. "myapp"). Like AgentName, the type exists for compile-time discipline; no runtime validation runs at construction. Unlike AgentName, the empty value is legitimate — it signals a global-scope agent (no project namespace), producing the 2-segment naming case documented at internal/consts/consts.go (running `clawker` outside a registered project).
func MustProjectSlug ¶
func MustProjectSlug(s string) ProjectSlug
MustProjectSlug is the unchecked companion to NewProjectSlug. Kept for callers (and tests) that prefer a single-return form. Since NewProjectSlug can't error, MustProjectSlug can't panic — but the name pairs with MustAgentName for the same construction-site readability.
func NewProjectSlug ¶
func NewProjectSlug(s string) (ProjectSlug, error)
NewProjectSlug wraps a string as a ProjectSlug. Always returns nil error — the signature retains `error` only so existing callers don't need to change shape if the validation ever has to return. Empty input produces the zero value (the global-scope-agent signal — no project namespace).
func (ProjectSlug) IsEmpty ¶
func (p ProjectSlug) IsEmpty() bool
IsEmpty reports whether the slug is the empty global-scope value. Use this rather than `s.String() == ""` so a future refactor of the underlying representation doesn't silently break the check.
func (ProjectSlug) Less ¶ added in v0.9.0
func (p ProjectSlug) Less(other ProjectSlug) bool
Less reports whether p sorts before other in lexicographic order on the underlying slug. The empty global-scope slug sorts before every non-empty value, which keeps Snapshot output deterministic for the docker.ContainerName 2-segment case.
func (ProjectSlug) String ¶
func (p ProjectSlug) String() string
String returns the underlying slug; empty for the global-scope case.