Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s
783 lines
31 KiB
Zig
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"));
|
|
}
|