Files
nxdns/src/web/auth.zig
T

783 lines
31 KiB
Zig

//! 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; a null
/// one means the settings table holds no hash row at all, which is the same
/// answer.
pub fn authEnabled(web: model.Web) bool {
const hash = web.password_hash orelse return false;
return 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 = null }));
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"));
}