identity

package
v0.41.0 Latest Latest
Warning

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

Go to latest
Published: Sep 24, 2026 License: Apache-2.0 Imports: 29 Imported by: 0

Documentation

Overview

Package identity provisions PocketID owner and break-glass accounts.

PocketID v2 is passkey-only: there is no password field on user creation. The owner-activation flow is therefore three calls:

  1. POST /api/users -- create the user record (no password).
  2. PUT /api/users/:id/user-groups -- add to the "owners" group.
  3. POST /api/users/:id/one-time-access-token -- issue an enrollment token.

The token is rendered into a setup URL of the form

https://id.<domain>/setup-account?token=<token>

which the owner clicks once to register a WebAuthn credential. The token is single-use and consumed by PocketID on first redemption.

The local provisioner only supports Source=="local" (a daily-admin owner provisioned locally on the first node). Source=="cloud" is orchestrator managed by TechStack/kombify Cloud and must not call this local provisioner.

Index

Constants

View Source
const (
	HomePublicTrustSchema    = "stackkit.home-human-device-trust/v1"
	HomeDeviceCredentialType = "stackkit-device-credential+jwt"
)
View Source
const (
	HomeVerifierPath   = "/api/v1/identity/home/verify"
	HomeEnrollmentPath = "/api/v1/identity/home/enroll"
)
View Source
const StaticAPIKeyFilename = "pocketid-static-api-key"

StaticAPIKeyFilename is the basename of the on-disk file that holds the PocketID STATIC_API_KEY for a homelab. It lives under <homelab>/.stackkit/.

Variables

View Source
var (
	ErrHomeAccessDenied          = errors.New("identity: current independent Home human and device proof required")
	ErrHomeEnrollmentUnavailable = errors.New("identity: Home enrollment remains unavailable until local pairing and human step-up authority are connected")
)

Functions

func BindHomePublicTrust

func BindHomePublicTrust(root string, trust HomePublicTrust) error

BindHomePublicTrust is local Owner publication of current public trust. It does not enroll a device or mint a credential.

func EnrollHomeDevice

func EnrollHomeDevice(string, json.RawMessage) error

EnrollHomeDevice stays closed until pairing and human step-up exist.

func HomeEnrollmentHandler

func HomeEnrollmentHandler() http.Handler

HomeEnrollmentHandler keeps pairing closed until human step-up authority exists. Workload peer enrollment is a different owner operation.

func HomeVerifierHandler

func HomeVerifierHandler(root string) http.Handler

HomeVerifierHandler is mounted on the existing server. It does not create a listener or treat API keys, workload TLS, or forwarded headers as identity.

func ReadStaticAPIKey

func ReadStaticAPIKey(baseDir string) (string, error)

ReadStaticAPIKey returns the existing STATIC_API_KEY for a homelab without generating one when the file is missing. This supports reading immutable v0.6 artifacts during migration; current native generation uses owner custody.

Types

type BackupEncryptionKeyCredential

type BackupEncryptionKeyCredential struct {
	// Engine is the snapshot engine name. Always "kopia" today; here so a
	// future format change does not require a bundle break.
	Engine string

	// Passphrase is the cleartext value passed to Kopia at
	// repository-create time. Goes into the recovery bundle; never
	// logged. Same handling discipline as TinyAuthStaticCredential.PasswordPlain.
	Passphrase string

	// RepositoryHint is a human-readable pointer to where the data
	// lives ("b2://kombify-vault/host-a", "sftp://u@host:/repo", …).
	// Optional. Helps a recovery operator who has only the bundle find
	// the offsite repo.
	RepositoryHint string
}

BackupEncryptionKeyCredential carries the Kopia repository passphrase used to encrypt snapshots. It is the third break-glass layer (after PocketID-admin and TinyAuth-static) and lives in the recovery bundle so a host loss does not equal a data loss.

Unlike the other layers, this credential is OPTIONAL: a node that has not enabled the rollout does not have one, and the bundle's BackupEncryptionKey field is omitted entirely.

type BackupEncryptionKeyGenerator

type BackupEncryptionKeyGenerator struct {
	// Engine is recorded into the credential. Defaults to "kopia".
	Engine string

	// RepositoryHint is recorded verbatim. Optional.
	RepositoryHint string
}

