milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,778 @@
|
||||
//! Web authentication (PLAN §3.11, §12.1, §19; milestone-8 rulings 17, 18, 29).
|
||||
//!
|
||||
//! Two independent pieces:
|
||||
//!
|
||||
//! * `verifyPassword` checks an operator's password against the argon2id PHC
|
||||
//! string in `web.password_hash`. The PHC string carries its own parameters,
|
||||
//! so this file names none: a hash written by an older binary with different
|
||||
//! parameters still verifies.
|
||||
//! * `Sessions` is the in-memory session table. A successful login mints a
|
||||
//! token, the browser carries it in a cookie, and every later request is
|
||||
//! authenticated by that cookie alone. Nothing is persisted: a restart logs
|
||||
//! every operator out, which is the behaviour a household admin UI wants and
|
||||
//! costs no schema.
|
||||
//!
|
||||
//! The table is a fixed array of `max_sessions` slots, so no request path
|
||||
//! allocates. A 33rd login evicts the least recently used session rather than
|
||||
//! failing: an operator who can prove the password must always get in, and 32
|
||||
//! concurrent browsers is already far past household scale.
|
||||
//!
|
||||
//! Only the SHA-256 digest of a token is stored. A memory disclosure therefore
|
||||
//! yields no usable cookie, and lookups compare digests with
|
||||
//! `std.crypto.timing_safe.eql`, which needs fixed-size arrays (slices are not
|
||||
//! accepted — `timing_safe.zig:12`).
|
||||
//!
|
||||
//! Secrets never reach a log line: no password, hash, token or cookie value is
|
||||
//! formatted anywhere in this file (ruling 29). The login handler logs the
|
||||
//! client address and the outcome, nothing else.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const model = @import("../config/model.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Sha256 = std.crypto.hash.sha2.Sha256;
|
||||
const base64 = std.base64.url_safe_no_pad;
|
||||
|
||||
const log = std.log.scoped(.web_auth);
|
||||
|
||||
/// Raw token length. 256 bits of `io.randomSecure` entropy.
|
||||
pub const token_bytes = 32;
|
||||
|
||||
/// Length of the cookie value: base64 (url-safe, unpadded) of `token_bytes`.
|
||||
pub const cookie_value_len = base64.Encoder.calcSize(token_bytes);
|
||||
|
||||
/// The cookie value as it appears on the wire.
|
||||
pub const Cookie = [cookie_value_len]u8;
|
||||
|
||||
pub const cookie_name = "nxdns_session";
|
||||
|
||||
/// `Secure` is deliberately absent: nxdns serves plain HTTP on the LAN and TLS
|
||||
/// termination, where an operator wants it, belongs to their reverse proxy.
|
||||
/// Setting `Secure` would make the cookie unusable in the supported deployment.
|
||||
pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/";
|
||||
|
||||
/// Longest password `verifyPassword` will hash. argon2id costs 19 MiB and a
|
||||
/// deliberate delay per call, so an unbounded body must not reach it; a
|
||||
/// passphrase longer than this is refused as if it were wrong.
|
||||
pub const max_password_len = 256;
|
||||
|
||||
/// Authentication is on exactly when a hash exists (ruling 17). An empty hash
|
||||
/// is the documented "no password set" state, not a misconfiguration.
|
||||
pub fn authEnabled(web: model.Web) bool {
|
||||
return web.password_hash.len != 0;
|
||||
}
|
||||
|
||||
pub const Outcome = enum {
|
||||
ok,
|
||||
/// Wrong password, or a hash this build cannot verify. Both are the same
|
||||
/// answer to the client.
|
||||
denied,
|
||||
/// Verification could not run (out of memory, unreadable PHC string). The
|
||||
/// handler answers 500, never 401: a broken hash must not read as a wrong
|
||||
/// password.
|
||||
unavailable,
|
||||
};
|
||||
|
||||
/// Verifies `password` against the PHC string in `password_hash`.
|
||||
///
|
||||
/// `strVerify` requires both an allocator (argon2.zig:600) and an `Io`
|
||||
/// (argon2.zig:619). It is slow by construction — the caller runs it on the
|
||||
/// connection task, which is why the API limiter counts login attempts like any
|
||||
/// other request.
|
||||
pub fn verifyPassword(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
password_hash: []const u8,
|
||||
password: []const u8,
|
||||
) std.Io.Cancelable!Outcome {
|
||||
if (password_hash.len == 0) return .denied;
|
||||
if (password.len == 0 or password.len > max_password_len) return .denied;
|
||||
|
||||
std.crypto.pwhash.argon2.strVerify(
|
||||
password_hash,
|
||||
password,
|
||||
.{ .allocator = gpa },
|
||||
io,
|
||||
) catch |err| switch (err) {
|
||||
error.PasswordVerificationFailed => return .denied,
|
||||
error.Canceled => return error.Canceled,
|
||||
else => {
|
||||
log.warn("verifying the web password failed: {s}", .{@errorName(err)});
|
||||
return .unavailable;
|
||||
},
|
||||
};
|
||||
return .ok;
|
||||
}
|
||||
|
||||
/// The password hash the running server authenticates against. `WebState.web`
|
||||
/// is the boot-time configuration and never changes, but `PUT /api/settings`
|
||||
/// can replace the password while the process runs, and the revoked credential
|
||||
/// must stop working before the next restart. The login path and the session
|
||||
/// gate read this holder, never the boot value.
|
||||
///
|
||||
/// A mutex-guarded copy-out rather than an atomic pointer swap: argon2
|
||||
/// verification holds the hash for tens of milliseconds, so a reader must not
|
||||
/// borrow the stored slice across a replacement. Copying at most `max_len`
|
||||
/// bytes under an uncontended mutex is cheap, and it lets `installAndRevoke`
|
||||
/// free the old allocation immediately instead of deferring reclamation.
|
||||
///
|
||||
/// Ownership: the boot value borrows the configuration arena and is never
|
||||
/// freed here. `installAndRevoke` takes ownership of a gpa allocation and
|
||||
/// frees the previous hash if this holder owned it; whoever owns the
|
||||
/// `WebState` calls `deinit`, which frees the last installed one the same way.
|
||||
pub const LiveHash = struct {
|
||||
/// Lock order: this mutex is taken BEFORE `Sessions.mutex`, never after.
|
||||
/// Two sites nest them: `confirmSession` holds it across
|
||||
/// `Sessions.createWithToken`, and `installAndRevoke` holds it across
|
||||
/// `Sessions.clearAll`. No code path may touch this holder while holding
|
||||
/// the session table's mutex.
|
||||
///
|
||||
/// The single direction is also the revocation argument: a confirm and an
|
||||
/// `installAndRevoke` serialize on this mutex, so a confirm either
|
||||
/// precedes the transition (the nested `clearAll` wipes the session it
|
||||
/// just minted) or follows it (the snapshot's generation is stale and
|
||||
/// nothing is minted). No interleaving exists in which a session minted
|
||||
/// under the new password is killed by its own transition.
|
||||
mutex: std.Io.Mutex = .init,
|
||||
hash: []const u8 = "",
|
||||
owned: bool = false,
|
||||
/// Bumped by every `installAndRevoke`. A login snapshot carries the
|
||||
/// generation it copied, and `confirmSession` refuses to mint a session
|
||||
/// for a snapshot an install has since replaced.
|
||||
generation: u64 = 0,
|
||||
|
||||
/// Every PHC string nxdns produces fits: `config/import.zig` and the
|
||||
/// settings handler both hash into a buffer of this size.
|
||||
pub const max_len = 256;
|
||||
|
||||
pub fn init(boot_hash: []const u8) LiveHash {
|
||||
return .{ .hash = boot_hash };
|
||||
}
|
||||
|
||||
/// Whether a password is set right now — ruling 17's gate, live.
|
||||
pub fn enabled(self: *LiveHash, io: std.Io) bool {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.hash.len != 0;
|
||||
}
|
||||
|
||||
/// What `copy` hands out: the hash to verify against and the generation
|
||||
/// it was copied under, for `confirmSession` to check after the slow
|
||||
/// verification.
|
||||
pub const Snapshot = struct {
|
||||
hash: []const u8,
|
||||
generation: u64,
|
||||
};
|
||||
|
||||
/// Copies the current hash into `buf`. `error.Oversize` means a stored
|
||||
/// hash this holder cannot hand out — only a hand-edited database, never
|
||||
/// a hash nxdns wrote — and the caller must fail closed as an internal
|
||||
/// error, not as a wrong password.
|
||||
pub fn copy(self: *LiveHash, io: std.Io, buf: *[max_len]u8) error{Oversize}!Snapshot {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
if (self.hash.len > buf.len) return error.Oversize;
|
||||
@memcpy(buf[0..self.hash.len], self.hash);
|
||||
return .{ .hash = buf[0..self.hash.len], .generation = self.generation };
|
||||
}
|
||||
|
||||
/// Takes ownership of `new_hash`, which must be a `gpa` allocation, frees
|
||||
/// the previous hash if this holder owned it, and ends every session in
|
||||
/// `sessions` before releasing the mutex. The swap, the generation bump
|
||||
/// and the revocation are one mutex-held operation on purpose: were the
|
||||
/// mutex released between them, a login verified against the new hash
|
||||
/// could confirm in the gap and the trailing `clearAll` would kill that
|
||||
/// fresh, legitimate cookie. `sessions` is optional only because a server
|
||||
/// can run without a session store; null skips the revocation, nothing
|
||||
/// else.
|
||||
pub fn installAndRevoke(
|
||||
self: *LiveHash,
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
sessions: ?*Sessions,
|
||||
new_hash: []const u8,
|
||||
) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
if (self.owned) gpa.free(self.hash);
|
||||
self.hash = new_hash;
|
||||
self.owned = true;
|
||||
self.generation += 1;
|
||||
if (sessions) |table| table.clearAll(io);
|
||||
}
|
||||
|
||||
/// Mints a session only when no `installAndRevoke` has replaced the hash
|
||||
/// since the snapshot at `generation` was taken. Argon2 verification runs
|
||||
/// on a copy outside any lock, so a settings PUT can replace the password
|
||||
/// while a login is still verifying the old one; minting afterwards would
|
||||
/// resurrect the revoked credential. The token bytes and the timestamp
|
||||
/// are produced before the mutex is taken: `randomSecure` may stall on
|
||||
/// entropy, and a stall inside this lock would block password installs
|
||||
/// and every request's `enabled`/`copy` check. Under the ordered locks
|
||||
/// only the generation check and the digest insert remain. An
|
||||
/// `installAndRevoke` therefore lands either before this call (the
|
||||
/// generation differs, null — the login is denied) or after it (its
|
||||
/// nested `clearAll` ends the session just minted). Null always means
|
||||
/// "the password changed under you", never an error.
|
||||
pub fn confirmSession(
|
||||
self: *LiveHash,
|
||||
io: std.Io,
|
||||
sessions: *Sessions,
|
||||
generation: u64,
|
||||
) Sessions.CreateError!?Cookie {
|
||||
var token: [token_bytes]u8 = undefined;
|
||||
try std.Io.randomSecure(io, &token);
|
||||
defer std.crypto.secureZero(u8, &token);
|
||||
const now_s = nowSeconds(io);
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
if (self.generation != generation) return null;
|
||||
return sessions.createWithToken(io, token, now_s);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *LiveHash, gpa: Allocator) void {
|
||||
if (self.owned) gpa.free(self.hash);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// One live session. `last_used` drives the LRU eviction and moves on every
|
||||
/// successful validation; `expires_at` is fixed at login, so a session ends at
|
||||
/// its TTL however busy it was.
|
||||
const Slot = struct {
|
||||
used: bool,
|
||||
digest: [Sha256.digest_length]u8,
|
||||
expires_at: i64,
|
||||
last_used: i64,
|
||||
};
|
||||
|
||||
pub const Sessions = struct {
|
||||
/// Concurrent connection tasks share one table, so every field below is
|
||||
/// written under this mutex.
|
||||
///
|
||||
/// `lockUncancelable` throughout: the critical sections are scans of 32
|
||||
/// slots with no I/O in them, and the callers are request handlers whose
|
||||
/// cancellation should land on the socket, not inside the session table.
|
||||
///
|
||||
/// Lock order: when held together with `LiveHash.mutex`, that mutex comes
|
||||
/// first (`LiveHash.confirmSession` and `LiveHash.installAndRevoke` are
|
||||
/// the sites that nest them). No code path may take `LiveHash.mutex`
|
||||
/// while holding this one.
|
||||
mutex: std.Io.Mutex,
|
||||
slots: [max_sessions]Slot,
|
||||
ttl_seconds: i64,
|
||||
|
||||
pub const max_sessions = 32;
|
||||
|
||||
pub const CreateError = std.Io.RandomSecureError;
|
||||
|
||||
/// `ttl_hours` is `web.session_ttl_hours`; `validate.zig` rejects zero.
|
||||
pub fn init(ttl_hours: u16) Sessions {
|
||||
std.debug.assert(ttl_hours > 0);
|
||||
return .{
|
||||
.mutex = .init,
|
||||
.slots = @splat(.{
|
||||
.used = false,
|
||||
.digest = @splat(0),
|
||||
.expires_at = 0,
|
||||
.last_used = 0,
|
||||
}),
|
||||
.ttl_seconds = @as(i64, ttl_hours) * 3600,
|
||||
};
|
||||
}
|
||||
|
||||
/// Mints a session from a caller-supplied token and clock and returns the
|
||||
/// cookie value to send back; the table keeps only the token's digest.
|
||||
/// `LiveHash.confirmSession` supplies real entropy gathered before any
|
||||
/// lock; a test supplies fixed bytes and is deterministic without seeding
|
||||
/// any global randomness.
|
||||
pub fn createWithToken(
|
||||
self: *Sessions,
|
||||
io: std.Io,
|
||||
token: [token_bytes]u8,
|
||||
now_s: i64,
|
||||
) Cookie {
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
Sha256.hash(&token, &digest, .{});
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.sweepLocked(now_s);
|
||||
const slot = self.freeSlotLocked() orelse self.lruSlotLocked();
|
||||
slot.* = .{
|
||||
.used = true,
|
||||
.digest = digest,
|
||||
.expires_at = now_s + self.ttl_seconds,
|
||||
.last_used = now_s,
|
||||
};
|
||||
|
||||
var cookie: Cookie = undefined;
|
||||
const encoded = base64.Encoder.encode(&cookie, &token);
|
||||
std.debug.assert(encoded.len == cookie.len);
|
||||
return cookie;
|
||||
}
|
||||
|
||||
/// True when `cookie_value` names a live session, which it then touches.
|
||||
/// Every malformed, unknown or expired value is the same `false`.
|
||||
pub fn validate(self: *Sessions, io: std.Io, cookie_value: []const u8) bool {
|
||||
return self.validateAt(io, cookie_value, nowSeconds(io));
|
||||
}
|
||||
|
||||
pub fn validateAt(self: *Sessions, io: std.Io, cookie_value: []const u8, now_s: i64) bool {
|
||||
const digest = digestOf(cookie_value) orelse return false;
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.sweepLocked(now_s);
|
||||
const slot = self.findLocked(digest) orelse return false;
|
||||
slot.last_used = now_s;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Drops the named session. True when one was dropped, which is what lets
|
||||
/// the logout handler answer the same way for a stale cookie as for a live
|
||||
/// one if it chooses to.
|
||||
pub fn logout(self: *Sessions, io: std.Io, cookie_value: []const u8) bool {
|
||||
const digest = digestOf(cookie_value) orelse return false;
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const slot = self.findLocked(digest) orelse return false;
|
||||
slot.used = false;
|
||||
slot.digest = @splat(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Ends every session. `PUT /api/settings` calls this when it changes
|
||||
/// `web.password_hash`: a password change must not leave the sessions it was
|
||||
/// meant to revoke alive.
|
||||
pub fn clearAll(self: *Sessions, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
for (&self.slots) |*slot| {
|
||||
slot.used = false;
|
||||
slot.digest = @splat(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sessions that have not expired by `now_s`. Expired slots are reclaimed on
|
||||
/// the way, so this is also the sweep the accessors perform.
|
||||
pub fn count(self: *Sessions, io: std.Io, now_s: i64) u32 {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.sweepLocked(now_s);
|
||||
var live: u32 = 0;
|
||||
for (self.slots) |slot| {
|
||||
if (slot.used) live += 1;
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
fn findLocked(self: *Sessions, digest: [Sha256.digest_length]u8) ?*Slot {
|
||||
var found: ?*Slot = null;
|
||||
for (&self.slots) |*slot| {
|
||||
if (!slot.used) continue;
|
||||
// Every live slot is compared, so the work done does not depend on
|
||||
// which one matches.
|
||||
if (std.crypto.timing_safe.eql([Sha256.digest_length]u8, slot.digest, digest)) {
|
||||
found = slot;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
fn sweepLocked(self: *Sessions, now_s: i64) void {
|
||||
for (&self.slots) |*slot| {
|
||||
if (slot.used and now_s >= slot.expires_at) {
|
||||
slot.used = false;
|
||||
slot.digest = @splat(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn freeSlotLocked(self: *Sessions) ?*Slot {
|
||||
for (&self.slots) |*slot| {
|
||||
if (!slot.used) return slot;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The table is full, so the oldest session makes room. Ties go to the
|
||||
/// lowest index; with 32 slots the choice among equally old sessions carries
|
||||
/// no meaning.
|
||||
fn lruSlotLocked(self: *Sessions) *Slot {
|
||||
var oldest: *Slot = &self.slots[0];
|
||||
for (self.slots[1..]) |*slot| {
|
||||
if (slot.last_used < oldest.last_used) oldest = slot;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
};
|
||||
|
||||
/// Decodes a cookie value back to the token and hashes it. Null when the value
|
||||
/// is not exactly one unpadded base64 encoding of `token_bytes` bytes.
|
||||
fn digestOf(cookie_value: []const u8) ?[Sha256.digest_length]u8 {
|
||||
if (cookie_value.len != cookie_value_len) return null;
|
||||
const decoded_len = base64.Decoder.calcSizeForSlice(cookie_value) catch return null;
|
||||
if (decoded_len != token_bytes) return null;
|
||||
|
||||
var token: [token_bytes]u8 = undefined;
|
||||
base64.Decoder.decode(&token, cookie_value) catch return null;
|
||||
defer std.crypto.secureZero(u8, &token);
|
||||
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
Sha256.hash(&token, &digest, .{});
|
||||
return digest;
|
||||
}
|
||||
|
||||
/// Session lifetimes are wall-clock hours, so they follow the operator's clock
|
||||
/// rather than the machine's uptime.
|
||||
fn nowSeconds(io: std.Io) i64 {
|
||||
return std.Io.Clock.real.now(io).toSeconds();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn tokenOf(n: u8) [token_bytes]u8 {
|
||||
return @splat(n);
|
||||
}
|
||||
|
||||
test "authEnabled follows the presence of a hash" {
|
||||
try testing.expect(!authEnabled(.{}));
|
||||
try testing.expect(!authEnabled(.{ .password_hash = "" }));
|
||||
try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }));
|
||||
}
|
||||
|
||||
test "a session created with a known token validates through its cookie value" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(24);
|
||||
const cookie = sessions.createWithToken(io, tokenOf(7), 1_700_000_000);
|
||||
|
||||
try testing.expectEqual(@as(usize, 43), cookie.len);
|
||||
for (cookie) |c| {
|
||||
try testing.expect(std.ascii.isAlphanumeric(c) or c == '-' or c == '_');
|
||||
}
|
||||
|
||||
try testing.expect(sessions.validateAt(io, &cookie, 1_700_000_001));
|
||||
try testing.expectEqual(@as(u32, 1), sessions.count(io, 1_700_000_001));
|
||||
|
||||
// The cookie value carries the token, so an independent encoding of the
|
||||
// same token is the same session.
|
||||
var expected: Cookie = undefined;
|
||||
_ = base64.Encoder.encode(&expected, &tokenOf(7));
|
||||
try testing.expectEqualStrings(&expected, &cookie);
|
||||
}
|
||||
|
||||
test "confirmSession with real entropy yields a validating cookie" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var live: LiveHash = .init("boot-hash");
|
||||
defer live.deinit(testing.allocator);
|
||||
var sessions: Sessions = .init(24);
|
||||
|
||||
const cookie = (try live.confirmSession(io, &sessions, 0)).?;
|
||||
try testing.expect(sessions.validate(io, &cookie));
|
||||
|
||||
const second = (try live.confirmSession(io, &sessions, 0)).?;
|
||||
try testing.expect(!std.mem.eql(u8, &cookie, &second));
|
||||
try testing.expect(sessions.validate(io, &cookie));
|
||||
try testing.expect(sessions.validate(io, &second));
|
||||
}
|
||||
|
||||
test "a wrong token of the right length is rejected" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(24);
|
||||
const cookie = sessions.createWithToken(io, tokenOf(1), 1_000);
|
||||
|
||||
var other: Cookie = undefined;
|
||||
_ = base64.Encoder.encode(&other, &tokenOf(2));
|
||||
try testing.expectEqual(cookie.len, other.len);
|
||||
try testing.expect(!sessions.validateAt(io, &other, 1_000));
|
||||
|
||||
// One flipped character of a live cookie is not that session either.
|
||||
var tampered = cookie;
|
||||
tampered[0] = if (tampered[0] == 'A') 'B' else 'A';
|
||||
try testing.expect(!sessions.validateAt(io, &tampered, 1_000));
|
||||
|
||||
try testing.expect(sessions.validateAt(io, &cookie, 1_000));
|
||||
}
|
||||
|
||||
test "malformed cookie values are rejected without touching the table" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(24);
|
||||
_ = sessions.createWithToken(io, tokenOf(3), 1_000);
|
||||
|
||||
try testing.expect(!sessions.validateAt(io, "", 1_000));
|
||||
try testing.expect(!sessions.validateAt(io, "short", 1_000));
|
||||
// 43 characters, one of them outside the url-safe alphabet.
|
||||
try testing.expect(!sessions.validateAt(io, "*" ** 43, 1_000));
|
||||
// The padded encoding is the right token but the wrong length.
|
||||
var padded: [44]u8 = undefined;
|
||||
_ = std.base64.url_safe.Encoder.encode(&padded, &tokenOf(3));
|
||||
try testing.expect(!sessions.validateAt(io, &padded, 1_000));
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), sessions.count(io, 1_000));
|
||||
}
|
||||
|
||||
test "a session expires at its ttl and frees its slot" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(2);
|
||||
const cookie = sessions.createWithToken(io, tokenOf(9), 0);
|
||||
|
||||
try testing.expect(sessions.validateAt(io, &cookie, 7199));
|
||||
// Use does not extend the lifetime.
|
||||
try testing.expect(!sessions.validateAt(io, &cookie, 7200));
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(io, 7200));
|
||||
}
|
||||
|
||||
test "the thirty-third session evicts the least recently used one" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(24);
|
||||
var cookies: [Sessions.max_sessions]Cookie = undefined;
|
||||
for (&cookies, 0..) |*cookie, i| {
|
||||
cookie.* = sessions.createWithToken(io, tokenOf(@intCast(i)), 1_000 + @as(i64, @intCast(i)));
|
||||
}
|
||||
try testing.expectEqual(@as(u32, Sessions.max_sessions), sessions.count(io, 2_000));
|
||||
|
||||
// Touching the oldest session makes a later one the eviction candidate.
|
||||
try testing.expect(sessions.validateAt(io, &cookies[0], 2_000));
|
||||
|
||||
const newcomer = sessions.createWithToken(io, tokenOf(200), 2_001);
|
||||
try testing.expectEqual(@as(u32, Sessions.max_sessions), sessions.count(io, 2_001));
|
||||
try testing.expect(sessions.validateAt(io, &newcomer, 2_001));
|
||||
try testing.expect(sessions.validateAt(io, &cookies[0], 2_001));
|
||||
try testing.expect(!sessions.validateAt(io, &cookies[1], 2_001));
|
||||
for (cookies[2..]) |cookie| {
|
||||
try testing.expect(sessions.validateAt(io, &cookie, 2_001));
|
||||
}
|
||||
}
|
||||
|
||||
test "an expired slot is reused before any live session is evicted" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(1);
|
||||
var cookies: [Sessions.max_sessions]Cookie = undefined;
|
||||
for (&cookies, 0..) |*cookie, i| {
|
||||
cookie.* = sessions.createWithToken(io, tokenOf(@intCast(i)), @intCast(i));
|
||||
}
|
||||
// The first session expires an hour after it was made; the rest are younger.
|
||||
const newcomer = sessions.createWithToken(io, tokenOf(100), 3_600);
|
||||
try testing.expect(!sessions.validateAt(io, &cookies[0], 3_600));
|
||||
try testing.expect(sessions.validateAt(io, &cookies[1], 3_600));
|
||||
try testing.expect(sessions.validateAt(io, &newcomer, 3_600));
|
||||
}
|
||||
|
||||
test "logout drops one session and leaves the others" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(24);
|
||||
const first = sessions.createWithToken(io, tokenOf(1), 1_000);
|
||||
const second = sessions.createWithToken(io, tokenOf(2), 1_000);
|
||||
|
||||
try testing.expect(sessions.logout(io, &first));
|
||||
try testing.expect(!sessions.validateAt(io, &first, 1_000));
|
||||
try testing.expect(sessions.validateAt(io, &second, 1_000));
|
||||
|
||||
// Logging the same cookie out twice is not an error, just no longer a hit.
|
||||
try testing.expect(!sessions.logout(io, &first));
|
||||
try testing.expect(!sessions.logout(io, "nonsense"));
|
||||
try testing.expectEqual(@as(u32, 1), sessions.count(io, 1_000));
|
||||
|
||||
// The freed slot is available again.
|
||||
const third = sessions.createWithToken(io, tokenOf(3), 1_001);
|
||||
try testing.expect(sessions.validateAt(io, &third, 1_001));
|
||||
try testing.expectEqual(@as(u32, 2), sessions.count(io, 1_001));
|
||||
}
|
||||
|
||||
test "clearAll ends every session" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var sessions: Sessions = .init(24);
|
||||
var cookies: [4]Cookie = undefined;
|
||||
for (&cookies, 0..) |*cookie, i| {
|
||||
cookie.* = sessions.createWithToken(io, tokenOf(@intCast(i)), 1_000);
|
||||
}
|
||||
|
||||
sessions.clearAll(io);
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(io, 1_000));
|
||||
for (cookies) |cookie| {
|
||||
try testing.expect(!sessions.validateAt(io, &cookie, 1_000));
|
||||
}
|
||||
|
||||
// The store keeps working after a clear.
|
||||
const fresh = sessions.createWithToken(io, tokenOf(9), 1_001);
|
||||
try testing.expect(sessions.validateAt(io, &fresh, 1_001));
|
||||
}
|
||||
|
||||
test "the live hash starts as the boot value and follows installAndRevoke" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var live: LiveHash = .init("boot-hash");
|
||||
defer live.deinit(gpa);
|
||||
try testing.expect(live.enabled(io));
|
||||
|
||||
var buf: [LiveHash.max_len]u8 = undefined;
|
||||
const boot = try live.copy(io, &buf);
|
||||
try testing.expectEqualStrings("boot-hash", boot.hash);
|
||||
try testing.expectEqual(@as(u64, 0), boot.generation);
|
||||
|
||||
// The boot value is borrowed; the first install must not free it. With no
|
||||
// session store the revocation half is skipped.
|
||||
live.installAndRevoke(io, gpa, null, try gpa.dupe(u8, "first-replacement"));
|
||||
const first = try live.copy(io, &buf);
|
||||
try testing.expectEqualStrings("first-replacement", first.hash);
|
||||
try testing.expectEqual(@as(u64, 1), first.generation);
|
||||
|
||||
// The second install frees the first — the leak detector is the assertion.
|
||||
live.installAndRevoke(io, gpa, null, try gpa.dupe(u8, "second-replacement"));
|
||||
const second = try live.copy(io, &buf);
|
||||
try testing.expectEqualStrings("second-replacement", second.hash);
|
||||
try testing.expectEqual(@as(u64, 2), second.generation);
|
||||
}
|
||||
|
||||
test "an empty live hash reads as authentication off" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var live: LiveHash = .{};
|
||||
defer live.deinit(testing.allocator);
|
||||
try testing.expect(!live.enabled(io));
|
||||
|
||||
var buf: [LiveHash.max_len]u8 = undefined;
|
||||
try testing.expectEqual(@as(usize, 0), (try live.copy(io, &buf)).hash.len);
|
||||
}
|
||||
|
||||
test "confirmSession mints for the copied generation and refuses a stale one" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var live: LiveHash = .init("boot-hash");
|
||||
defer live.deinit(gpa);
|
||||
var sessions: Sessions = .init(24);
|
||||
|
||||
var buf: [LiveHash.max_len]u8 = undefined;
|
||||
const snapshot = try live.copy(io, &buf);
|
||||
|
||||
const cookie = (try live.confirmSession(io, &sessions, snapshot.generation)).?;
|
||||
try testing.expect(sessions.validate(io, &cookie));
|
||||
|
||||
// An install between copy and confirm makes the snapshot stale: no
|
||||
// session, and the ones the install revoked stay revoked.
|
||||
live.installAndRevoke(io, gpa, &sessions, try gpa.dupe(u8, "new-hash"));
|
||||
try testing.expectEqual(@as(?Cookie, null), try live.confirmSession(io, &sessions, snapshot.generation));
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(io, 0));
|
||||
|
||||
// A snapshot of the new hash confirms again.
|
||||
const fresh = try live.copy(io, &buf);
|
||||
try testing.expect(try live.confirmSession(io, &sessions, fresh.generation) != null);
|
||||
}
|
||||
|
||||
test "a session confirmed before installAndRevoke does not survive it" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var live: LiveHash = .init("boot-hash");
|
||||
defer live.deinit(gpa);
|
||||
var sessions: Sessions = .init(24);
|
||||
|
||||
var buf: [LiveHash.max_len]u8 = undefined;
|
||||
const snapshot = try live.copy(io, &buf);
|
||||
const cookie = (try live.confirmSession(io, &sessions, snapshot.generation)).?;
|
||||
try testing.expect(sessions.validate(io, &cookie));
|
||||
|
||||
// The transition lands after the confirm: the nested clearAll ends the
|
||||
// session just minted, so the ordering leaves no cookie alive either way.
|
||||
live.installAndRevoke(io, gpa, &sessions, try gpa.dupe(u8, "new-hash"));
|
||||
try testing.expect(!sessions.validate(io, &cookie));
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(io, 0));
|
||||
}
|
||||
|
||||
test "a boot hash too long to copy is reported, not truncated" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var live: LiveHash = .init("x" ** (LiveHash.max_len + 1));
|
||||
defer live.deinit(testing.allocator);
|
||||
try testing.expect(live.enabled(io));
|
||||
|
||||
var buf: [LiveHash.max_len]u8 = undefined;
|
||||
try testing.expectError(error.Oversize, live.copy(io, &buf));
|
||||
}
|
||||
|
||||
test "verifyPassword accepts the password behind an import-path hash" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
// The same parameters `config/import.zig` hashes with (owasp_2id, PHC).
|
||||
var buf: [256]u8 = undefined;
|
||||
const hash = try std.crypto.pwhash.argon2.strHash("correct horse battery staple", .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
.mode = .argon2id,
|
||||
.encoding = .phc,
|
||||
}, &buf, io);
|
||||
try testing.expect(std.mem.startsWith(u8, hash, "$argon2id$"));
|
||||
|
||||
try testing.expectEqual(Outcome.ok, try verifyPassword(io, gpa, hash, "correct horse battery staple"));
|
||||
try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, hash, "correct horse battery stapl"));
|
||||
try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, hash, ""));
|
||||
try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, hash, "x" ** (max_password_len + 1)));
|
||||
}
|
||||
|
||||
test "verifyPassword denies with no hash and reports an unreadable one" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
try testing.expectEqual(Outcome.denied, try verifyPassword(io, gpa, "", "anything"));
|
||||
try testing.expectEqual(Outcome.unavailable, try verifyPassword(io, gpa, "not a phc string", "anything"));
|
||||
}
|
||||
Reference in New Issue
Block a user