Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

EdSSA is a zero-trust, post-quantum, stateless M2M authentication engine. The Community Edition (CE) ships as a single binary, edssa-server-ce, that sits as a sidecar in front of any HTTP service and refuses inbound traffic that doesn’t carry a valid X-EdSSA-Token.

What problem does it solve?

Most service-to-service authentication today is “send a bearer token in a header”. The token is a long-lived shared secret:

  • If it leaks (logs, env files, exception traces), it remains valid until rotated.
  • It can be replayed indefinitely by any party that observes one valid request.
  • It binds no information about the caller’s environment, identity, or per-request claim.

EdSSA replaces “send a secret” with “derive a per-request verifier from shared state”. The token on the wire is a fixed- width, wire-safe byte sequence whose content depends on:

  1. A long-lived seed shared between client and server.
  2. A per-request sub-identifier the client claims in the token itself (so the verifier knows which sub-fleet / sub-tenant to consult).
  3. Chaff slots that contain random bytes the verifier ignores (so observed tokens don’t reveal the secret layout).

The verifier performs an O(N) byte comparison with no heap allocations, no locks, no async calls, and no network hops. On the Phase-0 microbench at N=64 the verify call is ~22 ns — within the budget of even the most aggressive operator-grade deployment.

What’s in the Community Edition?

CE is a strict subset of the Enterprise build (see CE feature subset):

  • One fleet per binary.
  • Token width pinned at 64 bytes.
  • Audit tiers 0–2 (silent / errors / result).
  • No multi-fleet routing, no swarm, no payload channel, no Tier-3 response-chain replay defence, no Tier-4 Merkle audit.

Held-back paths refuse to validate at boot rather than running in a degraded mode. Operators who need them upgrade to the Enterprise build.

License

CE ships under the Business Source License 1.1 with Apache-2.0 as the Change License after 4 years per file. Additional Use Grant: “non-commercial OR commercial ≤ $1M ARR”. An explicit non-revocable patent grant covers permitted use.

See ADR-005 (D-5) for the policy + the executed LICENSE / PATENTS / NOTICE files at the repo root.

Where to next?

Autopliance — getting started

EdSSA Autopliance is the hosted compliance product: a subscription that turns a live evidence chain into audit-ready reports. This page takes you from nothing to a generated report, and then to a paid plan, without a sales call. Budget fifteen minutes.

If you want the self-hosted protocol instead, start at the Quickstart — Autopliance is the product for teams who want the evidence without running the infrastructure.

1. Create an account

Go to app.edssa.io/login and enter your work email. The same form creates the account — there is nothing separate to sign up for, and no password exists to choose or lose: sign-in links are single-use and expire in fifteen minutes.

Every new account starts on a 14-day trial. No card is asked for at any point during it.

2. Get your trial fleet

The app’s first step, Test Fleet, provisions a hosted fleet for you: a real verification boundary with its own evidence chain, not a simulation. Two things to know:

  • The seed is shown once. It is the fleet’s credential. The reveal page will not show it again, so store it like a password. (We keep the copy the verifier needs; the one on your screen is yours.)
  • The fleet is yours for the trial plus a grace period — converting on the last day does not cost you the chain you accumulated.

3. Drive it

A chain with nothing on it proves nothing, so send some traffic:

  • Have us drive it. The Run it step has a “drive it for me” button that sends realistic traffic from our side. This is the fastest route to a chain worth reporting on.
  • Drive it yourself. Anything that can set an HTTP header can participate. The SDK reference covers Go, Node.js and Python; the credential travels as one header (X-EdSSA-Token) with no callback and no round trip to us.

Either way, verified traffic accumulates into anchors on your chain. Ten anchors is enough for a report worth showing to someone.

4. Generate a report

The Generate step renders a report against your own chain. Pick a framework — the catalog spans 34, including NIS2, SOC 2, ISO 27001, GDPR Art. 28, DORA, the EU AI Act, FedRAMP and the Finnish Katakri/PiTuKri/Julkri set — and a format: HTML, PDF, Markdown, JSON, or OSCAL for the GRC pipelines that ingest machine-readable assessment results.

Report provenance is stated on the document and never inflated: a report generated from your live chain says anchored because its verdict was computed by walking that chain. Example reports for every framework are downloadable without any account on edssa.io/why/compliance — they say so on every page.

5. Upgrade when it earns it

The in-app pricing page lists the tiers; the entry tier is Autopliance at €149/month, and upgrading is one Stripe checkout — VAT is computed there, and an EU business enters its VAT ID for reverse charge. Invoices, card changes, plan changes and cancellation all live in the billing portal, reachable from your account page.

Two commitments worth knowing before you pay:

  • Evidence is never held hostage. If you stop paying, every report you generated stays downloadable and your chain is not deleted. The account goes read-only for new work; nothing you already have is withdrawn.
  • Prices are list prices. What the pricing page shows is what checkout charges. If the two ever disagree, checkout refuses rather than charging the difference.

6. When something breaks — or should exist

The in-app Support page (account menu → Support) files bugs, ideas and help asks; you get an acknowledgement by mail and a person replies from support@edssa.io. Emailing that address directly works too.

What Autopliance is, and is not

It is cryptographic evidence: proof that specific parties were genuinely themselves, present at recorded moments, in order, with nothing replayed or quietly inserted. It is not an attestation, an audit opinion or a certification, and we are not an accredited certification body — what a report says is what a verifier computed. Where a framework needs an auditor’s opinion, an Autopliance report is the evidence you hand the auditor, not the opinion itself.

Quickstart

Goal: a verified token reaches a real backend within 5 minutes from a fresh machine. This page is the in-mdBook mirror of the samples/nginx-quickstart/ sample — the sample’s README.md is the source of truth for the exact commands.

Prerequisites

  • Docker + docker compose v2.
  • A Rust toolchain (the repo pins 1.86) to build the image and run edssa-client. Any SDK works in place of the CLI.

The published image does not exist yet. edssa/server-ce:1.0.0 is referenced throughout the samples but has not been pushed to Docker Hub — docker pull fails with pull access denied. Publishing is operator-side, after the public migration of v1.0.0-ce. Step 2 below builds the image locally, and every command on this page is run against that locally-built image. Nothing here needs the registry.

Steps

  1. Clone the repo. Stay at the repo root — the image build needs the whole Cargo workspace as its context, not the sample directory.

    git clone https://github.com/edssa-io/edssa.git
    cd edssa
    
  2. Build the CE image. The build context is code/ (the binary path-deps edssa-core + edssa-audit):

    docker build -f samples/nginx-quickstart/Dockerfile.local \
      -t edssa-server-ce:dev code/
    

    Builds natively on both amd64 and arm64 (Apple Silicon) — the Dockerfile picks target-cpu from BuildKit’s TARGETARCH.

    Then point the sample at it — in samples/nginx-quickstart/docker-compose.yml, replace image: edssa/server-ce:1.0.0 with image: edssa-server-ce:dev.

  3. Generate a seed. The committed *.seed.example is public placeholder content — replace it before any real traffic.

    cd samples/nginx-quickstart
    LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom \
      | head -c 1024 > secrets/fleet-c1b2-demo.seed
    chmod 0400 secrets/fleet-c1b2-demo.seed
    
  4. Start the stack.

    docker compose up -d
    
  5. Verify rejection without a token.

    curl -i http://localhost:8080/
    # → HTTP/1.1 401 Unauthorized
    
  6. Send a verified request. edssa-client mints a credential and sends the request itself — it does not print a bare token, so this is one command rather than a TOKEN=$(…) capture.

    The derivation flags must match the fleet’s manifest entry. The sample’s ce.toml uses the balanced preset, so N=64 C=16 T=33; the CLI defaults (N=32 C=0) will be rejected. Run from code/, the Cargo workspace root:

    cd ../../code
    cargo run --quiet -p edssa-client --bin edssa-client -- \
      --target http://localhost:8080/ \
      --fleet c1b2-demo \
      --seed ../samples/nginx-quickstart/secrets/fleet-c1b2-demo.seed \
      --width-n 64 --chaff-c 16 --threshold-t 33
    

    The summary reports accepts 1, and the server logs edssa accept fleet=c1b2-demo.

    --bin edssa-client is required: the crate ships four binaries (edssa-agent, edssa-client, edssa-onboard, edssa-recover) and cargo run -p alone cannot choose between them.

  7. Re-present the credential with curl (optional — this is the 401-vs-200 contrast in its clearest form). --emit-tokens appends the exact accepted header to a file, one <status> <header> line per request:

    cargo run --quiet -p edssa-client --bin edssa-client -- \
      --target http://localhost:8080/ \
      --fleet c1b2-demo \
      --seed ../samples/nginx-quickstart/secrets/fleet-c1b2-demo.seed \
      --width-n 64 --chaff-c 16 --threshold-t 33 \
      --emit-tokens /tmp/edssa-tokens.txt
    
    TOKEN=$(awk '{print $2}' /tmp/edssa-tokens.txt | tail -1)
    curl -i -H "X-EdSSA-Token: $TOKEN" http://localhost:8080/
    # → HTTP/1.1 200 OK
    # → Hello from nginx — auth succeeded.
    

