constellation

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Apr 14, 2026 License: MIT Imports: 28 Imported by: 0

README

Constellation Protocol — Proof of Concept

Go Report Card

A distributed identity protocol where trust is earned through temporal consistency, not granted by authority.

Each node maintains a hash-chained event ledger in a git repository, broadcasts signed state snapshots to peers, and derives trust from behavioral history rather than certificate authority. Stolen keys are insufficient for impersonation because trust is coupled to the full event chain, not just a credential.

What This Proves

Three properties, each demonstrated by a dedicated test scenario:

1. Self-Referential Closure

Each node validates its own coherence through a 3-layer stack applied to its git-backed event ledger:

  • Hash chain integrity: event[i].prior_hash == hash(event[i-1]) for all events, using SHA-256 over RFC 8785 canonical JSON
  • Schema validation: Required fields present, valid RFC 3339 timestamps, non-empty hashes
  • Temporal monotonicity: Timestamps non-decreasing, sequence numbers contiguous

A node that detects its own incoherence reports pass: false on its /health endpoint. The validation is idempotent -- re-applying the rules to a consistent ledger leaves it unchanged.

2. O(1) Mutual Verification

Nodes exchange signed heartbeats containing {node_id, tree_hash, seq, last_hash, timestamp}. Verification requires:

  1. Check ECDSA signature (one crypto op)
  2. Verify NodeID matches public key (one hash)
  3. Check seq == last_known_seq + 1 (one comparison)

No event replay, no Merkle proof traversal, no state synchronization. The tree hash of the git events directory serves as a compact state fingerprint -- if two nodes agree on the tree hash, they agree on all events.

3. Stolen Keys Insufficient for Impersonation

An attacker with a stolen ECDSA private key can sign valid heartbeats, but cannot forge the event history. When the attacker begins broadcasting heartbeats, existing peers observe:

  • Sequence discontinuity: The attacker's seq counter starts from 1; peers expect last_known + 1
  • Identity conflict: Two different addresses claim the same NodeID within a 30-second window
  • Trust collapse: The EMA trust score drops below the rejection threshold (0.2)

The key alone is insufficient because trust is coupled to history. You can't impersonate a node without also producing an identical hash-chained event ledger — and the hash chain is computationally irreversible.

Architecture

Node Structure
Node
├── Identity     ECDSA P-256 keypair, NodeID = SHA-256(pubkey DER)
├── GitStore     go-git in-process repo, events as events/{seq:08d}.json
├── PeerRegistry Known peers, trust scores, identity conflict detection
├── Heartbeat    5s ticker: generate event → commit → sign state → broadcast
└── HTTP Server  6 endpoints for inter-node communication
Heartbeat Protocol

Every 5 seconds, each node:

  1. Generates a simulated event and appends it to the ledger
  2. Commits the event to git as events/{seq:08d}.json
  3. Computes the tree hash of the events/ directory
  4. Signs {node_id, listen_addr, tree_hash, seq, last_hash, timestamp} with its ECDSA key
  5. POSTs the signed heartbeat to all known peers

On receipt, the peer:

  1. Verifies the ECDSA signature
  2. Verifies that the NodeID matches the public key (prevents relay attacks)
  3. Checks sequence consistency (seq == last_known + 1)
  4. Updates the EMA trust score: trust = 0.8 * trust + 0.2 * (consistent ? 1.0 : 0.0)
  5. Checks for identity conflicts (same NodeID from different address within 30s)
Trust Scoring

Trust is tracked per-peer via exponential moving average (EMA) with decay factor 0.8:

Level Score Meaning
Trusted >= 0.7 Consistent heartbeat history, peer is reliable
Pending >= 0.4 Insufficient history to judge
Suspect >= 0.2 Recent inconsistencies detected
Rejected < 0.2 Persistent drift or identity conflict

After 2 consecutive drifts, a challenge is issued: the verifying node requests an event range from the suspect peer and re-validates the hash chain locally.

HTTP Endpoints
Method Path Purpose
POST /heartbeat Receive signed state snapshot from peer
GET /peers List all peers with trust scores
POST /challenge Request event range for re-validation
POST /join New node announces itself, receives peer list
GET /health Self-coherence check (3-layer validation)
GET /state Full state dump (node state + peer summaries)

