Documentation
¶
Overview ¶
Package helpers is the FORA SDK low-tier L1: stateless protocol helpers built directly on the L0 generated wire types (github.com/FORA-Protocol/protocol/gen/go).
Layering (ADR-020) ¶
L0 generated wire types gen/go/fora/v1, gen/go/vocab/* (consumed, never rebuilt) L1 stateless protocol helpers THIS PACKAGE (no IO, no state, no transport) L2 transport-neutral core + Connect bindings sdk/go/core; sdk/go/connect (client), sdk/go/connectserver (server) (state injected) L3 framework adapters separate packages (convert, never replace)
L1 owns exactly the protocol mechanics that are defined by the spec, are stateless / single-operation, and have two or more consumers (the inclusion test, ADR-020 §3): RFC 9421 request signing and verification, RFC 7638 JWK thumbprints, signed-URL signing/verification and proof-of-possession, money (canonical decimal string) parsing and formatting, the ADR-019 ErrorDetail mapping, idempotency-key minting, scope/subscription plumbing, the cross-field CEL validation rules, license-term canonicalisation with the ingest-tier registry-membership and coherence checks a publisher runs before pushing, and the KeyResolver abstraction with a well-known default.
What L1 never does ¶
L1 never owns state, never custodies a secret, and never performs transport. Keys arrive through the Signer interface (which may be a KMS the SDK cannot read); verifying keys arrive through an injected KeyResolver. Everything in this package is a pure function over its inputs or a small value type — it is safe to share across goroutines and trivial to unit-test, and it is the same code the Broker, Exchange, MCP shim, Edge, and external implementors all build on. This package is a relocation of the previously service-internal helpers (internal/httpsig, internal/forathumbprint, internal/forawellknown, src/exchange/internal/signing), made into one reusable surface — the crypto is kept byte-identical across the move (ADR-020 §8).
Index ¶
- Constants
- Variables
- func AppendSignature(ctx context.Context, req *http.Request, body []byte, signer Signer, ...) error
- func ApplyScopes(r *forav1.Requester, scopes ...string)
- func CanonicalAcceptanceBytes(offer *forav1.Offer, requester *forav1.Requester, idempotencyKey string) ([]byte, error)
- func CanonicalOfferBytes(offer *forav1.Offer) ([]byte, error)
- func CanonicalRequestAcceptanceBytes(payload *forav1.AgentRequestAcceptancePayload) ([]byte, error)
- func CanonicalRestrictionToken(kind forav1.RestrictionKind, token string) string
- func CanonicalizeMoney(s string) (string, error)
- func CatalogRejectionDetail(domain, message string, reason forav1.CatalogRejectionReason) *forav1.ErrorDetail
- func CheckWellKnownManifestVersion(ver string) error
- func CompileRegistrationSchema(raw []byte) (*RegistrationSchema, SchemaVerdict)
- func ContentDigest(body []byte) string
- func DisputeFailureDetail(domain, message string, reason forav1.DisputeFailureReason) *forav1.ErrorDetail
- func DomainVerificationFailureDetail(domain, message string, reason forav1.DomainVerificationFailureReason) *forav1.ErrorDetail
- func FormatMoney(d decimal.Decimal) (string, error)
- func HashURL(signed string) []byte
- func HostAnchored(anchor, candidate string) (bool, error)
- func HostOf(ref string) (string, error)
- func IsBareDomain(v string) bool
- func IsBareHost(ref string) (bool, error)
- func IsSafeSchemaPattern(p string) bool
- func KnownRestrictionToken(kind forav1.RestrictionKind, token string) bool
- func NewContext(ctx context.Context, v *VerifiedRequest) context.Context
- func NewIdempotencyKey() (string, error)
- func NewMultisigContext(ctx context.Context, sigs []VerifiedRequest) context.Context
- func NormalizeLicenseTerm(term *forav1.LicenseTerm)
- func NormalizeResourceEntry(entry *forav1.ResourceEntry)
- func NormalizeScopes(scopes []string) []string
- func ParseMoney(s string) (decimal.Decimal, error)
- func Reason(detail *forav1.ErrorDetail) any
- func RedactURL(raw string) string
- func RegistrationFailureDetail(domain, message string, reason forav1.RegistrationFailureReason, ...) *forav1.ErrorDetail
- func RequestAcceptancePayload(req *forav1.TransactionRequest) (*forav1.AgentRequestAcceptancePayload, error)
- func RetrievalAuthFailureDetail(domain, message string, reason forav1.RetrievalAuthFailureReason) *forav1.ErrorDetail
- func RetrievalAuthFailureReasonFromToken(token string) (forav1.RetrievalAuthFailureReason, bool)
- func ScopesSubset(sub, super []string) bool
- func SharedValidator() (protovalidate.Validator, error)
- func SignOffer(priv ed25519.PrivateKey, offer *forav1.Offer) (string, error)
- func SignOfferAcceptance(priv ed25519.PrivateKey, offer *forav1.Offer, requester *forav1.Requester, ...) (string, error)
- func SignOfferAcceptanceWith(ctx context.Context, signer Signer, offer *forav1.Offer, ...) (string, error)
- func SignRequest(ctx context.Context, req *http.Request, body []byte, signer Signer, ...) error
- func SignRequestAcceptance(priv ed25519.PrivateKey, req *forav1.TransactionRequest) (*forav1.AgentRequestAcceptance, error)
- func SignRequestAcceptanceWith(ctx context.Context, signer Signer, req *forav1.TransactionRequest) (*forav1.AgentRequestAcceptance, error)
- func SignatureAgentFromContext(ctx context.Context) string
- func Thumbprint(pub ed25519.PublicKey) (string, error)
- func ThumbprintBytes(pub ed25519.PublicKey) ([32]byte, error)
- func TransactionDenialDetail(domain, message string, reason forav1.DenialReason) *forav1.ErrorDetail
- func UsageReportRejectionDetail(domain, message string, reason forav1.UsageReportRejectionReason) *forav1.ErrorDetail
- func Validate(msg proto.Message) error
- func ValidateIdempotencyKey(key string) error
- func ValidationRuleIDs(err error) []string
- func VerifyOffer(offer *forav1.Offer, signatureHex string, pub ed25519.PublicKey) error
- func VerifyOfferAcceptance(offer *forav1.Offer, requester *forav1.Requester, ...) error
- func VerifyPresentedOffer(offer *forav1.Offer, exchangePub ed25519.PublicKey, now time.Time) error
- func VerifyRequestAcceptance(req *forav1.TransactionRequest, acceptance *forav1.AgentRequestAcceptance, ...) ([]byte, error)
- func VerifyRequestAcceptanceProjection(req *forav1.TransactionRequest, acceptance *forav1.AgentRequestAcceptance, ...) ([]byte, error)
- func WithSignatureAgent(ctx context.Context, dir string) context.Context
- type AgentBinding
- type AudienceVerdict
- type ComponentParam
- type CoveredComponent
- type EntryVerdict
- type KeyResolver
- type PoPOptions
- type RegistrationDataVerdict
- type RegistrationSchema
- type RuleViolation
- type RuleWarning
- type SchemaVerdict
- type SignOptions
- type SignedURL
- type Signer
- type StaticKeyResolver
- type VerifiedRequest
- func AllSignaturesFromContext(ctx context.Context) []VerifiedRequest
- func FromContext(ctx context.Context) *VerifiedRequest
- func VerifyMultisigRequest(req *http.Request, body []byte, resolve resolveFunc, opts VerifyOptions) ([]VerifiedRequest, error)
- func VerifyMultisigRequestResolved(ctx context.Context, req *http.Request, body []byte, resolver KeyResolver, ...) ([]VerifiedRequest, error)
- func VerifyRequest(req *http.Request, body []byte, pub ed25519.PublicKey, opts VerifyOptions) (*VerifiedRequest, error)
- func VerifyRequestResolved(ctx context.Context, req *http.Request, body []byte, resolver KeyResolver, ...) (*VerifiedRequest, error)
- type VerifiedURL
- type VerifyOptions
Constants ¶
const ( // ContentTypeProto is the Content-Type for binary protobuf bodies. ContentTypeProto = "application/proto" // ContentTypeJSON is the Content-Type for canonical proto-JSON bodies. ContentTypeJSON = "application/json" // ConnectProtocolVersionHeader carries the Connect unary protocol version. ConnectProtocolVersionHeader = "Connect-Protocol-Version" // ConnectProtocolVersion is the only Connect protocol version FORA speaks. ConnectProtocolVersion = "1" // ProtocolVersion is the FORA protocol version stamped on the `ver` field of // every FORA message — NOT the Connect transport version above. It is the one // source this value comes from, so a protocol bump is a single edit here // rather than a literal hunt across every message builder. Senders MUST stamp // it; receivers treat `ver` as advisory — see "Protocol version" in fora.proto // for the receive-side rule, which is stated once, there. The // /.well-known/fora.json document carries its own document version in a // separate namespace, which this constant does NOT supply — that is // WellKnownManifestVersion below. ProtocolVersion = "1.0" // WellKnownManifestVersion is the version of the /.well-known/fora.json // DOCUMENT layout, stamped on WellKnownManifest.ver by every party that serves // one. It is a namespace separate from ProtocolVersion and is never derived // from it: a change to the manifest layout bumps both numbers, a protocol // change that leaves the manifest untouched bumps only ProtocolVersion. Both // read "1.0" today because neither has moved yet; neither is derived from // the other. The receive-side check a manifest reader applies is // CheckWellKnownManifestVersion. WellKnownManifestVersion = "1.0" // RequestIDHeader correlates a request across services and the edge. RequestIDHeader = "X-Request-ID" // SignatureAgentHeader carries the signer's Web Bot Auth key-directory URL // (the WBA identity anchor). It is a required covered component: every FORA // signature commits to it, empty included, so the directory a verifier // resolves keys from is the one the signer bound. SignatureAgentHeader = "Signature-Agent" // WellKnownPath is the discovery document's path on every Exchange host. It is // the one bootstrap coordinate in the protocol: a client that knows only a // hostname fetches {scheme}://{host}{WellKnownPath} to learn the endpoint and // the keys, so the three SDKs agreeing on it is not a tidiness concern but the // precondition for interop. It was built inline at each call site before, once // per language, which is exactly the shape that lets one port drift silently. // Named here so the wire-constants vectors can carry it and the Python and // TypeScript parity suites replay it against this value. // WellKnownManifestVersion versions the document's content; WellKnownPath // specifies where it is served. WellKnownPath = "/.well-known/fora.json" )
Wire constants shared across the SDK. Encoding is negotiated per hop via Content-Type (ADR-020): application/proto for binary, application/json for canonical proto-JSON. connect-go serves both, so each leg picks independently.
const ( // RulePricingUnitRegistered rejects a bare (non-namespaced) Pricing.unit // that is not a registered metering token. RulePricingUnitRegistered = "pricing.unit.registered" // RuleQuotaMetricRegistered rejects a bare Quota.metric that is not a // registered quota token. RuleQuotaMetricRegistered = "quota.metric.registered" // RuleRestrictionCanonicalDisjoint rejects a restriction whose permitted and // prohibited lists name the same token once both are canonicalised. The wire // tier's rule compares the tokens AS WRITTEN, so two accepted spellings of one // token — an alias beside its registered form, or two spellings differing only // in ASCII case — pass it and collide only after the fold. This is that // property read on the values the fold produces. RuleRestrictionCanonicalDisjoint = "restriction.canonical_disjoint" // RuleRestrictionTokenRegistered warns about a bare restriction token that is // not registered on its axis. The term is accepted: the restriction // vocabulary is open and forward-compatible, and under scope-only projection // the Exchange never evaluates restrictions, so an unknown token can only // warrant a flag, never gate access. RuleRestrictionTokenRegistered = "restriction.token.registered" // RuleObligationOtherRequiresDetail warns about an OBLIGATION_KIND_OTHER // obligation carrying no detail — descriptive, not fatal. RuleObligationOtherRequiresDetail = "obligation.other.requires_detail" )
Rule ids of the ingest-tier checks. They follow the descriptor's CEL-id convention — the owning message in snake_case, then the rule — and never collide with a CEL id, which a conformance guard asserts.
const ( MaxRegistrationFieldErrorPathLen = 255 MaxRegistrationFieldErrorTextLen = 255 )
MaxRegistrationFieldErrorPathLen and MaxRegistrationFieldErrorTextLen are the wire bounds on RegistrationFieldError.path and .error. Same reason as above: the validator clamps to them so its output is always a message the contract accepts.
const AcceptanceSignatureAlgorithm = "EdDSA"
AcceptanceSignatureAlgorithm is the alg advertised on AgentAcceptance. Always EdDSA for Ed25519.
const (
AgentIDParam = "agent_id"
)
Query-parameter names on signed delivery URLs (shared with the edge verifier).
const AgentKeyHeader = "X-FORA-Agent-Key"
AgentKeyHeader carries the raw Ed25519 public key the fetcher presents, as base64url with no padding. ADR-013 chose a dedicated header over an inline JWK in keyid: the edge hashes this value and requires the digest to equal the URL's agent_id, so a fetcher cannot present one key while naming another.
const AlgEd25519 = "ed25519"
AlgEd25519 is the RFC 9421 alg value for Ed25519 signatures.
const BareDomainPattern = `` /* 173-byte string literal not displayed */
BareDomainPattern is the wire shape of a domain-valued field: a bare domain with an optional ":port", never a URL. "sub.example.com:443" passes; a value carrying a scheme, a path, userinfo, or a query never does.
One rule, three copies, all gated. These bytes are the protovalidate pattern carried by every field in the contract whose value is a bare host: the recipient-addressing fields — the `exchange` field on each addressed request, Offer.exchange and their neighbours — and ResourceEntry.domain, the host half of a catalog URI, which is not addressing anything but needs the same shape for the same reason, since a value carrying a scheme or a path would choose the URI rather than name the host. So the check a client makes before sending and the check the wire makes on arrival cannot answer differently.
It is a shape rule and not a routing one, which is the line worth keeping: it says what a host looks like, never that this host is one worth dialling. A field that holds a domain for some other purpose does not join by resemblance — the family is enumerated from the descriptor. The shared conformance vectors record the pattern beside the cases, and a guard in the conformance tier holds it against the descriptor and counts the fields that carry it.
The port is a real 1-65535 range rather than "one to five digits", which is why it is spelled out at this length. That distinction is load-bearing on exactly the values a digit count waves through: :0, :65536 and :99999 name no port at all, and :0443 is not a spelling of 443 but a different string that would compare unequal to it.
const BrokerKeyIDPrefix = "broker."
BrokerKeyIDPrefix is the keyID prefix that marks a broker relay key on the wire (shape "broker.<instance>.<rotation>"). Relay-key loaders stamp it and the multisig classifier uses it to route a signature into the relay slot. Shared so the producing and consuming sides cannot drift.
const MaxBareDomainLen = 260
MaxBareDomainLen is the length bound belonging to the same rule — the protovalidate `string.max_len` those fields carry. Without it a client would accept a pattern-valid but over-length value the server then rejects, which is the client/server split the shared rule exists to close.
const MaxRegistrationDataBytes = 16384
MaxRegistrationDataBytes bounds a submitted registration_data payload, measured as its RFC 8785 canonical JSON encoding.
The UNIT has to be named, and that is the whole point of this constant. Every other cap in this file is over bytes a party actually served; registration_data is not served as bytes at all — it arrives as a decoded google.protobuf.Struct — so "16KB" means nothing until an encoding is chosen, and two implementations choosing privately is the disagreement this package exists to remove. JCS is the choice because all three SDKs already compute it for the signing primitive, with a vetted canonicalizer each, and because it pins number formatting: a payload carrying 1e300 is seven bytes to one renderer and three hundred to another.
It bounds WORK, not storage. The schema's own caps bound the schema; nothing bounded the payload the schema is applied to, and validation cost is roughly the schema's cost multiplied by the elements in the payload — a subschema under `items` is counted once by MaxRegistrationSchemaEvaluations and evaluated once per element. The multiplier was the unbounded half.
const MaxRegistrationDataDepth = 32
MaxRegistrationDataDepth bounds how deeply a submitted registration_data payload may nest, counting JSON containers so a bare `{}` is depth 1. Same number and same counting rule as MaxRegistrationSchemaDepth, because it is the same question asked of the other document.
It exists because without it the ANSWER depended on the reader's runtime rather than on the payload. Canonicalising walks the payload recursively, and where that walk runs out of stack differs by language and even by interpreter version — one port refused a payload past about five hundred containers on one Python and accepted nine hundred on the next, while the other two SDKs accepted every depth tried. A static bound checked first turns that into one verdict every implementation reaches.
A business entity nests an address inside an object and stops; the deepest payload in the conformance corpus is three.
const MaxRegistrationDataMembers = 64
MaxRegistrationDataMembers bounds the number of members at the TOP LEVEL of a registration_data payload. Top level rather than recursive, deliberately: nested bulk is already bounded by the byte cap above, and a recursive count would refuse a small document that merely nests, which a business entity legitimately does (an address is an object).
const MaxRegistrationFieldErrors = 64
MaxRegistrationFieldErrors is the number of member failures a refusal may carry. It is the wire's own bound — RegistrationFailure.field_errors declares repeated.max_items = 64 — restated here so the validator never builds a list the contract would reject. A conformance guard reads both and fails if they part.
const MaxRegistrationSchemaBytes = 16384
MaxRegistrationSchemaBytes is the published schema's size cap, measured as the UTF-8 bytes of the data_schema member AS SERVED in fora.json — which is why the compile face takes raw bytes rather than a decoded document. A re-encoding is a different length than what the origin sent, and the cap is defined over what the origin sent.
const MaxRegistrationSchemaDepth = 32
MaxRegistrationSchemaDepth bounds how deeply the schema document may nest. It counts JSON containers, so a bare `{}` is depth 1. Deep allOf/$ref chains are the cheapest way to make a compile expensive, and a registration schema describing a business entity is three to five levels deep in practice.
const MaxRegistrationSchemaEvaluations = 10000
MaxRegistrationSchemaEvaluations bounds the WORK of checking a payload, which the size and depth caps do not. `anyOf` branches multiply along a reference chain, so a schema can be small and shallow and still cost an unbounded amount to evaluate: a 1,675-byte document five levels deep measures 16.7 million evaluations and takes 27 seconds against a two-member payload. Cost is linear in this count at roughly 1.5µs per evaluation, so the bound is really a time bound — about 15ms — expressed as a number a static walk can compute and a shared corpus can pin, which a stopwatch cannot. A registration schema describing a business entity measures a few dozen, so the headroom is several hundredfold.
const MaxRegistrationSchemaRefHops = 100
MaxRegistrationSchemaRefHops bounds how long a $ref chain may be, measured as the longest path of reference hops rather than as the number of references a document contains.
It is a SEPARATE axis from MaxRegistrationSchemaDepth, and the shape that forced it shows why: a chain of five hundred definitions, each referring to the next, is three JSON containers deep however long it is, so the depth cap never sees it. The evaluation cap does not see it either — a flat chain costs one evaluation per link, so five hundred links is five hundred against a bound of ten thousand.
What it bounds is the RECURSION every validator does while resolving that chain — this package's own cost walk included, which is why that walk refuses a chain already past the bound rather than following it to the end. The libraries the three SDKs hand an accepted schema to have no such guard, and one of them exhausted its interpreter stack at 495 links — throwing out of a face documented as returning a verdict, on a document every SDK had just called valid. A bound in the contract is what stops that, because it stops the document being published rather than asking three libraries to survive it.
100 is far above use and far below harm: a realistic registration schema chains one or two references, the deepest chain in an accepted conformance vector is eleven, and the crash needs about five hundred.
const OfferSignatureAlgorithm = "EdDSA"
OfferSignatureAlgorithm is the JOSE/JWA algorithm identifier advertised on signed offers (Offer.signature_algorithm). Always EdDSA for Ed25519.
const RegistrationSchemaCompileTimeout = 2 * time.Second
RegistrationSchemaCompileTimeout bounds the OTHER phase. Compiling is cheap in every shape measured (single-digit to tens of milliseconds across the three languages), and the evaluation cap above does not bound it at all: compilation spends its time in regex compilation, reference resolution, and — in the TypeScript port — generating and evaluating JavaScript source. A phase left unbounded because a different phase's bound happens to be tighter today is not bounded. Generous on purpose: it is a backstop against a shape nobody predicted, not a performance budget.
const RegistrationSchemaDialect = "https://json-schema.org/draft/2020-12/schema"
RegistrationSchemaDialect is the only $schema value a published data_schema may name. A document that names none is read as this dialect; one that names another is refused rather than validated under semantics its author did not intend.
Variables ¶
var ( ErrURLMissingSignature = errors.New("helpers: signed URL missing sig param") ErrURLMissingExpiry = errors.New("helpers: signed URL missing/invalid exp param") ErrURLExpired = errors.New("helpers: signed URL expired") ErrURLSignatureInvalid = errors.New("helpers: signed URL signature invalid") ErrURLNotAgentBound = errors.New("helpers: signed URL is not agent-bound (bearer)") ErrProofOfPossessionMismatch = errors.New("helpers: presented key does not match the URL's agent_id binding") )
Signed-URL error sentinels.
var ( ErrMissingSignatureInput = errors.New("helpers: missing Signature-Input header") ErrMissingSignature = errors.New("helpers: missing Signature header") ErrMissingContentDigest = errors.New("helpers: missing Content-Digest header") ErrMalformedSignatureInput = errors.New("helpers: malformed Signature-Input") ErrUnsupportedAlgorithm = errors.New("helpers: unsupported alg (ed25519 only)") ErrDigestMismatch = errors.New("helpers: content-digest mismatch") ErrSignatureVerify = errors.New("helpers: signature verification failed") ErrMissingRequiredComponent = errors.New("helpers: required covered component missing") ErrExpired = errors.New("helpers: signature expired") ErrFutureCreated = errors.New("helpers: signature created in the future") ErrMissingCreated = errors.New("helpers: missing created param") ErrMissingExpires = errors.New("helpers: missing expires param") // ErrBrokenSignatureChain signals a multisig request whose signatures do not // form a valid forwarding chain: labels are non-contiguous, reordered, or a // sigN (N>1) does not cover exactly its predecessor via // "signature";key="sigN-1" (forwarding chain, RFC 9421 §2.4). ErrBrokenSignatureChain = errors.New("helpers: signature chain broken (gap/reorder/missing link)") // ErrTooManyHops signals that the number of signatures on a request exceeds // the verifier's configured MaxSignatures budget (Exchange hop bound). ErrTooManyHops = errors.New("helpers: signature count exceeds hop budget") // ErrSignatureLifetimeTooLong signals that a signature's declared window // (expires − created) exceeds the verifier's MaxSignatureAge clamp. ErrSignatureLifetimeTooLong = errors.New("helpers: signature lifetime exceeds max age") )
Verifier-side error sentinels.
var ErrAcceptanceSignatureInvalid = errors.New("helpers: offer-acceptance signature invalid")
ErrAcceptanceSignatureInvalid signals offer-acceptance verification failure — a wrong key, or a tampered binding (offer signature, requester, or idempotency key).
var ErrAudienceIdentity = errors.New("helpers: configured Exchange identity is not a bare domain")
ErrAudienceIdentity signals that the recipient's OWN configured identity is unusable, so no audience check could run. It is a fault in this deployment, never in the request — a caller mapping it onto a status code owes the peer an internal error, not a rejection.
var ErrEmptyIdempotencyKey = errors.New("helpers: idempotency_key must be non-empty")
ErrEmptyIdempotencyKey is returned by ValidateIdempotencyKey for an empty key.
var ErrEmptyMoney = errors.New("helpers: empty money string (field is unset)")
ErrEmptyMoney is returned by ParseMoney for the empty (unset) wire string.
var ErrInvalidHost = errors.New("helpers: reference is not a usable host")
ErrInvalidHost signals a reference that cannot be read as a host at all.
var ErrInvalidKeyLength = fmt.Errorf("helpers: public key must be %d bytes", ed25519.PublicKeySize)
ErrInvalidKeyLength is returned when the supplied public key is not exactly ed25519.PublicKeySize (32) bytes.
var ErrInvalidPoPInput = errors.New("helpers: proof input is not usable in a signature base")
ErrInvalidPoPInput signals a proof input that cannot be written into a signature base without changing its shape — a control byte in the method or the target URI, which the line-delimited base would read as a component boundary.
var ErrKeyIDMismatch = errors.New("helpers: keyid is not the thumbprint of the presented key")
ErrKeyIDMismatch signals that the keyid does not match the RFC 7638 thumbprint of the key presented alongside it. The edge checks the same equality and answers thumbprint_mismatch, but by then the cause — a custody layer that paired a keyid with the wrong key — is several hops from the symptom. Refusing here names it at the source.
var ErrManifestVersionRefused = errors.New("helpers: well-known manifest version not accepted")
ErrManifestVersionRefused signals that a /.well-known/fora.json document carries a WellKnownManifest.ver this reader does not accept: a major version it does not implement, a value that is not MAJOR.MINOR, or no version at all.
It is a VERDICT on the document, distinct from a transport or decode failure: the manifest was fetched and parsed, and the reader refuses to act on any other member of it. A caller that classifies retryability reads this as final.
var ErrMissingTargetURI = errors.New("helpers: missing target URI (required by the agent-binding profile)")
ErrMissingTargetURI signals a proof requested without the URL it is meant to bind. @target-uri is half the covered set; signing without it would produce a proof valid for any URL the presented key is offered against, which is the replay this profile exists to stop.
var ErrOfferExpired = errors.New("helpers: offer expired")
ErrOfferExpired signals that a presented offer is not usable on freshness grounds: its signed expires_at is in the past, or it carries no expires_at at all (see VerifyPresentedOffer's fail-closed policy). Distinct from ErrOfferSignatureInvalid — the signature checked out, the offer is just stale.
var ErrOfferSignatureInvalid = errors.New("helpers: offer signature invalid")
ErrOfferSignatureInvalid signals offer verification failure (wrong key or a tampered payload — price, terms, expiry, …).
var ErrRequestAcceptanceSignatureInvalid = errors.New("helpers: request-acceptance signature invalid")
ErrRequestAcceptanceSignatureInvalid signals that the agent did not sign the request-acceptance payload presented by the caller.
var ErrUnknownFields = errors.New("helpers: message carries unknown fields; canonical bytes cannot represent them")
ErrUnknownFields refuses a message the canonical form cannot faithfully represent. proto-JSON renders only fields the schema defines, so a message carrying unknown ones canonicalizes to bytes that silently omit them. Both directions of that gap are wrong: a signer built against a newer schema covered more than this build can reconstruct, and an on-path party that appends unknown fields to an already-signed message would otherwise leave the signature verifying over bytes it never saw. Refusing the message is what makes the canonical form's coverage total rather than schema-relative.
CanonicalOfferBytes and SignOffer return it directly, so a caller persisting evidence can tell this refusal apart from a marshal fault. VerifyOffer wraps it alongside ErrOfferSignatureInvalid — a message that arrived carrying extra bytes is a tampered offer — so errors.Is matches either sentinel there.
var ErrUnknownKey = errors.New("helpers: unknown keyid")
ErrUnknownKey signals that a KeyResolver has no key for the requested keyid.
Functions ¶
func AppendSignature ¶
func AppendSignature(ctx context.Context, req *http.Request, body []byte, signer Signer, opts SignOptions) error
AppendSignature chains a new signature onto req WITHOUT disturbing any existing one (forwarding chain). It preserves an existing Content-Digest (only setting it when missing), binds Authorization, finds the next label sig(N+1) and predecessor sigN, builds the chain-linked covered set (the FORA base plus "signature";key="sigN" so sig(N+1) commits to its predecessor), asks the Signer to sign, and APPENDS to the Signature-Input / Signature headers. Appending to a request with no existing signatures produces a sig1 byte-for-byte identical to SignRequest — single-sig is the N=1 case.
func ApplyScopes ¶
ApplyScopes sets the requester's held scopes (normalized). It is how the caller supplies the entitlements it holds for a discover/resolve call.
func CanonicalAcceptanceBytes ¶
func CanonicalAcceptanceBytes(offer *forav1.Offer, requester *forav1.Requester, idempotencyKey string) ([]byte, error)
CanonicalAcceptanceBytes returns the exact canonical byte sequence an agent's offer acceptance covers: the accepted Offer.signature (which transitively binds the offer's pricing, terms, expiry, and issuing Exchange), plus the requester identity and idempotency key of the ENCLOSING execute request — in batch mode both come from the TransactionRequest, never from the per-item TransactionItem, which carries neither. The returned bytes are byte-identical to what SignOfferAcceptance signs and VerifyOfferAcceptance verifies over — persist them to re-verify an acceptance verbatim, independent of how the canonical form later evolves.
Re-verification also needs the persisted AgentAcceptance.signature and the signer's trusted public key: these bytes are the signed message, necessary but not by themselves sufficient. A passing signature proves only that the key holder signed THESE bytes, so a caller weighing them as evidence must also parse them and match their content against the transaction in question.
The canonical form is RFC 8785 JCS over canonical proto-JSON — JCS(protojson(AgentAcceptancePayload)) — the same definition the offer signature uses, stated normatively on Offer.signature in fora.proto: snake_case proto field names, enums as name strings, unpopulated fields omitted. AgentAcceptancePayload carries no signature fields, so the clear-then-render step reduces to a plain render. Any language (Go/TS/Python) reproduces the exact bytes from that definition, without a protobuf binary codec.
The payload is built here from the four scalars rather than taken from the caller, so it cannot carry the unknown fields CanonicalOfferBytes has to refuse; only the values read off the offer and requester reach the signed bytes.
Unpopulated fields are OMITTED before JCS — EVERY empty string field, not just the domain: the bytes for an empty requester id are not the bytes for any populated one. A port that assembles this object by hand instead of rendering the proto must reproduce that omission per field, or it signs bytes this function never produces.
Fails closed on a nil offer, a nil requester, or an unsigned offer (empty Offer.signature) — an empty anchor would let the acceptance float free of any concrete offer.
func CanonicalOfferBytes ¶
CanonicalOfferBytes returns the exact canonical byte sequence an Offer's signature is computed over: the ENTIRE Offer (pricing, terms, expires_at, …) with ONLY the signature and signature_algorithm fields cleared, per fora.proto Offer.signature. The returned bytes are byte-identical to what SignOffer signs and VerifyOffer verifies over — persist them to re-verify an Offer signature verbatim, independent of how the canonical form, or the Offer message, later evolves.
Re-verification also needs the persisted Offer.signature and the signer's trusted public key: these bytes are the signed message, necessary but not by themselves sufficient. A passing signature proves only that the key holder signed THESE bytes, so a caller weighing them as evidence must also parse them and match their content against the transaction in question.
The canonical form is RFC 8785 JCS over canonical proto-JSON — JCS(protojson(offer with sig cleared)) — rendered under the option set the Offer.signature comment in fora.proto defines normatively: snake_case proto field names, enums as name strings, unpopulated fields omitted. That definition, not this implementation, is what lets any language (Go/TS/Python) reproduce the exact signed bytes without a protobuf binary codec.
expires_at is covered (only signature/signature_algorithm are cleared), so a relaying Broker cannot extend or shorten a signed offer's TTL under an otherwise-valid signature.
An Offer carrying UNKNOWN fields at ANY depth is REFUSED (ErrUnknownFields) rather than rendered — proto-JSON emits only what the schema defines, so those bytes would silently drop the unknown content. The rule, and the depths it reaches, are stated once in the fora.proto Offer.signature comment; it is not restated here. VerifyOffer surfaces the refusal wrapped in ErrOfferSignatureInvalid, since a message that arrived carrying extra bytes is a tampered Offer, not an internal fault.
func CanonicalRequestAcceptanceBytes ¶
func CanonicalRequestAcceptanceBytes(payload *forav1.AgentRequestAcceptancePayload) ([]byte, error)
CanonicalRequestAcceptanceBytes returns the exact JCS(protojson(...)) bytes covered by an AgentRequestAcceptance signature.
func CanonicalRestrictionToken ¶
func CanonicalRestrictionToken(kind forav1.RestrictionKind, token string) string
CanonicalRestrictionToken returns the canonical form of a restriction token on an axis: RFC 8259 whitespace trimmed, ASCII case folded (lower for FUNCTION and USER_TYPE, upper for GEOGRAPHY) and, where the axis authors aliases, the alias resolved to its registered token. OTHER and any unknown axis carry custom, registry-less tokens and are returned unchanged. Applying it twice is a fixed point, which is what makes NormalizeLicenseTerm idempotent.
func CanonicalizeMoney ¶
CanonicalizeMoney normalizes a wire decimal string to its canonical form (parse then format). It is the convenience used when echoing a money value back onto the wire without doing arithmetic.
func CatalogRejectionDetail ¶
func CatalogRejectionDetail(domain, message string, reason forav1.CatalogRejectionReason) *forav1.ErrorDetail
CatalogRejectionDetail builds an ErrorDetail carrying a typed CatalogRejectionReason.
func CheckWellKnownManifestVersion ¶
CheckWellKnownManifestVersion applies the receive-side rule for WellKnownManifest.ver. It accepts a version whose MAJOR equals the major of WellKnownManifestVersion, whatever the MINOR — a minor revision of the manifest is additive by definition, so a reader ignores members it does not know and keeps reading. It refuses an unrecognised major, a value that is not of the form MAJOR.MINOR (two runs of ASCII digits joined by one dot), and the empty string, which is how an absent field arrives.
Absent is refused rather than tolerated: the field is required by the wire shape, and a document with no version is one whose layout the reader cannot classify. Why the gate runs before any other member is read, and fails closed, is stated once on WellKnownManifest.ver in the proto.
The returned error wraps ErrManifestVersionRefused and names the value found, so an operator can tell a version mismatch from a network failure. The echo is clipped to maxEchoedVer bytes: the document body is read up to 1 MiB and a refusal is never cached, so an unclipped echo would let a hostile origin size every error a resolve produces. The function is pure: the same input always yields the same verdict, and the three SDK languages pin that verdict to a shared corpus.
func CompileRegistrationSchema ¶
func CompileRegistrationSchema(raw []byte) (*RegistrationSchema, SchemaVerdict)
CompileRegistrationSchema checks a published data_schema against every rule above and compiles it.
raw is the schema AS SERVED — the exact UTF-8 bytes of the data_schema member in fora.json — because MaxRegistrationSchemaBytes is defined over those bytes.
The schema is non-nil only on SchemaAccepted. There is no error return: every way this can fail is a property of the schema, and both callers need to know WHICH, not merely that something went wrong. They read the same refusal differently, and that difference is the contract:
A CLIENT pre-checking a payload treats any non-accepted verdict as "do not pre-check" and sends anyway. The field's contract says so for an oversized schema, and it generalises: a local check that cannot run must not become a local veto, because the Exchange's own enforcement is the deciding one and a client that refused here would block a payload the Exchange would have taken. An EXCHANGE compiling its OWN configured schema treats the same verdict as an operator misconfiguration of this deployment. Nothing about a third party is involved, and serving a manifest advertising a schema it cannot itself enforce is the one outcome it must not reach.
func ContentDigest ¶
ContentDigest returns the RFC 9530 Content-Digest header value for body: sha-256=:<base64(SHA-256(body))>:.
func DisputeFailureDetail ¶
func DisputeFailureDetail(domain, message string, reason forav1.DisputeFailureReason) *forav1.ErrorDetail
DisputeFailureDetail builds an ErrorDetail carrying a typed DisputeFailureReason.
func DomainVerificationFailureDetail ¶
func DomainVerificationFailureDetail(domain, message string, reason forav1.DomainVerificationFailureReason) *forav1.ErrorDetail
DomainVerificationFailureDetail builds an ErrorDetail carrying a typed DomainVerificationFailureReason.
func FormatMoney ¶
FormatMoney renders an exact decimal as the canonical wire string: no sign, no exponent, and insignificant trailing fractional zeros stripped ("0.050" -> "0.05", "1.00" -> "1", "10" -> "10"). A negative value is rejected — FORA money is non-negative. The result always satisfies the wire pattern.
func HashURL ¶
HashURL returns the SHA-256 digest of a signed URL (the transaction_log.signed_url_hash value, 32 bytes).
func HostAnchored ¶
HostAnchored reports whether candidate is anchored to anchor — the same host and port, or a subdomain of that host on that port. Either side may be a bare domain, a host:port pair, or a full URL; a reference that does not parse is returned as an error, which callers treat as "not anchored".
The use is checking a value a remote document supplied against the host that served that document: it may point at itself or at one of its own subdomains, and nothing else. Without it, a host could redirect a signed request — or a revocation poll — to an unrelated third-party address that a dial-time address guard would happily allow, because the address is perfectly public.
The PORT is part of the comparison. What is being anchored is a place a signed call is sent, and a different port is a different service — one the party that published the anchor need not control. An Exchange reachable on a non-default port says so on both sides: the port belongs in the value the offer names as much as in the endpoint the manifest advertises.
A DEFAULT port and its omission are the same port, so https://x, https://x:443 and x all anchor to one another. url.Parse does not materialize an implicit port, and refusing an operator who merely wrote :443 out in full would be a spelling check wearing a security check's clothes.
The SCHEME is still not compared. Whether a leg may run in the clear is the guarded transport's decision, made in one place from one flag. Its only job here is choosing which port counts as the default — and a side that NAMED no scheme borrows the other's for that purpose, rather than being assumed to mean https.
That last part is load-bearing, not a nicety. Both anchors in this SDK arrive schemeless: a WBA directory's authority and an Offer.exchange host are bare host[:port] values. Assuming https for them meant an anchor of "a.example:80" kept its port (80 is not https's default) while the candidate "http://a.example:80" folded it away — the same authority reaching two answers, which silently un-anchored every plaintext directory that spelled :80 in full.
func HostOf ¶
HostOf extracts the host (including any port) from a bare domain, a host:port pair, or a full URL. A ref with no scheme is parsed as though it carried https, since a bare domain is otherwise indistinguishable from a path.
func IsBareDomain ¶
IsBareDomain reports whether v is a bare domain of the shape the wire admits.
This is NOT IsBareHost, and the two are deliberately kept apart because they answer different questions. IsBareHost asks whether a value is safe to concatenate into a URL — a structural question, answered by round-tripping the value through a URL parse, which accepts anything a host may hold. IsBareDomain asks whether a value is the SHAPE THE CONTRACT ADMITS, which is narrower: a trailing root dot, a leading or trailing hyphen, an underscore and a bracketed IPv6 literal are all usable hosts and none of them is a value the wire rule accepts. A caller vetting a value it is about to dial wants the first; a caller vetting a value that arrived in a message wants this one.
The length is checked FIRST, so the work stays bounded on hostile input. This is insurance rather than a fix for a known blowup: the pattern is unambiguous — every repetition is anchored by a literal dot no label class can consume — so it cannot backtrack catastrophically, and the cost of matching it is linear in all three languages. Bounding that cost is still worth the one comparison it takes, since the Python and TypeScript ports run it on backtracking engines where linear work on an unbounded string is a caller's choice to make, not ours. Doing it in this order costs nothing in agreement, even though the three languages count length in different units — a value whose byte, code-point and UTF-16 counts disagree contains something outside ASCII, and the pattern refuses it whichever check runs first.
func IsBareHost ¶
IsBareHost reports whether ref is EXACTLY a host — nothing a URL could carry besides the authority. It answers false for a ref with a scheme, userinfo, a path, a query, or a fragment, because HostOf had to strip something to reach the host. A port is NOT a strip: "exchange.example:8443" is a bare host, and the well-known resolver concatenates host-with-port unchanged.
It exists for the callers that hand a network-supplied domain to code which builds a URL by concatenation. There, narrowing a rich reference to its host is the wrong repair: the value was never a domain, and accepting it silently means the far side chose the path that gets fetched, not just the host it is fetched from. Comparing against the extracted host is what makes the rejection structural, rather than a blocklist of the separators anyone thought to name.
func IsSafeSchemaPattern ¶
IsSafeSchemaPattern reports whether a `pattern` uses only constructs all three SDK languages express identically.
Draft 2020-12 patterns are ECMA-262, and the three SDKs run three different engines over them: Go's RE2, JavaScript's RegExp, and Python's re. The three intersect on far less than any one of them accepts, and BOTH directions of the gap are a bug this face exists to close. Lookaround, atomic groups and backreferences are legal ECMA and refused by RE2, so a schema using them compiles in two SDKs and fails in the third. Inline flags, Unicode property classes, text anchors and POSIX bracket names run the other way — RE2 (or Python) takes them and JavaScript does not, or takes them to mean something else. The second kind is the more dangerous, because nothing errors: two SDKs both compile the pattern and then disagree about which payloads match it.
So the admitted alphabet is the intersection, expressed as six rules:
- an escape names a portable class, control character, \xHH, or a metacharacter standing for itself — see portableEscapes, an ALLOWLIST;
- a group opens with `(` or `(?:` and nothing else;
- `[:` does not appear inside a bracket expression, at any position — it is a POSIX class name to RE2 and the literal characters to JavaScript;
- a bracket expression closes, and does not open with `]`;
- a counted repeat does not exceed maxPortableRepeat;
- no quantified group has a body that can itself repeat or branch.
Rule 6 is aimed at catastrophic backtracking SEPARATELY and deliberately, because nothing in rules 1-5 covers it: `(a+)+` needs neither lookaround nor a backreference and sits comfortably inside the alphabet. See hasNestedQuantifier.
The scan is syntactic and deliberately a little conservative. It tracks escaping, so a literal `\(` is not read as a group, and it tracks bracket-expression interiors, because the constructs above mean different things inside a class than outside one. Over-refusing costs an author a rewrite; under-refusing costs the SDKs the agreement they exist to provide.
func KnownRestrictionToken ¶
func KnownRestrictionToken(kind forav1.RestrictionKind, token string) bool
KnownRestrictionToken reports whether an already-canonical token is registered on its axis. GEOGRAPHY registers only the non-ISO specials and admits any two-uppercase-letter ISO 3166-1 alpha-2 code structurally; OTHER and any unknown axis carry no registry and are never known.
func NewContext ¶
func NewContext(ctx context.Context, v *VerifiedRequest) context.Context
NewContext returns a copy of ctx carrying v under the single verified-request slot. Production code MUST NOT call this outside the verifying transport.
func NewIdempotencyKey ¶
NewIdempotencyKey returns a fresh cryptographically-random, URL-safe idempotency key. Use it once per logical operation; reuse a stored key only to deliberately replay.
func NewMultisigContext ¶
func NewMultisigContext(ctx context.Context, sigs []VerifiedRequest) context.Context
NewMultisigContext returns a copy of ctx carrying all verified signatures (sig1..sigN, in chain order). Production code MUST NOT call this outside the verifying transport.
func NormalizeLicenseTerm ¶
func NormalizeLicenseTerm(term *forav1.LicenseTerm)
NormalizeLicenseTerm rewrites the term's restriction tokens to their canonical form in place, on every axis that carries a canonicalisation rule. It touches nothing else — Pricing.unit and Quota.metric are exact registry values, scopes are matched verbatim — and is nil-safe and idempotent. Run it before ValidateLicenseTerm, whose checks read canonical tokens — all but the disjointness check, which folds what it compares and so reaches the same verdict on either form.
func NormalizeResourceEntry ¶
func NormalizeResourceEntry(entry *forav1.ResourceEntry)
NormalizeResourceEntry applies NormalizeLicenseTerm to every term of the entry, in place. Nil-safe.
func NormalizeScopes ¶
NormalizeScopes returns scopes with empty entries dropped, duplicates removed, and a stable (sorted) order — so two callers supplying the same set produce identical bytes on the wire.
func ParseMoney ¶
ParseMoney parses a canonical wire decimal string into an exact decimal. It rejects the empty string (ErrEmptyMoney) and any value the wire pattern forbids — signs, exponents, a leading dot — so a value that would fail the server's protovalidate never silently parses here.
func Reason ¶
func Reason(detail *forav1.ErrorDetail) any
Reason returns the active typed reason enum from detail — one of forav1.DenialReason, RetrievalAuthFailureReason, CatalogRejectionReason, RegistrationFailureReason, DisputeFailureReason, DomainVerificationFailureReason, or UsageReportRejectionReason — or nil when no reason is set. Callers type-switch on the result to branch on the failure, never on a string.
func RedactURL ¶
RedactURL reduces a signed URL to scheme://host/path, for a value headed somewhere more durable than the caller who already holds it — a log line, or an error an operator will read.
url.URL.Redacted() is NOT the tool for this. It masks userinfo passwords, and a delivery URL carries its credential in the QUERY: sig, kid, exp and agent_id. Redacted() would pass the signature through untouched while reading like a redaction, which is worse than not redacting at all.
An unparseable input yields "" rather than the original: a value that could not be sanitized is not one to emit.
func RegistrationFailureDetail ¶
func RegistrationFailureDetail(domain, message string, reason forav1.RegistrationFailureReason, fieldErrors ...*forav1.RegistrationFieldError) *forav1.ErrorDetail
RegistrationFailureDetail builds an ErrorDetail carrying a typed RegistrationFailureReason, plus the offending registration_data members when the reason is REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA.
fieldErrors is variadic rather than a fourth required parameter so the six reasons that carry no per-member detail keep calling this with three arguments. It is the one *Detail builder that reaches past the reason enum: the schema refusal is useless without naming what failed, whereas the sibling detail lists (TransactionDenial.restriction_mismatches, CatalogRejection.rejected_paths) are still caller-set post-construction. Passing field errors with any other reason is a caller error — the field's contract says the list is empty otherwise.
func RequestAcceptancePayload ¶
func RequestAcceptancePayload(req *forav1.TransactionRequest) (*forav1.AgentRequestAcceptancePayload, error)
RequestAcceptancePayload builds the complete ordered request-set payload an agent signs before any Broker fan-out.
func RetrievalAuthFailureDetail ¶
func RetrievalAuthFailureDetail(domain, message string, reason forav1.RetrievalAuthFailureReason) *forav1.ErrorDetail
RetrievalAuthFailureDetail builds an ErrorDetail carrying a typed RetrievalAuthFailureReason.
func RetrievalAuthFailureReasonFromToken ¶
func RetrievalAuthFailureReasonFromToken(token string) (forav1.RetrievalAuthFailureReason, bool)
RetrievalAuthFailureReasonFromToken resolves a delivery edge's refusal token to its typed RetrievalAuthFailureReason. The second result is false for a token this SDK does not recognise or cannot attribute unambiguously — the caller then falls back to its own failure class, which it owns, rather than promoting a value the edge did not actually state.
Fail-closed by construction: the token arrives in a body written by the host the fetch just went to, so an unrecognised value is never surfaced as a typed protocol reason.
func ScopesSubset ¶
ScopesSubset reports whether every scope in sub is present in super. It is the delegation-attenuation rule (Delegation.scopes MUST be a subset of the principal's granted scopes): use it to validate a delegation before relying on it, so an over-broad delegation is caught at the SDK boundary.
func SharedValidator ¶
func SharedValidator() (protovalidate.Validator, error)
SharedValidator returns the process-wide protovalidate.Validator that Validate wraps — built once and reused (compiling its CEL is expensive). The L2 validate interceptor injects it into connectrpc.com/validate so the interceptor and the L1 client-side pre-check share one engine, giving zero rule drift by construction.
func SignOffer ¶
SignOffer signs the canonical serialization of offer with priv and returns the hex-encoded Ed25519 signature for the Offer.signature field.
func SignOfferAcceptance ¶
func SignOfferAcceptance(priv ed25519.PrivateKey, offer *forav1.Offer, requester *forav1.Requester, idempotencyKey string) (string, error)
SignOfferAcceptance signs the canonical acceptance payload for the accepted offer with priv and returns the hex-encoded Ed25519 signature for the AgentAcceptance.signature field. requester and idempotencyKey come from the enclosing execute request.
func SignOfferAcceptanceWith ¶
func SignOfferAcceptanceWith(ctx context.Context, signer Signer, offer *forav1.Offer, requester *forav1.Requester, idempotencyKey string) (string, error)
SignOfferAcceptanceWith signs the canonical acceptance payload through an injected Signer, so an application whose key lives in a KMS or an HSM can produce an acceptance without ever handing the key over. It is the form the SDK's own client uses; SignOfferAcceptance above is the direct-key form for a caller that already holds the bytes. Both cover the identical payload from CanonicalAcceptanceBytes, so the two are interchangeable on the wire.
The acceptance is signed with the same key that signs the caller's requests: its thumbprint becomes the delivery URL's agent_id, which is what the edge later requires proof of possession of.
func SignRequest ¶
func SignRequest(ctx context.Context, req *http.Request, body []byte, signer Signer, opts SignOptions) error
SignRequest signs req with the FORA covered-component set and mutates it in place: it sets Content-Digest over body, binds the Authorization header (even when empty, so a later token injection is detected), builds the RFC 9421 signature base, asks the Signer to sign it, and writes the Signature-Input and Signature headers. body must be the exact bytes that will be transmitted.
func SignRequestAcceptance ¶
func SignRequestAcceptance(priv ed25519.PrivateKey, req *forav1.TransactionRequest) (*forav1.AgentRequestAcceptance, error)
SignRequestAcceptance signs req's complete ordered request set with priv.
func SignRequestAcceptanceWith ¶
func SignRequestAcceptanceWith(ctx context.Context, signer Signer, req *forav1.TransactionRequest) (*forav1.AgentRequestAcceptance, error)
SignRequestAcceptanceWith is SignRequestAcceptance for a KMS/HSM-backed Signer.
func SignatureAgentFromContext ¶
SignatureAgentFromContext returns the Signature-Agent value threaded by the resolved verify entrypoints, or "" when none was set.
func Thumbprint ¶
Thumbprint returns the RFC 7638 JWK Thumbprint of pub as a base64url-no-pad string. It returns ErrInvalidKeyLength when pub is not a 32-byte Ed25519 public key.
func ThumbprintBytes ¶
ThumbprintBytes returns the raw 32-byte SHA-256 digest underlying the thumbprint. The digest is what the transaction_log.agent_identity_hash BYTEA column stores (ADR-013 17.6); base64url-no-pad of it is the wire form.
func TransactionDenialDetail ¶
func TransactionDenialDetail(domain, message string, reason forav1.DenialReason) *forav1.ErrorDetail
TransactionDenialDetail builds an ErrorDetail carrying a typed DenialReason.
func UsageReportRejectionDetail ¶
func UsageReportRejectionDetail(domain, message string, reason forav1.UsageReportRejectionReason) *forav1.ErrorDetail
UsageReportRejectionDetail builds an ErrorDetail carrying a typed UsageReportRejectionReason.
func Validate ¶
Validate checks msg against its protovalidate rules. It returns nil when msg is valid and a *protovalidate.ValidationError (carrying the violated rule ids) otherwise. The validator is built once and reused (compiling its CEL is expensive); it is safe for concurrent use.
func ValidateIdempotencyKey ¶
ValidateIdempotencyKey enforces the protocol's min_len=1 constraint, so the SDK rejects an empty key before the server does.
func ValidationRuleIDs ¶
ValidationRuleIDs extracts the violated protovalidate rule ids from an error returned by Validate (nil/empty when err is not a validation error). Callers use it to branch on or log which constraint failed.
func VerifyOffer ¶
VerifyOffer verifies signatureHex against offer using pub. It checks signature integrity only; the expires-at-in-the-past policy ("MUST reject an offer whose expires_at is in the past") is enforced by the caller's {verified, rejected} selection layer, not here.
func VerifyOfferAcceptance ¶
func VerifyOfferAcceptance(offer *forav1.Offer, requester *forav1.Requester, idempotencyKey, signatureHex string, pub ed25519.PublicKey) error
VerifyOfferAcceptance verifies signatureHex (an AgentAcceptance.signature) against the canonical acceptance payload for the offer, using pub. It returns ErrAcceptanceSignatureInvalid on any mismatch (wrong key or tampered binding).
func VerifyPresentedOffer ¶
VerifyPresentedOffer is the stateless {verified, rejected} primitive for a reflected Offer: the agent presents the WHOLE signed Offer and the verifier checks it against its own key over the exact presented bytes — no reconstruct-from-catalog. It returns nil only when BOTH hold, in this order:
- offer.signature is a valid Ed25519 signature over the canonical Offer (expires_at included) under exchangePub — else ErrOfferSignatureInvalid.
- the signed expires_at is at or after now — else ErrOfferExpired.
Signature is checked first so freshness never leaks for a tampered offer. Freshness uses the injected now (never the wall clock); expires_at is inclusive, so now == expires_at is still valid. An offer with NO expires_at is rejected fail-closed: FORA offers are minted at discovery as now+TTL, so a missing expiry is a forever-token and unsafe to honor.
func VerifyRequestAcceptance ¶
func VerifyRequestAcceptance(req *forav1.TransactionRequest, acceptance *forav1.AgentRequestAcceptance, pub ed25519.PublicKey) ([]byte, error)
VerifyRequestAcceptance verifies the signature and the shared request envelope fields. It deliberately does not apply a fan-out projection rule; an Exchange must call VerifyRequestAcceptanceProjection instead.
func VerifyRequestAcceptanceProjection ¶
func VerifyRequestAcceptanceProjection(req *forav1.TransactionRequest, acceptance *forav1.AgentRequestAcceptance, exchange string, pub ed25519.PublicKey) ([]byte, error)
VerifyRequestAcceptanceProjection additionally proves that req.items is the complete ordered projection of the signed original set addressed to exchange.
func WithSignatureAgent ¶
WithSignatureAgent returns a copy of ctx carrying the request's Signature-Agent value (the signer's WBA key-directory URL). The resolved verify entrypoints thread it before per-signature resolution so a KeyResolver can fetch keys from the directory the signature commits to.
Types ¶
type AgentBinding ¶
type AgentBinding struct {
// AgentKey is the X-FORA-Agent-Key value: base64url, no padding.
AgentKey string
// SignatureInput is the full Signature-Input value, label included.
SignatureInput string
// Signature is the full Signature value, label included. The byte string
// inside the colons is STANDARD base64 while AgentKey above is base64url — an
// asymmetry that comes from RFC 8941's byte-sequence encoding meeting a header
// this profile defines itself, and one a verifier will not forgive.
Signature string
}
AgentBinding is the proof a fetcher attaches to a bound delivery request: the three header values, ready to apply. It is returned as values rather than written onto a request so a caller can sign before it has built one, so this tier stays free of any dialing surface, and so the emitted bytes can be asserted directly against the shared cross-language vectors.
func SignAgentBinding ¶
func SignAgentBinding(ctx context.Context, signer Signer, pub ed25519.PublicKey, opts PoPOptions) (AgentBinding, error)
SignAgentBinding produces the proof of possession a fetcher presents when it retrieves a delivery URL bound to an agent key. The covered set is exactly @method and @target-uri: a GET carries no body to digest, and the signed URL is itself the credential, already covered by @target-uri, so there is no Authorization header worth binding. That is why SignRequest cannot serve this profile — it enforces the five-component FORA set.
The key arrives as a Signer plus the public half rather than as a raw private key: custody stays with the application (a KMS or HSM signer never exposes its key), and the public half must be supplied separately because the presented-key header carries it and a Signer cannot yield it.
func (AgentBinding) Apply ¶
func (b AgentBinding) Apply(h http.Header)
Apply writes the binding's three headers onto h.
type AudienceVerdict ¶
type AudienceVerdict int
AudienceVerdict is the outcome of checking a request's claimed recipient against this Exchange's own identity.
const ( // AudienceNoVerdict is the zero value: the check did not run. It is returned // only alongside a non-nil error, and it is first so that a caller who // ignores that error reads "no answer" rather than an acceptance. AudienceNoVerdict AudienceVerdict = iota // AudienceAccepted means every claimed value names this Exchange. AudienceAccepted // AudienceEmpty means the request claimed no recipient at all — an empty // value, or no values. Treating that as "the caller did not claim one, so // let it pass" is what makes the check opt-in for whoever is sending, which // is the posture this primitive exists to end. AudienceEmpty // AudienceMalformed means a claimed value is not a bare domain. It is // separate from a mismatch because the two say different things to whoever // reads the rejection: one is a value in the wrong shape, the other a // well-formed value naming somebody else. AudienceMalformed // AudienceMismatch means a claimed value is a bare domain that names a // different Exchange. AudienceMismatch )
func CheckAudience ¶
func CheckAudience(self string, claimed ...string) (AudienceVerdict, error)
CheckAudience reports whether every claimed recipient names this Exchange.
self is this Exchange's own bare domain — the domain it publishes as its IDENTITY, which is the value it stamps into the offers it issues. It is not the host the process happens to listen on, and the two are allowed to differ: an Exchange at exchange.example may serve its API from api.exchange.example, so an operator who configures this from the listening host would refuse every request that named them correctly.
claimed holds the recipient values the request carries — ONE for a message with a single `exchange` field, MANY for a message whose audience lives per item (a TransactionRequest states it once per item, in each item's signed offer). Every value must name this Exchange; the first that does not decides the verdict, and a request carrying no values at all is refused rather than waved through.
The comparison is EXACT: a subdomain of this Exchange is a different party and does not name it. That is narrower than the endpoint rule, which does allow a manifest to advertise its endpoint on a subdomain of the host that served it — there the question is which addresses one Exchange may be reached at, here it is who the Exchange IS.
Two spellings of the same identity still match: case is folded, and a port of 443 written out is the same as leaving it off, since a schemeless domain is read as https throughout this SDK. Port 80 is NOT folded here: it is not the default of the scheme a bare domain implies. Elsewhere in the package canonicalPort does fold it, because there the caller supplies a scheme and 80 is http's default — a difference between two comparisons, not an inconsistency between them.
The returned error is non-nil only when self is unusable, and it always carries AudienceNoVerdict. Everything a request can get wrong is a verdict, never an error, so a caller can map the two onto different status codes without inspecting the text.
func (AudienceVerdict) String ¶
func (v AudienceVerdict) String() string
String renders the verdict as the stable token the shared conformance vectors record, so a port asserts against the same word rather than a number whose meaning depends on declaration order.
type ComponentParam ¶
ComponentParam is a single RFC 9421 §2.4 parameter on a covered-component identifier — e.g. the key="sig1" on `"signature";key="sig1"`.
type CoveredComponent ¶
type CoveredComponent struct {
Name string
Params []ComponentParam
}
CoveredComponent is one entry in a signature's covered-component set: a component name plus any RFC 9421 component parameters. Plain components (@method, content-digest, …) carry nil Params; a forwarding-chain link carries a single {Key:"key", Val:"sigN-1"} param on Name "signature".
type EntryVerdict ¶
type EntryVerdict struct {
Violations []RuleViolation
Warnings []RuleWarning
}
EntryVerdict is what ValidateResourceEntry reports: every reason the entry would be refused, wire tier first, then the ingest tier per term, followed by the warnings the accepted terms would carry.
func ValidateResourceEntry ¶
func ValidateResourceEntry(entry *forav1.ResourceEntry) EntryVerdict
ValidateResourceEntry reports the verdict the Exchange reaches for one entry, both tiers composed in the Exchange's order: the wire tier over the entry exactly as given (protovalidate, which recurses into the terms), then the ingest tier over a canonicalised COPY of the terms — the entry passed in is never modified. The Exchange stops at the first tier that fails; this face reports both so a publisher fixes everything in one round. Paths are relative to the entry ("terms[2].pricing.unit").
func (EntryVerdict) OK ¶
func (v EntryVerdict) OK() bool
OK reports whether the entry carries no violation. Warnings do not fail it.
type KeyResolver ¶
type KeyResolver interface {
// Resolve returns the Ed25519 public key registered for keyID, or an error
// wrapping ErrUnknownKey when the key is not known.
Resolve(ctx context.Context, keyID string) (ed25519.PublicKey, error)
}
KeyResolver is the injection point for verifying-key lookup (ADR-020 §4). The pure Verifier takes a key directly; the resolver is how an application supplies keys — from a well-known endpoint, a private registry, a preloaded set, a proxy, or mTLS. It is ONE interface for both faces: the client verifying offers it received and a server's verify interceptor resolve keys the same way, so custody and network policy stay with the application. The fetching implementations (well-known JWKS, WBA directory, endpoint discovery) live in sdk/go/resolvers (L2, I/O); this interface and the in-memory StaticKeyResolver stay in the L1 trust core, which does no network I/O.
type PoPOptions ¶
type PoPOptions struct {
// URL is the signed delivery URL, used VERBATIM as @target-uri — the exact
// bytes the Exchange minted, query parameters and all.
//
// This is a string and not a request value on purpose. The edge rebuilds the
// base from the raw request line it received, so the signing side must not
// route the URL through a parsed value first: doing so yields the DECODED
// path, expanding every percent-escape before it reaches the signed bytes.
// %2F is the sharpest case, since it decodes to a real separator and the
// signature would then cover a different path structure than the wire carried.
// The result is a proof that cannot verify, surfacing as a blanket 403 with no
// indication that the URL was the problem.
URL string
// KeyID is the RFC 9421 keyid: the RFC 7638 thumbprint of the presented key.
// It is the anchor of the three-way identity the edge enforces. Empty means
// "take the Signer's own key id". Either way it is cross-checked against the
// presented key before anything is signed.
KeyID string
// Created is the unix-seconds instant the proof was made. The edge rejects a
// created more than 300s in its own future, so a signer whose clock runs fast
// fails closed.
Created int64
// Expires is the unix-seconds cutoff after which the proof is stale. Keep it
// short: the covered set is only method and URL, so within this window the
// proof is replayable by anyone who observes the request.
Expires int64
// Method is the HTTP method being signed. Empty means GET. A signed URL is
// read-only in practice, but @method is covered precisely so a proof made for
// a GET cannot be lifted onto a write.
Method string
}
PoPOptions carries what a delivery-URL proof of possession needs beyond the key material. Only URL, Created and Expires are required.
type RegistrationDataVerdict ¶
type RegistrationDataVerdict int
RegistrationDataVerdict is the outcome of checking a submitted registration_data payload against the bounds above.
const ( // RegistrationDataNoVerdict is the zero value: nothing was decided. It is first so // a caller who ignores the result reads "no answer" rather than an acceptance, and // it is never returned. RegistrationDataNoVerdict RegistrationDataVerdict = iota // RegistrationDataAccepted means the payload is within every bound. It says // nothing about whether the payload conforms to a published schema — that is // RegistrationSchema.Validate, and it runs after this. RegistrationDataAccepted // RegistrationDataTooLarge means the canonical encoding exceeds // MaxRegistrationDataBytes. RegistrationDataTooLarge // RegistrationDataTooManyMembers means the top level carries more than // MaxRegistrationDataMembers members. RegistrationDataTooManyMembers // RegistrationDataTooDeep means the payload nests more than // MaxRegistrationDataDepth containers. The token matches SchemaVerdict's own // "too_deep" deliberately: same word, same counting rule, asked of the other // document. RegistrationDataTooDeep // RegistrationDataUncanonicalizable means the payload has NO JSON REPRESENTATION, // so it can be neither measured nor stored. The class has two members, and both // are values the protobuf binary decoder accepts and protojson refuses: // // a NON-FINITE NUMBER, since JSON has no NaN or Infinity while Struct's // number_value is an IEEE-754 double that holds one; // // a Value with NO MEMBER OF ITS KIND ONEOF SET, which protojson refuses with // "none of the oneof fields is set". // // BOTH entry points return this verdict. What differs is which payloads can still // reach it. CheckRegistrationDataStruct sees the class in full, on the raw Struct. // CheckRegistrationData sees only what survived into a Go map: a real float64 NaN or // infinity still fails json.Marshal there, so a map a caller assembled by hand is // answered correctly — but a map produced by AsMap has already lost the evidence, // because AsMap is not invertible for either member. A non-finite number arrives as // the string "NaN", "Infinity" or "-Infinity", which a payload may also carry // legitimately, and an unset kind arrives as nil, which is also what a real JSON null // gives. So neither member can be MOVED to the map-based face: not because that face // cannot answer the verdict, but because after the conversion there is nothing left // to answer it about. // // It is a verdict rather than an error because this face, like the rest of the // registration surface, does not throw. RegistrationDataUncanonicalizable )
func CheckRegistrationData ¶
func CheckRegistrationData(data map[string]any) RegistrationDataVerdict
CheckRegistrationData bounds a submitted registration_data payload that has ALREADY been converted to a Go map.
PREFER CheckRegistrationDataStruct. An Exchange holds *structpb.Struct, and neither member of the uncanonicalizable class survives the conversion to a map. structpb renders a NaN or an infinity as the STRING "NaN", "Infinity" or "-Infinity", and a string is a well-formed payload with nothing left to refuse; a Value with no kind set renders as nil, which is what a real JSON null gives. Both are indistinguishable from a legitimate payload afterwards — an operator legally named NaN is a valid string value that has to be accepted — so neither check can be recovered here at any cost. They have to run before the conversion.
This function still ANSWERS that verdict, for a caller whose payload never was a Struct: a map holding a real float64 NaN fails json.Marshal below and is refused. See RegistrationDataUncanonicalizable. What it cannot do is see the cases a conversion has already erased.
data is the decoded object. A nil or empty payload is accepted: sending no business data is a matter for the published schema's `required` list, not for a size bound.
This runs BEFORE RegistrationSchema.Validate, for the same reason the schema's size cap runs before the schema is parsed: the bound exists to stop work, so it has to precede the work. An Exchange refuses an over-bound payload outright — this is a malformed request rather than a schema failure, so it is NOT REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, which names non-conformance to a published schema and applies only when one is published.
func CheckRegistrationDataStruct ¶
func CheckRegistrationDataStruct(data *structpb.Struct) RegistrationDataVerdict
CheckRegistrationDataStruct bounds a submitted registration_data payload in the form it actually arrives in: RegisterRequest.GetRegistrationData(), before any conversion. This is the entry point an Exchange wants.
It answers the same verdicts as CheckRegistrationData for every payload both can see, and it delegates to it for the byte bound so the two cannot drift. What it adds is the class the map-based face is blind to: a payload with no JSON representation.
Two kinds of value reach the walk intact and have no JSON form. Struct's number_value is an IEEE-754 double, so a NaN or an infinity crosses the wire unchanged — structpb.NewNumberValue does not refuse one and the binary codec carries it. And a Value may arrive with no member of its kind oneof set at all, which the binary decoder accepts and protojson refuses. Either one makes the payload RegistrationDataUncanonicalizable: no canonical form, so no measurable size.
Converting first destroys the evidence for both. AsMap renders the non-finite values as the strings "NaN", "Infinity" and "-Infinity", which a payload may legitimately carry, and renders an unset kind as nil, which is also what a real JSON null gives.
The order is the one RegisterRequest.registration_data states, and each step is where it is for a reason:
member count and depth run FIRST, on the raw Struct, so that a payload already known to be too deep is never converted at all. AsMap walks the whole payload RECURSIVELY, and refusing first skips that work: the binary decoder admits nesting far past this bound, so the payloads this skips are ones that really do arrive over the wire, not only ones built in memory; the non-finite check runs before the byte bound, because the byte bound is defined as the length of the canonical encoding, and a payload with no canonical form has no length to compare — answering "too large" for it would state something untrue.
A nil or empty Struct is accepted, exactly as a nil map is.
func (RegistrationDataVerdict) String ¶
func (v RegistrationDataVerdict) String() string
String renders the verdict as the token the shared corpus records.
type RegistrationSchema ¶
type RegistrationSchema struct {
// contains filtered or unexported fields
}
RegistrationSchema is a compiled, accepted data_schema. It is immutable and safe for concurrent use, so a server compiles the operator's schema once at start-up and a client caches one per Exchange.
func (*RegistrationSchema) Validate ¶
func (s *RegistrationSchema) Validate(data map[string]any) []*forav1.RegistrationFieldError
Validate checks a registration_data payload against the schema and names what failed. A nil result means the payload conforms.
data is the decoded object — RegisterRequest.GetRegistrationData().AsMap() at a call site. The result is ready to hand straight to RegistrationFailureDetail alongside REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA.
Two properties of the output are contract, not presentation:
No entry ever echoes a submitted value. `error` states the CONSTRAINT that was violated and nothing else — the same leakage rule ErrorDetail.message carries, and it has teeth here because a refusal travels back over the wire while registration_data is an operator's business data. The underlying library's own messages quote the offending value, so this face builds its text from the failed keyword instead of rendering the library's. The order is deterministic. Entries are deduplicated and sorted by path, then by keyword, before the list is capped. Three validators walk a failing document in three different orders, so an unsorted list is one no shared corpus could pin.
A nil receiver reports no failures, because "no schema" means "nothing to enforce" — the pass-through case an Exchange publishing no data_schema is entitled to, and the case a client is REQUIRED to fall into when it cannot check locally. Note what that implies: a caller that drops the verdict from CompileRegistrationSchema holds nil after every refusal too, and this call then reports success. That is correct for a client, whose refusal is "send anyway and let the Exchange decide", and wrong for an Exchange, which must treat any verdict other than SchemaAccepted or SchemaNotPublished as a misconfiguration of its own deployment. The verdict is the only thing that separates the two, so an Exchange must not discard it.
type RuleViolation ¶
RuleViolation is one reason an entry or a term would be refused. Rule is the rule id (an ingest-tier id above, or the protovalidate rule id of a wire-tier violation); Path is the snake_case proto-JSON field path relative to the message that was checked, e.g. "terms[2].pricing.unit"; Token is the offending value when the rule is about one token, else empty; Message is the human-readable reason.
Message is identical across the three SDKs for an INGEST-tier violation, which is SDK-owned code in each and whose strings are what an Exchange puts in warnings[]. It is not, and cannot be, for a wire-tier one: there the reason comes from each language's own validator — protovalidate, Zod, Pydantic — three engines with three vocabularies. That is why the shared corpus records a wire-tier violation as a boolean and an ingest-tier one as a whole finding.
func (*RuleViolation) Error ¶
func (v *RuleViolation) Error() string
Error implements error so ValidateLicenseTerm can return a violation as its error while callers still branch on the typed fields via errors.As.
type RuleWarning ¶
RuleWarning is one non-fatal finding. Its Message is the exact string the Exchange puts in PushResourcesResponse.warnings for the same finding.
func ValidateLicenseTerm ¶
func ValidateLicenseTerm(term *forav1.LicenseTerm) ([]RuleWarning, error)
ValidateLicenseTerm runs the ingest-tier checks over one term. It returns a *RuleViolation as the error for a hard reject, in a fixed order: a bare Pricing.unit that is not a registered token, then the first offending quota metric, then the first restriction whose permitted and prohibited lists name one token once canonicalised. When the term is accepted it returns the warnings it would carry: one per unregistered bare restriction token, in restriction order with permitted before prohibited, then one per OBLIGATION_KIND_OTHER obligation without detail. Empty and vendor-namespaced (containing ":") tokens are never membership-checked.
Every check but one reads the term as ALREADY CANONICAL, which is what NormalizeLicenseTerm produces. The exception is the disjointness check, which folds what it compares: it exists because a rule that compares spellings answers a question about tokens, so a rule written to close that gap must not in turn assume its own caller folded first.
The wire tier is not re-run here: token format, PER_UNIT⇒unit, FREE⇒rate 0, one restriction per kind and the presence rules are protovalidate's, and ValidateResourceEntry composes the two tiers. Disjointness is the one property BOTH tiers assert, over different values — permitted∩prohibited over the tokens as written, and the rule below over the tokens the fold produces — so a term the first clears can still fail the second, and a term that fails both is reported by both.
type SchemaVerdict ¶
type SchemaVerdict int
SchemaVerdict is the outcome of compiling a published data_schema.
const ( // SchemaNoVerdict is the zero value: nothing was decided. It is first so that a // caller who ignores the verdict entirely reads "no answer" rather than an // acceptance, and it is never returned. SchemaNoVerdict SchemaVerdict = iota // SchemaAccepted means the schema passed every rule and is usable. SchemaAccepted // SchemaMalformed means the bytes are not JSON, or the document is not a JSON // object at its top level. 2020-12 would admit a bare boolean as a schema, but // data_schema is a google.protobuf.Struct and cannot carry one. SchemaMalformed // SchemaWrongDialect means a $schema in the document names a dialect other than // draft 2020-12. SchemaWrongDialect // SchemaRemoteRef means a reference points outside the document. Separate from // SchemaMalformed because it is the one refusal that describes an attack rather // than a mistake, and an operator reading it should look at who authored the // schema, not at whether it parses. SchemaRemoteRef // SchemaTooLarge means the raw bytes exceed MaxRegistrationSchemaBytes. SchemaTooLarge // SchemaTooDeep means the document nests past MaxRegistrationSchemaDepth. SchemaTooDeep // SchemaUnsafePattern means a `pattern` uses a construct outside the alphabet // all three SDK languages can express identically. SchemaUnsafePattern // SchemaTooComplex means checking a payload against the schema would cost more // than MaxRegistrationSchemaEvaluations. The document itself may be small: this // bounds the work, which the size and depth caps do not. SchemaTooComplex // SchemaRefCycle means a reference chain returns to a schema already on it. The // cycle is legal JSON Schema — it is how a recursive structure is written — but // its evaluation cost has no static bound, and it is what makes two of the three // ports abort rather than answer. Registration data describes a business entity, // which is not a recursive shape, so refusing the construct costs nothing real. // Separate from SchemaTooComplex because the remedy is different: a cycle is a // modelling choice to undo, not a budget to trim. SchemaRefCycle // SchemaRefChainTooLong means a reference chain is longer than // MaxRegistrationSchemaRefHops. It is its own verdict rather than SchemaTooComplex // or SchemaTooDeep because it is its own rule: a flat chain is cheap to evaluate and // shallow to nest, and neither of those caps can see it. SchemaRefChainTooLong // SchemaCompileTimeout means compilation ran past RegistrationSchemaCompileTimeout. SchemaCompileTimeout // SchemaUncompilable means the document is well-formed JSON, passes the rules // above, and is still not a valid 2020-12 schema — including a same-document // reference that resolves to nothing. SchemaUncompilable // SchemaNotPublished means there was no schema to compile: the Exchange publishes // none. It is a verdict rather than an error because it is a normal, common state // with its own contract — registration_data passes through uninspected — and // because collapsing it into a refusal would leave a caller unable to tell "there // is nothing to enforce" from "I refused to enforce what I was given". SchemaNotPublished )
func (SchemaVerdict) String ¶
func (v SchemaVerdict) String() string
String renders the verdict as the stable token the shared conformance vectors record, so a port asserts against the same word rather than a number whose meaning depends on declaration order.
type SignOptions ¶
SignOptions tune SignRequest. Created/Expires are injected (L1 reads no clock) as unix-seconds; the verifier enforces the window against its own clock.
type SignedURL ¶
SignedURL carries the issued URL plus audit metadata. Hash is SHA-256 of the full URL (the transaction_log.signed_url_hash value).
func SignURLEd25519 ¶
func SignURLEd25519(priv ed25519.PrivateKey, keyID, rawURL, agentID string, expiry time.Time) (SignedURL, error)
SignURLEd25519 signs rawURL with priv, embedding exp (and kid when keyID is set, agent_id when agentID is set) and the base64url-no-pad signature over "GET\n<url>". The URL is signed as OPAQUE BYTES — scheme/host/path are preserved verbatim; only the query is deterministically re-encoded. The result matches the edge worker's verifier.
type Signer ¶
type Signer interface {
// KeyID is the RFC 9421 keyid the verifier resolves a public key for.
KeyID() string
// Algorithm is the RFC 9421 alg value (e.g. AlgEd25519).
Algorithm() string
// Sign returns the signature over the signature base. ctx lets a remote
// signer carry deadlines/cancellation; local signers ignore it.
Sign(ctx context.Context, signatureBase []byte) ([]byte, error)
}
Signer performs the raw crypto half of RFC 9421 request signing. The SDK builds the signature base (covered components + parameters); the Signer signs exactly those bytes. Splitting it this way means a KMS/HSM/remote signer satisfies the same interface and the SDK never sees the private key — custody stays with the application (ADR-020 §3, fora-sdk-api.md "The core abstraction: Signer").
func NewEd25519Signer ¶
func NewEd25519Signer(keyID string, priv ed25519.PrivateKey) (Signer, error)
NewEd25519Signer wraps an in-memory Ed25519 private key as a Signer. For KMS/HSM custody, implement Signer directly instead.
type StaticKeyResolver ¶
type StaticKeyResolver struct {
// contains filtered or unexported fields
}
StaticKeyResolver serves public keys from an in-memory map — for preloaded key sets and tests.
func NewStaticKeyResolver ¶
func NewStaticKeyResolver(keys map[string]ed25519.PublicKey) *StaticKeyResolver
NewStaticKeyResolver returns a resolver seeded with keys (copied).
type VerifiedRequest ¶
type VerifiedRequest struct {
KeyID string
Algorithm string
Label string
Signature string
Created int64
Expires int64
PublicKey ed25519.PublicKey
// SignatureAgent is the (covered, therefore signed) Signature-Agent header
// value — the signer's WBA key-directory URL. Empty when the signer bound
// no directory (the static bootstrap path).
SignatureAgent string
}
VerifiedRequest carries the proven signature metadata. PublicKey is the key the signature verified against, carried so downstream consumers bind to the proven key (e.g. its RFC 7638 thumbprint as agent_id) rather than re-resolving the claimed KeyID.
func AllSignaturesFromContext ¶
func AllSignaturesFromContext(ctx context.Context) []VerifiedRequest
AllSignaturesFromContext returns all verified signatures (the multisig case). It returns a single-element slice for a single-sig request, or nil if no signatures are in context.
func FromContext ¶
func FromContext(ctx context.Context) *VerifiedRequest
FromContext returns the VerifiedRequest stashed in ctx, or nil. For a multisig request it returns the first (agent) signature; it reads the multisig slot first then falls back to the single slot, so the N=1 read path is unchanged.
func VerifyMultisigRequest ¶
func VerifyMultisigRequest(req *http.Request, body []byte, resolve resolveFunc, opts VerifyOptions) ([]VerifiedRequest, error)
VerifyMultisigRequest verifies ALL signatures on req against keys from resolve, returning the VerifiedRequest list in label order (sig1, sig2, …). It rejects a chain exceeding opts.MaxSignatures (when set) before any crypto, enforces the structural forwarding chain, then cryptographically verifies each signature — so a stripped, reordered, or substituted predecessor is rejected.
func VerifyMultisigRequestResolved ¶
func VerifyMultisigRequestResolved(ctx context.Context, req *http.Request, body []byte, resolver KeyResolver, opts VerifyOptions) ([]VerifiedRequest, error)
VerifyMultisigRequestResolved verifies ALL signatures on req, resolving each label's key via resolver, and returns the VerifiedRequest list in sig1..sigN order. It is the multi-hop sibling of VerifyRequestResolved: it enforces the hop bound (opts.MaxSignatures) and the structural forwarding chain before cryptographically verifying every hop, so a stripped, reordered, or substituted predecessor is rejected. A single-signature request is the N=1 case and verifies identically.
func VerifyRequest ¶
func VerifyRequest(req *http.Request, body []byte, pub ed25519.PublicKey, opts VerifyOptions) (*VerifiedRequest, error)
VerifyRequest verifies req+body against pub. body MUST be the exact bytes that produced the request payload. It verifies the FIRST signature label only — for a single-signer request that is the whole request; for a multisig request use VerifyMultisigRequest / VerifyMultisigRequestResolved.
func VerifyRequestResolved ¶
func VerifyRequestResolved(ctx context.Context, req *http.Request, body []byte, resolver KeyResolver, opts VerifyOptions) (*VerifiedRequest, error)
VerifyRequestResolved resolves the request's signing key via resolver, then runs the pure VerifyRequest. It is the convenience the server interceptor and a key-resolving client use: the resolver does the IO, VerifyRequest stays pure.
type VerifiedURL ¶
type VerifiedURL struct {
AgentID string // RFC 7638 thumbprint the URL is bound to ("" = bearer)
KeyID string // the kid param, if present
Expiry time.Time
}
VerifiedURL carries the verified metadata extracted from a signed URL.
func VerifyURLEd25519 ¶
VerifyURLEd25519 verifies a signed URL against pub, then checks expiry against now. On success it returns the bound agent_id (if any), kid, and expiry. It verifies the signature before trusting expiry (both are covered by the sig).
func (VerifiedURL) Bound ¶
func (v VerifiedURL) Bound() bool
Bound reports whether the URL is agent-bound (carries an agent_id).
func (VerifiedURL) CheckProofOfPossession ¶
func (v VerifiedURL) CheckProofOfPossession(presentedPub ed25519.PublicKey) error
CheckProofOfPossession enforces the agent binding: the presented public key's RFC 7638 thumbprint must equal the URL's agent_id. It returns ErrURLNotAgentBound for a bearer URL (the caller decides whether bearer access is acceptable) and ErrProofOfPossessionMismatch when the key does not match.
type VerifyOptions ¶
type VerifyOptions struct {
Now time.Time
MaxFutureSkew time.Duration
// MaxSignatures bounds the number of signatures accepted on a multisig
// request — the Exchange hop bound. 0 means unbounded; only the
// Exchange-terminal consumer sets it (= max_intermediary_hops + 1). A request
// carrying more signatures is rejected with ErrTooManyHops before any
// signature is cryptographically verified. Ignored by single-sig VerifyRequest.
MaxSignatures int
// MaxSignatureAge clamps a signature's declared lifetime (expires − created).
// MaxFutureSkew only bounds the future edge; without an upper bound on the
// window a signer can set expires = now + 10y and, if the replay store is
// absent or forgotten, keep replaying the same bytes for years. 0 (default)
// means unbounded — back-compatible; a server-terminal consumer sets it (the
// FORA target is minutes). A signature whose window exceeds it is rejected
// with ErrSignatureLifetimeTooLong before the Ed25519 check.
MaxSignatureAge time.Duration
}
VerifyOptions tune VerifyRequest. The zero value is production-correct: Now defaults to the wall clock and MaxFutureSkew to defaultMaxFutureSkew. Tests inject Now for determinism.
Source Files
¶
- acceptance.go
- audience.go
- canonicalsign.go
- constants.go
- context.go
- doc.go
- errordetail.go
- hosts.go
- idempotency.go
- keyresolver.go
- licenseterm.go
- manifestversion.go
- money.go
- offer.go
- pop.go
- presented_offer.go
- regschema.go
- request_acceptance.go
- retrievaltoken.go
- scopes.go
- sigbase.go
- sign.go
- signedurl.go
- thumbprint.go
- validate.go
- verify.go