secrets

package
v0.2.4 Latest Latest
Warning

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

Go to latest
Published: Jun 3, 2026 License: MPL-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package secrets implements the per-tenant secret store.

See internal docs/todo-secret-store.md (design of record) and internal docs/todo-secret-store-implementation.md (implementation arc).

This file holds the cryptographic primitives only. AES-256-GCM envelope encryption: each secret is encrypted with a freshly- generated per-secret DEK; the DEK is wrapped (AES-256-GCM) by a host-local master key. Both layers are AAD-bound to row identity so a stolen blob cannot be moved between tenants, secrets, or versions without GCM verification failing.

The primitives are row-agnostic — callers supply the AAD bytes. This separation lets the Store (PR 2) assemble AAD from row fields without the crypto layer knowing the row schema.

Index

Constants

View Source
const FormatPlaceholder = "{}"

FormatPlaceholder is the substitution token in a `format` template. Exactly one occurrence required; everything else is treated as literal characters. The placeholder is deliberately not printf- style (`%s`) to (a) close the printf-injection surface and (b) make "single substitution point" a structural property rather than a discipline.

Variables

View Source
var (
	// ErrSecretNotFound signals no active row for (tenant, scope, name).
	ErrSecretNotFound = errors.New("secrets: not found")
	// ErrSecretExists signals a duplicate name within (tenant, stack).
	// Admin handlers translate to HTTP 409.
	ErrSecretExists = errors.New("secrets: name already in use for this scope")
	// ErrInvalidName signals a name that violates the shape rule.
	// Admin handlers translate to HTTP 400.
	ErrInvalidName = errors.New("secrets: invalid name (must match [A-Za-z][A-Za-z0-9_]*)")
)

Sentinel errors.

View Source
var ErrInvalidFormat = errors.New("secrets: format template must contain exactly one '{}' placeholder")

ErrInvalidFormat is returned when a format template is missing the placeholder, has more than one occurrence, or is otherwise mal- formed. Admin handlers / op handlers translate to a 400 with code `invalid_format`.

View Source
var ErrMasterKeyMalformed = errors.New("secrets: master key file is malformed")

ErrMasterKeyMalformed is returned when the file exists but its perms or contents are invalid.

View Source
var ErrMasterKeyMissing = errors.New("secrets: master key file does not exist")

ErrMasterKeyMissing is returned when the configured master-key file does not exist. Distinguishable from malformed-file errors so chassis-boot logic can choose to log-and-continue rather than fail loud (the secret store is opt-in; missing key = feature off).

Functions

func Decrypt

func Decrypt(mk MasterKeyProvider, es *EncryptedSecret, outerAAD, innerAAD []byte) ([]byte, error)

Decrypt verifies AAD on both layers and returns plaintext.

AAD mismatch, ciphertext tampering, or wrap-DEK tampering all surface as the same authentication failure — AES-GCM's Open returns a generic error and we wrap it. Callers should not distinguish among these failure modes; they're all "this blob is not what you think it is."

Decrypt fails if es.KeyVersion != mk.Version(). Multi-version MK support (where a decrypt path falls back to an older MK generation during a rewrap window) is Phase 2.

func DistinctNames

func DistinctNames(refs []Ref) []string

DistinctNames returns the unique NAMEs across a list of Refs in stable order. Useful for the processor splice: materialize each distinct NAME once, even if the same name appears in multiple refs (e.g. the same key used in both a header and a body field).

func EnsureRequestCache

func EnsureRequestCache(ctx context.Context) (context.Context, func())

EnsureRequestCache installs a request cache on ctx if one isn't already present, returning the (possibly new) ctx and a cleanup closure that the caller defers.

On the outermost call (no cache yet), this is equivalent to WithRequestCache. On a recursive call (cache already installed), it returns the ctx unchanged and a no-op cleanup. Net effect: the outermost Run owns the cache lifetime; inner Runs reuse the same cache for memoization across stage jumps without prematurely zeroing it.

func HasRefs

func HasRefs(meta string) bool

HasRefs reports whether op.Meta has any `secrets` declaration at all (without parsing it). Cheap pre-check for the processor splice's fast path.

func MintFileMasterKey

func MintFileMasterKey(path string) error