Structural Isomorphism: Constellation vs. Blockchain

Concept Blockchain Constellation Protocol
Identity Public key / address NodeID = SHA-256(pubkey DER)
State Global UTXO set / account trie Per-node git tree hash
Append-only log Block chain (linked list of blocks) Hash-chained events in git (one file per event)
Tamper evidence Merkle root in block header SHA-256 chain: event[i].prior_hash = hash(event[i-1])
State fingerprint Merkle Patricia Trie root Git tree hash of events/ directory
Consensus PoW / PoS / PBFT (O(n) to O(n^2)) EMA trust scoring from heartbeat consistency (O(1) per peer)
Finality Probabilistic (6 confirmations) or expensive (PBFT) Instant (coherence check = final)
Fork choice rule Longest chain / heaviest subtree Highest coherence score
Sybil resistance PoW (energy) / PoS (capital) Not needed (cooperative threat model)
Threat model Adversarial (Byzantine nodes) Cooperative (nodes drift, not lie)
Challenge mechanism Fraud proofs / validity proofs Event range re-validation on drift
Node discovery Gossip protocol (Kademlia DHT) Join handshake + peer list exchange
Scaling Each node increases consensus cost for all Each node only increases local cost until closure
Thermodynamic cost PoW: mass energy per hash Coherence validation: ln(2) per distinction (Landauer)

The key divergence: blockchain assumes no trust boundary, requiring expensive global consensus. This protocol assumes cooperative agents that may drift but don't actively deceive, enabling O(1) verification via temporal coupling.

Running

Prerequisites
  • Go 1.24+
  • Docker and Docker Compose (for containerized testing)
  • jq and curl (for test scripts)
Local (3 processes)
cd apps/constellation-poc
go build -o constellation-poc .

# Terminal 1: Start 3 nodes
./constellation-poc node --name alpha --port 8101 --hostname localhost \
    --data-dir /tmp/constellation/alpha --peers localhost:8102,localhost:8103 &
./constellation-poc node --name beta  --port 8102 --hostname localhost \
    --data-dir /tmp/constellation/beta  --peers localhost:8101,localhost:8103 &
./constellation-poc node --name gamma --port 8103 --hostname localhost \
    --data-dir /tmp/constellation/gamma --peers localhost:8101,localhost:8102 &

# Wait ~30s for trust to converge, then query:
curl -s http://localhost:8101/peers | jq '.[] | {node_id, trust, trust_level}'
curl -s http://localhost:8101/health | jq .
Docker Compose (3-node constellation)
cd apps/constellation-poc
docker compose up -d --build

# Query nodes on host ports 8101-8103:
curl -s http://localhost:8101/peers | jq .
curl -s http://localhost:8102/health | jq .
CLI Commands
# Start a node
constellation-poc node --name NAME --port PORT --hostname HOST \
    --data-dir DIR --peers HOST1:PORT1,HOST2:PORT2

# Query node state
constellation-poc status --target http://localhost:8101

# Tamper info (actual tampering done via file modification or docker exec)
constellation-poc tamper --target http://localhost:8101

Test Scenarios

Scenario 1: Happy Path — Trust Convergence

Start 3 nodes, wait for ~6 heartbeat cycles (30s), verify all peers reach trusted status.

bash test/scenario_happy.sh

Expected output:

Port 8101: 2 trusted / 2 total peers
Port 8102: 2 trusted / 2 total peers
Port 8103: 2 trusted / 2 total peers
[PASS] All 3 nodes show 2+ trusted peers

What it demonstrates: Nodes that maintain consistent hash-chained ledgers converge to mutual trust through temporal coupling alone — no certificate authority, no pre-shared secrets, no consensus protocol.

Scenario 2: Drift Detection — Tamper Self-Detection

Start 3 nodes, wait for trust, then corrupt an event file in alpha's git repo. Verify alpha's /health endpoint detects the tampering.

bash test/scenario_drift.sh

Expected output:

Alpha's /health reports pass: false
  hash_chain: tampered at seq N: computed abc... != stored def...
[PASS]

What it demonstrates: The 3-layer coherence validation detects any modification to the event history. The hash chain is self-verifying — each event commits to the hash of all prior events.

Scenario 3: Key Theft — Stolen Credentials Rejected

