Skip to content

Latest commit

 

History

History
152 lines (121 loc) · 7.43 KB

File metadata and controls

152 lines (121 loc) · 7.43 KB

Security

Set security.mode = .aead for authenticated encryption, replay protection, bounded pre-authentication work, and traffic-key updates. With .none (the default) all crypto compiles out. See the security model before deployment.

const Cfg = magnet.Config{
    .channels = Schema,
    .protocol_id = 0xC0FFEE,
    .capabilities = 0b111,
    .required_capabilities = 0b001,
    .security = .{
        .mode = .aead,
        .forward_secrecy = true,
    },
};

Authenticated handshake

The server replies to a valid hello with a context-bound stateless cookie and allocates no connection until the client echoes it. The cookie binds the source address, protocol/app versions, capability offer, and ephemeral public key. The endpoint also applies a fixed, per-source token bucket before emitting challenges and never sends beyond the 3× anti-amplification budget of an unvalidated address.

server.secSetup(psk, challenge_secret);
var prng = std.Random.DefaultPrng.init(seed); // use an OS CSPRNG in production
_ = client.connectToForwardSecret(server_addr, psk, prng.random());

Without forward_secrecy, call connectTo. With it, a fresh client X25519 key and the server's stateless ephemeral key are mixed into the PSK-derived schedule. Server ephemeral keys are recoverable only while the current/previous cookie secret is retained, so rotating and erasing those secrets provides delayed forward secrecy without half-open connection state.

capabilities is an authenticated supported-feature bitset. Negotiation returns the intersection, exposed as session.negotiatedCapabilities(). Either peer's required bits must survive or the handshake fails, preventing downgrade of a critical extension. app_version remains a simple equality gate when nonzero.

Traffic keys and reset

Directional keys update automatically according to key_update_packets and key_update_ms, or explicitly with session.requestKeyUpdate(). The receiver authenticates the next phase before committing it and retains the preceding receive key for reordered old packets. session.keyEpoch() exposes the local epoch.

AEAD sessions derive an independent packet-number skip schedule from a sender-local secret that is never transmitted or derived from peer-known traffic keys. A peer sees a gap only after it has occurred and is disconnected if it acknowledges that never-sent number. delivery.pn_skip_period controls the frequency and defaults to 32.

For connectTo and connectToWithToken, generate a fresh secret for every connection from OS entropy (io is any std.Io instance, such as init.io in main or a driver's Threaded instance):

var local_secret: [16]u8 = undefined;
try std.Io.randomSecure(io, &local_secret);
_ = client.connectTo(server_addr, psk, local_secret);

The forward-secret connect methods draw this value from their existing std.Random argument. A server derives a unique local secret from its private challenge secret and a non-repeating endpoint counter. Low-level Session users must call setPacketNumberFilterKey after secSetup; a missing key fails closed before transmission.

CID handshakes deliver a CID-bound stateless-reset token. A server that lost connection state can emit a small reset for an unknown CID without allocating; only a peer holding the authenticated token accepts it. Reset packets cannot create reset loops.

Connect tokens

A backend signs short-lived connect tokens authorizing a client for specific servers. Keys derive from the token; expired, forged, replayed, or wrong-server tokens are rejected. Enable with security.tokens = true:

const tok = magnet.proto.conn.token.issue(csprng, issuer_key, protocol_id, expire_s, .{ … });
server.secSetupTokens(issuer_key, challenge_secret, own_addr);
var local_secret: [16]u8 = undefined;
try std.Io.randomSecure(io, &local_secret);
_ = client.connectToWithToken(server_addr, &tok, local_secret);

When forward secrecy is also enabled, use connectToWithTokenForwardSecret. Serialize a token for backend delivery with tok.encodeForClient and restore it with Token.decodeFromBackend; the UDP form deliberately omits the client's key copies. Real drivers supply Unix time and rotate challenge secrets. Deterministic drivers should call setTokenTime and rotateChallengeSecret themselves.

Tokens use XChaCha20-Poly1305 and random 192-bit nonces. Production issuers must pass a CSPRNG; issueWithNonce exists only for deterministic tooling.

Accepted token MACs remain in the fixed replay table until their authenticated expiry. The table never evicts an unexpired entry: new token admissions fail closed when limits.token_dedup_cap is full. A retransmitted response for the same live session is recognized separately and only re-arms the encrypted proof packet.

Resumption and guarded 0-RTT

proto.conn.resumption issues opaque, short-lived XChaCha tickets containing a fresh resumption secret, protocol/capability binding, and an early-data ceiling. The application sends Offer(max_early_data) through its existing signaling/admission path. The server calls Server.accept before allocating a connection; this authenticates the ticket, checks expiry/capabilities, enforces a bounded one-time replay cache, and invokes a mandatory Policy callback. Build it with Policy.init(Context, &context, callback) so the policy context and callback parameter are checked as one concrete type before being erased for storage.

Only replay-safe operations belong in early data: reads, idempotent state replacement, or requests carrying an application deduplication key. Never accept purchases, increments, one-time grants, or other non-idempotent mutations. After acceptance, the client/server call resumeTo / acceptResumed with the returned secret, capability set, and a fresh sender-local 16-byte secret on each side. CID resumption is currently intentionally unsupported; perform a full CID handshake instead.

Size the replay cache for all valid tickets in the ticket lifetime. Once an entry is evicted, the network layer cannot remember it; applications protecting valuable actions need their own durable idempotency store as well.

Use acceptWithKeys(.{ .current = new, .previous = old }, ...) during ticket-key rotation. Persist Server.saveReplayState with a separate checkpoint-authentication key and restore it with restoreReplayState before accepting early data after restart; the checkpoint is HMAC-authenticated and can be written atomically through runtime.checkpoint. Do not persist live traffic keys/packet numbers as a restart mechanism; resume with a fresh session instead.

Certificates

Ed25519 certificates plus X25519 ECDH provide identity without a shared password:

const id = magnet.proto.conn.identity;
const master = try id.agree(my_x25519_kp, &my_cert, &peer_cert, ca_pubkey, now_s, initiator);
// Feed `master` to secSetup/connectTo like any other 32-byte master key.

Connection IDs and migration

security.connection_ids = true addresses a session by an opaque ID instead of its IP. A port rebind keeps the path state; an IP change must answer a path challenge before full sending resumes. Combine CID migration with the validated multipath wrapper when multiple simultaneous interfaces should remain available.

Runnable: encrypted.zig, connect_tokens.zig, cert_identity.zig, migration.zig.