Documentation
¶
Overview ¶
Package verify turns a token somebody else issued into a proof this installation will act on.
Each verifier owns one kind of token and says so plainly: it either recognises the token and answers for it, or it hands it back unrecognised. Nothing here falls back to trusting anything.
Index ¶
Constants ¶
const ( AWSAlgES384 = "ES384" AWSAlgRS256 = "RS256" )
The signing algorithms AWS signs an outbound identity federation token with. `GetWebIdentityToken` takes exactly these two, so an account row can only ever narrow this set, never widen it.
const ( // DefaultAWSMaxAge is how old a token may be, by its `iat`, whatever // its `exp` allows. AWS lets a token live for an hour, and nothing here // needs one that long: the exchange happens at once, and the token it // is exchanged for is the thing that is kept. DefaultAWSMaxAge = 5 * time.Minute // MaxAWSMaxAge is the longest age a deployment may configure: AWS's // own ceiling on a token's lifetime. MaxAWSMaxAge = time.Hour )
Defaults and bounds for AWSAccount.
const ( TypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" TypeJWT = "urn:ietf:params:oauth:token-type:jwt" )
The RFC 8693 subject token types a verifier answers for. Both are accepted because callers disagree in practice and the argument is not worth having at three in the morning: what decides the outcome is the signature, not the label the caller put on it.
const GitHubIssuer = "https://token.actions.githubusercontent.com"
GitHubIssuer is where GitHub Actions mints workflow identity tokens. It is a constant and not configuration: an installation that could be pointed at another issuer is an installation where one values file makes every CI matcher meaningless.
Variables ¶
This section is empty.
Functions ¶
func ParseAWSRoleARN ¶
ParseAWSRoleARN reads the `sub` of an outbound identity federation token as an IAM role: `arn:<partition>:iam::<account>:role/<path><name>` (partition `aws`).
THE DECISION. AWS documents `sub` as "the ARN of the IAM principal that requested the token" and every example it gives (the token claims page, the getting-started guide, the announcement) is the role ARN of the principal, with the account and a role path shown as `role/<name>` — never the `arn:<partition>:sts::<account>:assumed-role/<name>/<session>` form that `sts:GetCallerIdentity` returns for the same caller. Nothing in the documentation says `sub` can take the assumed-role form, so this parser does NOT accept it: it is refused with an error saying so. The session name is chosen by whoever assumes the role, so a rule that depended on it would be a rule its caller can write; the role is the identity. Should AWS ever document the session form, accepting it is a one-line change in this function and a new test, not a configuration option.
Users, the account root, federated users and every other ARN are refused. So is any partition but `aws`.
Types ¶
type AWSAccount ¶
type AWSAccount struct {
// Account is the 12-digit AWS account id this row trusts.
Account string
// Name is the estate's word for the account, for the logs.
Name string
// Issuer is the account's `iss`:
// `https://<id>.tokens.sts.global.api.aws`, from
// `aws iam get-outbound-web-identity-federation-info`.
Issuer string
// JWKSURI is where the issuer publishes its keys. Empty means
// `{Issuer}/.well-known/jwks.json`, which is where AWS puts them.
JWKSURI string
// OrgID, when set, is the AWS Organizations id the token's `org_id`
// claim must carry.
OrgID string
// Algs are the signature algorithms accepted. Empty means both
// [AWSAlgES384] and [AWSAlgRS256].
Algs []string
// Audience the role must have requested from STS. Required: without
// one nothing is verified.
Audience string
// MaxAge rejects a token whose `iat` is older. Zero means
// [DefaultAWSMaxAge].
MaxAge time.Duration
// Client fetches the keys. Nil means [http.DefaultClient].
Client *http.Client
// KeyRefresh is the shortest time between two fetches of the key set
// caused by an unknown `kid`; without it a caller could make this
// service hammer AWS with tokens naming keys that do not exist. Zero
// means ten seconds.
KeyRefresh time.Duration
// Now is the clock. Nil means [time.Now].
Now func() time.Time
// contains filtered or unexported fields
}
AWSAccount verifies the token an AWS IAM role gets from `sts:GetWebIdentityToken` (outbound identity federation) for ONE AWS account, against the key set that account's own issuer publishes.
It is the third sibling of Cluster and GitHub, and carries the same two kinds of trust boundary:
**The account row.** Anybody can open an AWS account, enable outbound federation and mint a perfectly valid token for a role of their own with any audience they like. Signature and expiry therefore prove only that SOME AWS account vouched for the role. What makes it ours is that the token's issuer is one this installation was told to trust, and that the account the token claims (`aws_account`), the account in the role's ARN and the account the row names are all the same. There is no default account.
**The audience.** A token for another service is a valid token, so the audience must equal the one configured, exactly.
On top of those, a token older than MaxAge by its `iat` is refused even though `exp` still allows it, and the algorithm is pinned to the row's list rather than read from the token's header.
The identity is the IAM ROLE. Which function, instance or task is running as it is the account owner's concern; the function ARN a Lambda token carries is passed on for audit and for a matcher that asks for it, and is never the subject.
func (*AWSAccount) Verify ¶
Verify implements issuer.Verifier.
A token whose `iss` is not this row's comes back issuer.ErrUnverified, so the next verifier may try it. One that names this row's issuer and fails anything after that is a final refusal.
type AWSAccountRow ¶
type AWSAccountRow struct {
// Account is the 12-digit AWS account id.
Account string `yaml:"account"`
// Name is the estate's own word for the account, for logs.
Name string `yaml:"name"`
// Issuer is the account's `iss`, from
// `aws iam get-outbound-web-identity-federation-info`.
Issuer string `yaml:"issuer"`
// JWKSURI is where its keys are. Empty means
// `{issuer}/.well-known/jwks.json`.
JWKSURI string `yaml:"jwksUri,omitempty"`
// OrgID, when set, must equal the token's `org_id` claim.
OrgID string `yaml:"orgId,omitempty"`
// Algs narrows the accepted algorithms. Default `[ES384, RS256]`.
Algs []string `yaml:"algs,omitempty"`
}
AWSAccountRow is one account's row.
type AWSFederation ¶
type AWSFederation struct {
// Audience the role must request from `sts:GetWebIdentityToken`.
// Required whenever an account is listed; it is the trust boundary.
Audience string `yaml:"audience"`
// MaxAge rejects a token whose `iat` is older, whatever its `exp`
// allows. Default 5m, at most 1h.
MaxAge time.Duration `yaml:"maxAge,omitempty"`
// Accounts are the trusted accounts. Empty is an installation with no
// AWS proof, which is a real posture and not a failure.
Accounts []AWSAccountRow `yaml:"accounts"`
}
AWSFederation is the set of AWS accounts whose roles may exchange their outbound identity federation token for one of this installation's. Like Federation it holds no secret: an account, a name and a URL per row. It is the policy document's exchange.aws (internal/config), which holds the rows to their rules when the document is loaded.
func (AWSFederation) Names ¶
func (f AWSFederation) Names() []string
Names lists the accounts, for the line an operator reads at start.
func (AWSFederation) Verifiers ¶
func (f AWSFederation) Verifiers(httpClient *http.Client) []*AWSAccount
Verifiers turns the rows into verifiers, one per account, each answering only for tokens carrying its own issuer.
type Cluster ¶
type Cluster struct {
// Name is what the cluster is called in a `workload` matcher and in
// the subject of the token this exchange mints: the estate's own word
// for it, the same one that scopes a group name.
Name string
// Issuer is the `iss` its ServiceAccount tokens carry — for EKS the
// cluster's OIDC provider URL, for Talos whatever
// `--service-account-issuer` says.
Issuer string
// JWKSURI is where its public keys are. Empty discovers it from the
// issuer, which works wherever the cluster publishes a discovery
// document at a reachable address.
JWKSURI string
// Audience the token must have been minted for: this issuer's
// exchange audience. Without one nothing is verified, because every
// ServiceAccount token in that cluster would be a proof.
Audience string
// Client fetches the keys. Nil means [http.DefaultClient].
Client *http.Client
// contains filtered or unexported fields
}
Cluster verifies a Kubernetes ServiceAccount token against the key set its own API server publishes.
This is what makes ONE issuer serve MANY clusters. The other way to check such a token is a TokenReview, which asks the cluster — and asking means holding a kubeconfig for every cluster whose workloads may exchange, inside the service whose whole point is to hold almost no credential. A key set is public: EKS publishes one per cluster (it is what IRSA rests on) and Talos serves the same keys at the API server's `/openid/v1/jwks`. So a remote cluster's workload proves itself exactly the way a GitHub job does, and adding a cluster is one row of configuration naming a URL.
What is given up is TokenReview's deletion check: a token stays valid until it expires even if the ServiceAccount is deleted. Bound tokens are short-lived, so the window is minutes, and the recovery sign-in deliberately keeps TokenReview — on the day everything else is broken it should depend on nothing but the API server.
The audience is the trust boundary, exactly as it is for GitHub. A ServiceAccount token minted for another service is a perfectly valid token, and accepting it here would let whatever holds it exchange for one of ours.
func (*Cluster) Verify ¶
Verify implements issuer.Verifier.
A token this verifier does not RECOGNISE — one whose `iss` is another cluster's, or GitHub's — comes back issuer.ErrUnverified so the next verifier may try it. A token it recognises and refuses is final: a token from this cluster with a bad signature must never be retried as something else.
type FederatedCluster ¶
type FederatedCluster struct {
// Name is the estate's own word for the cluster — `dev`, `prod`
// — the same one that scopes a group name. It becomes the cluster in
// a `workload` matcher and in the subject of the minted token, so a
// row renamed is every rule about it changed.
Name string `yaml:"name"`
// Issuer is the `iss` its ServiceAccount tokens carry: for EKS the
// cluster's OIDC provider URL, for Talos whatever
// `--service-account-issuer` says.
Issuer string `yaml:"issuer"`
// JWKSURI is where its public keys are. Empty discovers it from the
// issuer, which works wherever the cluster publishes a discovery
// document at an address this service can reach.
JWKSURI string `yaml:"jwksUri,omitempty"`
}
FederatedCluster is one cluster's row.
type Federation ¶
type Federation struct {
Clusters []FederatedCluster `yaml:"clusters"`
}
Federation is the set of clusters whose ServiceAccount tokens this installation will act on: the policy document's exchange.clusters (internal/config), held to their rules when the document is loaded. It is the whole of what makes one issuer serve many clusters, and it holds no secret: every row is a name and two URLs.
func (Federation) Names ¶
func (f Federation) Names() []string
Names lists the clusters, for the line an operator reads at start.
type GitHub ¶
type GitHub struct {
// Owners are the GitHub organisations (and users) whose repositories
// this installation will act on, lowercased on comparison.
Owners []string
// OwnerIDs are the numeric ids of those organisations, as GitHub puts them
// in `repository_owner_id`. A login can be renamed, and a deleted
// organisation's login can be taken by someone else; the id cannot.
// When set, the token's `repository_owner_id` must be one of them, in
// addition to its `repository_owner` being in Owners. Unset asks
// nothing of it, which is every installation before this existed.
OwnerIDs []string
// RepositoryIDs are the numeric ids of the repositories a token may come
// from, as GitHub puts them in `repository_id`. When set, the token's
// `repository_id` must be one of them, whatever its owner. Unset asks
// nothing of it.
RepositoryIDs []string
// Audience the workflow must have requested: this issuer's URL.
Audience string
// Client fetches the keys. Nil means [http.DefaultClient].
Client *http.Client
// contains filtered or unexported fields
}
GitHub verifies a GitHub Actions workflow identity token.
Two settings, and BOTH are the trust boundary rather than tuning.
**Owners.** Anybody may run a workflow in their own repository and get a perfectly valid token from this issuer. Signature and expiry therefore prove nothing about whether the job is ours: what does is the `repository_owner` claim, checked against an allow-list this installation states. Without one, every repository on GitHub is a candidate CI identity, so an empty list verifies nothing at all.
**Audience.** GitHub mints the audience the workflow asked for. A token minted for another service — a cloud provider, a vendor's API — is a valid GitHub token, and accepting it here would let anyone who can read a build log replay it. So the audience must equal this issuer's own, and a token carrying somebody else's is refused rather than shrugged at.
func (*GitHub) Verify ¶
Verify implements issuer.Verifier.
A token this verifier does not RECOGNISE comes back issuer.ErrUnverified, so the next verifier may try it. A token it recognises and refuses comes back as a refusal, which is final: a GitHub token with a bad signature must never be retried as something else.