Start 3 nodes, wait for trust, copy alpha's private key to an attacker node, start the attacker. Verify that beta and gamma reject the impostor.

bash test/scenario_theft.sh

Expected output:

Beta:  NodeID a7ecf... → rejected: true, trust: 0
Gamma: NodeID a7ecf... → rejected: true, trust: 0
[PASS]

What it demonstrates: A stolen ECDSA key can sign valid heartbeats but cannot forge event history. The attacker's heartbeats show sequence discontinuity (seq 1 when peers expect seq N+1), triggering identity conflict detection. Trust is coupled to history, not credentials.

Scenario 4: Dynamic Join — New Node Achieves Trust

Start 3 nodes, wait for trust, then start delta pointing at alpha. Verify delta discovers all peers through the join handshake and achieves trusted status.

bash test/scenario_join.sh

Expected output:

Alpha: delta trust 0.84 (trusted)
Delta: alpha trust 0.84 (trusted), beta trust 0.80 (trusted), gamma trust 0.80 (trusted)
[PASS]

What it demonstrates: Trust is earned through consistent behavior over time, not granted by authority. A new node bootstraps by joining the constellation and building a coherent event history that peers can verify.

Run All Scenarios
bash test/run_scenarios.sh

File Structure

apps/constellation-poc/
├── go.mod                      # Standalone module, dep: go-git/v5
├── go.sum
├── ledger.go                   # RFC 8785 canonical JSON, SHA-256 hash chain
├── identity.go                 # ECDSA P-256 keygen, NodeID, sign/verify
├── gitstore.go                 # go-git in-process repo, event storage
├── coherence.go                # 3-layer validation (chain, schema, temporal)
├── node.go                     # Node lifecycle, state management
├── protocol.go                 # HTTP handlers (6 endpoints)
├── heartbeat.go                # Background ticker, ECDSA-signed state broadcast
├── constellation.go            # EMA trust scoring, identity conflict detection
├── main.go                     # CLI: node, inject, tamper, status
├── Dockerfile                  # Multi-stage: golang:1.24-alpine → alpine:3.21
├── docker-compose.yml          # 3-node constellation (alpha, beta, gamma)
├── docker-compose.test.yml     # Test overlays (delta join, attacker theft)
└── test/
    ├── scenario_happy.sh       # Trust convergence
    ├── scenario_drift.sh       # Tamper detection
    ├── scenario_theft.sh       # Key theft rejection
    ├── scenario_join.sh        # Dynamic join
    └── run_scenarios.sh        # Run all, report PASS/FAIL

Connection to CogOS

Constellation is the trust layer in the CogOS ecosystem. It handles identity verification and trust scoring across distributed nodes.

In a multi-node CogOS deployment (laptop, phone, desktop, cloud), each node maintains its own workspace and verifies peer coherence through Constellation. The kernel imports Constellation as a Go library via the ConstellationBridge interface -- in standalone mode, a NilBridge provides healthy defaults with zero overhead.

Workspace sync uses Syncthing BEP as the transport layer, with signed SyncEnvelopes gated by trust score before ingestion.

Repo Purpose
cogos The daemon
constellation Distributed identity and trust -- this repo
mod3 Voice -- multi-model TTS
charts Helm charts for deployment
desktop macOS dashboard app
skills Agent skill library

For the full system specification: CogOS System Spec For the research paper thesis: Paper Thesis

Theoretical Context

The protocol models identity as a fixed point of a self-validating process:

  • Self-referential closure (x = F(x)): A node's coherence check is idempotent -- re-applying validation leaves the system unchanged
  • Temporal coupling (not mechanical): Nodes couple through shared timeline, not forced consensus

The key insight: blockchain's O(n^2) consensus cost arises from treating identity as a static credential in an adversarial environment. When identity is instead tied to behavioral history, verification becomes O(1) per peer and stolen credentials become insufficient for impersonation.

Documentation

Overview

coherence.go — 3-layer coherence validation for constellation nodes.

Validates the integrity of a node's event ledger:

  1. Hash chain integrity (event[i].prior_hash == hash(event[i-1]))
  2. Schema validation (required fields present, valid timestamps)
  3. Temporal monotonicity (timestamps non-decreasing, sequences contiguous)

