certspotter

package module
v0.0.0-...-b051332 Latest Latest
Warning

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

Go to latest
Published: Feb 20, 2017 License: MPL-2.0 Imports: 28 Imported by: 0

README

Cert Spotter is a Certificate Transparency log monitor from SSLMate that
alerts you when a SSL/TLS certificate is issued for one of your domains.
Cert Spotter is easier than other open source CT monitors, since it does
not require a database.  It's also more robust, since it uses a special
certificate parser that ensures it won't miss certificates.

Cert Spotter is also available as a hosted service by SSLMate that
requires zero setup and provides an easy web dashboard to centrally
manage your certificates.  Visit <https://sslmate.com/certspotter>
to sign up.

You can use Cert Spotter to detect:

* Certificates issued to attackers who have compromised a certificate
  authority and want to impersonate your site.

* Certificates issued to attackers who are using your infrastructure
  to serve malware.

* Certificates issued in violation of your corporate policy
  or outside of your centralized certificate procurement process.

* Certificates issued to your infrastructure providers without your
  consent.


USING CERT SPOTTER

The easiest way to use Cert Spotter is to sign up for an account at
<https://sslmate.com/certspotter>.  If you want to run Cert Spotter on
your own server, follow these instructions.

Cert Spotter requires Go version 1.5 or higher.

1. Install Cert Spotter using go get:

	go get software.sslmate.com/src/certspotter/cmd/certspotter

2. Create a file called ~/.certspotter/watchlist listing the DNS names
   you want to monitor, one per line.  To monitor an entire domain tree
   (including the domain itself and all sub-domains) prefix the domain
   name with a dot (e.g. ".example.com").  To monitor a single DNS name
   only, do not prefix the name with a dot.

3. Create a cron job to periodically run:

	certspotter

   When Cert Spotter detects a certificate for a name on your watchlist,
   it writes a report to standard out, which the Cron daemon emails
   to you.  Make sure you are able to receive emails sent by Cron.

   Cert Spotter also saves a copy of matching certificates in
   ~/.certspotter/certs.

You can add and remove domains on your watchlist at any time.  However,
the certspotter command only notifies you of certificates that were
logged since adding a domain to the watchlist, unless you specify the
-all_time option, which requires scanning the entirety of every log
and takes several hours to complete with a fast Internet connection.
To examine preexisting certificates, it's better to use the Cert
Spotter service <https://sslmate.com/certspotter>, the Cert Spotter
API <https://sslmate.com/certspotter/api>, or a CT search engine such
as <https://crt.sh>.


COMMAND LINE FLAGS

  -watchlist FILENAME
	File containing identifiers to watch, one per line, as described
	above (use - to read from stdin).  Default: ~/.certspotter/watchlist
  -no_save
	Do not save a copy of matching certificates.
  -all_time
	Scan for certificates from all time, not just those added since
	the last run of Cert Spotter.  Unless this option is specified,
	no certificates are scanned the first time Cert Spotter is run.
  -logs FILENAME
	JSON file containing logs to scan, in the format documented at
	<https://www.certificate-transparency.org/known-logs>.
	Default: use the logs trusted by Chromium.
  -state_dir PATH
	Directory for storing state. Default: ~/.certspotter
  -verbose
	Be verbose.


WHAT CERTIFICATES ARE DETECTED BY CERT SPOTTER?

Any certificate that is logged to a Certificate Transparency log trusted
by Chromium will be detected by Cert Spotter.  Currently, the following
certificates are logged:

* EV certificates

* All certificates issued by the following CAs:

	* Let's Encrypt <https://letsencrypt.org/certificates/#certificate-transparency>
	* StartCom <https://www.startssl.com/NewsDetails?date=20160323>
	* Symantec <https://security.googleblog.com/2015/10/sustaining-digital-certificate-security.html>
	* WoSign <https://www.wosign.com/english/News/2016_wosign_CT.htm>

