No description
  • Scheme 75.1%
  • Shell 12.3%
  • Rust 10.3%
  • Makefile 1.2%
  • Python 1%
Find a file
ober 403898d18e
Some checks failed
required-ci / required (push) Failing after 3m44s
Merge pull request #21
2026-09-23 18:04:14 -04:00
.forgejo Use FreeBSD Forgejo CI runner 2026-08-03 14:52:03 -06:00
.jerboa Security hardening and release readiness 2026-06-23 10:51:31 -06:00
bin feat: add native Genode SMTP package 2026-08-25 16:41:06 -06:00
docs Pass numeric NULL to privilege-drop setgroups FFI 2026-09-05 21:25:09 -06:00
examples fix: close security audit findings 2026-07-11 17:25:10 -06:00
fuzz feat: complete sending mail pipeline 2026-08-04 19:52:57 -06:00
lib/jerboa-smtp Merge pull request #21 2026-09-23 18:04:14 -04:00
rust feat: add native Genode SMTP package 2026-08-25 16:41:06 -06:00
scripts Repair release soak DATA fixture required sender header 2026-09-05 21:18:07 -06:00
static feat: complete sending mail pipeline 2026-08-04 19:52:57 -06:00
support Harden required WASM parser admission and lifecycle boundaries 2026-09-05 21:09:16 -06:00
tests Merge pull request #21 2026-09-23 18:04:14 -04:00
wasm Harden inbound SMTP production policy 2026-08-20 18:35:48 -06:00
.gitignore feat: complete sending mail pipeline 2026-08-04 19:52:57 -06:00
.gitsafeignore chore: gitsafe fingerprints for vendor-lock env hashes 2026-08-03 18:08:05 -06:00
.jerbuild feat(wip): ECIES mail-store encryption + SQLite store + jsqlite vendoring 2026-08-03 18:09:17 -06:00
add-to-jerboa.md feat(wip): ECIES mail-store encryption + SQLite store + jsqlite vendoring 2026-08-03 18:09:17 -06:00
AGENTS.md docs(agents): add checkout hygiene policy (work dirs under ~/work, cleanup when done) 2026-09-12 19:09:34 -06:00
build.ss Add native file publication helper 2026-06-10 20:24:17 -06:00
jpkg.lock jpkg: complete binary package setup 2026-07-30 15:22:50 -06:00
jpkg.policy.sexp jpkg: complete binary package setup 2026-07-30 15:22:50 -06:00
jpkg.sexp bump package version for SMTP security fixes 2026-09-23 15:50:17 -06:00
LICENSE Switch to MIT license 2026-07-21 13:42:20 -06:00
Makefile feat: add native Genode SMTP package 2026-08-25 16:41:06 -06:00
not-prod.md docs: add MANDATORY RULES to prevent test tampering 2026-08-03 15:27:13 -06:00
plan.md feat: complete sending mail pipeline 2026-08-04 19:52:57 -06:00
PRODUCTION-STATUS.md Add outbound DKIM signing 2026-08-21 12:53:47 -06:00
README.md Harden required WASM parser admission and lifecycle boundaries 2026-09-05 21:09:16 -06:00
secure2-kimi.md docs: secure2 handoff + protected validation tests for local LLM 2026-08-03 18:22:24 -06:00
SECURITY.md fix: close security audit findings 2026-07-11 17:25:10 -06:00
sending-mail.md feat: complete sending mail pipeline 2026-08-04 19:52:57 -06:00
smpt-prod.md Harden SMTP deployment and add FreeBSD packaging 2026-08-01 16:32:45 -06:00
vendor-lock.env feat(wip): ECIES mail-store encryption + SQLite store + jsqlite vendoring 2026-08-03 18:09:17 -06:00
VERSION bump package version for SMTP security fixes 2026-09-23 15:50:17 -06:00

jerboa-smtp

Security-first SMTP server in Jerboa.

This project is starting from the same core security idea as jerboa-dns: Jerboa owns policy and orchestration, while hostile protocol bytes are parsed in small Rust WebAssembly modules with fixed caps and fuel.

Current Status