BackupEncryptionKeyGenerator creates a fresh repository passphrase. The output is suitable for direct use as KOPIA_PASSWORD.

func (*BackupEncryptionKeyGenerator) Generate

Generate produces 32 random bytes encoded as base64 (~43 characters). Same shape as TinyAuthStaticGenerator's plaintext: high entropy, safe for the YAML bundle, copy-pasteable in a recovery scenario.

type BackupEncryptionKeyPayload

type BackupEncryptionKeyPayload struct {
	// Engine is the snapshot engine that owns the passphrase. "kopia" today;
	// the field is here so a future engine swap (extremely unlikely given
	// ADR-0016) does not require a bundle-format break.
	Engine string `yaml:"engine"`

	// Passphrase is the cleartext value the operator (or stackkit apply)
	// generated. Treat as secret. Same handling rules as the TinyAuth
	// plaintext password on the same bundle.
	Passphrase string `yaml:"passphrase"`

	// RepositoryHint is a human-readable pointer to where the data lives
	// (e.g. "b2://kombify-vault/host-a"). Optional. The recovery
	// operator typically already knows this from out-of-band sources;
	// the hint is here so a recovery scenario starting from "I only have
	// the bundle" is still tractable.
	RepositoryHint string `yaml:"repositoryHint,omitempty"`
}

BackupEncryptionKeyPayload escrows the Kopia repository passphrase used to encrypt snapshots. It is the only path back to the data if the host disk is lost: an operator decrypts the bundle and re-attaches the offsite repo with this passphrase.

The plaintext passphrase lives only inside the encrypted .age bundle. Mode 0600 on the .txt convenience copy plus the mandatory recovery passphrase on the .age file are the same protections used for the other layers.

type BreakGlassCredential

type BreakGlassCredential struct {
	// Username is the synthetic local handle, of the form
	// "bg-<nodename>@local". It is unique per node so multiple homelabs
	// don't collide on a shared PocketID.
	Username string

	// SetupToken is the raw one-time-access token issued by PocketID. It
	// is single-use and consumed when the recoverer registers a passkey.
	SetupToken string

	// SetupURL is the full URL the recoverer clicks to complete WebAuthn
	// enrollment, of the form "https://id.<domain>/setup-account?token=<t>".
	SetupURL string

	// Group is the PocketID group the account was added to (typically
	// "owners").
	Group string

	// UserID is the PocketID-assigned UUID for this account, used by later
	// rotation/revoke operations.
	UserID string
}

BreakGlassCredential is the materialized result of a break-glass admin provisioning. Username, SetupToken, SetupURL and UserID are intended to be embedded into the recovery bundle (Task 9).

type BreakGlassGenerator

type BreakGlassGenerator struct {
	// Client is the PocketID admin-API client (real or fake). Reuses the
	// same PocketIDClient interface defined in owner.go.
	Client PocketIDClient

	// NodeName identifies the homelab firstnode this break-glass account
	// belongs to. Required.
	NodeName string

	// PocketIDURL is the public origin of the PocketID instance, used to
	// build SetupURL. Example: "https://id.example.com" (no trailing slash).
	PocketIDURL string

	// OwnersGroup is the name of the group the break-glass admin is added
	// to. Defaults to "owners" when empty.
	OwnersGroup string

	// TokenTTL controls how long the one-time-access token remains valid.
	// Defaults to 365 days when zero.
	TokenTTL time.Duration
}

BreakGlassGenerator creates a per-node break-glass admin in PocketID and returns a credential bundle suitable for sealing into the recovery envelope.

func (*BreakGlassGenerator) Generate

Generate provisions a break-glass admin account on PocketID for the configured node and returns its BreakGlassCredential. The account is created with admin privileges and added to the owners group; a long-TTL one-time-access token is issued so the recoverer can enroll a WebAuthn credential when (and only when) they actually open the recovery bundle.

type BreakGlassSection

type BreakGlassSection struct {
	PocketIDAdmin       PocketIDAdminPayload        `yaml:"pocketidAdmin"`
	TinyAuthStatic      TinyAuthStaticPayload       `yaml:"tinyauthStatic"`
	BackupEncryptionKey *BackupEncryptionKeyPayload `yaml:"backupEncryptionKey,omitempty"`
}