Beyond the quickstart

Time-to-first-token measurement

The Phase-8 exit criterion is “≤ 5 min on a clean macOS / Linux machine”. The measured baseline (mac mini M2, fresh git clone, warm Docker cache) is 3 m 12 s, dominated by:

StepTime
Clone repo + cd into sample~10 s
Pull edssa/server-ce:1.0.0 (cold cache)~30 s
Pull nginx:1.27-alpine (cold cache)~10 s
Generate seed~5 s
docker compose up -d to first healthy~15 s
cargo run -p edssa-client (warm target dir)~3 s
smoke-test the 401 + 200 paths~5 s

A cold cargo build adds ~1 minute on first run; subsequent runs of the smoke flow are sub-30s.

Architecture

EdSSA’s hot path is a single function: verify_token(&[u8; N], &ActiveEdssaState<N>). Everything else is plumbing around keeping the state fresh and the verifier honest.

FIG. 1 — Overall system architecture

📌 Figure pending publication. FIG. 1 is part of the A1-publication-ready patent application; it will land here alongside the public migration step that drops the figures into docs-site/src/figures/fig-1-architecture.png.

The system is three layers:

  1. Client. Mints a token from (seed, sub_id, claim_state) and stamps X-EdSSA-Token: <fleet_id>-<token> on every outbound request.
  2. Verifier (edssa-server-ce). Sits as a sidecar; on each inbound request it loads the fleet’s ActiveEdssaState via a wait-free ArcSwap and performs the O(N) match.
  3. Backend. Receives forwarded traffic with the X-EdSSA-Token header stripped (Zero-Trust edge separation — the credential never crosses the verify-to-app boundary).

FIG. 2 — Credential construction

📌 Figure pending publication. FIG. 2 will land at docs-site/src/figures/fig-2-construction.png.

Token construction:

  1. The client knows the fleet’s seed and picks a sub-ID for the request.
  2. For each slot i ∈ [chaff_count, N − SUB_ID_SLOTS), the client derives the secret byte:
    • CE first cut: wire_byte(SHA-256(seed ‖ i_le)[0]).
    • Enterprise (with the ratchet running): the ratchet’s cell at position i is hashed forward on a clock-driven cadence; the verifier publishes the new ActiveEdssaState atomically.
  3. Chaff slots [0, chaff_count) carry random bytes the verifier ignores.
  4. Sub-ID slots [N − SUB_ID_SLOTS, N) carry the ASCII-hex encoding of the sub-ID (encode_sub_id).

Verification mirrors the construction step-for-step. Bytes at chaff positions don’t contribute to the match count; the match count must reach threshold_T for the token to be accepted.

FIG. 3 — Synchronisation and tolerance windowing

📌 Figure pending publication. FIG. 3 will land at docs-site/src/figures/fig-3-synchronisation.png.

The Enterprise build’s drift corrector (F-16) gates ratchet advancement on a median-of-3 over independent time-anchoring oracles. CE pins the verifier state at boot and does not run a ratchet, so this figure is purely informational for CE operators.

Plasticity presets (ADR-008)

PresetNCTM = N − C − SUB_ID_SLOTS − TWhen to use
high-security6416440No margin; every secret byte must match. HFT credentials, control-plane authority.
balanced (default)64163311Tolerates ~25 % byte loss. Most application-tier M2M.
high-resilience64162222Half of R_eff may flip. Radio links, IoT mesh, lossy WAN paths.

CE pins N = 64 and accepts all three presets. Operators can override chaff_C / threshold_T per-fleet if the preset doesn’t fit; width_N is the only field CE refuses to deviate from.

Wire format

FieldBytesNotes
Fleet ID3–32 ASCII chars ([a-z0-9-], no leading/trailing dash)Sent as the part of the header before the last -
TokenEDSSA_TOKEN_WIDTH = 64 bytesWire-safe by construction (wire_byte mapping)
Sub-ID slotsLast 4 bytes of the tokenASCII hex; SUB_ID_MAX = 0xFFFF

Header: X-EdSSA-Token: <fleet_id>-<token>.

ADR-004 documents why the parser splits at the last dash (token bytes never contain -, so the fleet-token boundary is unambiguous).

What changes in Enterprise

FeatureCEEnterprise
Multi-fleet routing
Token widths64 only32 / 64 / 128 / 256
Ratchet driver✗ (static state)✓ (Phase-2 ratchet)
Drift corrector (F-16)n/a
Audit Tier 3 (trace)
Audit Tier 4 (Merkle / F-21)
Tier-3 response chain (F-19)
F-03 swarm
F-04 relay rolesedge-onlyedge-only / peer / anchor
Payload channel (F-26..28)
Cooperative recovery (F-24/F-25)
ML-KEM onboarding (F-07)

CE feature subset

The Community Edition binary edssa-server-ce enforces these constraints at boot. Held-back paths fail validation with a clear error message naming the offending knob + a pointer back to this page; there is no EDSSA_CE_PERMIT_*=1 override.

What’s in

CapabilityCE constraint
Token width NPinned at 64
Plasticity presets (ADR-008)All three (high-security / balanced / high-resilience)
Per-field plasticity overridechaff_C and threshold_T only; width_N not overridable
Fleet countExactly 1 per process (singular [fleet] table)
Audit tierOne of silent / errors / result (Tiers 0–2)
CriticalityPinned at standard
Sub-ID rangeAny [lo, hi) with 1 ≤ lo < hi ≤ 65536
Replay defenceNone at the CE binary layer (Phase-2 ratchet + Tier-1 Bloom are Enterprise)
Relay role (F-04)edge-only

What’s held back

CapabilityWhy held back
Multi-fleet routing (F-01 with N > 1 fleets)Enterprise — multi-tenant + auth lives on the panel
Token widths 32 / 128 / 256Compile matrix kept lean for CE; Enterprise compiles the full set
Ratchet driverPhase-2 hot-publish + ratchet is Enterprise; CE ships static state
Drift corrector (F-16)Depends on Enterprise control oracles (NTS / GNSS / ledger timestamp)
[fleet.swarm] block (F-03)EdSSA Swarm
[fleet.relay] role ∈ {peer, anchor}F-04 higher-tier relay roles
Audit Tier 3 (trace)Per-byte derivation chain digest — Enterprise
Audit Tier 4 (merkle)F-21 tamper-evident Merkle anchors
Tier-3 response-ID chain (F-19)Per-response binding for sensitive / critical fleets
Schema-embedded payload channel (F-26 / F-27 / F-28)Enterprise — substrate landed in Phase 7, wiring in Phase 9
Cooperative post-compromise recovery (F-24 / F-25)SPAKE2 / recipe / recovery anchor — Enterprise
ML-KEM onboarding (F-07)Phase-6 handshake — Enterprise
WebAuthn / SSO panel auth (D-2)Panel is Enterprise
Multi-region, 24/7 supportOperational, not a code feature

What the validation gate rejects

Every reject names the offending knob + this docs page anchor:

Manifest inputError pointer
[[fleet]] (multi-fleet)#single-fleet
width_N = 32 (or 128 / 256)#width
audit_tier = "trace" or "merkle"#audit
criticality = "sensitive" or "critical"#replay
[fleet.swarm] block present#swarm
[fleet.relay] role ∈ {peer, anchor}#relay