MintFileMasterKey writes 32 fresh random bytes to path with 0600 perms. Refuses to overwrite an existing file (O_EXCL); the caller is responsible for any "do you want to overwrite" UX before removing an existing file.

Used by `txco auth secrets init` (explicit), `txco dev`'s auto-mint (dev workdir), and LoadOrMintFileMasterKey (chassis boot). Same primitive, multiple call sites, one implementation.

func Substitute

func Substitute(format string, cleartext []byte) (string, error)

Substitute applies a literal-with-one-slot template. The template must contain exactly one occurrence of `{}`; cleartext fills that slot. Anything else in the template (including bytes that happen to look like `{` or `}` individually) is treated as a literal.

Examples:

Substitute("Bearer {}", []byte("sk_live_abc")) → "Bearer sk_live_abc"
Substitute("{}", []byte("sk_live_abc"))        → "sk_live_abc"
Substitute("token {} expires", []byte("xyz"))  → "token xyz expires"

Errors:

Substitute("no placeholder",   []byte("v")) → ErrInvalidFormat
Substitute("two {} and {}",    []byte("v")) → ErrInvalidFormat

An empty cleartext is allowed and substitutes as the empty string — the format template's surrounding literal still applies. This is useful for a "rotate to empty then re-issue" workflow, but op handlers should generally reject empty cleartext one layer up.

func ValidateFormat

func ValidateFormat(format string) error