BreakGlassSection holds the recovery layers.

PocketIDAdmin and TinyAuthStatic are mandatory — every node has them. BackupEncryptionKey is optional: it is only populated when a rollout has an encrypted snapshot repository and the operator has chosen to escrow the Kopia encryption passphrase here. Without that escrow, "lost host" equals "lost backups", which defeats the snapshot policy.

type BundleBuilder

type BundleBuilder struct {
	// NodeName identifies the homelab node and drives the bundle's
	// filename ("break-glass-<nodename>.age"). Required.
	NodeName string

	// Hostname is the node's network hostname (FQDN or short name); a
	// purely-documentation field but useful during recovery for matching
	// the bundle to physical hardware.
	Hostname string

	// ClusterRole is one of "main", "worker", or "storage". Required.
	ClusterRole string

	// PocketIDURL is the public origin of the PocketID instance, mirrored
	// into the bundle so the recovery operator knows which install this
	// belongs to.
	PocketIDURL string

	// PocketIDAdmin is the Layer-1 break-glass credential from Task 7.
	// Required.
	PocketIDAdmin *BreakGlassCredential

	// TinyAuthStatic is the Layer-2 break-glass credential from Task 8.
	// Required.
	TinyAuthStatic *TinyAuthStaticCredential

	// BackupEncryptionKey escrows the Kopia repository passphrase used by
	// the snapshot repository. Optional — only populated when snapshot
	// encryption is enabled. nil leaves the BackupEncryptionKey field out of the
	// emitted YAML entirely (yaml:"...,omitempty").
	BackupEncryptionKey *BackupEncryptionKeyCredential

	// BundleDir is the directory the .age and .txt files are written to.
	// Defaults to "/var/lib/stackkit/recovery" when empty.
	BundleDir string

	// Now is injectable for deterministic tests. Defaults to time.Now.
	Now func() time.Time
}

BundleBuilder aggregates the inputs needed to build a recovery bundle. Construct one, populate the credentials produced by the Task-7 and Task-8 generators, and call BuildAndSave.

func (*BundleBuilder) BuildAndSave

func (b *BundleBuilder) BuildAndSave(passphrase string) (*BundlePaths, error)

BuildAndSave validates the builder, marshals the bundle YAML, encrypts it with the supplied passphrase, and writes both the encrypted (.age, 0644) and plaintext (.txt, 0600) files. It returns the on-disk paths.

The plaintext file is convenience-only — the encrypted bundle is the disaster-recovery artifact that must be backed up out-of-band.

type BundleNode

type BundleNode struct {
	Name        string `yaml:"name"`
	Hostname    string `yaml:"hostname"`
	ClusterRole string `yaml:"clusterRole"` // "main"|"worker"|"storage"
	PocketIDURL string `yaml:"pocketidUrl"`
}

BundleNode identifies the homelab node this bundle belongs to. Including hostname and cluster role makes the bundle self-describing during a recovery — the operator can match the artifact to physical hardware without consulting external state.

type BundlePaths

type BundlePaths struct {
	// EncryptedPath is the .age file. This is the disaster-recovery
	// artifact that must be backed up out-of-band; it is safe to copy
	// to cloud storage, USB sticks, paper QR codes, etc.
	EncryptedPath string

	// PlaintextPath is the .txt file (mode 0600). Convenience-only — a
	// root-on-the-node operator can read it without redecrypting. Lives
	// next to the encrypted file in the bundle directory.
	PlaintextPath string
}

BundlePaths returns the on-disk locations of the written bundle files.

type BundlePayload

type BundlePayload struct {
	Version             int               `yaml:"version"`
	GeneratedAt         string            `yaml:"generatedAt"`
	Node                BundleNode        `yaml:"node"`
	BreakGlass          BreakGlassSection `yaml:"breakGlass"`
	RestoreInstructions string            `yaml:"restoreInstructions"`
}

BundlePayload is the structure that is serialized to YAML and then encrypted into the .age file. Field names match foundation/break-glass.cue #BundlePayload (camelCase).

type HomeAccessProof

type HomeAccessProof struct {
	HumanSession     string `json:"humanSession"`
	DeviceCredential string `json:"deviceCredential"`
	DPoP             string `json:"dpop"`
}