Upgrade path

When you need a held-back feature:

  1. Try a different operational shape first. E.g., run two CE binaries side-by-side instead of asking for multi-fleet; use a Tier-2 sliding-window audit pipeline you build yourself instead of asking for Tier-3 trace.
  2. Switch to the Enterprise binary. Same edssa-core engine, same wire format, same client SDK — only the operator-facing binary changes. There is no client-side rewrite when you upgrade.

The CE manifest is a subset of the Enterprise manifest, so an Enterprise binary will load a CE manifest without modification. The reverse is not true.

Config reference

CE configuration is environment variables plus a single TOML manifest read at boot.

Environment variables

VariableDefaultRequired?Meaning
EDSSA_LISTEN_ADDR0.0.0.0:8080NoBind address
EDSSA_BACKEND_URLhttp://backend:3000NoUpstream that authenticated requests forward to
EDSSA_CE_MANIFEST(none)YesPath to the CE TOML manifest
RUST_LOGedssa_server_ce=infoNoTracing filter (per tracing-subscriber’s EnvFilter)

CE manifest schema (ce.toml)

version = 1

[fleet]
id                  = "c1b2-demo"     # ADR-004 format, required
seed_path           = "/path/to/seed" # raw bytes, ≥ 64; required
preset              = "balanced"      # OR explicit chaff_C + threshold_T
# chaff_C           = 16              # override individual fields if needed
# threshold_T       = 33              # width_N override is rejected (CE pins 64)
ratchet_interval_ms = 1000            # accepted but unused in CE first cut
sub_id_range        = [1, 1024]       # half-open [lo, hi); default [1, 1024]
criticality         = "standard"      # CE pins at "standard"
audit_tier          = "result"        # one of {silent, errors, result}

Required fields

  • version: must equal 1. Future manifest schema bumps will preserve version = 1 as a long-supported alias.
  • [fleet]: exactly one. The CE parser uses a singular [fleet] table; the Enterprise [[fleet]] array form is rejected with a CE-specific error and a #single-fleet pointer.
  • fleet.id: 3–32 lowercase ASCII letters/digits/dashes, no leading or trailing dash (ADR-004).
  • fleet.seed_path: absolute or relative to the manifest’s directory. Must exist at boot.
  • Plasticity: either fleet.preset (one of high-security / balanced / high-resilience) or all three of fleet.width_N, fleet.chaff_C, fleet.threshold_T.

Optional fields

  • fleet.ratchet_interval_ms: validated to be ≥ 100 but not consumed in the CE first cut (the ratchet driver is Enterprise-only). Carrying the field through lets you reuse the same manifest under the Enterprise binary unchanged.
  • fleet.sub_id_range: [lo, hi). Defaults to [1, 1024). Max hi is SUB_ID_MAX + 1 = 65536.
  • fleet.criticality: defaults to "standard". Any other value is rejected — CE doesn’t ship the Tier-2 sliding window or Tier-3 response chain.
  • fleet.audit_tier: defaults to "result". "trace" and "merkle" are explicitly rejected with the #audit pointer.

Fields that reject the manifest

  • [fleet.swarm] (any content): F-03 is Enterprise.
  • [fleet.relay] with role ∈ {peer, anchor}: only edge-only is CE-compatible.
  • width_N set to anything other than 64 (whether as a preset override or a standalone field).
  • Any unknown field that serde doesn’t accept silently.

Examples

Minimal

version = 1
[fleet]
id        = "demo"
seed_path = "fleet.seed"
preset    = "balanced"

Silent audit, custom sub-ID range

version = 1
[fleet]
id           = "prod-quiet"
seed_path    = "fleet.seed"
preset       = "high-security"
audit_tier   = "silent"
sub_id_range = [1, 8]

Explicit plasticity (override the preset’s defaults)

version = 1
[fleet]
id          = "prod-aggressive"
seed_path   = "fleet.seed"
preset      = "balanced"
threshold_T = 38       # +5 from balanced's 33; tighter margin

CLI reference

The Community Edition’s sidecar is edssa-server-ce. The workspace also builds edssa-client (a token-minting load client, useful for smoke-testing a CE sidecar) and edssa-admin (an operator CLI oriented at the Enterprise edssa-proxy). All three are source-built today (cargo build --release -p <crate>) and will publish to Cargo / Homebrew alongside the v1.0.0-ce release.

edssa-server-ce

The CE verifier sidecar. Reads its config from environment variables (Config reference) and a single TOML manifest at the path EDSSA_CE_MANIFEST points to.

edssa-server-ce

There are no positional arguments and no flags — every behaviour is driven by env / manifest so the binary works equivalently in a container, a systemd unit, a Helm chart, or a bare-shell launch.

Exit codes

CodeMeaning
0Clean shutdown (SIGTERM received)
!=0Config invalid (env var unset / manifest fails the validation gate / seed unreadable)

The first 401 in the error stream names the violated CE constraint

Endpoints

PathMethodAuthBehaviour
/healthzGETnone200 OK “ok”; for kube liveness / readiness probes
**required X-EdSSA-Token headerforwards to EDSSA_BACKEND_URL; 401 (empty body) on reject

Per ADR-006, production 401 bodies are intentionally empty so the verifier doesn’t leak which check failed.

edssa-client (token-minting load client)

Source-built today (cargo run -p edssa-client); ships as a released binary via Homebrew tap + GitHub Releases in the operator-side Phase-8 publication step.

edssa-client is a traffic client, not a one-shot minter: it constructs --count tokens against a fleet seed, POSTs each to --target with the X-EdSSA-Token header, records per-request latency in an HdrHistogram, and prints a summary. It exits non-zero if any request was rejected (unless --fail-fast stopped the run on the first reject). Use it to smoke-test a running sidecar.

edssa-client \
  --target http://127.0.0.1:8080/ \
  --fleet  c1b2-demo \
  --seed   secrets/fleet-c1b2-demo.seed \
  --shape  phase2 \
  --width-n 64 --chaff-c 16 --threshold-t 33 \
  --count  100

Flags

FlagRequired?DefaultMeaning
--target <url>yesFull URL of the upstream behind the sidecar
--fleet <id>yesFleet ID (ADR-004 format); must match the sidecar
--seed <path>yesRaw seed bytes
--shape <s>nophase2Wire shape: phase2 (ratchet derivation, manifest-driven proxy) or phase1 (legacy single-fleet)
--width-n <n>no32Token width; 32 or 64 for phase2. For CE set --width-n 64
--chaff-c <n>no0Leading chaff slots; match the fleet’s chaff_C
--threshold-t <n>no0Verifier threshold; 0 means “max R_eff for the chosen width/chaff”
--ratchet-step <n>no0Ratchet ticks the proxy is ahead (CE never ratchets, so 0)
--sub-id <n>no1Sub-ID claim; range-checked against the fleet’s sub_id_range
--count <n>no1Number of requests to send
--fail-fastnooffStop after the first reject (negative tests)
--jsonnooffEmit one JSON object per request + a JSON summary
--auto-ticknooffDrift-correct the ratchet step from the proxy’s /api/fleets/<fleet>/ratchet-step endpoint (Enterprise proxy; incompatible with --shape phase1)

CE note. The CE binary pins width_N = 64 and ships static (un-ratcheted) state, so the CE-matching invocation is --shape phase2 --width-n 64 --ratchet-step 0 with --chaff-c / --threshold-t matching your manifest preset (e.g. balanced--chaff-c 16 --threshold-t 33). The header is assembled by the client as <fleet>-<hex> per ADR-012; there is no separate “print the token” mode.

edssa-admin (operator CLI)

The operator CLI for managing fleets and inspecting on-box state. It is part of the same source tree but is oriented at the Enterprise edssa-proxy deployment: it edits a multi-fleet fleets.toml manifest (preserving operator comments and key order), validates it, and signals the running proxy with SIGHUP.

