//! 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")); }