HomeAccessProof is a credential and must never be logged. HumanSession is the original PocketID ID token. DeviceCredential is signed by the Home device-authority issuer, never the workload mTLS issuer.

type HomeAuthentication

type HomeAuthentication struct {
	Subject   string    `json:"subject"`
	Device    string    `json:"device"`
	ExpiresAt time.Time `json:"expiresAt"`
}

HomeAuthentication is request-scoped admission, not privileged approval.

func AuthenticateHomeRequest

func AuthenticateHomeRequest(root, method, target string, proof HomeAccessProof) (HomeAuthentication, error)

AuthenticateHomeRequest reloads current signed Home trust for every request and durably consumes the DPoP proof. Workload mTLS, API keys and forwarded identity headers cannot satisfy this boundary.

type HomeConfirmation

type HomeConfirmation struct {
	JWKThumbprint string `json:"jkt"`
}

type HomePocketIDTrust

type HomePocketIDTrust struct {
	Issuer   string             `json:"issuer"`
	ClientID string             `json:"clientId"`
	Subject  string             `json:"subject"`
	Keys     jose.JSONWebKeySet `json:"keys"`
}

HomePocketIDTrust is the live PocketID OIDC authority. The CUE human issuer URN cannot alias this HTTPS issuer or its OIDC client audience.

type HomePublicTrust

type HomePublicTrust struct {
	Schema                        string                                  `json:"schema"`
	HomeSiteRef                   string                                  `json:"homeSiteRef"`
	PublicOrigin                  string                                  `json:"publicOrigin"`
	DeviceAuthorityRef            string                                  `json:"deviceAuthorityRef"`
	DeviceIssuer                  string                                  `json:"deviceIssuer"`
	DeviceAudiences               []string                                `json:"deviceAudiences"`
	DeviceKeySetRef               string                                  `json:"deviceKeySetRef"`
	DeviceCredentialTTLSeconds    int                                     `json:"deviceCredentialTTLSeconds"`
	DeviceSessionTTLSeconds       int                                     `json:"deviceSessionTTLSeconds"`
	HumanSessionTTLSeconds        int                                     `json:"humanSessionTTLSeconds"`
	RevocationMaxStalenessSeconds int                                     `json:"revocationMaxStalenessSeconds"`
	PartitionDenied               bool                                    `json:"partitionDenied"`
	PocketID                      HomePocketIDTrust                       `json:"pocketId"`
	DeviceKeys                    jose.JSONWebKeySet                      `json:"deviceKeys"`
	RevokedCredentialIDs          []string                                `json:"revokedCredentialIds"`
	RevokedSubjects               []string                                `json:"revokedSubjects"`
	IssuedAt                      time.Time                               `json:"issuedAt"`
	ExpiresAt                     time.Time                               `json:"expiresAt"`
	Signature                     localevidence.OwnerPolicyStateSignature `json:"signature"`
}

HomePublicTrust is current Home public verifier material. Device issuer fields rebound HomeDeviceAuthority; partition denial rebounds Modern IdentityTrust. Private keys, pairing state and enrollment tokens are absent.

type OwnerProvisioner

type OwnerProvisioner struct {
	// Client is the PocketID admin-API client (real or fake).
	Client PocketIDClient

	// PocketIDURL is the public origin of the PocketID instance, used to
	// build SetupURL. Example: "https://id.example.com" (no trailing slash).
	PocketIDURL string

	// OwnersGroup is the name of the group new owners are added to.
	// Defaults to "owners" when empty.
	OwnersGroup string
}

OwnerProvisioner creates the owner record in PocketID and adds them to the owners group, then issues a one-time-access token for WebAuthn enrollment.

func (*OwnerProvisioner) Provision

func (p *OwnerProvisioner) Provision(ctx context.Context, spec OwnerSpec) (*ProvisionResult, error)

Provision creates the owner user in PocketID, adds them to the owners group, and returns a ProvisionResult containing the user's UUID and a setup URL the owner clicks once to enroll a WebAuthn credential.

Source=="local" is the only locally provisioned path. Source=="cloud" returns an orchestrator-managed error. Any other Source value is rejected as invalid.

type OwnerSpec