In CE the manifest (EDSSA_CE_MANIFEST) is loaded once at boot and edssa-server-ce does not handle SIGHUP, so the manifest-mutation and reload subcommands do not apply to a CE sidecar — to change a CE fleet you edit ce.toml and restart the binary (see the Operator runbook). The read-only inspection subcommands (verify-anchor, verify-inclusion, trigger, tenants, usage, compute-fingerprint, compliance-export, transparency-publish) operate on Enterprise audit / multi-tenant state and are documented in full on the Enterprise docs; they are listed here for completeness so the surface matches the shipped binary.

Source-built today (cargo run -p edssa-admin -- <args>); ships as a released binary alongside the Phase-8 publication step.

Global flags

FlagEnvDefaultMeaning
--manifest <path>EDSSA_FLEETS_TOML/opt/p1/secrets/fleets.tomlManifest the fleet / compliance-export commands read/write
--proxy-pidfile <path>EDSSA_PROXY_PIDFILE/var/run/edssa-proxy.pidPidfile SIGHUP is sent to after mutating commands
--no-signaloffSkip the SIGHUP after a mutating command

Subcommands (as shipped)

CommandMutates?Purpose
fleet listnoList fleets in the manifest
fleet add --id … --seed-path … [--preset …]yesRegister a fleet, then SIGHUP the proxy
fleet remove --id …yesRemove a fleet, then SIGHUP the proxy
fleet reload [--fleet …]signals onlyRe-validate the manifest and SIGHUP the proxy (does not edit it)
schema rotate --fleet …n/aDeferred stub — exits with a message pointing at the Phase-5 panel
verify-anchor --fleet … [--tenant …] [--against-witness rekor --pubkey …]noWalk the Tier-4 Merkle anchor chain for a fleet (Enterprise audit DB)
verify-inclusion --fleet … --anchor … (--event-json … | --leaf-hex …)noPer-event Merkle inclusion proof
trigger list|lookup|validatenoInspect the F-26 trigger registry
tenants list|show|validatenoInspect the Phase-9 multi-tenant seed
usage report|verify|exportnoPer-tenant metering rollups (Phase-10)
compute-fingerprint --cert …noSHA-256 of a TLS leaf cert for swarm peer pinning
compliance-export --regime … --out …noRegulator-ready evidence archive
transparency-publishnoEmit the public transparency-roots JSON (requires --features rekor)
rotate apply --fleet … --seed …yesStage a new seed and SIGHUP the proxy (cooperative seed rotation)

The verify-anchor --against-witness rekor and transparency-publish paths require the binary to be built with --features rekor; without it they exit with a “rebuild with --features rekor” hint. None of these inspect or mutate CE sidecar state — they read Enterprise audit / tenant stores.

Enterprise overview

The Community Edition (edssa-server-ce) ships under BSL 1.1 with a strict feature subset documented in CE feature subset. Everything held back from CE lives in the Enterprise build of edssa-proxy + the operator panel + the sibling crates edssa-tenant, edssa-sso.

What’s in Enterprise

CapabilityCEEnterprise
Token widths64 only32 / 64 / 128 / 256
Fleet count1 per processunbounded
Multi-tenant hierarchy (Tenant / Organization / Fleet)✓ (Multi-tenant)
Per-tenant audit isolation (cryptographic + filesystem + RBAC)
SAML / OIDC SSO✓ (SSO)
Per-fleet + per-source-IP + per-(fleet, sub-ID) rate limiting✓ (Production hardening)
Tier-3 trace + Tier-4 Merkle audit
F-03 swarm, F-04 relay roles
F-06 EdSSA Orbit (orbit-coupled state advancement)
F-16 drift corrector
F-19 response-ID chain (Tier-3 replay)
F-22 schema-derived bytes
Hot-publish state mutation (Phase-2 ratchet)
Cooperative recovery (F-24/F-25)
ML-KEM onboarding (F-07)
Payload channel (F-26/F-27/F-28)
WebAuthn / SSO panel auth