* All DV certificates issued by GlobalSign <https://www.globalsign.com/en/blog/google-updates-certificate-transparency-policy/>.

* Certificates that are detected when crawling web pages and doing
  Internet-wide scans.

Starting from October 2017, all new certificates must be logged (and
therefore detectable by Cert Spotter) to be trusted by Google Chrome.


SECURITY

Cert Spotter assumes an adversarial model in which an attacker produces
a certificate that is accepted by at least some clients but goes
undetected because of an encoding error that prevents CT monitors from
understanding it.  To defend against this attack, Cert Spotter uses a
special certificate parser that keeps the certificate unparsed except
for the identifiers.  If one of the identifiers matches a domain on your
watchlist, you will be notified, even if other parts of the certificate
are unparsable.

Cert Spotter takes special precautions to ensure identifiers are parsed
correctly, and implements defenses against identifier-based attacks.
For instance, if a DNS identifier contains a null byte, Cert Spotter
interprets it as two identifiers: the complete identifier, and the
identifier formed by truncating at the first null byte.  For example, a
certificate for example.org\0.example.com will alert the owners of both
example.org and example.com.  This defends against null prefix attacks
<http://www.thoughtcrime.org/papers/null-prefix-attacks.pdf>.

SSLMate continuously monitors CT logs to make sure every certificate's
identifiers can be successfully parsed, and will release updates to
Cert Spotter as necessary to fix parsing failures.

Cert Spotter understands wildcard and redacted DNS names, and will alert
you if a wildcard or redacted certificate might match an identifier on
your watchlist.  For example, a watchlist entry for sub.example.com would
match certificates for *.example.com or ?.example.com.

Cert Spotter is not just a log monitor, but also a log auditor which
checks that the log is obeying its append-only property.  A future
release of Cert Spotter will support gossiping with other log monitors
to ensure the log is presenting a single view.

Documentation

Index

Constants

View Source
const (
	FETCH_RETRIES    = 10
	FETCH_RETRY_WAIT = 1
)
View Source
const UnparsableDNSLabelPlaceholder = "<unparsable>"

Variables

