security

package
v0.9.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

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

View Source
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.

View Source
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.

View Source
const SignatureHeader = "X-Hubtask-Signature"

SignatureHeader is the header a subscriber verifies (automation.md §3.1).

View Source
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

View Source
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.

func (CursorCodec) EncodeID

func (c CursorCodec) EncodeID(id shared.ID) string

EncodeID is the cursor of a walk keyed on an identifier alone.

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) Issue

func (i McpSessionIssuer) Issue(tenantID, accountID shared.ID, now time.Time) string

Issue mints the identifier a handshake answers with.

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 (i MediaTokenIssuer) ValidateDownload(
	token string, mediaID shared.ID, now time.Time,
) (shared.ID, error)

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

Decode reads a position back; every failure is the same answer, as the other codecs give.

func (PlatformEventCursorCodec) Encode

Encode returns the cursor for a position.

type Position

type Position struct {
	Keys []string
	ID   shared.ID
}

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.

func At

func At(sortKey string, id shared.ID) Position

At is the boundary of a walk with a single sort key - every list in the product but the query language's.

func (Position) SortKey

func (p Position) SortKey() string

SortKey is the single key of a one-key boundary, and the empty string for a boundary that has none. A walk that sorts by several fields reads Keys instead.

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.

func (TriggerCursorCodec) Encode

func (c TriggerCursorCodec) Encode(position outbox.Position) string

Encode returns the cursor for a position.

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) Sign

func (WebhookSigner) Sign(signing secret.Secret, at time.Time, body []byte) string

Sign renders the header value for one body at one moment.

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

func (WebhookSigner) Verify(header string, body []byte, secrets ...secret.Secret) bool

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL