Files
cobalt/src/auth.zig
T
2026-09-01 20:00:46 +02:00

451 lines
17 KiB
Zig

const std = @import("std");
const dbmod = @import("db.zig");
const Allocator = std.mem.Allocator;
const Db = dbmod.Db;
const Hmac256 = std.crypto.auth.hmac.sha2.HmacSha256;
/// Username/password authentication and coarse-grained role authorization,
/// layered over the same Db as tables/indexes/collections under its own
/// namespace tag 'A' (row.zig/index.zig use 'R'/'I'/'M', collections.zig
/// uses 'X', so no keyspace ever collides):
/// "A" "U\0" username -> UserRecord{role, rounds, salt[16], hash[32]}
/// Passwords are never stored: only a per-user salted PBKDF2-HMAC-SHA256
/// hash, using `std.crypto.pwhash.pbkdf2` - the same primitive the standard
/// library itself ships for exactly this purpose, not a hand-rolled one.
pub const nsTag: u8 = 'A';
const userPrefixBytes = [_]u8{ nsTag, 'U', 0 };
const saltLen = 16;
const hashLen = 32;
const recordLen = 1 + 4 + saltLen + hashLen;
pub const AuthError = error{
UserNotFound,
UserExists,
InvalidCredentials,
Malformed,
};
/// Roles form a total order, not a set of independent flags: readwrite
/// implies readonly, admin implies both. `atLeast` is the only check any
/// caller (server.zig's dispatch) ever needs.
pub const Role = enum(u8) {
readonly = 0,
readwrite = 1,
admin = 2,
pub fn fromByte(b: u8) !Role {
return std.meta.intToEnum(Role, b) catch error.Malformed;
}
pub fn parse(text: []const u8) !Role {
if (std.mem.eql(u8, text, "readonly")) return .readonly;
if (std.mem.eql(u8, text, "readwrite")) return .readwrite;
if (std.mem.eql(u8, text, "admin")) return .admin;
return error.Malformed;
}
pub fn name(self: Role) []const u8 {
return switch (self) {
.readonly => "readonly",
.readwrite => "readwrite",
.admin => "admin",
};
}
pub fn atLeast(self: Role, min: Role) bool {
return @intFromEnum(self) >= @intFromEnum(min);
}
};
/// Recommended real-world cost factor for interactive login (OWASP's floor
/// for PBKDF2-HMAC-SHA256 as of 2023). A constant rather than a value
/// baked into `hashPassword` so tests can pass a tiny round count and stay
/// fast, while `main.zig` and any other real caller default to this.
pub const recommendedRounds: u32 = 100_000;
fn userKey(allocator: Allocator, username: []const u8) ![]u8 {
const out = try allocator.alloc(u8, userPrefixBytes.len + username.len);
@memcpy(out[0..userPrefixBytes.len], &userPrefixBytes);
@memcpy(out[userPrefixBytes.len..], username);
return out;
}
const UserRecord = struct {
role: Role,
rounds: u32,
salt: [saltLen]u8,
hash: [hashLen]u8,
fn encode(self: UserRecord) [recordLen]u8 {
var out: [recordLen]u8 = undefined;
out[0] = @intFromEnum(self.role);
std.mem.writeInt(u32, out[1..5], self.rounds, .little);
@memcpy(out[5 .. 5 + saltLen], &self.salt);
@memcpy(out[5 + saltLen .. recordLen], &self.hash);
return out;
}
fn decode(data: []const u8) !UserRecord {
if (data.len != recordLen) return error.Malformed;
var rec: UserRecord = undefined;
rec.role = try Role.fromByte(data[0]);
rec.rounds = std.mem.readInt(u32, data[1..5], .little);
@memcpy(&rec.salt, data[5 .. 5 + saltLen]);
@memcpy(&rec.hash, data[5 + saltLen .. recordLen]);
return rec;
}
};
fn randomSalt() [saltLen]u8 {
var salt: [saltLen]u8 = undefined;
std.crypto.random.bytes(&salt);
return salt;
}
fn hashPassword(password: []const u8, salt: [saltLen]u8, rounds: u32) ![hashLen]u8 {
var out: [hashLen]u8 = undefined;
try std.crypto.pwhash.pbkdf2(&out, password, &salt, rounds, Hmac256);
return out;
}
/// Fixed salt/rounds used to cost a lookup against a username that does not
/// exist, so `verify` spends roughly the same CPU whether the account is
/// missing or the password was merely wrong - closing the most obvious
/// timing side channel for username enumeration over the network (the
/// key comparison itself still only runs on the "user exists" path, since
/// there is nothing real to compare against otherwise).
const dummySalt = [_]u8{0} ** saltLen;
const dummyRounds: u32 = 4096;
pub const UserInfo = struct {
username: []u8,
role: Role,
pub fn deinit(self: UserInfo, allocator: Allocator) void {
allocator.free(self.username);
}
};
pub fn freeUserList(allocator: Allocator, users: []UserInfo) void {
for (users) |u| u.deinit(allocator);
allocator.free(users);
}
/// Username/password/role management layered over a `*Db`. Every method
/// touches only keys under `userPrefixBytes`; unrelated data in the same
/// Db (tables, collections, raw KV) is never read or written. Not
/// synchronized on its own, same as every other module built over Db -
/// server.zig serializes access with its own connection-handling mutex.
pub const Store = struct {
db: *Db,
pub fn init(db: *Db) Store {
return .{ .db = db };
}
pub fn createUser(self: Store, allocator: Allocator, username: []const u8, password: []const u8, role: Role, rounds: u32) !void {
const key = try userKey(allocator, username);
defer allocator.free(key);
if (try self.db.get(allocator, key)) |existing| {
allocator.free(existing);
return error.UserExists;
}
const salt = randomSalt();
const record = UserRecord{
.role = role,
.rounds = rounds,
.salt = salt,
.hash = try hashPassword(password, salt, rounds),
};
const encoded = record.encode();
try self.db.put(key, &encoded);
}
fn mustGet(self: Store, allocator: Allocator, key: []const u8) !UserRecord {
const raw = try self.db.get(allocator, key) orelse return error.UserNotFound;
defer allocator.free(raw);
return UserRecord.decode(raw);
}
pub fn setPassword(self: Store, allocator: Allocator, username: []const u8, newPassword: []const u8, rounds: u32) !void {
const key = try userKey(allocator, username);
defer allocator.free(key);
var record = try self.mustGet(allocator, key);
record.rounds = rounds;
record.salt = randomSalt();
record.hash = try hashPassword(newPassword, record.salt, rounds);
const encoded = record.encode();
try self.db.put(key, &encoded);
}
pub fn setRole(self: Store, allocator: Allocator, username: []const u8, role: Role) !void {
const key = try userKey(allocator, username);
defer allocator.free(key);
var record = try self.mustGet(allocator, key);
record.role = role;
const encoded = record.encode();
try self.db.put(key, &encoded);
}
pub fn deleteUser(self: Store, allocator: Allocator, username: []const u8) !void {
const key = try userKey(allocator, username);
defer allocator.free(key);
_ = try self.mustGet(allocator, key);
try self.db.delete(key);
}
/// Verifies `password` for `username`, returning the stored role on
/// success. See `dummySalt`/`dummyRounds` for why a missing user still
/// pays a full PBKDF2 pass before failing. The final comparison uses
/// `std.crypto.utils.timingSafeEql` rather than `std.mem.eql`, since an
/// early-exit byte comparison on the hash would leak how many leading
/// bytes matched to a network attacker timing repeated attempts.
pub fn verify(self: Store, allocator: Allocator, username: []const u8, password: []const u8) !Role {
const key = try userKey(allocator, username);
defer allocator.free(key);
const raw = try self.db.get(allocator, key) orelse {
_ = hashPassword(password, dummySalt, dummyRounds) catch {};
return error.InvalidCredentials;
};
defer allocator.free(raw);
const record = try UserRecord.decode(raw);
const candidate = try hashPassword(password, record.salt, record.rounds);
if (!std.crypto.utils.timingSafeEql([hashLen]u8, candidate, record.hash)) return error.InvalidCredentials;
return record.role;
}
/// Whether at least one admin account exists - `main.zig` uses this to
/// refuse turning on server-side auth enforcement until an operator has
/// created a way back in, so enabling `--auth` can never lock everyone
/// out of a fresh database.
pub fn hasAnyAdmin(self: Store, allocator: Allocator) !bool {
var it = try self.db.scanPrefix(allocator, &userPrefixBytes);
defer it.deinit();
while (try it.next()) |entry| {
const record = UserRecord.decode(entry.value) catch continue;
if (record.role == .admin) return true;
}
return false;
}
/// Returns every user with their role, independently owned - free with
/// `freeUserList`. Order follows key order (lexicographic by username).
pub fn listUsers(self: Store, allocator: Allocator) ![]UserInfo {
var it = try self.db.scanPrefix(allocator, &userPrefixBytes);
defer it.deinit();
var out = std.ArrayList(UserInfo).init(allocator);
errdefer {
for (out.items) |u| u.deinit(allocator);
out.deinit();
}
while (try it.next()) |entry| {
const record = UserRecord.decode(entry.value) catch continue;
const uname = entry.key[userPrefixBytes.len..];
try out.append(.{ .username = try allocator.dupe(u8, uname), .role = record.role });
}
return out.toOwnedSlice();
}
};
fn testDb(allocator: Allocator, dir: std.fs.Dir) !Db {
const path = try dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
return Db.open(allocator, path, .{});
}
test "role ordering is a total order across readonly/readwrite/admin" {
try std.testing.expect(Role.readwrite.atLeast(.readonly));
try std.testing.expect(Role.admin.atLeast(.readonly));
try std.testing.expect(Role.admin.atLeast(.readwrite));
try std.testing.expect(Role.admin.atLeast(.admin));
try std.testing.expect(!Role.readonly.atLeast(.readwrite));
try std.testing.expect(!Role.readwrite.atLeast(.admin));
}
test "Role.parse rejects an unknown role name" {
try std.testing.expectEqual(Role.admin, try Role.parse("admin"));
try std.testing.expectError(error.Malformed, Role.parse("superuser"));
}
test "createUser then verify accepts the correct password" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readwrite, 16);
const role = try store.verify(allocator, "alice", "hunter2");
try std.testing.expectEqual(Role.readwrite, role);
}
test "verify rejects a wrong password without revealing which part was wrong" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readonly, 16);
try std.testing.expectError(error.InvalidCredentials, store.verify(allocator, "alice", "wrong"));
}
test "verify rejects an unknown username the same way as a wrong password" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try std.testing.expectError(error.InvalidCredentials, store.verify(allocator, "ghost", "anything"));
}
test "createUser rejects a duplicate username" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readonly, 16);
try std.testing.expectError(error.UserExists, store.createUser(allocator, "alice", "different", .admin, 16));
}
test "setPassword replaces the password and invalidates the old one" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try store.createUser(allocator, "alice", "old-pass", .readonly, 16);
try store.setPassword(allocator, "alice", "new-pass", 16);
try std.testing.expectError(error.InvalidCredentials, store.verify(allocator, "alice", "old-pass"));
try std.testing.expectEqual(Role.readonly, try store.verify(allocator, "alice", "new-pass"));
}
test "setPassword on a missing user reports UserNotFound" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try std.testing.expectError(error.UserNotFound, store.setPassword(allocator, "ghost", "x", 16));
}
test "setRole changes role without disturbing the password" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readonly, 16);
try store.setRole(allocator, "alice", .admin);
try std.testing.expectEqual(Role.admin, try store.verify(allocator, "alice", "hunter2"));
}
test "deleteUser removes the account so verify and mustGet both fail" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .readonly, 16);
try store.deleteUser(allocator, "alice");
try std.testing.expectError(error.InvalidCredentials, store.verify(allocator, "alice", "hunter2"));
try std.testing.expectError(error.UserNotFound, store.deleteUser(allocator, "alice"));
}
test "hasAnyAdmin reflects whether any created user carries the admin role" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try std.testing.expect(!(try store.hasAnyAdmin(allocator)));
try store.createUser(allocator, "alice", "hunter2", .readwrite, 16);
try std.testing.expect(!(try store.hasAnyAdmin(allocator)));
try store.createUser(allocator, "root", "toor", .admin, 16);
try std.testing.expect(try store.hasAnyAdmin(allocator));
}
test "listUsers returns every account with its role" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try store.createUser(allocator, "alice", "a-pass", .readonly, 16);
try store.createUser(allocator, "bob", "b-pass", .readwrite, 16);
try store.createUser(allocator, "root", "r-pass", .admin, 16);
const users = try store.listUsers(allocator);
defer freeUserList(allocator, users);
try std.testing.expectEqual(@as(usize, 3), users.len);
var sawAdmin = false;
for (users) |u| {
if (std.mem.eql(u8, u.username, "root")) {
try std.testing.expectEqual(Role.admin, u.role);
sawAdmin = true;
}
}
try std.testing.expect(sawAdmin);
}
test "users survive a Db close and reopen" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
{
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try store.createUser(allocator, "alice", "hunter2", .admin, 16);
}
var db = try testDb(allocator, tmp.dir);
defer db.close();
const store = Store.init(&db);
try std.testing.expectEqual(Role.admin, try store.verify(allocator, "alice", "hunter2"));
}
test "a malformed stored record is reported as Malformed, not a crash" {
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var db = try testDb(allocator, tmp.dir);
defer db.close();
const key = try userKey(allocator, "alice");
defer allocator.free(key);
try db.put(key, "too short to be a real record");
const store = Store.init(&db);
try std.testing.expectError(error.Malformed, store.verify(allocator, "alice", "anything"));
}