View Source
var DefaultLogs = []LogInfo{
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfahLEimAoz2t01p3uMziiLOl/fHTDM0YDOhBRuiBARsV4UvxG2LdNgoIGLrtCzWE0J5APC2em4JlvR8EEEFMoA=="),
		Url: "ct.googleapis.com/pilot",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1/TMabLkDpCjiupacAlP7xNi0I1JYP8bQFAHDG1xhtolSY1l4QgNRzRrvSe8liE+NPWHdjGxfx3JhTsN9x8/6Q=="),
		Url: "ct.googleapis.com/aviator",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAkbFvhu7gkAW6MHSrBlpE1n4+HCFRkC5OLAjgqhkTH+/uzSfSl8ois8ZxAD2NgaTZe1M9akhYlrYkes4JECs6A=="),
		Url: "ct1.digicert-ct.com/log",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIFsYyDzBi7MxCAC/oJBXK7dHjG+1aLCOkHjpoHPqTyghLpzA9BYbqvnV16mAw04vUjyYASVGJCUoI3ctBcJAeg=="),
		Url: "ct.googleapis.com/rocketeer",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEluqsHEYMG1XcDfy1lCdGV0JwOmkY4r87xNuroPS2bMBTP01CEDPwWJePa75y9CrsHEKqAy8afig1dpkIPSEUhg=="),
		Url: "ct.ws.symantec.com",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolpIHxdSlTXLo1s6H1OCdpSj/4DyHDc8wLG9wVmLqy1lk9fz4ATVmm+/1iN2Nk8jmctUKK2MFUtlWXZBSpym97M7frGlSaQXUWyA3CqQUEuIJOmlEjKTBEiQAvpfDjCHjlV2Be4qTM6jamkJbiWtgnYPhJL6ONaGTiSPm7Byy57iaz/hbckldSOIoRhYBiMzeNoA0DiRZ9KmfSeXZ1rB8y8X5urSW+iBzf2SaOfzBvDpcoTuAaWx2DPazoOl28fP1hZ+kHUYvxbcMjttjauCFx+JII0dmuZNIwjfeG/GBb9frpSX219k1O4Wi6OEbHEr8at/XQ0y7gTikOxBn/s5wQIDAQAB"),
		Url: "ctlog.api.venafi.com",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6pWeAv/u8TNtS4e8zf0ZF2L/lNPQWQc/Ai0ckP7IRzA78d0NuBEMXR2G3avTK0Zm+25ltzv9WWis36b4ztIYTQ=="),
		Url: "vega.ws.symantec.com",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7UIYZopMgTTJWPp2IXhhuAf1l6a9zM7gBvntj5fLaFm9pVKhKYhVnno94XuXeN8EsDgiSIJIj66FpUGvai5samyetZhLocRuXhAiXXbDNyQ4KR51tVebtEq2zT0mT9liTtGwiksFQccyUsaVPhsHq9gJ2IKZdWauVA2Fm5x9h8B9xKn/L/2IaMpkIYtd967TNTP/dLPgixN1PLCLaypvurDGSVDsuWabA3FHKWL9z8wr7kBkbdpEhLlg2H+NAC+9nGKx+tQkuhZ/hWR65aX+CNUPy2OB9/u2rNPyDydb988LENXoUcMkQT0dU3aiYGkFAY0uZjD2vH97TM20xYtNQIDAQAB"),
		Url: "ctserver.cnnic.cn",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAETtK8v7MICve56qTHHDhhBOuV4IlUaESxZryCfk9QbG9co/CqPvTsgPDbCpp6oFtyAHwlDhnvr7JijXRD9Cb2FA=="),
		Url: "ct.googleapis.com/icarus",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEmyGDvYXsRJsNyXSrYc9DjHsIa2xzb4UR7ZxVoV6mrc9iZB7xjI6+NrOiwH+P/xxkRmOFG6Jel20q37hTh58rA=="),
		Url: "ct.googleapis.com/skydiver",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESPNZ8/YFGNPbsu1Gfs/IEbVXsajWTOaft0oaFIZDqUiwy1o/PErK38SCFFWa+PeOQFXc9NKv6nV0+05/YIYuUQ=="),
		Url: "ct.startssl.com",
		MMD: 86400,
	},
	{

		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzBGIey1my66PTTBmJxklIpMhRrQvAdPG+SvVyLpzmwai8IoCnNBrRhgwhbrpJIsO0VtwKAx+8TpFf1rzgkJgMQ=="),
		Url: "ctlog.wosign.com",
		MMD: 86400,
	},
}
View Source
var OpenLogs = []LogInfo{
	{
		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfahLEimAoz2t01p3uMziiLOl/fHTDM0YDOhBRuiBARsV4UvxG2LdNgoIGLrtCzWE0J5APC2em4JlvR8EEEFMoA=="),
		Url: "ct.googleapis.com/pilot",
		MMD: 86400,
	},
	{
		Key: mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIFsYyDzBi7MxCAC/oJBXK7dHjG+1aLCOkHjpoHPqTyghLpzA9BYbqvnV16mAw04vUjyYASVGJCUoI3ctBcJAeg=="),
		Url: "ct.googleapis.com/rocketeer",
		MMD: 86400,
	},
}

Logs which accept submissions from anyone

View Source
var UnderwaterLogs = []LogInfo{
	{
		Description: "Google 'Submariner' log",
		Key:         mustDecodeBase64("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEOfifIGLUV1Voou9JLfA5LZreRLSUMOCeeic8q3Dw0fpRkGMWV0Gtq20fgHQweQJeLVmEByQj9p81uIW4QkWkTw=="),
		Url:         "ct.googleapis.com/submariner",
		MMD:         86400,
	},
}