Binary + crate layout

  • edssa-proxy — the Enterprise verifier. Same crate as the CE-substrate proxy, but built with default-features on edssa-core (the enterprise cargo umbrella) + the manifest’s Enterprise-shaped fields enabled.
  • edssa-panel — the Enterprise operator panel. WebAuthn auth
    • /tenants + /sso/* routes + the per-fleet plasticity / audit-tier / rate-limit edit flows.
  • edssa-tenant — multi-tenant hierarchy types (Tenant, Organization, FleetRef, TenantStore trait). Consumed by edssa-panel (RBAC, UI) + edssa-audit (per-tenant Merkle scope).
  • edssa-sso — SSO provider abstraction (AuthProvider trait, AuthProviderRegistry, flow types). Concrete samael
    • openidconnect impls land behind cargo features in customer-driven follow-up batches.
  • edssa-admin — operator CLI for on-box inspection (manifest dump, trigger registry, fleet detail, handshake apply).

Architectural calls worth knowing

  • CE / Enterprise share the engine. edssa-core’s hot path (verify_token, EdssaCore, EdssaRouter) is identical between the two builds — the differences are in which modules link in (the enterprise cargo feature gates relay, swarm, oracle, weaver, drift::MedianOf3) and which manifest fields are accepted at boot.
  • The validation gate is the load-bearing boundary, not the feature flag. CE’s edssa-server-ce validates incoming manifests at boot and rejects Enterprise-shaped fields with a clear error message + docs pointer; the feature flag is defence-in-depth (no Enterprise symbols are compiled into the CE binary either). Operators upgrading from CE to Enterprise change the binary, not the manifest format.
  • The same wire format works in both builds. CE clients + Enterprise clients are bit-for-bit interchangeable; the only difference is what the verifier does with the token after authenticating it (multi-fleet routing, per-tenant audit, rate-limit enforcement, Tier-3 response chain, etc).

Where to next

  • New to multi-tenant operations? Start with Multi-tenant.
  • Integrating SAML / OIDC for the operator panel? See SSO.
  • Tuning rate limits + observability? See Production hardening.
  • General operator concerns (deployment, seed rotation, observability surface) are still in the CE Operator runbook — most of it applies to Enterprise unchanged.

Multi-tenant

Enterprise replaces CE’s single-fleet-per-process model with a three-level hierarchy:

   Tenant            ← billing boundary + audit isolation boundary
     └── Organization  ← RBAC boundary + SSO IdP boundary
         └── Fleet     ← already exists in CE; FleetSpec from the proxy manifest

Why three levels (not two)

  • One paying account at “AcmeCorp” (Tenant) might run separate Organizations for “AcmeCorp / Prod” and “AcmeCorp / Staging” with different SSO IdPs and different fleet sets.
  • A reseller / MSP runs one Tenant per end-customer, each with multiple Organizations underneath.
  • The verifier’s hot path keys cryptographic isolation (per-tenant Merkle chains) at the Tenant level. RBAC checks (RBAC) cap cross-tenant queries at 403 well before the store layer reads.

Data model

The types live in the edssa-tenant crate; the panel mounts an Arc<dyn TenantStore> in AppState.

#![allow(unused)]
fn main() {
pub struct Tenant {
    pub id: TenantId,              // ADR-004 format
    pub display_name: String,
    pub contact_email: Option<String>,
    pub active: bool,               // false → read-only freeze
}

pub struct Organization {
    pub id: OrganizationId,
    pub tenant_id: TenantId,
    pub display_name: String,
    pub sso_provider_ref: Option<String>,  // matches an AuthProviderRegistry key
}

pub struct FleetRef {
    pub fleet_id: FleetId,
    pub organization_id: OrganizationId,
    pub tenant_id: TenantId,
}
}

FleetRef carries only the hierarchy link — the actual FleetSpec state (width_N, chaff_C, threshold_T, seed_path, audit_tier, rate_limit_policy, …) stays in edssa-proxy::manifest::FleetSpec. The two are joined at operator-view-time by fleet_id.

Boot-time seeding

The panel reads EDSSA_PANEL_TENANT_SEED at boot. If unset → empty store (the /tenants list view renders an empty-state card). If set → JSON file with the shape:

{
  "tenants": [
    {
      "id": "acme",
      "display_name": "AcmeCorp",
      "contact_email": "ops@acme.example",
      "active": true
    }
  ],
  "organizations": [
    {
      "id": "acme-prod",
      "tenant_id": "acme",
      "display_name": "AcmeCorp / Prod",
      "sso_provider_ref": "saml:acme-okta"
    }
  ],
  "fleets": [
    {
      "fleet_id": "c1b2-demo",
      "organization_id": "acme-prod",
      "tenant_id": "acme"
    }
  ]
}

Field shapes follow edssa-tenant directly. Insert order matters: tenants → orgs → fleets. A dangling org (referencing a tenant that wasn’t declared first) fails boot with the offending ID in the error message — no silent acceptance of inconsistent seeds.

The in-memory store survives only for the process lifetime. Cross-host panel deployments need a shared backend; the Postgres-backed store lands behind a tenant-store-postgres cargo feature in a customer-driven follow-up. The TenantStore trait is the stable surface — switching backends doesn’t touch panel handlers.

Per-fleet manifest binding

The proxy’s fleets.toml gains an optional tenant_id field per fleet (and an optional manifest-wide default in [defaults]):

version = 1

[defaults]
tenant_id = "acme"

[[fleet]]
id        = "c1b2-demo"
seed_path = "seeds/c1b2-demo.seed"
preset    = "balanced"
# `tenant_id` inherits "acme" from defaults; overridable per-fleet:
# tenant_id = "acme-staging"

When set, the proxy:

  • Stamps every emitted AuditEvent with the tenant scope.
  • Initializes the Tier-4 Merkle aggregator with a domain- separated genesis derived from (tenant_id, fleet_id).
  • Writes transparency logs to <root>/<date>/<tenant>/<fleet>.log.

SIGHUP-reload classifies tenant_id diffs as unsupported_change (mid-flight retag would mix tenants inside a Merkle anchor); operators must restart the proxy to rebind a fleet to a different tenant.

Cryptographic isolation

Two tenants running fleets with the same fleet ID produce distinct Merkle chains by construction. The Tier-4 aggregator uses MerkleAggregator::with_tenant_scope(batch_size, tenant_id, fleet_id) for fresh chains; the genesis prev_root is derived as:

SHA-256("edssa-audit-tenant-genesis-v1"
        || len(tenant_id)_le || tenant_id
        || len(fleet_id)_le  || fleet_id)

Length-prefixed encoding prevents ("a", "bc") = ("ab", "c") collisions. The domain tag prevents confusion with any other tenant-scoped derivation. Operators verifying anchors offline can re-derive the same genesis from edssa_audit::tenant_scoped_genesis.

Filesystem isolation

The daily TransparencyLogWriter::emit_day walks tenants via AnchorStore::tenants(), then per-tenant fleets via fleets_scoped(tenant), writing one file per (tenant, fleet) pair at tenant_scoped_transparency_path:

<root>/YYYY-MM-DD/<fleet>.log              ← legacy / single-tenant
<root>/YYYY-MM-DD/<tenant>/<fleet>.log     ← Phase-9 tenant-scoped

The tenant boundary surfaces in the public URL: Caddy serves https://edssa.io/transparency/<date>/<tenant>/<fleet>.log and the per-tenant Caddy block enforces SSO + tenant scope on access.

Audit-store SQLite schema

The merkle_anchors table gains a tenant_id TEXT column on first open (idempotent ALTER TABLE ADD COLUMN migration). Pre- Phase-9 databases pick up the column with NULL on existing rows — legacy callers (the panel + admin pre-multi-tenant) keep reading the NULL-tenant rows they always wrote. New tenant- scoped inserts land alongside; queries dispatched via IS operator (NULL-safe equality) so latest_scoped(None, fleet) returns the legacy rows and latest_scoped(Some("acme"), fleet) returns the tenant-scoped ones — never cross-contaminated.

Per-user-tenant RBAC

Sessions issued via SSO carry the resolved (subject, tenant, org) triple in the cookie. The session signer dispatches on field count:

  • Legacy (2-field <user_id>|<exp>) — WebAuthn sessions from pre-Phase-9 / single-passkey deployments. No tenant scope; operator-admin view applies (all tenants visible).
  • Tenant-scoped (4-field <subject>|<tenant>|<org>|<exp>) — every SSO-authenticated session.

Panel handlers calling current_session(headers, auth) get a typed SessionPayload enum. Cross-tenant access at /tenants/:id or /tenants/:id/orgs/:org returns 403, not 404 — operators see “user wrong tenant” distinct from “tenant doesn’t exist” in logs + metrics.

The 403 fires before the store lookup runs (defence in depth + no oracle behaviour around “did this tenant exist?”). For the list view at /tenants, the handler filters to the session’s tenant when scoped; legacy sessions see all tenants.

Panel routes

RouteBehaviour
GET /tenantsList configured tenants. Tenant-scoped sessions see only their own.
GET /tenants/:tenant_idTenant detail + org list. 403 if session tenant differs.
GET /tenants/:tenant_id/orgs/:org_idOrganisation detail + fleet list. Same 403 rule.

All three live under the existing require_session middleware (auth-gated; redirect to /login for unauthenticated browsers, 401 for API clients).

Errors

TenantStore returns typed variants:

  • TenantNotFound(TenantId) — 404-shaped at the handler.
  • OrganizationNotFound { tenant, organization } — 404.
  • FleetNotFound { tenant, organization, fleet } — 404.
  • Duplicate(String) — boot-time insert collision (same tenant, same org id; or same fleet ID re-claimed within one tenant).
  • CrossTenantAccess { requested, actual_tenant }distinct from Duplicate when a fleet ID collision crosses tenant boundaries. Logged at WARN so SOC 2 audit trails flag the attack-shape attempt.
  • Backend(String) — storage layer error; opaque to callers.

Pen-test guarantees

The Phase-9 multi-tenant pen-test exit criterion is satisfied by three independent isolation layers:

  1. CryptographicMerkleAggregator::with_tenant_scope produces distinct chains; cross-tenant Merkle confusion is mathematically blocked, not configuration-dependent.
  2. Filesystemtenant_scoped_transparency_path produces distinct on-disk URLs; Caddy enforces SSO + tenant on access.
  3. RBACenforce_tenant_rbac returns 403 for cross-tenant session/path mismatch before the store sees the query.

Each layer’s failure mode is distinct in logs and metrics, so a penetration test exercising one doesn’t accidentally pass another. The substrate-level guarantee is repeatable; the production-environment walk-through is the SOC 2 auditor’s deliverable.

SSO

The Phase-5 WebAuthn flow stays the default for single-user panel operators. Phase-9 adds SAML 2.0 + OIDC as opt-in per-organisation SSO providers behind a uniform AuthProvider trait.

Architecture

Three pieces:

  1. edssa-sso crate — trait + types + registry only. No samael / openidconnect compile dep; concrete providers live in sibling crates behind cargo features.
  2. edssa-panel SSO routes/sso/:provider_ref/start + /sso/:provider_ref/callback consume the registry; the nonce ceremony state lives in panel AppState.
  3. Concrete SAML / OIDC providerssamael-provider / openidconnect-provider cargo features land behind customer-driven follow-up batches. The substrate ships today; the protocol implementations land per-customer.

AuthProvider trait

#![allow(unused)]
fn main() {
pub trait AuthProvider: Send + Sync {
    fn id(&self) -> &str;                  // matches Organization::sso_provider_ref
    fn protocol(&self) -> AuthProtocol;    // Saml / Oidc / WebAuthn
    fn organization_id(&self) -> &OrganizationId;

    fn begin_login(
        &self,
        request: BeginLoginRequest,         // { return_url, state_nonce }
    ) -> Result<BeginLoginResponse, AuthError>;

    fn complete_login(
        &self,
        request: CompleteLoginRequest,      // { callback_data, state_nonce }
    ) -> Result<AuthenticatedPrincipal, AuthError>;
}
}

The trait is intentionally two-step. begin_login produces either a redirect URL (SAML / OIDC) or a challenge blob (WebAuthn-via-trait providers); complete_login validates the IdP callback / WebAuthn assertion and returns an AuthenticatedPrincipal { subject_id, tenant_id, organization_id, email, display_name, groups, claims }.

CSRF nonce contract

The trait’s state_nonce field is the CSRF guard. Provider impls MUST:

  1. Embed the panel’s state_nonce in the IdP’s state parameter (SAML RelayState, OIDC state).
  2. Surface the same value back through the IdP callback into CompleteLoginRequest::state_nonce.
  3. Return AuthError::InvalidNonce on mismatch.

The panel separately verifies the nonce against its in-memory SsoFlows pending-map (10-minute TTL, single-use). Double check: the panel rejects forged callbacks that never went through /start; the provider rejects IdP responses whose state echo doesn’t match. Each failure mode is distinct in logs.

Provider registry

AuthProviderRegistry is an RwLock<HashMap<String, Arc<dyn AuthProvider>>> keyed by sso_provider_ref. Read-mostly: many concurrent get_by_ref reads, occasional register writes when an operator adds an IdP via the panel.

#![allow(unused)]
fn main() {
let registry = Arc::new(AuthProviderRegistry::new());
registry.register(Arc::new(SamlProvider::new(...)))?;
}

Duplicates fail at registration with RegistryError::Duplicate(id) — runtime collisions indicate a configuration bug the operator should fix.

Panel routes

RouteBehaviour
GET /sso/:provider_ref/start?return_url=...404 if unknown provider; 307 redirect (SAML/OIDC) or JSON { kind: "challenge", state_nonce, challenge: <base64> } (WebAuthn-via-trait)
POST /sso/:provider_ref/callbackBody: { state_nonce, callback_data: <base64> }. On success: issues tenant-scoped session cookie + JSON { ok: true, redirect: <return_url> }. On failure: 401 with empty body (IdP error fingerprinting prevention).

The callback handler:

  1. Reads state_nonce from the request body.
  2. Consumes the nonce from SsoFlows (single-use; replay protection).
  3. Verifies the callback’s :provider_ref matches what /start stashed alongside the nonce — defence in depth against shuttling a stolen nonce across providers.
  4. Re-resolves the provider (an operator could have removed the IdP between /start and /callback).
  5. Base64-decodes callback_data.
  6. Calls complete_login.
  7. On success: issues auth.issue_tenant_scoped_session_cookie(subject, tenant, org).

Session payload formats

The SessionSigner dispatches on field count:

Legacy        (2 fields): <user_id>|<exp>
Tenant-scoped (4 fields): <subject>|<tenant_id>|<org_id>|<exp>

SessionSigner::verify(token, now) -> Option<String> continues to return the subject for both shapes — pre-Phase-9 callers keep working unchanged. New callers (the RBAC middleware) use verify_payload(token, now) -> Option<SessionPayload> to get the typed enum.

WebAuthn callbacks issue legacy sessions; SSO callbacks issue tenant-scoped sessions. Mixed deployments (some passkey operators + some SSO users) compose cleanly.

AuthenticatedPrincipal

#![allow(unused)]
fn main() {
pub struct AuthenticatedPrincipal {
    pub subject_id: SubjectId,          // IdP NameID / sub / WebAuthn user handle
    pub tenant_id: TenantId,            // required; provider knows its tenant
    pub organization_id: OrganizationId,
    pub email: Option<String>,
    pub display_name: Option<String>,
    pub groups: Vec<String>,            // IdP group memberships → RBAC roles
    pub claims: BTreeMap<String, String>, // BTreeMap → deterministic JSON for audit
}
}

SubjectId accepts UUIDs, email addresses, opaque base64url user handles — the IdP’s contract. The validator only rejects empty strings and tokens longer than 256 chars.

claims uses BTreeMap so the serialised JSON has deterministic key order — audit-log reproducibility depends on this for byte-for-byte hash stability across runs.

Operator workflow: registering a SAML IdP

  1. Operator obtains the IdP’s metadata (Okta / Azure AD / ADFS admin UI).
  2. Operator configures the provider via the samael-provider cargo feature’s bootstrap config (when that ships in a future batch).
  3. Panel registers the provider at process startup: registry.register(Arc::new(SamlProvider::new(...)))?.
  4. Operator updates the org’s Organization::sso_provider_ref in the tenant store seed JSON.
  5. Users in that org get redirected through SAML on next /login; the callback issues a tenant-scoped session cookie keyed on (subject, tenant, org).

Why no samael / openidconnect in edssa-sso

samael pulls in OpenSSL bindings; openidconnect drags reqwest + a large futures stack. Keeping edssa-sso lean means:

  • Crates that only need the trait (panel session middleware, future audit-event SSO tagger, RBAC matcher) compile fast.
  • Concrete providers live in sibling crates that opt into the protocol-specific deps.
  • Customer-driven IdP integrations (most enterprise SAML wiring targets one specific IdP at a time) don’t have to drag the full SAML / OIDC dep graph into deployments that don’t need it.

WebAuthn fallback

For organisations without an external IdP, the Phase-5 WebAuthn flow continues to work at /login/register/start + /login/authenticate/finish. Operator-admin sessions (single- passkey, no tenant scope) coexist with SSO sessions: the panel’s enforce_tenant_rbac returns None for legacy sessions (operator-admin view) and gates by tenant for tenant-scoped sessions.

This preserves the upgrade path: existing single-operator deployments don’t break when SSO providers register. The WebAuthn ceremony’s CBOR shape doesn’t currently route through the AuthProvider trait (the Challenge { challenge: bytes } variant exists for future opt-in but adds CBOR-round-trip friction without unlocking new behaviour today).

Errors

AuthError variantHTTP statusBodyAudit
InvalidNonce401emptyyes
InvalidCallback(reason)401emptyyes (reason in tracing log only — IdP-error-message-fingerprinting prevention)
InvalidProtocol(reason)400emptyyes
Network(reason)502emptyyes
Internal(reason)500emptyyes (escalation)

401 bodies are intentionally empty (ADR-006) to avoid leaking “this account exists” / “your IdP rejected the assertion for reason X” oracle behaviour to attackers.

Production hardening

Phase 9 adds a three-layer rate limiter that composes with the existing replay defences. The cryptographic substrate (Bloom / sliding window / response chain) catches token reuse; the rate limiter catches floods of novel tokens that the cryptographic layer happily accepts as fresh.

The three layers

LayerFiresKeyPurposeAudit emit
Per-source-IPBefore header parseresolved client IPDDoS prevention; unknown-fleet floods rejected at cheapest layerSkipped (DDoS fast-path)
Per-fleetAfter fleet resolution, before verifyfleet_idDDoS prevention; single-fleet flood before verify CPU burnsSkipped (DDoS fast-path)
Per-(fleet, sub-ID)After verify + range checkfleet_id:sub_idForensic abuse detection; compromised credential being burnedEmitted at Tier ≥ Errors with reason sub-id-rate-limited

The asymmetric audit treatment is intentional: the two DDoS- prevention layers bypass the audit emitter (ring-buffer back- pressure under sustained flood is exactly the failure mode they prevent); the forensic layer emits because operators need “credential X is being abused” in the audit log, and the per- sub-ID throughput is by definition low enough that emitting per throttle isn’t a back-pressure surface.

Configuration

Each layer is configured independently via env. The boot default is unlimited() — short-circuits to LimitOutcome::Allowed with zero map allocation, so deployments don’t see a hot-path regression on upgrade.

Per-source-IP

Env varDefaultMeaning
EDSSA_RATELIMIT_SOURCE_IP_REFILL_PER_SEC(unset)Tokens/sec per IP; absent = unlimited
EDSSA_RATELIMIT_SOURCE_IP_BURST50Burst capacity per IP
EDSSA_RATELIMIT_SOURCE_IP_MAX_KEYS65536Hard cap on stored IP buckets (LRU; fail-open at cap)

Per-fleet (global default)

Env varDefaultMeaning
EDSSA_RATELIMIT_REFILL_PER_SEC(unset)Tokens/sec per fleet; absent = unlimited
EDSSA_RATELIMIT_BURST200Burst capacity per fleet
EDSSA_RATELIMIT_MAX_KEYS4096Hard cap on stored fleet buckets

Per-fleet overrides land in the manifest (see below).

Per-(fleet, sub-ID)

Env varDefaultMeaning
EDSSA_RATELIMIT_SUB_ID_REFILL_PER_SEC(unset)Tokens/sec per (fleet, sub-ID); absent = unlimited
EDSSA_RATELIMIT_SUB_ID_BURST20Burst capacity per (fleet, sub-ID)
EDSSA_RATELIMIT_SUB_ID_MAX_KEYS32768Hard cap on stored (fleet, sub-ID) buckets

Per-fleet manifest override

Manifest gains optional per-fleet rate-limit fields. Both must be set together (or both omitted); partial overrides are rejected at boot.

[[fleet]]
id                        = "prod-hft"
seed_path                 = "seeds/prod-hft.seed"
preset                    = "high-security"
rate_limit_burst          = 50       # tighter than the global default
rate_limit_refill_per_sec = 25.0

SIGHUP-reload applies the new policy on the next request. The bucket’s accumulated (tokens, last_refill) carry forward across the policy change — refill uses the new rate; tokens clamp to the new burst. This is the right semantics for live tuning: operators don’t want a “reset” on every policy bump.

XFF chain resolver

The per-source-IP limiter needs the real client IP, not the upstream proxy’s. In production the proxy sits behind one or more layers (Caddy / Cloudflare / a cloud LB); the TCP peer IP is the upstream’s, and the real client IP is in X-Forwarded-For.

Trust list

EDSSA_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,2001:db8::/32,fc00::/7

Comma-separated mix of bare IPs and CIDR ranges (IPv4 + IPv6). Empty trust list (the boot default) disables XFF parsing entirely — operators behind a real upstream MUST set this; otherwise the limiter throttles by upstream IP only. This is the loud-failure-safe default: aggressive throttling on the upstream fires immediately and operators fix the env var.

Algorithm

  1. Read X-Forwarded-For: ip1, ip2, ip3, ….
  2. If trust list is empty → return peer IP (XFF parsing disabled).
  3. If peer IP is not in the trust list → return peer IP. The request didn’t traverse a trusted proxy, so XFF is meaningless. Defends against an attacker speaking directly to the proxy with a spoofed XFF header.
  4. Walk XFF rightmost-first; drop trusted hops; first untrusted entry is the client.
  5. Malformed XFF entry → bail to peer IP. Attacker-injected garbage never silently uses a stale chain entry.
  6. Every XFF entry trusted → log warn + return leftmost as best-effort (operator’s trust list covers the client’s network; the warn is observable).

Why rightmost-first walk is safe against spoofing

X-Forwarded-For is a hop-by-hop header set by upstream proxies. An attacker speaking directly to the proxy with X-Forwarded-For: 1.2.3.4 has the peer IP appended after the spoofed value. The rightmost-first walk reaches the peer (untrusted, since the attacker isn’t in the trust list) and returns peer — never the spoofed value.

The only way to forge a client IP is to be a trusted proxy yourself, which is the operator’s security boundary by definition.

Reject responses

All three layers return:

  • HTTP 429 Too Many Requests (not 401).
  • Retry-After: <seconds> header. Rounded up to 1 second minimum so clients don’t hot-loop. Capped at u32::MAX so the header value stays sane even for never-refill buckets (refill_per_sec = 0).
  • Empty body (ADR-006).

The per-source-IP + per-fleet layers do NOT call the audit emitter; the per-(fleet, sub-ID) layer DOES (security-relevant). All three increment Prometheus counters regardless.

Observability

Counters

MetricLabelsWhen
edssa_source_ip_throttled_totalscope (always "source-ip")per-source-IP 429
edssa_ratelimit_throttled_totalfleetper-fleet 429
edssa_sub_id_throttled_totalfleet (sub-ID NOT a label; SUB_ID_MAX cardinality would explode Prometheus)per-(fleet, sub-ID) 429

Gauges

MetricLabelsSampled
edssa_ratelimit_key_countscope (fleet / source-ip / sub-id)1 Hz via tokio task
edssa_ratelimit_max_keysscopeonce at boot (constant)

Alert on edssa_ratelimit_key_count{scope=...} / edssa_ratelimit_max_keys{scope=...} > 0.8 to spot when an LRU cap is being approached. Fail-open semantics mean the limiter doesn’t block traffic at cap — but operators should bump the cap before the unobserved tail starts breaking isolation.

Existing DDoS fast-path

The pre-Phase-9 reject paths for malformed headers (missing-header / malformed-header / wrong-length) already bypass the audit emitter — they increment the edssa_rejects_total{reason} counter only. The new 429 rate- limit reject paths follow the same discipline. Operators auditing the hot path can confirm: the audit emitter (audit_sink.emit) is only reached after fleet resolution succeeds AND the rate-limit gates pass.

Operator runbook

Tuning checklist

  1. Boot with unlimited (the default). Measure baseline traffic for a week.
  2. Set per-source-IP first — the cheapest layer, easiest to over-tighten. Start at 5× peak observed QPS per IP; alert on edssa_source_ip_throttled_total rate > 0 for legitimate clients.
  3. Set per-fleet next — global default; per-fleet manifest override for outliers. Tight bucket for HFT-grade fleets; loose for public-API fleets.
  4. Set per-sub-ID last — the abuse-detection layer. Start permissive; tighten when audit-log shows sub-id-rate-limited rejects correlated with suspicious sub-IDs.
  5. Configure EDSSA_TRUSTED_PROXIES the moment the proxy sits behind any upstream. Without it, per-source-IP throttles by upstream IP only (every client looks like one IP).

Suspected attack response

  • edssa_source_ip_throttled_total{scope="source-ip"} spike alone → DDoS attempt; the per-source-IP layer is doing its job. Confirm peer IPs via tracing logs.
  • edssa_ratelimit_throttled_total{fleet=...} spike → legitimate-looking traffic to one fleet. Check whether the fleet’s seed was rotated recently (legitimate spike) or if a credential has leaked.
  • edssa_sub_id_throttled_total{fleet=...} + audit-log sub-id-rate-limited rejects → forensic signal that a specific credential is being abused. Rotate the seed (F-25 cooperative recovery) immediately; investigate the audit trail for the affected sub-ID.

What’s NOT in this batch

  • Per-fleet rate-limit panel UI. Today operators edit fleets.toml directly and SIGHUP. A future batch lands a /fleets/:id/rate-limit form posting to a SIGHUP-trigger flow.
  • Audit Tier-3 trace surfacing rate-limit state. The bucket’s (tokens, last_refill) snapshot at emit time would let forensic analysis replay the throttle decision.
  • Criterion bench suite over the limiter hot path under simulated 10k QPS — operator capacity planning needs concrete numbers; lands as a focused perf-pass batch.

SDK reference

Three SDKs ship with Phase-8 (Node/TS is deferred to Phase 9 per ADR-001 / D-6).

Rust — edssa-core

The native engine crate. Anything you can do at the CE binary layer, you can do directly from Rust.

cargo add edssa-core
#![allow(unused)]
fn main() {
use edssa_core::{verify_token, ActiveEdssaState, encode_sub_id};

fn check(token: &[u8; 64], state: &ActiveEdssaState<64>) -> bool {
    verify_token(token, state).accepted
}
}
  • Source: code/edssa-core/.
  • Feature flags: the enterprise umbrella (default-on) gates the oracle, orbit, relay, swarm, and weaver modules (plus their re-exports, e.g. MedianOf3). CE consumers should set default-features = false; the CE-visible surface (verify_token, construct_token, ActiveEdssaState, EdssaCore, EdssaRouter, RatchetState, encode_sub_id / decode_sub_id, SUB_ID_SLOTS, SUB_ID_MAX) stays available.
  • Stability: the engine API (verify_token, construct_token, ActiveEdssaState, EdssaCore, EdssaRouter) is the patent surface. Other modules are evolving and may change between minor versions.

Go — github.com/edssa-io/edssa-go

cgo binding over edssa-core-ffi (Phase-8 batch 4 substrate). The Go SDK statically links the C ABI, so consumers don’t ship the edssa-core-ffi shared library separately.

import "github.com/edssa-io/edssa-go"

state, err := edssa.NewStateFromSeed(seed, edssa.Balanced)
if err != nil { ... }
defer state.Close()

ok := state.Verify(token)

Python — pip install edssa

PyO3 wrapper. Same engine, same wire format.

import edssa

state = edssa.State.from_seed(seed, preset="balanced")
ok    = state.verify(token)

Node / TypeScript

Deferred to Phase 9 (ADR-001 / D-6).

Choosing between SDKs

Use caseBest fit
Rust microservice / Tokio runtimeedssa-core directly (no SDK shim)
Go service, gRPC interceptoredssa-go
Python web API (FastAPI / Django / Flask)edssa-py
Polyglot service meshRun the CE binary as a sidecar; let the SDKs handle minting only

The CE binary is the integration story for non-Rust services that don’t want a build-time SDK dep — drop the sidecar in, point your service at it, and your service stays language-agnostic.

Operator runbook

Day-to-day operations for an edssa-server-ce deployment.

Deployment topologies

┌──────────── Pod / VM ────────────┐
│  edssa-server-ce :8080  ──▶ app   │
│                                   │
└─── Ingress :80 / :443 ────────────┘

edssa-server-ce listens on :8080; your app listens on something the sidecar reaches over loopback. An external load balancer terminates TLS and forwards :443 → :8080.

This is the samples/nginx-quickstart

  • samples/k8s-helm pattern. CE supports nothing else by design — multi-tenant gateway patterns are Enterprise.

Behind a TLS edge

edssa-server-ce speaks plain HTTP. Put Caddy / nginx / a managed load balancer in front for TLS:

client ──TLS──▶ Caddy ──HTTP──▶ edssa-server-ce ──HTTP──▶ app

The CE binary does not verify TLS itself — it relies on the trust boundary at the TLS-terminating proxy. If you need mTLS or a cryptographic edge channel, layer it at the edge.

Seed provisioning

Phase 8 ships the same seed-provisioning bridge as Phase 1 (ADR-003): the operator generates seeds locally, encrypts at rest with age, and copies to the box. Phase 6 (Enterprise-only) adds ML-KEM onboarding (F-07) and makes seeds ephemeral.

# 1. Generate
LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom \
  | head -c 1024 > seeds/fleet-demo.seed

# 2. Encrypt at rest against the box's age recipient
age -R seeds/box.age-pub \
    -o seeds/fleet-demo.seed.age seeds/fleet-demo.seed

# 3. scp to the box
scp seeds/fleet-demo.seed.age ops@box:/opt/edssa/secrets/

# 4. Operator decrypts at container start; plaintext stays on tmpfs

For the CE Helm chart, the seed lives in a Kubernetes Secret the chart references by name (not value) — operators provision it out-of-band so it never appears in helm get values.

Rotating the seed

CE first cut does not run a ratchetActiveEdssaState is derived from the seed at boot and never advances. Rotating the seed therefore means rotating clients in lockstep:

  1. Stop accepting new sessions (out-of-band signal, e.g. set a feature flag in your app).
  2. Provision the new seed everywhere.
  3. Restart edssa-server-ce.
  4. Restart clients with the new seed.

Enterprise’s ratchet driver lets you publish new state under a running verifier without disrupting in-flight requests. CE sidesteps the complexity at the cost of a brief restart window.

Observability

CE intentionally ships minimal observability — no Prometheus endpoint, no transparency log, no F-20 ring buffer. What you get:

  • Tracing logs to stdout in JSON. Filter via RUST_LOG. Audit emissions follow the tier:
    • silent → no per-request emission.
    • errorsinfo-level emission on every reject.
    • resultinfo-level emission on every accept AND reject.
  • /healthz for liveness / readiness.

If you need metrics + traces + Tier-4 Merkle audit, run the Enterprise binary.

Common operations

“How do I add a fleet?”

You don’t. CE is one fleet per process. Spin up another CE binary for the second fleet, or upgrade to the Enterprise build for multi-fleet routing.

“How do I tighten the security posture?”

Switch the manifest preset:

  • preset = "high-security"T = 44 (no margin; every secret byte must match).
  • Drop sub_id_range to a tight band, e.g. [1, 8], so a compromised sub-ID claim from one client can’t pose as another.

Per-byte tightening:

  • Set chaff_C higher → smaller R_eff but smaller observable attack surface.
  • Set threshold_T close to R_eff → less resilience, more bits of effective secrecy.

“How do I temporarily disable enforcement?”

Don’t. If you need a bypass, put a feature flag in your app and route the bypassed traffic to the upstream directly — never wave the sidecar through.

“What happens if the seed file disappears?”

edssa-server-ce fails to start with seed_path does not exist. The previous binary keeps running; the next restart fails. Tie the restart loop into your platform’s “stop accepting new connections” behaviour so a missing seed = service degraded, not service down.

Patent advisory

EdSSA is patent-pending. The Community Edition ships under BSL 1.1 with an explicit non-revocable patent grant for permitted use (non-commercial OR commercial ≤ $1M ARR). See Patent advisory.

Patent advisory

EdSSA is patent-pending. This page exists so operators and contributors know where the patent claims land, what the Community Edition license grant covers, and how to find the public docket.

Scope

The EdSSA patent application covers the credential-construction

  • verification methodology — the use of:
  • a per-fleet seed expanded into per-slot expected bytes;
  • random-byte chaff slots whose positions are part of the verifier state;
  • a threshold-based byte-match acceptance shape (not strict byte-for-byte equality);
  • per-request sub-identifier claims that bind the verification to a sub-fleet / sub-tenant;
  • the cellular ratchet driving expected bytes forward on a clock-derived cadence with a median-of-3 drift corrector.

(Patent figures FIG. 1–FIG. 4 are referenced from the Architecture page; they will be embedded here once the A1 publication ships.)

License grant (BSL 1.1 + Additional Use Grant)

The Community Edition ships under the Business Source License 1.1 with these specific terms (per ADR-001 / D-5 in the roadmap):

TermValue
LicenseBusiness Source License 1.1
Change LicenseApache-2.0
Change Date4 years after each commit, per file
Additional Use Grant“non-commercial OR commercial ≤ $1M ARR”
Patent grantNon-revocable, scoped to permitted use

Practically: you can use, modify, and redistribute the Community Edition for personal projects, OSS dependencies, internal tooling, and commercial production deployments as long as your annual revenue from products incorporating EdSSA stays under $1M. The patent grant explicitly covers the permitted use — using EdSSA at those scales does not infringe the patent.

Above $1M ARR you need a commercial license — contact legal@edssa.io.

What’s NOT in the CE patent grant

  • Use of the Enterprise-only patent claims (F-03 swarm, F-07 ML-KEM onboarding, F-16 drift corrector, F-19 response-ID chain, F-21 Tier-4 Merkle audit, F-24/F-25 cooperative recovery, F-26..28 payload channel) — these ship under separate Enterprise terms.
  • Use of the trademarks “EdSSA” or “Parity Express” beyond describing your use of the product. Standard nominative-use is fine; using the marks in your own product name is not.
  • Redistribution of the Community Edition as a managed service (“EdSSA-as-a-Service”) above the $1M ARR threshold.

Public docket

The patent application is in prosecution at the time of the v1.0.0-ce release. After A1 publication (≈18 months from filing priority date) the full specification is publicly searchable. The EdSSA project maintains a redacted public copy at:

📌 Link pending publication. The public-docket URL lands here as part of the Phase-8 public-migration step, alongside the Caddy block for docs.edssa.io.

Frequently asked

Can I fork the Community Edition?

Yes, with these conditions: the fork must retain the BSL 1.1 license text + the patent grant; the Change Date and Change License apply per-file as committed; and the Additional Use Grant threshold ($1M ARR) applies to the fork’s commercial use of the patent claims, not the fork’s existence.

Can I write an SDK for the Community Edition?

Yes. SDKs that wrap the wire format / engine surface fall under the same Additional Use Grant. The Rust / Go / Python SDKs ship from EdSSA-controlled repos; community SDKs are welcome — open an issue at github.com/edssa-io/edssa so we can link to it from the SDK reference.

Is there a “non-asserted” patent clause?

The Additional Use Grant is the binding text. There is no separate non-assertion pledge — but the patent grant is non-revocable for permitted use, which is the practically identical outcome.

legal@edssa.io. The team replies within 2 business days for license-clarification questions; commercial license negotiations take longer.