constellation.go — Trust scoring and identity conflict detection.

Trust is tracked per-peer via exponential moving average (EMA) of heartbeat consistency. Thresholds: trusted >= 0.7, pending >= 0.4, suspect >= 0.2, rejected < 0.2.

gitstore.go — In-process git repository for event storage.

Uses go-git/v5 to manage a bare git repo where events are committed as individual JSON files under events/{seq:08d}.json. The tree hash of the events/ directory serves as the node's state fingerprint for mutual verification.

heartbeat.go — Background heartbeat ticker and peer communication.

Every 5 seconds: generate a simulated event, append to ledger, commit to git, sign the state snapshot, POST to all known peers.

identity.go — ECDSA P-256 identity for constellation nodes.

Adapted from apps/cogos/bep_tls.go. Simplified to just key operations: generate, load, sign, verify, and NodeID derivation (SHA-256 of pubkey DER).

ledger.go — Hash-chained event ledger for constellation nodes.

Adapted from apps/cogos-v3/ledger.go. Events are canonicalized (RFC 8785), hashed (SHA-256), and chained via prior_hash fields.

node.go — Constellation node lifecycle.

A Node holds its identity (ECDSA keypair), git-backed event store, peer registry, and coherence state. It manages startup (init repo, load/generate keys) and graceful shutdown.

protocol.go — HTTP handlers for inter-node communication.

Endpoints:

POST /heartbeat  — receive peer heartbeat
GET  /peers      — list peers + trust state
POST /challenge  — request event range verification
POST /join       — new node announces itself
GET  /health     — self coherence check
GET  /state      — full dump for testing

run.go — Constellation Protocol PoC CLI entry point.

Subcommands:

node    — Start a constellation node
inject  — Inject an event into a running node
tamper  — Corrupt an event in a node's git store
status  — Query a node's state and peer trust

Index

Constants

View Source
const (
	TrustThresholdTrusted   = 0.7
	TrustThresholdPending   = 0.4
	TrustThresholdSuspect   = 0.2
	EMADecay                = 0.8
	IdentityConflictWindow  = 30 * time.Second
	MaxDriftBeforeChallenge = 2
)

Trust thresholds.

Variables

This section is empty.

Functions

func CanonicalizeEvent

func CanonicalizeEvent(payload *EventPayload) ([]byte, error)

CanonicalizeEvent produces RFC 8785 canonical JSON for an event payload.

func FormatNodeID

func FormatNodeID(nodeID string) string

FormatNodeID returns a short form of the node ID (first 12 hex chars).

func HashEvent

func HashEvent(canonicalBytes []byte) string

HashEvent computes the SHA-256 hash of canonical bytes.

func PublicKeyFromDER

func PublicKeyFromDER(der []byte) (*ecdsa.PublicKey, error)

PublicKeyFromDER parses an ECDSA public key from DER bytes.

func RegisterHandlers

func RegisterHandlers(mux *http.ServeMux, node *Node)

RegisterHandlers wires up the HTTP mux for a node.

func Run

func Run()

Run is the CLI entry point for the constellation binary.

func SaveIdentity

func SaveIdentity(id *NodeIdentity, dir string) error

SaveIdentity writes the private key to disk as PEM.

func TrustLevel

func TrustLevel(score float64) string

TrustLevel returns a human-readable trust label.

func Verify

func Verify(pubKey *ecdsa.PublicKey, data, signature []byte) bool

Verify checks a signature against a public key.

func VerifyHeartbeat

func VerifyHeartbeat(hb *Heartbeat) (bool, *ecdsa.PublicKey, error)

VerifyHeartbeat checks the ECDSA signature on a heartbeat.

Types

type CoherenceCheck

type CoherenceCheck struct {
	Layer  string `json:"layer"`
	Pass   bool   `json:"pass"`
	Detail string `json:"detail,omitempty"`
}

CoherenceCheck is the result of a single validation layer.

type CoherenceReport

type CoherenceReport struct {
	Pass      bool             `json:"pass"`
	Checks    []CoherenceCheck `json:"checks"`
	Timestamp string           `json:"timestamp"`
}

CoherenceReport is the result of validating a node's ledger.

func ValidateCoherence

func ValidateCoherence(events []*EventEnvelope) *CoherenceReport