Logs which monitor certs from distrusted roots

Functions

func GetFullChain

func GetFullChain(entry *ct.LogEntry) [][]byte

func IsPrecert

func IsPrecert(entry *ct.LogEntry) bool

func MatchesWildcard

func MatchesWildcard(dnsName string, pattern string) bool

func ReadSTHFile

func ReadSTHFile(path string) (*ct.SignedTreeHead, error)

func ValidatePrecert

func ValidatePrecert(precertBytes []byte, tbsBytes []byte) error

func VerifyConsistencyProof

func VerifyConsistencyProof(proof ct.ConsistencyProof, first *ct.SignedTreeHead, second *ct.SignedTreeHead) bool

func WriteProofFile

func WriteProofFile(path string, proof ct.ConsistencyProof) error

func WriteSTHFile

func WriteSTHFile(path string, sth *ct.SignedTreeHead) error

Types

type AttributeTypeAndValue

type AttributeTypeAndValue struct {
	Type  asn1.ObjectIdentifier
	Value asn1.RawValue
}

type CertInfo

type CertInfo struct {
	TBS *TBSCertificate

	Subject                RDNSequence
	SubjectParseError      error
	Issuer                 RDNSequence
	IssuerParseError       error
	SANs                   []SubjectAltName
	SANsParseError         error
	SerialNumber           *big.Int
	SerialNumberParseError error
	Validity               *CertValidity
	ValidityParseError     error
	IsCA                   *bool
	IsCAParseError         error
}

func MakeCertInfoFromLogEntry

func MakeCertInfoFromLogEntry(entry *ct.LogEntry) (*CertInfo, error)

func MakeCertInfoFromRawCert

func MakeCertInfoFromRawCert(certBytes []byte) (*CertInfo, error)

func MakeCertInfoFromRawTBS

func MakeCertInfoFromRawTBS(tbsBytes []byte) (*CertInfo, error)

func MakeCertInfoFromTBS

func MakeCertInfoFromTBS(tbs *TBSCertificate) *CertInfo

func (*CertInfo) Environ

func (info *CertInfo) Environ() []string

func (*CertInfo) NotAfter

func (info *CertInfo) NotAfter() *time.Time

func (*CertInfo) NotBefore

func (info *CertInfo) NotBefore() *time.Time

func (*CertInfo) ParseIdentifiers

func (cert *CertInfo) ParseIdentifiers() (*Identifiers, error)

func (*CertInfo) PubkeyHash

func (info *CertInfo) PubkeyHash() string

func (*CertInfo) PubkeyHashBytes

func (info *CertInfo) PubkeyHashBytes() []byte

type CertValidity

type CertValidity struct {
	NotBefore time.Time
	NotAfter  time.Time
}

type Certificate

type Certificate struct {
	Raw asn1.RawContent

	TBSCertificate     asn1.RawValue
	SignatureAlgorithm asn1.RawValue
	SignatureValue     asn1.RawValue
}

func ParseCertificate

func ParseCertificate(certBytes []byte) (*Certificate, error)

func (*Certificate) GetRawTBSCertificate

func (cert *Certificate) GetRawTBSCertificate() []byte

func (*Certificate) ParseTBSCertificate

func (cert *Certificate) ParseTBSCertificate() (*TBSCertificate, error)

type CollapsedMerkleTree

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

func CloneCollapsedMerkleTree

func CloneCollapsedMerkleTree(source *CollapsedMerkleTree) *CollapsedMerkleTree

func EmptyCollapsedMerkleTree

func EmptyCollapsedMerkleTree() *CollapsedMerkleTree

func NewCollapsedMerkleTree

func NewCollapsedMerkleTree(nodes []ct.MerkleTreeNode, size uint64) (*CollapsedMerkleTree, error)

func (*CollapsedMerkleTree) Add

func (tree *CollapsedMerkleTree) Add(hash ct.MerkleTreeNode)