The first slice is project skeleton plus a sandboxed SMTP command parser:

  • Jerboa protocol representation for SMTP commands.
  • no_std Rust/WASM command parser with envelope reverse-path/forward-path parsing and normalization for MAIL FROM and RCPT TO.
  • no_std Rust/WASM DATA full-block framer for CRLF, terminator, dot-stuffing, line-limit, and NUL rejection checks.
  • Jerboa host wrapper that validates command and envelope metadata returned by the sandbox.
  • Pure Jerboa SMTP session state machine for command sequencing, no-relay policy, and explicit local-recipient allowlisting.
  • Strict JSON config loader with local-domain, local-recipient, IPv4, and resource-limit validation.
  • Durable queue admission API with sharded spool layout, unguessable queue IDs, envelope validation, atomic reserved quota counters, and metadata-as-admission-marker semantics.
  • Queue janitor with janitor --config FILE for safe temp-file cleanup and orphan message-file removal, including leftover delivery-marker temps.
  • no_std Rust native file publication helper linked into the standalone binary: open validated directories, create temp with openat(O_EXCL), write and fsync bytes, linkat into the final directory without overwriting, fsync the destination directory, then unlink temp by basename.
  • DATA-aware session path: DATA enters a pending state, the complete DATA block is framed in WASM, and accepted messages are queued before 250.
  • Binary-port SMTP listener with serve --config FILE [--once], configured IPv4 bind address, bounded concurrent workers, global/per-IP shedding, absolute command/DATA/connection deadlines, minimum DATA progress-rate enforcement, DATA collection, and queue handoff.
  • Local Maildir delivery worker with deliver --config FILE, configured local-recipient Maildir layout, native final-file publication, delivered markers published through native no-overwrite file operations, and idempotent repeat runs that clean stale incoming files after a delivered marker exists.
  • Smoke tests for plain parsing, sandboxed command parsing, sandboxed DATA parsing, config, native publication, queue admission, Maildir delivery, server connection handling, and session behavior.
  • Local pickup with jsmtp send, authenticated STARTTLS submission with serve --submission, Argon2id credential management, and outbound relay sweeps via jsmtp relay.

See plan.md for the full implementation plan.

Native Genode build and runtime packaging is documented in docs/genode.md.

Build And Test

make build
make test

Release evidence:

make release-evidence

The release bundle records SMTP load/slow-client status under dist/release-evidence/soak/. By default this is a blocked, record-only status; set JSMTP_RUN_RELEASE_SOAK=1 with production-scale JSMTP_SOAK_SESSIONS to capture current SMTP session evidence plus local DATA queueing and slow-DATA rejection smokes. Deployment confinement and STARTTLS/AUTH policy are documented in docs/deployment-hardening.md and copied into dist/release-evidence/deployment-policy.txt. Bounded local coverage-guided fuzz evidence is recorded with:

JSMTP_RUN_COVERAGE_FUZZ=1 JSMTP_FUZZ_RUNS=2048 make fuzz-evidence

When JSMTP_RUN_COVERAGE_FUZZ=1 is set for make release-evidence, the release bundle also records coverage_fuzz_status=local-smoke-recorded under dist/release-evidence/fuzz-evidence/ after smtp_command and smtp_data complete.

Build just the WASM parser:

make wasm

Run the CLI parser smoke:

make run ARGS='parse "EHLO mx.example"'

Run the pure session smoke:

make run ARGS='session "EHLO client.example" "MAIL FROM:<alice@example.net>" "RCPT TO:<bob@example.org>"'

The CLI session smoke uses the default config, which has no local domains, so that recipient is denied as relay by design.

Run the session smoke with validated local-recipient policy:

make run ARGS='session --config examples/jsmtp.json "EHLO client.example" "MAIL FROM:<alice@example.net>" "RCPT TO:<bob@local.test>"'

The command parser requires WASM by default. JSMTP_ALLOW_WASM_COMMAND_FALLBACK=1 enables the in-process Jerboa parser only for development fallback.

Run a single-connection listener:

make run ARGS='serve --config examples/jsmtp.json --once'

Deliver queued local messages once:

make run ARGS='deliver --config examples/jsmtp.json'

Queue locally generated outbound mail:

make run ARGS='send --config examples/jsmtp.json --from sender@local.test --to user@example.net' < message.eml

Run one outbound relay sweep:

make run ARGS='relay --config examples/jsmtp.json'

Run an authenticated submission listener:

make run ARGS='serve --config examples/jsmtp.json --submission'

Clean queue temp files and orphan message files:

make run ARGS='janitor --config examples/jsmtp.json'

Security Defaults

Production deployments must run as an unprivileged service identity behind service-manager confinement; see docs/deployment-hardening.md. Command parser fallback is development-only and requires JSMTP_ALLOW_WASM_COMMAND_FALLBACK=1.

The (std wasm sandbox) loader binds registered static symbols first in the standalone daemon. Development tests may still use jerbuild's trusted native library cache explicitly.

