Auth
Crate hopf-auth: TrustPolicy, credential stores, and SASL mechanisms (Gumdrop parity except GSSAPI, which is planned). Independent of hopf-core (own VERSION). HTTP Basic / Digest / Bearer factories live in hopf-http and consume these types; FTP uses TrustPolicy directly, while POP3/IMAP/SMTP drive the full SASL mechanism set from a CredentialStore.
Contents
Standards
| Mechanism / topic | Reference |
|---|---|
| PLAIN | RFC 4616 |
| LOGIN | Legacy draft |
| CRAM-MD5 | RFC 2195 |
| DIGEST-MD5 | RFC 2831 (deprecated) |
| SCRAM-SHA-256 | RFC 5802 / 7677 (PBKDF2) |
| SCRAM-SHA-256-PLUS | RFC 5802bis / RFC 5929 tls-server-end-point channel binding — not part of Gumdrop's original seven-mechanism set; excluded from SaslMechanism::all() since no protocol crate wires real channel-binding data through yet (see Limitations) |
| OAUTHBEARER | RFC 7628 |
| XOAUTH2 | Google's pre-standard bearer mechanism (XOAUTH2) — what Gmail and Microsoft 365 advertise on IMAP, POP3 and SMTP submission; the client answers an error challenge with the empty response the mechanism requires |
| EXTERNAL | RFC 4422 App. A |
| HTTP Digest | RFC 7616 helpers |
| Compare | Constant-time via subtle |
Architecture
Protocol (FTP / SMTP / HTTP auth factory)
│
├── TrustPolicy::evaluate(IdentityMaterial, PeerContext)
│ → Accept | Reject
│
└── SASL: create_server / create_client
↔ CredentialStore (passwords, HA1, SCRAM, tokens, certs)
PasswordTrustPolicy is a small demo policy; production apps implement TrustPolicy or back SASL with a real CredentialStore.
TrustPolicy
| Type | Role |
|---|---|
TrustPolicy |
evaluate → TrustDecision::{Accept,Reject} |
IdentityMaterial |
UsernamePassword / Bearer / CertDn / CertSan / Opaque |
PeerContext |
Optional peer SocketAddr |
PasswordTrustPolicy |
In-memory username/password map |
use hopf_auth::{IdentityMaterial, PeerContext, TrustDecision, TrustPolicy};
fn evaluate(&self, id: &IdentityMaterial, peer: &PeerContext) -> TrustDecision {
// …
}FTP stock handler and SMTP AUTH PLAIN call this layer. See FTP, SMTP.
Credential stores
| Type | Role |
|---|---|
CredentialStore |
Trait backend (Gumdrop Realm-like) |
PasswordStore |
In-memory SCRAM (+ optional Digest HA1), tokens, cert keys (demo; no plaintext retention) |
LdapCredentialStore (hopf-ldap) |
Production LDAP search-then-bind (PLAIN/LOGIN); keeps LDAP out of hopf-auth |
PamCredentialStore (feature pam) |
System PAM password check (PLAIN/LOGIN); Unix; blocking — off-reactor only |
ScramCredentials |
StoredKey / ServerKey derivation |
TokenValidation |
Bearer checks |
CertificateIdentity |
Cert mapping |
CredentialStore overridable methods: password_match, plaintext_password, digest_ha1, cram_md5_digest, scram_credentials, validate_bearer, authenticate_certificate, authorize_as, supported_mechanisms.
use hopf_auth::PasswordStore;
let store = PasswordStore::new()
.with_user("alice", "secret")
.with_token("tok", "alice")
.with_certificate("cn=alice", "alice")
.shared();SCRAM iterations default 4096 (ScramCredentials::derive(password, salt, iterations)).
SASL mechanisms
pub enum SaslMechanism {
Plain,
Login,
CramMd5,
DigestMd5,
ScramSha256,
OauthBearer,
External,
}Helpers on the enum: name, from_name, is_challenge_response, requires_tls, all.
| Type | Role |
|---|---|
SaslServer / SaslClient |
Step traits |
SaslServerStep |
Challenge / Complete{username,final_message} / Failure |
SaslClientStep |
Response / Complete / Failure |
SaslServerOptions |
hostname, realm, peer_certificate, channel_binding |
create_server / create_client |
Factories |
use hopf_auth::{create_server, create_client, SaslMechanism, SaslServerOptions};
let mut server = create_server(
SaslMechanism::ScramSha256,
store,
SaslServerOptions {
hostname: "localhost".into(),
realm: "hopf".into(),
peer_certificate: None,
channel_binding: None,
},
);
let mut client = create_client(SaslMechanism::ScramSha256, "alice", "secret", "localhost", None);
// Alternate challenge/response via server.step / client.evaluateSymmetric client + server exchanges for all seven Gumdrop-parity mechanisms, plus SCRAM-SHA-256-PLUS. requires_tls guides operators (e.g. PLAIN should be under TLS).
SCRAM channel binding
ScramSha256Client::new_plus(username, password, channel_binding) / ScramSha256Server::with_channel_binding(channel_binding) add real tls-server-end-point (RFC 5929 section 4) channel binding: the caller supplies this connection's channel-binding bytes (a hash of the server's TLS certificate — hopf-auth has no TLS awareness of its own, the same pattern ExternalServer::with_peer_certificate already uses). The server independently recomputes the expected c= value from the GS2 header the client actually sent plus its own channel-binding data and rejects a mismatch — the property that defeats a MITM relaying the SASL exchange across two different TLS connections. Not implemented: cross-mechanism downgrade detection (noticing a MITM stripped PLUS from the advertised mechanism list) — that needs the session/capability-advertisement layer, not this mechanism module.
HTTP Digest helpers
Module http_digest: challenge_header, new_nonce, verify_authorization, client_authorization. verify_authorization takes an expected_nonce: Option<&str> — real replay protection requires the caller to track issued nonces and pass the specific one being redeemed (single-use, with expiry), which hopf-http's DigestAuthFactory/DigestAuthHandler now does: each challenge nonce is tracked in a map, consumed (removed) on first use, and pruned after DigestAuthConfig::nonce_ttl (default 5 minutes, overridable via with_nonce_ttl) if never used — so a captured, cryptographically-valid Authorization: Digest header can't be replayed, even verbatim.
HTTP Basic / Digest / Bearer request wrappers are in hopf-http (BasicAuthFactory, DigestAuthFactory, BearerAuthFactory) and call into trust / digest helpers — see HTTP server.
OAuth 2.0 token introspection
Module oauth_introspection implements RFC 7662 for real, replacing the local static token map PasswordStore::validate_bearer uses by default:
IntrospectionRequest::to_form_body()— theapplication/x-www-form-urlencodedtoken+token_type_hintbody per section 2.1.IntrospectionResponse::parse(json)— readsactive/username/scope/expfrom a section 2.2 response via a small purpose-built JSON reader (no JSON crate dependency); unknown/nested fields (aud,iss,client_id, ...) are structurally skipped, not rejected.IntrospectionTransport— the caller supplies the actual HTTP POST (hopf-authhas no network I/O of its own;hopf-http, which does, already depends onhopf-auth, so the dependency can't run the other way). Wire it tohopf-http's client, run off the reactor thread the same way other blocking work in this workspace is.IntrospectionCredentialStore::new(inner, transport)— aCredentialStorethat delegates every method toinnerexceptvalidate_bearer, which calls the introspection endpoint and requiresactive: true(plus a localexpcheck as defence in depth against a stale cached response, even though the RFC doesn't require it).
Configuration
SaslServerOptions
| Property | Type | Default | Notes |
|---|---|---|---|
hostname |
string | "localhost" |
SCRAM / Digest host |
realm |
string | "hopf" |
Digest realm |
peer_certificate |
optional | None |
EXTERNAL |
channel_binding |
optional bytes | None |
SCRAM-SHA-256-PLUS — this connection's tls-server-end-point data |
PasswordStore
| Knob | Default | Notes |
|---|---|---|
| SCRAM iterations | 4096 |
PBKDF2 |
| Builders | — | with_digest_realm (before users, for DIGEST/HTTP Digest HA1), with_scram_iterations, with_user (enroll then discard password), with_scram / insert_scram, with_token, with_certificate, shared |
Enrollment accepts a password once, then stores only SCRAM-SHA-256 material (and HA1 when a digest realm is set). plaintext_password always returns None — CRAM-MD5 and POP3 APOP are not supported by this store. password_match re-derives StoredKey and compares in constant time.
No Cargo features for the demo store.
PamCredentialStore
Optional feature pam on hopf-auth (and umbrella hopf feature pam). Unix only; links libpam / OpenPAM via in-tree FFI.
// Cargo.toml: hopf-auth = { version = "…", features = ["pam"] }
use hopf_auth::{PamCredentialStore, PamStoreConfig};
let store = PamCredentialStore::new(PamStoreConfig::new("login"))
.shared();
// SmtpConfig::new(…).with_store(store)
- Mechanisms: PLAIN, LOGIN only (no digest/SCRAM/bearer).
password_matchis blocking — call from a storage/worker pool, never a reactor thread.- Default service name:
login(DEFAULT_PAM_SERVICE). Point at a custom PAM stack (e.g./etc/pam.d/hopf) when deploying.
Wiring
FTP:
let policy = PasswordTrustPolicy::default().with_user("ftp", "ftp").shared();
FtpConfig::new(addr, root, policy);SMTP (full SASL mechanism set via CredentialStore, same as POP3/IMAP below):
SmtpConfig::new(addr, hostname)
.with_tls(acceptor)
.with_store(store)
.auth_required(true);HTTP (in hopf-http): wrap a ServerHandlerFactory with BasicAuthFactory / DigestAuthFactory / BearerAuthFactory using a TrustPolicy or realm config (BasicAuthConfig { realm }, DigestAuthConfig { realm }).
Examples
No dedicated examples/auth crate. Consumed by:
- Cookbook: FTP —
FTP_USER/FTP_PASS - Cookbook: SMTP
examples/smtp-ldap—LdapCredentialStore+SmtpConfig::with_store- HTTP hello examples with auth factories when configured
Limitations
- GSSAPI / Kerberos (RFC 4752) — not implemented today; planned as an optional
gssapifeature with KDC/keytab work onStorageExecutor(Gumdrop parity). Would also unlock RFC 1961 GSSAPI auth for SOCKS. - DIGEST-MD5 kept for parity only (deprecated by RFC 6331).
- OAUTHBEARER and XOAUTH2 carry the token in the client “password” slot.
PasswordStoreis in-memory (demo-grade): enrolls from a password then keeps SCRAM (+ optional Digest HA1) only. For production password AUTH, useLdapCredentialStoreor featurepam’sPamCredentialStore.- CRAM-MD5 and POP3 APOP need a recoverable secret (or a custom
cram_md5_digest/ APOP verifier) — not provided byPasswordStore. - SMTP wire currently advertises AUTH PLAIN only (SMTP), even though the auth crate supports more mechanisms.
- SCRAM-SHA-256-PLUS has no cross-mechanism downgrade detection — see the channel binding section.
- No protocol crate in this workspace wires real
tls-server-end-pointchannel-binding data through tocreate_serveryet, which is whyScramSha256Plusis excluded fromSaslMechanism::all()(and so isn't advertised by any store's defaultsupported_mechanisms()) even though the mechanism itself is fully implemented and tested. IntrospectionCredentialStore(RFC 7662) needs a caller-suppliedIntrospectionTransportfor the actual HTTP call —hopf-authhas no network I/O of its own, so nothing in this workspace wires one up by default;PasswordStore::validate_bearer(the default) is still a local static token map.