func (*CollapsedMerkleTree) CalculateRoot

func (tree *CollapsedMerkleTree) CalculateRoot() ct.MerkleTreeNode

func (*CollapsedMerkleTree) GetSize

func (tree *CollapsedMerkleTree) GetSize() uint64

func (*CollapsedMerkleTree) MarshalJSON

func (tree *CollapsedMerkleTree) MarshalJSON() ([]byte, error)

func (*CollapsedMerkleTree) UnmarshalJSON

func (tree *CollapsedMerkleTree) UnmarshalJSON(b []byte) error

type EntryInfo

type EntryInfo struct {
	LogUri                string
	Entry                 *ct.LogEntry
	IsPrecert             bool
	FullChain             [][]byte // first entry is logged X509 cert or pre-cert
	CertInfo              *CertInfo
	ParseError            error // set iff CertInfo is nil
	Identifiers           *Identifiers
	IdentifiersParseError error
	Filename              string
}

func (*EntryInfo) Environ

func (info *EntryInfo) Environ() []string

func (*EntryInfo) Fingerprint

func (info *EntryInfo) Fingerprint() string

func (*EntryInfo) FingerprintBytes

func (info *EntryInfo) FingerprintBytes() []byte

func (*EntryInfo) HasParseErrors

func (info *EntryInfo) HasParseErrors() bool

func (*EntryInfo) InvokeHookScript

func (info *EntryInfo) InvokeHookScript(command string) error

func (*EntryInfo) Write

func (info *EntryInfo) Write(out io.Writer)

type Extension

type Extension struct {
	Id       asn1.ObjectIdentifier
	Critical bool `asn1:"optional"`
	Value    []byte
}

type Identifiers

type Identifiers struct {
	DNSNames []string // stored as ASCII, with IDNs in Punycode
	IPAddrs  []net.IP
}

func NewIdentifiers

func NewIdentifiers() *Identifiers

func (*Identifiers) AddCN

func (ids *Identifiers) AddCN(value string)

func (*Identifiers) AddDnsSAN

func (ids *Identifiers) AddDnsSAN(value []byte)

func (*Identifiers) AddIPAddress

func (ids *Identifiers) AddIPAddress(value net.IP)

type LogInfo

type LogInfo struct {
	Description string `json:"description"`
	Key         []byte `json:"key"`
	Url         string `json:"url"`
	MMD         int    `json:"maximum_merge_delay"`
}

func (*LogInfo) FullURI

func (info *LogInfo) FullURI() string

func (*LogInfo) ID

func (info *LogInfo) ID() []byte

func (*LogInfo) ParsedPublicKey

func (info *LogInfo) ParsedPublicKey() (crypto.PublicKey, error)

type LogInfoFile

type LogInfoFile struct {
	Logs []LogInfo `json:"logs"`
}

type ProcessCallback

type ProcessCallback func(*Scanner, *ct.LogEntry)

type RDNSequence

type RDNSequence []RelativeDistinguishedNameSET

func ParseRDNSequence

func ParseRDNSequence(rdnsBytes []byte) (RDNSequence, error)

func (RDNSequence) ParseCNs

func (rdns RDNSequence) ParseCNs() ([]string, error)

func (RDNSequence) String

func (rdns RDNSequence) String() string

type RelativeDistinguishedNameSET

type RelativeDistinguishedNameSET []AttributeTypeAndValue

type Scanner

type Scanner struct {
	// Base URI of CT log
	LogUri string

	LogId []byte
	// contains filtered or unexported fields
}

Scanner is a tool to scan all the entries in a CT Log.

func NewScanner

func NewScanner(logUri string, logId []byte, publicKey crypto.PublicKey, opts *ScannerOptions) *Scanner

Creates a new Scanner instance using |client| to talk to the log, and taking configuration options from |opts|.

func (*Scanner) CheckConsistency

func (s *Scanner) CheckConsistency(first *ct.SignedTreeHead, second *ct.SignedTreeHead) (bool, error)