ValidateCoherence runs all 3 validation layers on a set of events.

type EventEnvelope

type EventEnvelope struct {
	HashedPayload EventPayload  `json:"hashed_payload"`
	Metadata      EventMetadata `json:"metadata"`
}

EventEnvelope is the on-disk event shape committed to the git store.

func NewEvent

func NewEvent(nodeID, eventType string, seq int64, priorHash string, data map[string]interface{}) (*EventEnvelope, error)

NewEvent creates a new event envelope with hash chaining.

type EventMetadata

type EventMetadata struct {
	Hash string `json:"hash"`
	Seq  int64  `json:"seq"`
}

EventMetadata is NOT included in the hash.

type EventPayload

type EventPayload struct {
	Type      string                 `json:"type"`
	Timestamp string                 `json:"timestamp"`
	NodeID    string                 `json:"node_id"`
	PriorHash string                 `json:"prior_hash,omitempty"`
	Data      map[string]interface{} `json:"data,omitempty"`
}

EventPayload is the content that gets canonicalized and hashed.

type GitStore

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

GitStore wraps an on-disk git repository for event storage.

func NewGitStore

func NewGitStore(path string) (*GitStore, error)

NewGitStore initializes a new git repository at the given path.

func (*GitStore) AppendEvent

func (gs *GitStore) AppendEvent(envelope *EventEnvelope) error

AppendEvent writes an event to events/{seq:08d}.json and commits it.

func (*GitStore) CommitHash

func (gs *GitStore) CommitHash() (string, error)

CommitHash returns the current HEAD commit hash.

func (*GitStore) CorruptEvent

func (gs *GitStore) CorruptEvent(seq int64) error

CorruptEvent overwrites an event file with tampered data (for testing).

func (*GitStore) LastEvent

func (gs *GitStore) LastEvent() (*EventEnvelope, error)

LastEvent returns the most recent event, or nil if none.

func (*GitStore) ReadEventRange

func (gs *GitStore) ReadEventRange(startSeq, endSeq int64) ([]*EventEnvelope, error)

ReadEventRange returns events from startSeq to endSeq (inclusive).

func (*GitStore) TreeHash

func (gs *GitStore) TreeHash() (string, error)

TreeHash computes the hash of the current HEAD tree's events/ subtree. This is the state fingerprint used for mutual verification.

type Heartbeat

type Heartbeat struct {
	NodeID     string `json:"node_id"`
	ListenAddr string `json:"listen_addr"` // sender's listening address
	TreeHash   string `json:"tree_hash"`
	Seq        int64  `json:"seq"`
	LastHash   string `json:"last_hash"`
	Timestamp  string `json:"timestamp"`
	PublicKey  string `json:"public_key"` // base64-encoded DER
	Signature  string `json:"signature"`  // base64-encoded ASN.1
}

Heartbeat is the signed state snapshot sent to peers.

type HeartbeatRunner

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

HeartbeatRunner manages the background heartbeat loop.

func NewHeartbeatRunner

func NewHeartbeatRunner(node *Node, interval time.Duration) *HeartbeatRunner

NewHeartbeatRunner creates a heartbeat runner.

func (*HeartbeatRunner) Start

func (hr *HeartbeatRunner) Start()

Start begins the heartbeat loop.

func (*HeartbeatRunner) Stop

func (hr *HeartbeatRunner) Stop()

Stop halts the heartbeat loop.

type Node

type Node struct {
	Name     string
	Identity *NodeIdentity
	Store    *GitStore
	Peers    *PeerRegistry
	Port     int
	DataDir  string
	Hostname string // externally reachable hostname (default: localhost)
	// contains filtered or unexported fields
}

Node is a self-referentially closed unit in the constellation.

func NewNode

func NewNode(name string, port int, dataDir string) (*Node, error)

NewNode creates and initializes a node.

func (*Node) AppendEvent

func (n *Node) AppendEvent(eventType string, data map[string]any) error

AppendEvent creates a new event and commits it to the git store.

func (*Node) CurrentState

func (n *Node) CurrentState() (*NodeState, error)

CurrentState returns the node's current state snapshot.

func (*Node) ListenAddr

func (n *Node) ListenAddr() string

ListenAddr returns the externally reachable address for this node.

func (*Node) SelfCheck