ValidateFormat reports whether a format template is well-formed without actually substituting anything. Useful in two places:

  • Admin handlers validating an inbound `secrets.<path>.format` declaration before persisting the txcl rule.
  • The processor splice doing a fast-fail pre-check before materializing any cleartext (so a typo in format doesn't decrypt and then throw the cleartext on the floor).

func WithBag

func WithBag(ctx context.Context, bag *SecretBag) context.Context

WithBag attaches a SecretBag pointer to ctx. The pointer lets handlers Get materialized cleartext without copying the bag value (which would copy the map header, not the entries — same map reference either way, but pointer is the clearer contract).

func WithRequestCache

func WithRequestCache(ctx context.Context) (context.Context, func())

WithRequestCache returns a context carrying a fresh per-request cache. The returned cleanup zeroes every cached cleartext.

Each call returns a NEW cache: callers should not pass the same cache across requests. Use EnsureRequestCache from the processor splice — that's the right idiom for "install at outermost Run, skip on recursive inner Runs that share the cache".

func Zero

func Zero(b []byte)

Zero overwrites the bytes of b with zeros. Safe to call on nil.

Callers that materialize cleartext should zero their copies when done — particularly after using a cleartext to set an outbound header, sign a request, or write a derived non-secret value back into the envelope. PR 2's SecretBag.Zero() uses this.

Types

type EncryptedSecret

type EncryptedSecret struct {
	Nonce      []byte // 12-byte AES-GCM nonce (outer / secret layer)
	Ciphertext []byte // ciphertext ‖ GCM tag
	WrappedDEK []byte // DEK encrypted with MK (includes GCM tag)
	DEKNonce   []byte // 12-byte AES-GCM nonce (inner / wrap layer)
	KeyVersion int    // master-key generation that wrapped the DEK
}

EncryptedSecret is the on-disk representation of one secret version. PR 2's Store places these fields directly into tenant_secret_versions row columns.

func Encrypt

func Encrypt(mk MasterKeyProvider, plaintext, outerAAD, innerAAD []byte) (*EncryptedSecret, error)

Encrypt seals plaintext with a freshly-generated DEK, then wraps the DEK with mk's key. AAD is bound to BOTH layers.

The caller assembles AAD from row identity (see internal docs/todo-secret-store.md §3 for the recommended layout):

outerAAD = tenant_id ‖ secret_id ‖ version_no ‖ name ‖ key_version
innerAAD = tenant_id ‖ secret_id ‖ version_no ‖ key_version

Either AAD may be nil; nil and empty are treated identically by AES-GCM. The chosen layout is opaque to this package — the invariant is only that Encrypt and Decrypt receive the same AAD for the same EncryptedSecret.

type FileMasterKey

type FileMasterKey struct {
	// contains filtered or unexported fields
}

FileMasterKey is the bundled MasterKeyProvider: a 32-byte master key loaded from a host-local file. The file must have 0600 perms (no group/other bits set) and contain exactly masterKeySize bytes.

The key is read once at construction and held in memory. The file is not re-read during the process lifetime — operators rotate the master key by restarting the chassis with a new file (multi-version online rotation is Phase 2; see internal docs/todo-secret-store.md §3).

func LoadOrMintFileMasterKey

func LoadOrMintFileMasterKey(path string, notifyMint func(path string)) (*FileMasterKey, error)

LoadOrMintFileMasterKey is the chassis-boot-time entry point. Loads the master key from path if the file exists and is valid; auto-mints a fresh key at path if the file is missing.

On first-mint, calls notifyMint(path) so the caller can log the event prominently — auto-creating a key carries a real operator obligation (back this up; losing it makes every stored secret unrecoverable). Pass nil to skip the notification.

Returns any wrapped error other than ErrMasterKeyMissing (malformed file, bad perms, mint failure, etc.). Callers treat non-nil error as "feature off; log it and continue booting".

Mirrors the runtime DB lifecycle: the chassis creates what it needs on first run; operators override the path via config when they want artifacts somewhere else.

func NewFileMasterKey

func NewFileMasterKey(path string) (*FileMasterKey, error)

NewFileMasterKey reads the master key from path.

Errors:

  • wraps ErrMasterKeyMissing if the file does not exist
  • wraps ErrMasterKeyMalformed if perms are not 0600 or size != 32
  • wraps the underlying os.Stat / read error for any other failure

On success, the file's bytes are copied into the returned FileMasterKey and the original buffer is zeroed.

func (*FileMasterKey) Key

func (m *FileMasterKey) Key() []byte

Key returns the master key. The returned slice aliases internal storage; callers must not mutate. (Per MasterKeyProvider contract.)

func (*FileMasterKey) Version

func (m *FileMasterKey) Version() int

Version returns the master-key generation. Always 1 for FileMasterKey in v1; multi-version is Phase 2.

type MasterKeyProvider

type MasterKeyProvider interface {
	// Key returns the 32-byte master key. Callers must not mutate
	// or retain the returned slice beyond the immediate call.
	Key() []byte
	// Version returns the master-key generation. v1 is always 1;
	// multi-version overlap (online MK rotation) is Phase 2.
	Version() int
}

MasterKeyProvider yields the host-local master key.

This interface is the explicit overlay seam. The chassis ships FileMasterKey only; a downstream overlay (or any other deployment that needs KMS/HSM-backed keys) implements MasterKeyProvider in a separate package and registers it without the chassis caring. No KMS-vendor code lives in the chassis tree by design — see internal docs/todo-secret-store.md §1.6.

type Ref

type Ref struct {
	// Path is the dotted path on the outbound request where the
	// (formatted) cleartext should be applied. Relative to the op's
	// outbound message — e.g. `headers.authorization` for an HTTP
	// header, `body.api_key` for a JSON body field.
	Path string
	// Secret is the NAME of the secret to look up in op.Secrets
	// (which is populated by the processor splice from
	// op.Meta.secrets's leaf NAMEs).
	Secret string
	// Format is the optional `{}`-template (see format.go); empty
	// string means "raw substitution — the cleartext is the value".
	Format string
}

Ref is one parsed entry from `op.Meta.secrets`. Each Ref binds an outbound path (where the cleartext belongs) to the NAME of the secret to materialize and an optional format template.

Example: a txcl rule with

WITH secrets.headers.authorization.secret = "STRIPE_API_KEY",
     secrets.headers.authorization.format = "Bearer {}"

produces a single Ref:

Ref{Path: "headers.authorization", Secret: "STRIPE_API_KEY", Format: "Bearer {}"}

func ParseRefs

func ParseRefs(meta string) ([]Ref, error)

ParseRefs walks the `secrets` subtree of op.Meta (a JSON-string produced by the WITH-clause decoration pipeline) and returns one Ref per leaf path. Leaves are objects of the shape

{ "secret": "NAME", "format": "..{}.." }   // .format optional

The walker descends through arbitrary nesting, treating any node that itself has a string `secret` field as a leaf (so `secrets.body.nested.api_key.{secret,format}` works the same as `secrets.headers.x.{secret,format}`).

Returns (nil, nil) for an op with no `secrets` declaration — callers must NOT treat that as an error; it just means "no secrets to materialize for this op".

Returns an error if any leaf is malformed: missing `secret`, wrong type, or a `format` template that fails ValidateFormat.

type Resolver

type Resolver struct {
	// contains filtered or unexported fields
}

Resolver is the thin layer between PR 3's processor splice and the Store. It exists to add per-request memoization on top of the Store's per-call materialization: a NAME used by multiple ops in one request decrypts ONCE, not N times.

Resolver is constructed by chassis/app/app.go at boot when a master key is configured (PR 2 wiring). It's held on the processor.Unit for the request path to consult. If no master key is configured, the chassis holds a nil Resolver and the PR 3 splice fails loud with `secret_store_unavailable` whenever an op declares secrets.

SlugToID, when non-nil, lets callers materialize by tenant slug (the runtime identity pinned on request context) instead of by tenant_id (the immutable storage key in the tenant_secrets table). app.go wires this to tenants.Store.LookupBySlug at boot. If nil, MaterializeForOpSlug returns an error.

func NewResolver

func NewResolver(store *Store, slugToID func(ctx context.Context, slug string) (string, error)) *Resolver

NewResolver returns a Resolver over the given Store. The Store must have a non-nil MasterKeyProvider — pass nil only if you want every MaterializeForOp call to fail (e.g. in tests that exercise the "feature off" path).

slugToID is optional; pass nil if the caller will only use MaterializeForOp (tenant_id form) and never MaterializeForOpSlug. The processor splice (PR 3) requires it; admin handlers (PR 4) will too once they're wired.

func (*Resolver) MaterializeForOp

func (r *Resolver) MaterializeForOp(ctx context.Context,
	tenantID, stack, name string,
) ([]byte, *SecretMetadata, error)

MaterializeForOp decrypts (tenant, stack, name) → cleartext. Honors the stack-scoped → tenant-wide fallback in design §2.

If a per-request cache is installed on ctx via WithRequestCache, the result is memoized under a (tenantID, stack-or-fallback, name) key so subsequent calls in the same request decrypt zero times. The cache is INTENTIONALLY NOT thread-safe-shared across requests — it's request-scoped only — and Get/Set are guarded by a per- cache mutex so concurrent ops within one request stay safe.

**Slice ownership**: each call returns a slice that the caller owns and may zero independently. The cache holds its own private copy, so a caller's `bag.Zero()` on its returned slice does not corrupt the cache's entry — subsequent ops in the same request asking for the same name still get correct cleartext. The cache's cleanup wipes the cache's copies once at request end.

Without a cache on ctx, the call goes straight to the Store.

Caller is responsible for zeroing the returned slice when done (SecretBag.Zero does this automatically for the PR 3 wiring).

func (*Resolver) MaterializeForOpSlug

func (r *Resolver) MaterializeForOpSlug(ctx context.Context,
	tenantSlug, stack, name string,
) ([]byte, *SecretMetadata, error)

MaterializeForOpSlug is the slug-keyed variant of MaterializeForOp. Resolves the slug to a tenant_id via the constructor-supplied SlugToID callback, then defers to MaterializeForOp. Used by the processor splice, which has the slug pinned on context but not the tenant_id.

The slug→id resolution is NOT cached separately — it's a covered point read against the in-memory dbcache mirror; the per-request cache deduplicates the much-more-expensive Materialize call.

func (*Resolver) Store

func (r *Resolver) Store() *Store

Store exposes the Resolver's underlying Store for admin CRUD. The Resolver is the read-side façade (with caching); the Store is the full backend (create / rotate / revoke / etc.). PR 4's admin endpoints construct nothing of their own; they just call methods on this Store.

type SecretBag

type SecretBag struct {
	// contains filtered or unexported fields
}

SecretBag is the per-request, in-process-only container for materialized secret cleartext. PR 2 of the per-tenant secret store (internal docs/todo-secret-store.md §4.1).

The bag is **structurally non-serializable**: every standard encoder (json, text, gob) panics if it ever reaches the bag's MarshalXxx method. Combined with the `json:"-"` tag on the `Operation.Secrets` field, this gives a defense-in-depth invariant: cleartext cannot reach any envelope, trace, log, mock, or continuation by construction. No taint flag to forget, no redaction discipline to maintain — a violation is a panic, not a silent leak.

Bag values are cheap to copy: the underlying map is shared by reference, so an Operation.Copy() produces a new Operation that sees the same materialized secrets. This is intentional — a copied op is a new execution instance within the same request scope, and must see the same in-process cleartext to do its work.

The zero value is usable: Get returns (nil, false); Set lazy- allocates; Names returns nil; Zero is a no-op.

func BagFromContext

func BagFromContext(ctx context.Context) *SecretBag

BagFromContext returns the SecretBag pointer attached to ctx by WithBag, or nil if none was attached. A nil return means the caller is in a context that didn't materialize secrets (e.g. the no-refs fast path), and the handler should treat any secret lookup as not-available.

func (SecretBag) Get

func (b SecretBag) Get(name string) (cleartext []byte, ok bool)

Get returns the cleartext for name and a presence flag. The returned slice aliases bag-owned storage; callers must not mutate. After the bag's Zero() runs, the bytes become zero — handlers that need to retain the cleartext beyond the bag's lifetime must copy.

func (SecretBag) GobEncode

func (b SecretBag) GobEncode() ([]byte, error)

GobEncode panics for the same reason. Defends against encoding/gob (used by some caching layers and persistence shims) reaching the bag.

func (SecretBag) Len

func (b SecretBag) Len() int

Len returns the number of materialized secrets in the bag. Useful for tests and for assertions in the processor splice.

func (SecretBag) MarshalJSON

func (b SecretBag) MarshalJSON() ([]byte, error)

MarshalJSON panics. The `json:"-"` tag on Operation.Secrets keeps well-behaved encoders away from the bag; this panic is the defense-in-depth guarantee for any code path that tries to marshal a bag directly (logger formatters, error wrappers, debug dumpers). Failing loud is the point: a silent leak would be worse.

func (SecretBag) MarshalText

func (b SecretBag) MarshalText() ([]byte, error)

MarshalText panics for the same reason as MarshalJSON. Some formatters (e.g. zap's encoder, fmt's %v on structs implementing encoding.TextMarshaler) reach for MarshalText before MarshalJSON.

func (SecretBag) Names

func (b SecretBag) Names() []string

Names returns the set of secret names currently in the bag, sorted for deterministic iteration. Useful for audit logging which NAMEs an op materialized (NEVER use this to log values).

func (*SecretBag) Set

func (b *SecretBag) Set(name string, cleartext []byte)

Set stores cleartext under name. Lazy-allocates the underlying map on first call. Callers should not retain the slice after Set; the bag owns it (and will zero it via Zero()).

func (*SecretBag) Zero

func (b *SecretBag) Zero()

Zero overwrites every held cleartext with zero bytes and clears the map. Safe to call on a zero-value bag and safe to call twice.

The processor (PR 3) calls Zero via `defer` on every exit path from Run() — success, error, or panic — so cleartext lives only for the duration of one op's execution. Handlers should not call Zero themselves; the processor frame owns wipe.

type SecretMetadata

type SecretMetadata struct {
	SecretID      string
	TenantID      string
	Stack         *string // nil = tenant-wide
	Name          string
	Description   string
	CreatedAt     time.Time
	CreatedBy     string
	LastRotatedAt *time.Time
	RevokedAt     *time.Time // typically nil for rows the Store returns
	KeyVersion    int
	VersionNo     int // active version number (latest non-revoked)
}

SecretMetadata is the metadata view of a tenant_secrets row joined with its active tenant_secret_versions row. NEVER carries cleartext — callers materialize via the resolver path.

type Store

type Store struct {
	DB *sql.DB
	MK MasterKeyProvider
	// contains filtered or unexported fields
}

Store is the thin façade over tenant_secrets + tenant_secret_versions. Plain *sql.DB (no dialect seam) matches chassis/tenants/store.go: runtime tables stay SQLite-only per-machine. If/when the runtime store moves to Postgres for HA, this and tenants.Store adopt the dialect together (so the schema-level decision is uniform).

func NewStore

func NewStore(db *sql.DB, mk MasterKeyProvider) *Store

NewStore builds a Store against the given runtime *sql.DB and MasterKeyProvider. The MK is required — a nil MK means "feature off"; callers wiring boot logic (PR 2 step in chassis/app/app.go) should construct the Store only when SecretMasterKeyPath is set.

func (*Store) CreateSecret

func (s *Store) CreateSecret(ctx context.Context,
	tenantID string, stack *string, name, description, createdBy string,
	value []byte,
) (*SecretMetadata, error)

CreateSecret stores a new value supplied by the caller (operator). stack=nil → tenant-wide; stack=non-nil → scoped to that stack. Returns ErrSecretExists if (tenant_id, stack, name) is already active (the COALESCE-bound unique index catches NULL-stack dupes too — see db/schema/sqlite/runtime/0008_tenant_secrets.sql).

func (*Store) GenerateSecret

func (s *Store) GenerateSecret(ctx context.Context,
	tenantID string, stack *string, name, description, createdBy string,
	byteLen int,
) (cleartext []byte, meta *SecretMetadata, err error)

GenerateSecret mints byteLen random bytes (raw — caller may choose to base64/hex-render for the operator on the way out) and stores them. Returns cleartext exactly once for the caller to surface; caller is responsible for zeroing.

func (*Store) ListSecrets

func (s *Store) ListSecrets(ctx context.Context, tenantID string) ([]*SecretMetadata, error)

ListSecrets returns metadata for all active secrets in a tenant, both tenant-wide and stack-scoped. Ordered by name then stack (NULL stack first via COALESCE).

func (*Store) LookupSecretMetadata

func (s *Store) LookupSecretMetadata(ctx context.Context,
	tenantID string, stack *string, name string,
) (*SecretMetadata, error)

LookupSecretMetadata returns metadata for one secret in an exact scope. stack=nil → look up the tenant-wide row; stack=non-nil → look up exactly that stack-scoped row. No fallback (that's MaterializeSecretForOp's job). Returns ErrSecretNotFound if absent.

func (*Store) MaterializeSecretForOp

func (s *Store) MaterializeSecretForOp(ctx context.Context,
	tenantID, stack, name string,
) ([]byte, *SecretMetadata, error)

MaterializeSecretForOp is the runtime resolution path. Performs stack-scoped → tenant-wide fallback per internal docs/todo-secret-store.md §2:

  1. Try (tenant_id, stack, name) — stack-scoped wins if present.
  2. Fall back to (tenant_id, stack IS NULL, name) — tenant-wide.
  3. Otherwise ErrSecretNotFound.

Returns cleartext and metadata of the row that won. Caller is responsible for zeroing the cleartext (SecretBag.Zero does this automatically once PR 3 wires the request-scoped bag).

Pass stack="" for "I have no current stack; only look at tenant-wide" (admin paths, internal hooks).

func (*Store) RevealSecretValue

func (s *Store) RevealSecretValue(ctx context.Context,
	tenantID string, stack *string, name string,
) ([]byte, *SecretMetadata, error)

RevealSecretValue is the break-glass Go hook for high-trust paths. Not wired to any v1 HTTP endpoint or CLI (per design §5). Exact- scope match; no fallback. Caller is responsible for zeroing.

func (*Store) RevokeSecret

func (s *Store) RevokeSecret(ctx context.Context,
	tenantID string, stack *string, name string,
) error

RevokeSecret soft-deletes by setting revoked_at on the parent row. Version rows are preserved for audit history. Once revoked, the COALESCE-bound unique index frees the (tenant_id, stack, name) slot for re-creation.

func (*Store) RotateSecret

func (s *Store) RotateSecret(ctx context.Context,
	tenantID string, stack *string, name string, newValue []byte,
) (*SecretMetadata, error)

RotateSecret writes a new version row under the same secret_id using an operator-supplied value. Old versions are kept for audit. Updates last_rotated_at and key_version on the parent row.

func (*Store) RotateSecretGenerated

func (s *Store) RotateSecretGenerated(ctx context.Context,
	tenantID string, stack *string, name string, byteLen int,
) ([]byte, *SecretMetadata, error)

RotateSecretGenerated is RotateSecret with chassis-minted bytes. Returns cleartext exactly once.

func (*Store) UpdateSecretDescription

func (s *Store) UpdateSecretDescription(ctx context.Context,
	tenantID string, stack *string, name, newDescription string,
) (*SecretMetadata, error)

UpdateSecretDescription mutates only the description field. Name is immutable (design §1.7); this is the only "update" path.

Jump to

Keyboard shortcuts

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