- Scheme 75.1%
- Shell 12.3%
- Rust 10.3%
- Makefile 1.2%
- Python 1%
|
|
||
|---|---|---|
| .forgejo | ||
| .jerboa | ||
| bin | ||
| docs | ||
| examples | ||
| fuzz | ||
| lib/jerboa-smtp | ||
| rust | ||
| scripts | ||
| static | ||
| support | ||
| tests | ||
| wasm | ||
| .gitignore | ||
| .gitsafeignore | ||
| .jerbuild | ||
| add-to-jerboa.md | ||
| AGENTS.md | ||
| build.ss | ||
| jpkg.lock | ||
| jpkg.policy.sexp | ||
| jpkg.sexp | ||
| LICENSE | ||
| Makefile | ||
| not-prod.md | ||
| plan.md | ||
| PRODUCTION-STATUS.md | ||
| README.md | ||
| secure2-kimi.md | ||
| SECURITY.md | ||
| sending-mail.md | ||
| smpt-prod.md | ||
| vendor-lock.env | ||
| VERSION | ||
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 FROMandRCPT 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 FILEfor 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,linkatinto the final directory without overwriting, fsync the destination directory, then unlink temp by basename. - DATA-aware session path:
DATAenters a pending state, the complete DATA block is framed in WASM, and accepted messages are queued before250. - 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 withserve --submission, Argon2id credential management, and outbound relay sweeps viajsmtp 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.