Documentation
¶
Overview ¶
Package security holds the cryptographic primitives. Nothing here is home-grown: every construction is one from the standard library, used the way its documentation says (security.md §8).
Index ¶
- Constants
- Variables
- type AuditFingerprinter
- type AuthAttemptHasher
- type CursorCodec
- type FeedTokenHasher
- type InboundTokenHasher
- type McpSessionIssuer
- type MediaTokenIssuer
- func (i MediaTokenIssuer) Issue(purpose MediaTokenPurpose, tenantID, mediaID shared.ID, expiresAt time.Time) string
- func (i MediaTokenIssuer) Validate(token string, purpose MediaTokenPurpose, mediaID shared.ID, now time.Time) (shared.ID, error)
- func (i MediaTokenIssuer) ValidateDownload(token string, mediaID shared.ID, now time.Time) (shared.ID, error)
- func (i MediaTokenIssuer) ValidateUpload(token string, mediaID shared.ID, now time.Time) (shared.ID, error)
- type MediaTokenPurpose
- type OauthClientSecretHasher
- type OauthCodeHasher
- type OidcFlowHasher
- type PendingTokenHasher
- type PlatformEventCursorCodec
- type Position
- type RecoveryCodeHasher
- type RedemptionTokenHasher
- type SessionRefreshHasher
- type SessionTokenIssuer
- type StepUpTokenHasher
- type StreamCursorCodec
- type StreamPosition
- type TokenHasher
- type TriggerCursorCodec
- type WebhookSigner
Constants ¶
const AuditFingerprintScheme = "hmac-sha256:"
AuditFingerprintScheme prefixes every fingerprint this type writes. A fingerprint without it was written unkeyed by an earlier version, and the two must never be read as two values.
const McpSessionLifetime = 12 * time.Hour
McpSessionLifetime is how long a session identifier stays usable.
Long enough that an agent working through a task does not re-handshake in the middle of it, short enough that an identifier copied out of a log is useless by the time somebody reads the log. It is not a credential - a request carrying one is authenticated by its bearer token like every other request, and the session says only *which conversation* the request belongs to - so the lifetime is about tidiness rather than about containment.
const SignatureHeader = "X-Hubtask-Signature"
SignatureHeader is the header a subscriber verifies (automation.md §3.1).
const SignatureTolerance = 5 * time.Minute
SignatureTolerance is how far a signature's timestamp may be from the receiver's clock before it should be refused as a replay. It is documented here rather than enforced here: this side signs, and the window is the subscriber's to apply - but the number they should apply is ours to state, and stating it in the code that produces the timestamp is the only place it cannot drift from.
Variables ¶
var ErrMcpSessionUnknown = errors.New("unknown MCP session")
ErrMcpSessionUnknown is every way a presented session identifier is not one this server minted for this actor: a forgery, a truncation, one that has expired, and one belonging to somebody else. **One answer for all four**, deliberately - saying which check failed tells whoever is probing how far their forgery got, which is the page cursor's reasoning applied to a session.
Functions ¶
This section is empty.
Types ¶
type AuditFingerprinter ¶
type AuditFingerprinter struct {
// contains filtered or unexported fields
}
AuditFingerprinter is the audit trail's keyed fingerprint (audit.md §4, security.md §8).
HMAC-SHA-256 under a pepper derived from the installation secret, over the workspace and the value. Not a password hash and not meant to be one: the point is that two entries of one workspace compare without either being readable, and a guess at a name cannot be checked without the secret, which lives in the environment and never in the database.
func NewAuditFingerprinter ¶
func NewAuditFingerprinter(installationSecret secret.Secret) AuditFingerprinter
func (AuditFingerprinter) Fingerprint ¶
func (f AuditFingerprinter) Fingerprint(tenantID shared.ID, value string) string
Fingerprint is deterministic within one workspace and one secret, which is the whole of its comparability. The zero byte after the workspace keeps the two parts from running into each other; the identifier's text form has a fixed length, so it is a separator, not an escape.
type AuthAttemptHasher ¶
type AuthAttemptHasher struct {
// contains filtered or unexported fields
}
AuthAttemptHasher turns a lockout subject - an address somebody signed in as, or the network they came from - into the value stored in auth_attempt.subject_hash. Hashed so the ledger can count attempts against addresses that hold no account without becoming a list of guessed addresses (T-02).
func NewAuthAttemptHasher ¶
func NewAuthAttemptHasher(installationSecret secret.Secret) AuthAttemptHasher
func (AuthAttemptHasher) Hash ¶
func (h AuthAttemptHasher) Hash(subject string) []byte
type CursorCodec ¶
type CursorCodec struct {
// contains filtered or unexported fields
}
CursorCodec turns a page boundary into an opaque cursor and back.
Opaque and signed, per api-guidelines.md §4. Signing is not about confidentiality - both halves are in the page the client just read - it is about the boundary being one this server produced. An unsigned cursor is a query parameter in disguise: a client, or an agent generating one, would eventually craft a sort key by hand, and the endpoint's contract would quietly have become "seek to an arbitrary position" with the index and the collation assumptions that go with it.
It lives here rather than in the application layer because it is a cryptographic primitive keyed on the installation secret, and the secret has no business above the adapters (security.md §8) - the same reasoning that keeps the token pepper in TokenHasher. The core passes the cursor through as an opaque string and never looks inside it.
func NewCursorCodec ¶
func NewCursorCodec(installationSecret secret.Secret) CursorCodec
func (CursorCodec) Decode ¶
func (c CursorCodec) Decode(cursor string) (Position, error)
Decode reads a boundary back, or reports the cursor as invalid.
Every failure is the same answer - a validation error naming the field - and deliberately so. Tampered, truncated, from another installation, or produced before a key rotation are one situation from the client's point of view: this cursor is not usable, start the walk again. Saying which would tell whoever is probing whether their forgery got as far as the signature.
func (CursorCodec) DecodeID ¶
func (c CursorCodec) DecodeID(cursor string) (shared.ID, error)
DecodeID reads such a cursor back.
func (CursorCodec) Encode ¶
func (c CursorCodec) Encode(position Position) string
Encode returns the cursor for a boundary. The empty position is the empty cursor: "there is no next page" is the absence of a cursor rather than a cursor meaning nothing.
type FeedTokenHasher ¶
type FeedTokenHasher struct {
// contains filtered or unexported fields
}
FeedTokenHasher turns a presented feed token into the value stored in calendar_feed.token_hash.
The construction is TokenHasher's, for TokenHasher's reasons: HMAC-SHA-256 with a pepper that lives in the environment rather than in the database, so a stolen dump has nothing to attack offline; no per-token salt and no Argon2, because a 256-bit random secret has nothing to guess at and this runs on every fetch of every subscribed calendar, where a deliberately slow hash would be the rate limit.
It is deterministic, which is what makes the lookup an index seek on a unique index rather than a row-by-row comparison - and therefore what makes it constant-time in the only sense that matters here: the server never compares a stored secret against a presented one at all.
func NewFeedTokenHasher ¶
func NewFeedTokenHasher(installationSecret secret.Secret) FeedTokenHasher
func (FeedTokenHasher) Hash ¶
func (h FeedTokenHasher) Hash(presented string) []byte
Hash covers the whole presented string, tenant half included, so a token cannot be rewritten to name another tenant and still match.
type InboundTokenHasher ¶
type InboundTokenHasher struct {
// contains filtered or unexported fields
}
InboundTokenHasher turns a presented inbound token into the value stored in `automation_rule.inbound_token_hash`.
FeedTokenHasher's construction, for FeedTokenHasher's reasons: HMAC-SHA-256 with a pepper that lives in the environment rather than in the database, so a stolen dump has nothing to attack offline; no per-token salt and no Argon2, because a 256-bit random secret has nothing to guess at and this runs on every delivery, where a deliberately slow hash would be the rate limit.
It is deterministic, which is what makes the lookup an index seek on a unique index - and therefore what makes it constant-time in the only sense that matters here: the server never compares a stored secret against a presented one at all.
func NewInboundTokenHasher ¶
func NewInboundTokenHasher(installationSecret secret.Secret) InboundTokenHasher
func NewJumbleIntakeHasher ¶
func NewJumbleIntakeHasher(installationSecret secret.Secret) InboundTokenHasher
NewJumbleIntakeHasher is the same construction under the intake's own purpose label.
func NewSetupCodeHasher ¶
func NewSetupCodeHasher(installationSecret secret.Secret) InboundTokenHasher
NewSetupCodeHasher is the same construction under the setup code's own purpose label. A code has sixty bits rather than 256, which is why it lives thirty minutes, is spent on its first use and is guessed against a rate limit (security.md, T-24); the pepper keeps a stolen row from being checked offline.
func (InboundTokenHasher) Hash ¶
func (h InboundTokenHasher) Hash(presented string) []byte
Hash covers the whole presented string, tenant half included, so a token cannot be rewritten to name another tenant and still match.
type McpSessionIssuer ¶
type McpSessionIssuer struct {
// contains filtered or unexported fields
}
McpSessionIssuer mints and checks the `Mcp-Session-Id` of the MCP transport.
**A signed statement rather than a row**, and that is the decision worth reading. MCP lets a server hand a client a session identifier at `initialize` and expects it back on every later request; the obvious implementation is a map in the process. This installation runs the `api` role stateless and horizontally scaled (ADR-0046), so a map would bind a client to whichever pod answered its handshake - and the next request, load-balanced elsewhere, would be told its session does not exist. A client cannot recover from that: it re-handshakes, lands on a third pod, and the failure looks like a flapping server.
So the identifier carries what it asserts - the workspace, the account, and when it was minted - and an HMAC over the three. Any pod can then check it with no shared state, which is the same property the media token and the page cursor already have and for the same reason.
It is not a credential and does not authenticate anything. Every request that carries one is authenticated by its bearer token first; the session is checked *against that actor*, so a session belonging to somebody else is refused even when presented with a perfectly good token.
func NewMcpSessionIssuer ¶
func NewMcpSessionIssuer(installationSecret secret.Secret) McpSessionIssuer
NewMcpSessionIssuer derives the signing key from the installation secret, under this token's own label.
func (McpSessionIssuer) Validate ¶
func (i McpSessionIssuer) Validate( session string, tenantID, accountID shared.ID, now time.Time, ) error
Validate judges a presented identifier against the actor presenting it.
The identity in the token is compared with the identity the request authenticated as, rather than trusted from the token: a session says which conversation a request belongs to, and a request that authenticated as one person may not continue another's.
type MediaTokenIssuer ¶
type MediaTokenIssuer struct {
// contains filtered or unexported fields
}
MediaTokenIssuer signs the content routes' capability tokens: on a local-storage installation, /media/{id}:content plays the part a presigned URL plays on an object-storage one, and the token is what makes the URL the credential - bound to one object, one direction, and an expiry.
func NewMediaTokenIssuer ¶
func NewMediaTokenIssuer(installationSecret secret.Secret) MediaTokenIssuer
NewMediaTokenIssuer derives the signing key from the installation secret, under this token's own label.
func (MediaTokenIssuer) Issue ¶
func (i MediaTokenIssuer) Issue( purpose MediaTokenPurpose, tenantID, mediaID shared.ID, expiresAt time.Time, ) string
Issue mints the token for one object, one direction, until expiresAt.
The tenant travels in the token, in clear and signed. The content routes carry no bearer credential - the URL is the capability, exactly as a presigned bucket URL is - so there is nothing else on the request that could say which tenant to open the transaction as, and a tenant read from the path or a header would be a way around row level security (multi-tenancy.md §2.2). Signed rather than merely carried: swapping it invalidates the tag.
func (MediaTokenIssuer) Validate ¶
func (i MediaTokenIssuer) Validate( token string, purpose MediaTokenPurpose, mediaID shared.ID, now time.Time, ) (shared.ID, error)
Validate judges a presented token. A forgery of any kind is one indistinguishable answer, for the reason a page cursor's is: saying which check failed tells whoever is probing how far their forgery got. Expiry is the one distinguished refusal - an expired token is a token this server really minted, and "stage the upload again" is actionable where "invalid" is not. It returns the tenant the token was minted in, which is what the caller opens its transaction as. The value is only ever returned after the tag has been verified, so a caller cannot be handed a tenant nobody signed for.
func (MediaTokenIssuer) ValidateDownload ¶
func (MediaTokenIssuer) ValidateUpload ¶
func (i MediaTokenIssuer) ValidateUpload( token string, mediaID shared.ID, now time.Time, ) (shared.ID, error)
ValidateUpload and ValidateDownload are Validate with the purpose named rather than passed.
They exist so that the REST layer can hold this as an interface of its own without knowing the purpose type - presentation may not import infrastructure - and they make the one mistake that matters impossible to write: a handler cannot pass the wrong direction if it cannot pass one.
type MediaTokenPurpose ¶
type MediaTokenPurpose string
MediaTokenPurpose separates the two directions: a token minted for an upload must not open a download, and the purpose is signed so it cannot be flipped.
const ( MediaTokenUpload MediaTokenPurpose = "upload" MediaTokenDownload MediaTokenPurpose = "download" )
type OauthClientSecretHasher ¶
type OauthClientSecretHasher struct {
// contains filtered or unexported fields
}
OauthClientSecretHasher turns a confidential client's secret into the value stored in oauth_client.secret_hash.
func NewOauthClientSecretHasher ¶
func NewOauthClientSecretHasher(installationSecret secret.Secret) OauthClientSecretHasher
func (OauthClientSecretHasher) Hash ¶
func (h OauthClientSecretHasher) Hash(presented string) []byte
type OauthCodeHasher ¶
type OauthCodeHasher struct {
// contains filtered or unexported fields
}
OauthCodeHasher turns a presented authorization code into the value stored in oauth_code.code_hash.
func NewOauthCodeHasher ¶
func NewOauthCodeHasher(installationSecret secret.Secret) OauthCodeHasher
func (OauthCodeHasher) Hash ¶
func (h OauthCodeHasher) Hash(presented string) []byte
type OidcFlowHasher ¶
type OidcFlowHasher struct {
// contains filtered or unexported fields
}
OidcFlowHasher turns a presented sign-in state into the value stored in oidc_flow.state_hash.
func NewOidcFlowHasher ¶
func NewOidcFlowHasher(installationSecret secret.Secret) OidcFlowHasher
func (OidcFlowHasher) Hash ¶
func (h OidcFlowHasher) Hash(presented string) []byte
type PendingTokenHasher ¶
type PendingTokenHasher struct {
// contains filtered or unexported fields
}
PendingTokenHasher turns a presented pending credential into the value stored in auth_pending.token_hash.
func NewPendingTokenHasher ¶
func NewPendingTokenHasher(installationSecret secret.Secret) PendingTokenHasher
func (PendingTokenHasher) Hash ¶
func (h PendingTokenHasher) Hash(presented string) []byte
type PlatformEventCursorCodec ¶
type PlatformEventCursorCodec struct {
// contains filtered or unexported fields
}
PlatformEventCursorCodec turns a position in the installation's events into an opaque cursor and back - signed, for the trigger cursor's reason: it decides where a platform resumes.
func NewPlatformEventCursorCodec ¶
func NewPlatformEventCursorCodec(installationSecret secret.Secret) PlatformEventCursorCodec
NewPlatformEventCursorCodec keys the codec on the installation secret.
func (PlatformEventCursorCodec) Decode ¶
func (c PlatformEventCursorCodec) Decode(cursor string) (adminrepo.PlatformPosition, error)
Decode reads a position back; every failure is the same answer, as the other codecs give.
func (PlatformEventCursorCodec) Encode ¶
func (c PlatformEventCursorCodec) Encode(position adminrepo.PlatformPosition) string
Encode returns the cursor for a position.
type Position ¶
Position is a page boundary: the sort keys of the last row of a page, and that row's identifier.
Keys rather than one key, because a query may be sorted by several fields at once (api-guidelines.md §3) and a boundary in such a walk is the whole tuple. Every list that sorts by one thing carries one key, which is what At builds.
The identifier is not decoration. A sort key alone is only a boundary if it is unique, and a fractional index is unique in practice rather than by constraint; the pair is what api-guidelines.md §4 means by "the sort key plus id", and what lets the comparison be a strict `>` on a total order.
type RecoveryCodeHasher ¶
type RecoveryCodeHasher struct {
// contains filtered or unexported fields
}
RecoveryCodeHasher turns a normalised recovery code into the value stored in account_recovery_code.code_hash. The pepper is what makes eighty bits enough: without the installation secret there is nothing to brute-force a dump against.
func NewRecoveryCodeHasher ¶
func NewRecoveryCodeHasher(installationSecret secret.Secret) RecoveryCodeHasher
func (RecoveryCodeHasher) Hash ¶
func (h RecoveryCodeHasher) Hash(normalised string) []byte
type RedemptionTokenHasher ¶
type RedemptionTokenHasher struct {
// contains filtered or unexported fields
}
RedemptionTokenHasher turns a presented redemption token into the value stored in account.redemption_token_hash.
func NewRedemptionTokenHasher ¶
func NewRedemptionTokenHasher(installationSecret secret.Secret) RedemptionTokenHasher
func (RedemptionTokenHasher) Hash ¶
func (h RedemptionTokenHasher) Hash(presented string) []byte
type SessionRefreshHasher ¶
type SessionRefreshHasher struct {
// contains filtered or unexported fields
}
SessionRefreshHasher turns a presented refresh token into the value stored in session_refresh_token.token_hash.
func NewSessionRefreshHasher ¶
func NewSessionRefreshHasher(installationSecret secret.Secret) SessionRefreshHasher
func (SessionRefreshHasher) Hash ¶
func (h SessionRefreshHasher) Hash(presented string) []byte
type SessionTokenIssuer ¶
type SessionTokenIssuer struct {
// contains filtered or unexported fields
}
SessionTokenIssuer signs the access half of the pair (security.md §5): fifteen minutes, verified by its signature without a database read - the same discipline every cursor and feed token already follows. What the signature does not answer - is the session still alive, may the account act - is the row's business, read afterwards by whoever holds the claims.
func NewSessionTokenIssuer ¶
func NewSessionTokenIssuer(installationSecret secret.Secret) SessionTokenIssuer
NewSessionTokenIssuer derives the signing key from the installation secret, under this token's own label.
func (SessionTokenIssuer) Issue ¶
func (i SessionTokenIssuer) Issue(claims port.SessionClaims) string
Issue mints the token for one session, until expiresAt.
Everything the claims carry travels in clear and signed, MediaTokenIssuer's reasoning: the bearer route has nothing else that could say which tenant to open the transaction as, and a value read from a header would be a way around row level security. Swapping any of it invalidates the tag.
func (SessionTokenIssuer) Validate ¶
func (i SessionTokenIssuer) Validate(presented string, now time.Time) (port.SessionClaims, error)
Validate judges a presented token. A forgery of any kind is one indistinguishable answer, for the reason a page cursor's is; expiry is the one distinguished refusal, because an expired token is one this server really minted and "refresh" is actionable where "invalid" is not.
type StepUpTokenHasher ¶
type StepUpTokenHasher struct {
// contains filtered or unexported fields
}
StepUpTokenHasher turns a presented step-up token into the value stored in session.step_up_token_hash.
func NewStepUpTokenHasher ¶
func NewStepUpTokenHasher(installationSecret secret.Secret) StepUpTokenHasher
func (StepUpTokenHasher) Hash ¶
func (h StepUpTokenHasher) Hash(presented string) []byte
type StreamCursorCodec ¶
type StreamCursorCodec struct {
// contains filtered or unexported fields
}
StreamCursorCodec turns a position in the change log into an opaque cursor and back.
Opaque and signed for the reason the page cursor is: an unsigned cursor is a query parameter in disguise, and a client that could craft one would be seeking to an arbitrary position in another walk's index. It is doubly so here, because this one travels as `Last-Event-ID` - a header a browser resends by itself, which makes it exactly the value nobody is watching.
It lives here rather than in the application layer because it is a cryptographic primitive keyed on the installation secret, and the secret has no business above the adapters (security.md §8). The application holds it behind an interface of its own and never looks inside the string.
func NewStreamCursorCodec ¶
func NewStreamCursorCodec(installationSecret secret.Secret) StreamCursorCodec
func (StreamCursorCodec) Decode ¶
func (c StreamCursorCodec) Decode(cursor string) (StreamPosition, error)
Decode reads a position back, or reports the cursor as invalid.
Every failure is the same answer, deliberately. Tampered, truncated, from another installation, or produced before a key rotation are one situation from the client's point of view - this cursor is not usable, resynchronise - and saying which would tell whoever is probing whether their forgery got as far as the signature.
func (StreamCursorCodec) Encode ¶
func (c StreamCursorCodec) Encode(position StreamPosition) string
Encode returns the cursor for a position.
Seconds rather than nanoseconds: the question it answers is measured in days, and a shorter value is a shorter header on every one of a stream's events.
type StreamPosition ¶
type StreamPosition struct {
Seq int64
IssuedAt time.Time
// Epoch is the workspace's synchronisation epoch the cursor was minted under: a
// restore advances it, and a cursor from an older epoch is refused, because the rows a
// restore wrote are in no change log entry the cursor could reach.
Epoch int64
// Kind and After are set while an initial synchronisation is under way: the kind being
// walked and the key of the last row handed out. Both empty is a delta position - the only
// kind a stream resumes from. They are inside the signed payload for the same reason the
// moment is: a client that could edit them could skip a kind and believe itself complete.
Kind string
After string
}
StreamPosition is where a client stands in the change log, and when it was told so.
The moment is the point of the type. ADR-0021 refuses timestamps *as* cursors - the position is the sequence number and nothing else orders the walk - but "is this cursor older than the tombstone period" is a question about time, and the only honest answer needs the moment the client was last handed one (offline-sync.md §7). Carrying it inside the signed cursor means the client cannot lie about it and the server does not have to remember.
type TokenHasher ¶
type TokenHasher struct {
// contains filtered or unexported fields
}
TokenHasher turns a presented token into the value stored in access_token.token_hash.
HMAC-SHA-256 with the installation secret as the key, which is what "SHA-256 with a server-side pepper" means in practice (security.md §8): the pepper lives in the environment, never in the database, so a stolen database dump cannot be brute-forced offline. No salt per token and no Argon2 here on purpose - a 256-bit random secret has nothing to guess at, and this runs on every single request, where a deliberately slow hash would be the rate limit.
func NewTokenHasher ¶
func NewTokenHasher(installationSecret secret.Secret) TokenHasher
func (TokenHasher) Hash ¶
func (h TokenHasher) Hash(presented string) []byte
Hash is deterministic, which is the point: the lookup is an index seek on the hash rather than a scan comparing every row. The hash covers the whole presented string, tenant half included, so a token cannot be rewritten to name another tenant and still match.
type TriggerCursorCodec ¶
type TriggerCursorCodec struct {
// contains filtered or unexported fields
}
TriggerCursorCodec turns a position in the outbox into an opaque cursor and back.
Signed, for the page cursor's reason and more sharply: this value decides where in the event log a caller resumes, and a client that could craft one would be asking to be handed events from a window it was never given. Unsigned it would be a query parameter in disguise (T-06).
It lives here rather than in the application layer because it is a cryptographic primitive keyed on the installation secret, and the secret has no business above the adapters (security.md §8). The application holds it behind an interface and never looks inside the string, which is what "opaque" means from that side - and what lets the encoding change without a contract change.
Microseconds rather than seconds, unlike the stream cursor. The stream's moment answers a question measured in days; this one is half of a sort key, and a cursor rounded to the second would re-answer or step over every event sharing that second. It is the resolution PostgreSQL stores timestamptz at, so the value round-trips exactly.
func NewTriggerCursorCodec ¶
func NewTriggerCursorCodec(installationSecret secret.Secret) TriggerCursorCodec
func (TriggerCursorCodec) Decode ¶
func (c TriggerCursorCodec) Decode(cursor string) (outbox.Position, error)
Decode reads a position back, or reports the cursor as invalid.
Every failure is the same answer, as in the stream codec and for the same reason. Tampered, truncated, from another installation, or produced before a key rotation are one situation from the caller's point of view - this cursor is not usable, start again - and saying which would tell whoever is probing how far their forgery got.
type WebhookSigner ¶
type WebhookSigner struct{}
WebhookSigner produces the signature line of one delivery.
`t=<unix seconds>,v1=<hex hmac-sha256 of "<t>.<body>">`, which is the shape automation.md §3.1 fixes and which Stripe made the convention a subscriber's library already knows.
Two properties are worth naming because they are what the shape buys. The timestamp is inside the signed string, so a captured delivery cannot be replayed later with its own timestamp rewritten - changing `t` invalidates `v1`. And the version prefix means a second scheme can be added beside `v1` rather than replacing it, so a subscriber that verifies only `v1` keeps working through the change that introduces `v2`.
func NewWebhookSigner ¶
func NewWebhookSigner() WebhookSigner
func (WebhookSigner) SignedAt ¶
func (WebhookSigner) SignedAt(header string) time.Time
SignedAt reads the timestamp out of a header value, so that a receiver can apply the replay window. Zero when the header is not one of ours.
func (WebhookSigner) Verify ¶
Verify reports whether a header value matches a body under one of the secrets given.
Several secrets, because a rotation leaves two valid for its grace, and because this exists for the tests and the end-to-end session rather than for the delivery path - this system signs and does not receive. It is here so that "the signature verifies against the documented formula" is a thing a test can assert with our own code rather than with a second implementation of it, which would only prove the two copies agree.