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:
- POST /api/users -- create the user record (no password).
- PUT /api/users/:id/user-groups -- add to the "owners" group.
- 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
- Variables
- func BindHomePublicTrust(root string, trust HomePublicTrust) error
- func EnrollHomeDevice(string, json.RawMessage) error
- func HomeEnrollmentHandler() http.Handler
- func HomeVerifierHandler(root string) http.Handler
- func ReadStaticAPIKey(baseDir string) (string, error)
- type BackupEncryptionKeyCredential
- type BackupEncryptionKeyGenerator
- type BackupEncryptionKeyPayload
- type BreakGlassCredential
- type BreakGlassGenerator
- type BreakGlassSection
- type BundleBuilder
- type BundleNode
- type BundlePaths
- type BundlePayload
- type HomeAccessProof
- type HomeAuthentication
- type HomeConfirmation
- type HomePocketIDTrust
- type HomePublicTrust
- type OwnerProvisioner
- type OwnerSpec
- type PocketIDAdminPayload
- type PocketIDClient
- type ProvisionResult
- type TinyAuthStaticCredential
- type TinyAuthStaticGenerator
- type TinyAuthStaticPayload
Constants ¶
const ( HomePublicTrustSchema = "stackkit.home-human-device-trust/v1" HomeDeviceCredentialType = "stackkit-device-credential+jwt" )
const ( HomeVerifierPath = "/api/v1/identity/home/verify" HomeEnrollmentPath = "/api/v1/identity/home/enroll" )
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 ¶
var ( ErrHomeAccessDenied = errors.New("identity: current independent Home human and device proof required") )
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 ¶
HomeEnrollmentHandler keeps pairing closed until human step-up authority exists. Workload peer enrollment is a different owner operation.
func HomeVerifierHandler ¶
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 ¶
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 ¶
func (g *BackupEncryptionKeyGenerator) Generate() (*BackupEncryptionKeyCredential, error)
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 ¶
func (g *BreakGlassGenerator) Generate(ctx context.Context) (*BreakGlassCredential, error)
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 ¶
func (g *TinyAuthStaticGenerator) Generate() (*TinyAuthStaticCredential, error)
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.