DATA can now be framed by the WASM parser and admitted through the queue API, including from the SMTP session state machine and the binary-port listener. Queued messages can be delivered to local Maildir storage by running the delivery worker.

Network DATA always uses the WASM parser. max_message_bytes defaults to 65536 (64 KiB) and cannot exceed it; EHLO SIZE advertises the configured ceiling. The listener rejects excess input before any plaintext temporary-file spill, and the sandbox API independently rejects inputs beyond its 64 KiB cap. Larger messages require a future bounded streaming WASM implementation.

Command, DATA, and DNS guest initialization, reset/allocation/invocation/output copy, and shutdown are serialized per parser. Positive guest output lengths must fit the allocated output buffer before host memory allocation. The exported decode-smtp-command-output, decode-smtp-data-output, and decode-dns-answer-output functions validate bounded, exactly consumed output packets; they perform no WASM invocation.

local_recipients is required in config. RCPT accepts only full normalized addresses in that allowlist; unknown local users are rejected during the SMTP transaction with 550 No such local recipient.

Inbound MX policy defaults reject unauthenticated MAIL FROM domains that match local_domains; legitimate local senders must use authenticated submission. strict_helo validates HELO/EHLO names and rejects clients that impersonate the server hostname or a local domain. max_rcpt_failures limits directory-harvest probing before closing the session. SPF checks default to spf_policy:"log" and can be disabled or enforced with "off" or "enforce"; spf_resolver_ip defaults to dns_resolver_ip. dns_query_timeout_seconds bounds each UDP DNS lookup used by SPF, DKIM, DMARC, DNSBL, FCrDNS, and outbound MX/A resolution; the default is 1 second. Accepted network mail is prepended with Received: and Authentication-Results: trace headers. DATA acceptance requires a From: header by default (require_from_header:true) and rejects messages above max_received_hops to prevent mail loops. DMARC policy evaluation defaults to dmarc_policy:"log" and can reject with "enforce" when the published policy is p=reject and neither SPF nor DKIM aligns. DKIM verification supports bounded rsa-sha256 signatures with simple/relaxed canonicalization and DNS TXT public keys. Outbound DKIM signing is available for jsmtp send and authenticated submission when dkim_signing_domain, dkim_signing_selector, and dkim_signing_key_path are all configured. Signing uses RSA-SHA256 with relaxed/relaxed canonicalization and fails queue admission rather than sending unsigned mail when the configured key cannot sign. Connection-time reputation checks are disabled by default. dnsbl_zones can list up to eight DNSBL zones, with dnsbl_policy set to "log" or "enforce"; enforcement rejects listed clients before the SMTP greeting. fcrdns_policy:"log" records forward-confirmed reverse DNS results but never rejects mail in this milestone.

The listener accepts continuously into bounded workers. max_connections and max_connections_per_ip shed excess clients with 421; the defaults are 64 and 8. connection_timeout_seconds, command_timeout_seconds, idle_timeout_seconds, and data_timeout_seconds are absolute deadlines that activity cannot extend. The default DATA rate floor is 1024 bytes/second after the configured grace period.

tls_available:true enables native rustls STARTTLS when certificate and key paths are configured. Public MX service on port 25 remains opportunistic: local-recipient delivery is allowed without TLS. Submission service on port 587 must be started separately with serve --submission; it requires STARTTLS before AUTH or MAIL.

auth_available:true is valid only with submission_enabled:true and tls_available:true. AUTH is SASL PLAIN only and verifies Argon2id PHC records from submission_credentials_path. Port 25 keeps AUTH disabled and is not an open relay.

Outbound relay uses a separate plaintext outbound_spool_root. Static routes are tried before DNS MX/A fallback. Outbound STARTTLS is not implemented in this branch, so outbound_tls_policy defaults to "off" and explicit "opportunistic" or "required" policies are rejected fail-closed.

Queue publication currently writes the message file before its metadata file; consumers must treat metadata as the admission marker and ignore unmatched message files. The file publication primitive is now native Rust and refuses overwrite; queue and Maildir publication use directory-relative native calls with validated basenames. Local delivery publishes the delivered marker before deleting the incoming message and metadata files, so restart cleanup treats the marker as authoritative.

Quota admission uses a durable, atomically replaced counter under a native cross-process lock, so normal admission is O(1) and concurrent reservations cannot exceed the configured cap. Startup and the offline janitor reconcile the counter from disk. Queue IDs are distributed across 256 two-hex-digit shards under tmp, incoming, and delivered to avoid single-directory growth; legacy unsharded queue files remain readable and cleanable.