func (n *Node) SelfCheck() (*CoherenceReport, error)

SelfCheck runs coherence validation on the node's own ledger.

func (*Node) Start

func (n *Node) Start(initialPeers []string) error

Start begins serving HTTP and running heartbeats.

func (*Node) Stop

func (n *Node) Stop()

Stop gracefully shuts down the node.

type NodeIdentity

type NodeIdentity struct {
	PrivateKey *ecdsa.PrivateKey
	PublicKey  *ecdsa.PublicKey
	NodeID     string // hex-encoded SHA-256 of DER-encoded public key
}

NodeIdentity holds the ECDSA keypair and derived node ID.

func GenerateIdentity

func GenerateIdentity() (*NodeIdentity, error)

GenerateIdentity creates a new ECDSA P-256 keypair and derives the NodeID.

func LoadIdentity

func LoadIdentity(dir string) (*NodeIdentity, error)

LoadIdentity reads an ECDSA private key from disk.

func (*NodeIdentity) MarshalPublicKey

func (id *NodeIdentity) MarshalPublicKey() ([]byte, error)

MarshalPublicKey returns the DER-encoded public key.

func (*NodeIdentity) Sign

func (id *NodeIdentity) Sign(data []byte) ([]byte, error)

Sign signs arbitrary data with the node's private key.

type NodeState

type NodeState struct {
	NodeID   string `json:"node_id"`
	Name     string `json:"name"`
	Seq      int64  `json:"seq"`
	LastHash string `json:"last_hash"`
	TreeHash string `json:"tree_hash"`
}

NodeState is the snapshot sent in heartbeats.

type PeerRegistry

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

PeerRegistry manages the set of known peers.

func NewPeerRegistry

func NewPeerRegistry() *PeerRegistry

NewPeerRegistry creates an empty registry.

func (*PeerRegistry) AddPeer

func (pr *PeerRegistry) AddPeer(addr string)

AddPeer registers a peer address (identity learned on first heartbeat).

func (*PeerRegistry) AllPeers

func (pr *PeerRegistry) AllPeers() []*PeerState

AllPeers returns all peer states.

func (*PeerRegistry) GetByID

func (pr *PeerRegistry) GetByID(nodeID string) *PeerState

GetByID returns the state for a node ID.

func (*PeerRegistry) GetPeer

func (pr *PeerRegistry) GetPeer(addr string) *PeerState

GetPeer returns the state for a peer address.

func (*PeerRegistry) ProcessHeartbeat

func (pr *PeerRegistry) ProcessHeartbeat(addr string, hb *Heartbeat, pubKey *ecdsa.PublicKey) error

ProcessHeartbeat updates peer state based on a received heartbeat. Returns an error if an identity conflict is detected.

func (*PeerRegistry) Summarize

func (pr *PeerRegistry) Summarize() []PeerSummary

Summarize returns a JSON-friendly summary of all peers.

type PeerState

type PeerState struct {
	NodeID     string           `json:"node_id"`
	Addr       string           `json:"addr"`
	PublicKey  *ecdsa.PublicKey `json:"-"`
	PublicDER  []byte           `json:"public_key_der,omitempty"`
	LastSeq    int64            `json:"last_seq"`
	LastHash   string           `json:"last_hash"`
	TreeHash   string           `json:"tree_hash"`
	Trust      float64          `json:"trust"`
	DriftCount int              `json:"drift_count"`
	LastSeen   time.Time        `json:"last_seen"`
	Rejected   bool             `json:"rejected"`
}

PeerState tracks a remote peer's last known state and trust.

type PeerSummary

type PeerSummary struct {
	NodeID     string  `json:"node_id"`
	Addr       string  `json:"addr"`
	Seq        int64   `json:"seq"`
	Trust      float64 `json:"trust"`
	TrustLevel string  `json:"trust_level"`
	DriftCount int     `json:"drift_count"`
	LastSeen   string  `json:"last_seen"`
	Rejected   bool    `json:"rejected,omitempty"`
}

PeerSummary is the JSON-friendly view of a peer.

Directories

Path Synopsis
cmd
constellation command
cmd/constellation/main.go — Thin entry point for go install support.
cmd/constellation/main.go — Thin entry point for go install support.

Jump to

Keyboard shortcuts

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