func (*Scanner) GetSTH

func (s *Scanner) GetSTH() (*ct.SignedTreeHead, error)

func (Scanner) Log

func (s Scanner) Log(msg string)

func (*Scanner) MakeCollapsedMerkleTree

func (s *Scanner) MakeCollapsedMerkleTree(sth *ct.SignedTreeHead) (*CollapsedMerkleTree, error)

func (*Scanner) Scan

func (s *Scanner) Scan(startIndex int64, endIndex int64, processCert ProcessCallback, tree *CollapsedMerkleTree) error

func (Scanner) Warn

func (s Scanner) Warn(msg string)

type ScannerOptions

type ScannerOptions struct {
	// Number of entries to request in one batch from the Log
	BatchSize int

	// Number of concurrent proecssors to run
	NumWorkers int

	// Don't print any status messages to stdout
	Quiet bool
}

ScannerOptions holds configuration options for the Scanner

func DefaultScannerOptions

func DefaultScannerOptions() *ScannerOptions

Creates a new ScannerOptions struct with sensible defaults

type SubjectAltName

type SubjectAltName struct {
	Type  int
	Value []byte
}

func (SubjectAltName) String

func (san SubjectAltName) String() string

type TBSCertificate

type TBSCertificate struct {
	Raw asn1.RawContent

	Version            int `asn1:"optional,explicit,default:1,tag:0"`
	SerialNumber       asn1.RawValue
	SignatureAlgorithm asn1.RawValue
	Issuer             asn1.RawValue
	Validity           asn1.RawValue
	Subject            asn1.RawValue
	PublicKey          asn1.RawValue
	UniqueId           asn1.BitString `asn1:"optional,tag:1"`
	SubjectUniqueId    asn1.BitString `asn1:"optional,tag:2"`
	Extensions         []Extension    `asn1:"optional,explicit,tag:3"`
}

func ParseTBSCertificate

func ParseTBSCertificate(tbsBytes []byte) (*TBSCertificate, error)

func ReconstructPrecertTBS

func ReconstructPrecertTBS(tbs *TBSCertificate) (*TBSCertificate, error)

func (*TBSCertificate) GetExtension

func (tbs *TBSCertificate) GetExtension(id asn1.ObjectIdentifier) []Extension

func (*TBSCertificate) GetRawIssuer

func (tbs *TBSCertificate) GetRawIssuer() []byte

func (*TBSCertificate) GetRawPublicKey

func (tbs *TBSCertificate) GetRawPublicKey() []byte

func (*TBSCertificate) GetRawSubject

func (tbs *TBSCertificate) GetRawSubject() []byte

func (*TBSCertificate) ParseBasicConstraints

func (tbs *TBSCertificate) ParseBasicConstraints() (*bool, error)

func (*TBSCertificate) ParseIssuer

func (tbs *TBSCertificate) ParseIssuer() (RDNSequence, error)

func (*TBSCertificate) ParseSerialNumber

func (tbs *TBSCertificate) ParseSerialNumber() (*big.Int, error)

func (*TBSCertificate) ParseSubject

func (tbs *TBSCertificate) ParseSubject() (RDNSequence, error)

func (*TBSCertificate) ParseSubjectAltNames

func (tbs *TBSCertificate) ParseSubjectAltNames() ([]SubjectAltName, error)

func (*TBSCertificate) ParseSubjectCommonNames

func (tbs *TBSCertificate) ParseSubjectCommonNames() ([]string, error)

func (*TBSCertificate) ParseValidity

func (tbs *TBSCertificate) ParseValidity() (*CertValidity, error)

Directories

Path Synopsis
cmd
ct
client
Package client is a CT log client implementation and contains types and code for interacting with RFC6962-compliant CT Log instances.
Package client is a CT log client implementation and contains types and code for interacting with RFC6962-compliant CT Log instances.

Jump to

Keyboard shortcuts

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