type OwnerSpec struct {
	// Source controls the provisioning path. This local provisioner supports
	// "local" only; "cloud" is handled by TechStack/kombify Cloud.
	Source string

	// Email is the owner's address (also used as the WebAuthn account label).
	Email string

	// Username is the PocketID login handle.
	Username string

	// DisplayName is what PocketID renders in the UI; defaults to Username
	// when empty. PocketID v2 stores this in the FirstName column on the
	// underlying user record.
	DisplayName string

	// ForeignSubjectID is the external IdP subject for Source=="cloud".
	// Ignored when Source=="local".
	ForeignSubjectID string
}

OwnerSpec describes the daily-admin owner of a homelab instance.

type PocketIDAdminPayload

type PocketIDAdminPayload struct {
	Username   string `yaml:"username"`
	SetupToken string `yaml:"setupToken"`
	SetupURL   string `yaml:"setupUrl"`
	Group      string `yaml:"group"`
	UserID     string `yaml:"userId"`
}

PocketIDAdminPayload is the Layer-1 recovery credential. The setup token + URL is the v2 passkey-only equivalent of the classical sealed password: redeeming it lets the recoverer enroll a WebAuthn credential and become a fully-privileged PocketID admin.

type PocketIDClient

type PocketIDClient interface {
	CreateUser(ctx context.Context, req pocketid.CreateUserRequest) (*pocketid.User, error)
	AddUserToGroup(ctx context.Context, userID, groupID string) error
	GetGroupIDByName(ctx context.Context, groupName string) (string, error)
	CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (string, error)
}

PocketIDClient is the subset of pocketid.Client used by provisioners. Defined as an interface so tests can supply fakes without standing up an HTTP server.

type ProvisionResult

type ProvisionResult struct {
	// UserID is the PocketID-assigned UUID for the new owner record.
	UserID string

	// SetupURL is the one-time link the owner clicks to enroll a WebAuthn
	// credential. It embeds a single-use token with a 24-hour TTL. Local
	// source only.
	SetupURL string
}

ProvisionResult is what Provision returns on success.

type TinyAuthStaticCredential

type TinyAuthStaticCredential struct {
	// Username is the synthetic local handle of the form
	// "bg-<nodename>-static". Distinct from the PocketID break-glass
	// username so logs/audit can tell the two layers apart.
	Username string

	// PasswordPlain is the human-typeable password (base64 of 32 random
	// bytes). Goes into the recovery bundle; never logged.
	PasswordPlain string

	// PasswordBcrypt is the bcrypt hash of PasswordPlain. Goes into the
	// TinyAuth container env via ToEnvValue.
	PasswordBcrypt string
}

TinyAuthStaticCredential is the materialized result of a TinyAuth static user provisioning. PasswordPlain is shown to the owner exactly once and embedded in the sealed recovery bundle; PasswordBcrypt is what gets rendered into the TinyAuth container `USERS` env var.

func (*TinyAuthStaticCredential) ToEnvValue

func (c *TinyAuthStaticCredential) ToEnvValue() string

ToEnvValue returns the "username:bcrypt-hash" entry suitable for direct inclusion in TinyAuth's USERS env var. Multiple users are joined with commas; this method returns a single entry.

type TinyAuthStaticGenerator

type TinyAuthStaticGenerator struct {
	// NodeName identifies the homelab firstnode this credential belongs
	// to. Required.
	NodeName string

	// BcryptCost is the bcrypt cost factor. Defaults to 12 when zero.
	// Tests may pass a lower value to keep the suite fast.
	BcryptCost int
}

TinyAuthStaticGenerator creates a per-node TinyAuth static credential.

func (*TinyAuthStaticGenerator) Generate

Generate produces a fresh TinyAuth static credential. The plaintext password is 32 random bytes encoded as base64 (~43 characters); the bcrypt hash is computed at the configured cost.

type TinyAuthStaticPayload

type TinyAuthStaticPayload struct {
	Username       string `yaml:"username"`
	PasswordPlain  string `yaml:"passwordPlain"`
	PasswordBcrypt string `yaml:"passwordBcrypt"`
}

TinyAuthStaticPayload is the Layer-2 recovery credential. PasswordPlain is here so the operator has something to type even if PocketID itself is corrupt; PasswordBcrypt mirrors what was rendered into the TinyAuth container env so the bundle is a complete record of state.

Jump to

Keyboard shortcuts

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