Documentation
¶
Overview ¶
Package oidclogin implements `behalf login` — the verified identity root (D5, Q17, Q21, Q22) — and its offline re-verification.
The flow is ordinary OAuth 2.1 authorization-code + PKCE (S256) on a loopback redirect against any conformant OIDC provider, with one deviation: the OIDC nonce is the RFC 7638 JWK thumbprint of a freshly generated device Ed25519 key. OIDC Core requires the AS to echo the nonce into the signed ID token, so the IdP signs the thumbprint back without knowing it is doing anything unusual. The offline predicate is three checks (D5, Q17):
- the IdP signed the ID token (against the JWKS snapshot taken at login),
- nonce == jkt(device_pubkey),
- the root delegation statement is signed by that device key.
Everything above the root is asserted; skipping login yields permanently-asserted records (Q21).
Index ¶
Constants ¶
const ( CheckIdPSignature = "idp-signature" // check 1 CheckNonceBinding = "nonce-binding" // check 2 CheckRootDelegSig = "root-delegation-signature" // check 3 )
Check names (the D5 predicate, in order).
const DefaultRootMaxDepth = 4
DefaultRootMaxDepth is del_max_depth minted on the root delegation: the deepest chain the D5 prototype exercised plus one for the proxy hop.
const DefaultRootTTL = 90 * 24 * time.Hour
DefaultRootTTL is the root delegation statement's validity. The ID token's own exp is minutes; the root delegation is the standing local authority anchor, so it gets its own expiry, recorded verbatim in the hop and re-minted by the next login.
const LoginStateFile = "login.json"
LoginStateFile is the login index file name under the state dir.
const OtelConventionsVersion = "1.29.0"
OtelConventionsVersion is the gen_ai.* conventions version in force at capture (Q8, Q49).
const PayloadTypeStatement = "application/vnd.behalf.root-delegation+json"
PayloadTypeStatement is the DSSE payloadType for root delegation statements.
const RootParHash = "0000000000000000000000000000000000000000000000000000000000000000"
RootParHash is par_hash at depth 0. The AAT draft gives the root no parent, but the frozen hop schema requires the field; all-zeros is the explicit no-parent sentinel. Exported so internal/aat mints and checks the same sentinel rather than defining a second one that could drift.
const SpoolFile = "spool.jsonl"
SpoolFile is the local receipt spool under the state dir. Each line is a signed spool entry; the log service consumes it when it lands.
const StatementSchemaVersion = "behalf.sh/root-delegation/v1"
StatementSchemaVersion is the root delegation statement's projection key.
const VerificationMethodRoot = "oidc-nonce-binding"
VerificationMethodRoot names the D5 three-check root predicate on the hop's verification object (Q17).
Variables ¶
var ErrBlobMissing = errors.New("oidclogin: blob missing from store")
ErrBlobMissing marks a blob that is not in the store — the customer deleted it (or never had it). Distinct from ErrBlobTampered because a verifier must tell "deleted" apart from "altered" (Q22, Q36, D7).
var ErrBlobTampered = errors.New("oidclogin: blob content does not match its digest")
ErrBlobTampered marks a blob whose bytes no longer hash to their name.
var ErrNoLogin = errors.New("oidclogin: no login recorded")
ErrNoLogin means no login has ever completed in this state dir. Records emitted from here carry asserted attribution forever (Q21): immutability means no retro-upgrade.
Functions ¶
Types ¶
type Check ¶
type Check struct {
Name string
Status CheckStatus
Detail string
}
Check is one predicate check's result.
type CheckStatus ¶
type CheckStatus string
CheckStatus is one check's disposition.
const ( CheckPass CheckStatus = "pass" CheckFail CheckStatus = "fail" CheckSkipped CheckStatus = "skipped" )
Check dispositions.
type Config ¶
type Config struct {
// Issuer is the OIDC issuer URL (its /.well-known/openid-configuration
// must be conformant). Required.
Issuer string
// ClientID is the OAuth public client id. Required.
ClientID string
// Dir is the resolved state directory (identity.ResolveDir). Required.
Dir string
// Scopes defaults to [openid, email, profile].
Scopes []string
// NoBrowser suppresses opening the system browser; the auth URL is
// delivered via OnAuthURL for the caller to print (or, in tests, to
// drive programmatically).
NoBrowser bool
// OnAuthURL, if set, receives the authorization URL once the loopback
// listener is accepting the redirect.
OnAuthURL func(url string)
// HTTPClient overrides the client used for discovery, JWKS snapshot
// and token exchange. Nil means http.DefaultClient.
HTTPClient *http.Client
// Now overrides the clock (tests). Nil means time.Now.
Now func() time.Time
// Entropy overrides the ULID entropy source that mints the root
// delegation's `jti`, the root receipt's `receipt_id` and its `run_id`.
// Nil means crypto/rand, which is what a real login uses. A
// deterministic recording injects a fixed stream so the login it
// performs is byte-reproducible (see cmd/behalf-record).
Entropy io.Reader
// DeviceKey, if set, is bound as the human's device key instead of a
// freshly generated one.
//
// DEMO AND TEST MATERIAL ONLY. A real login generates a fresh key per
// login (that is what makes the nonce bind THIS login to THIS key), and
// the CLI never sets this. It exists for the same reason
// cmd/behalf-record pins the emitter key: a recording signed by a random
// key is not reproducible and cannot be named in a checked-in alias map.
// The login it performs is otherwise the real flow — real PKCE, real
// token exchange, a real IdP-signed ID token echoing jkt(DeviceKey).
DeviceKey *identity.Key
}
Config configures Login.
type LoginState ¶
type LoginState struct {
SchemaVersion string `json:"schema_version"` // behalf.sh/login-state/v1
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
SubDigest string `json:"sub_digest"`
DeviceJKT string `json:"device_jkt"`
IDTokenDigest string `json:"id_token_digest"`
JWKSDigest string `json:"jwks_digest"`
StatementDigest string `json:"statement_digest"`
ReceiptID string `json:"receipt_id"`
LoggedInAt string `json:"logged_in_at"` // RFC 3339
}
LoginState is the persisted index of the last completed login.
func LoadLoginState ¶
func LoadLoginState(stateDir string) (*LoginState, error)
LoadLoginState reads login.json from stateDir. Returns ErrNoLogin (wrapped) if it does not exist.
type Report ¶
type Report struct {
State State
Checks []Check
Reasons []string
Login *LoginState
// DeviceJKT is the thumbprint of the key this login bound — read from
// the signed statement where it survives, else from login.json (which is
// an index, not evidence). A chain root whose cnf.jwk is not this key was
// not minted by this human's device key.
DeviceJKT string
// StatementDigest addresses the signed root delegation statement in the
// customer-held store: the evidence behind a verified depth-0 hop.
StatementDigest string
// Issuer is the OIDC issuer that signed the ID token.
Issuer string
}
Report is what VerifyRoot found. Reasons explains every non-verified outcome in plain language.
func VerifyRoot ¶
VerifyRoot runs the offline root predicate against stateDir. It returns ErrNoLogin (wrapped) when no login has ever completed there.
type Result ¶
type Result struct {
StateDir string
Issuer string
ClientID string
SubDigest string
DeviceJKT string
IDTokenDigest string
JWKSDigest string
StatementDigest string
ReceiptID string
SealedReceipt []byte // the sealed receipt payload bytes, as spooled
}
Result is what a completed login produced and persisted.
type Statement ¶
type Statement struct {
SchemaVersion string `json:"schema_version"`
JTI string `json:"jti"` // per-hop token id, behalf extension (Q11)
Issuer string `json:"issuer"`
SubDigest string `json:"sub_digest"` // sha256(issuer "\n" sub), never the raw sub (Q40)
NonceJKT string `json:"nonce_jkt"` // = jkt(device_pubkey) = the OIDC nonce (D5)
IDTokenDigest string `json:"id_token_digest"`
JWKSDigest string `json:"jwks_digest"`
DeviceJWK dsse.JWK `json:"device_jwk"`
DelegatedAt string `json:"delegated_at"` // RFC 3339
Exp int64 `json:"exp"` // unix seconds
}
Statement is the root delegation statement payload. Field order is serialization order; it is sealed once and the bytes are frozen (the span rule, export-format-v1.md §1.2).