minsec

API reference

The v1 wire protocol: enrollment, signed report submission, the feed, and the DNSBL zone.

Base URL: https://api.minsec.io. All request and response bodies are JSON unless stated otherwise. Errors are:

{"error": "code", "message": "human readable, optional"}

This is the contract minsec-sync implements. You do not need any of it to use minsec — but the feed and DNSBL endpoints are public, and the signing construction is here so a third-party agent is possible.

Enrollment

Unattended, and gated by a proof-of-work. The agent generates an Ed25519 keypair on first run, keeps the private key (mode 0600) forever, and stores the host_id it receives.

POST /v1/enroll/challenge

Request body {}.

200 {
  "challenge": "<opaque base64url string>",
  "difficulty": 20,
  "expires_in": 300,
  "algo": "sha256-pow-v1"
}

429 with Retry-After when the per-IP enrollment rate limit is hit. The challenge is opaque, single-use, expires after expires_in seconds, and is bound to the requesting address — solve and enroll from the same address, promptly.

Proof of work (sha256-pow-v1)

Find an ASCII nonce such that sha256(challenge || nonce) — concatenating the raw ASCII strings — has at least difficulty leading zero bits. Difficulty 20 is roughly one to five seconds of one CPU core.

POST /v1/enroll

{
  "challenge": "<as issued>",
  "pow_nonce": "<solution>",
  "pubkey": "<base64 (std) 32-byte Ed25519 public key>",
  "agent_version": "minsec-sync/0.1.0",
  "install_token": null
}
201 {
  "host_id": "<uuid v7>",
  "report_url": "/v1/reports",
  "feed_url": "/v1/feed",
  "min_report_interval": 3600
}

min_report_interval is the server's advertised floor, in seconds, between report submissions from one host. It is 3600 on the free tier — the packaged minsec-sync.timer runs hourly to match — and a client should treat a larger value returned here as authoritative rather than assuming the default.

Failures: 403 challenge_rejected (invalid, expired, wrong IP, or already used — request a fresh challenge and re-solve), 403 pow_rejected, 400 bad_pubkey, 409 already_enrolled (this key already has a host_id; keep using the stored one).

Report submission

Request signing

Reports carry a detached Ed25519 signature over this canonical string — newline-separated, no trailing newline:

minsec-report-v1
<host_id>
<timestamp>
<hex(sha256(request body bytes))>

timestamp is unix seconds, decimal, exactly as sent in the header; the server rejects anything more than ±300 s from its clock. The digest is over the exact body bytes sent — there is no JSON canonicalisation anywhere in this protocol.

Content-Type: application/json
X-Minsec-Host: <host_id>
X-Minsec-Timestamp: <unix seconds>
X-Minsec-Signature: <base64 (std) 64-byte signature>

Every authentication failure — unknown host, bad signature, skewed timestamp, revoked agent — returns the same 401 {"error":"unauthorized"}.

POST /v1/reports

Body ≤ 1 MiB, ≤ 1000 items per batch.

{
  "seq": 42,
  "agent_version": "minsec-sync/0.1.0",
  "reports": [
    {"ts": 1724371190, "ip": "203.0.113.7/32", "filter": "sshd", "count": 5, "ban_ttl": 3600},
    {"ts": 1724371195, "ip": "2001:db8:1:2::/64", "filter": "postfix-sasl", "count": 3, "ban_ttl": 7200, "escalation": 2}
  ]
}
  • seq must be strictly increasing per agent. A batch with seq at or below the last accepted value returns 200 {"accepted":0,"rejected":0,"duplicate":true} and stores nothing — so retrying after a network failure is always safe.
  • ip is a CIDR string: IPv4 /24/32, IPv6 /48/64. Anything narrower than /64 is aggregated to /64 server-side.
  • filter matches ^[a-z0-9._-]{1,64}$. It is an opaque rule name, mapped through the signature registry to a category.
  • escalation is optional, 0–255: the reporter's repeat-offender depth for this address.
  • Report only automatic bans. Never manual bans, never unbans.
  • Invalid items are dropped and counted in rejected; they never fail the batch. Items past the day's remaining quota are dropped the same way, so a batch that straddles the ceiling returns a smaller accepted than the number of items sent; the next batch gets 429 quota_exceeded.
200 {"accepted": 17, "rejected": 1}

Other responses: 413 too_large, 400 too_many_items, 429 quota_exceeded (daily per-agent quota — honour Retry-After).

Blocklist feed

GET /v1/feed/{tier}/{family}

tierbasic | high, familyv4 | v6. Public. Full details and the delta protocol: blocklist feed.

Signature categories

Every reported filter name resolves to exactly one category:

CategoryMeaning
mail-authcredential abuse against mail submission or webmail (postfix-sasl, dovecot, roundcube)
mail-mxdictionary / RCPT abuse at the MX (postfix)
web-authweb application login brute force (wordpress, apache-auth, nginx-auth)
web-exploitexploitation attempt against a known vulnerability
infrageneric infrastructure abuse (sshd, proftpd, webmin)
unknownno registry entry — scored for visibility, never published

Quorum is counted per (address, category). An agent labelling all of its reports with a single signature name still contributes exactly one vote toward that category, so a category listing always requires independent reporters that agree on the category. Votes are weighted by reporter reputation and by the signature's registered confidence.

DNSBL zone

GET /v1/dnsbl/{family}

familyv4 | v6. An rbldnsd zone file. Public. Bit assignments and SpamAssassin rules: DNSBL for mail.

Health

  • GET /healthz — process liveness, 200 ok.
  • GET /readyz — readiness (database reachable), 200 ok or 503.