orama/vault/src/auth/session.zig

127 lines
3.9 KiB
Zig

/// Session token management.
///
/// After successful challenge-response auth, the server issues an HMAC-based
/// session token. Clients include this token in subsequent requests.
///
/// Token format: base64(identity_hash || expiry_timestamp || hmac_tag)
/// The HMAC binds the identity and expiry to the server secret.
const std = @import("std");
const HmacSha256 = std.crypto.auth.hmac.sha2.HmacSha256;
pub const SESSION_EXPIRY_NS: i128 = 3600 * std.time.ns_per_s; // 1 hour
pub const SessionToken = struct {
identity: [64]u8, // hex-encoded identity hash (padded)
identity_len: u8,
expiry_ns: i128,
tag: [HmacSha256.mac_length]u8,
};
pub const SessionError = error{
SessionExpired,
InvalidSession,
};
/// Issue a session token for the given identity.
pub fn issueToken(identity: []const u8, server_secret: [32]u8) SessionToken {
const now = std.time.nanoTimestamp();
const expiry = now + SESSION_EXPIRY_NS;
var id_buf: [64]u8 = .{0} ** 64;
const copy_len = @min(identity.len, 64);
@memcpy(id_buf[0..copy_len], identity[0..copy_len]);
var mac = HmacSha256.init(&server_secret);
mac.update(&id_buf);
var expiry_bytes: [16]u8 = undefined;
std.mem.writeInt(i128, &expiry_bytes, expiry, .little);
mac.update(&expiry_bytes);
var tag: [HmacSha256.mac_length]u8 = undefined;
mac.final(&tag);
return SessionToken{
.identity = id_buf,
.identity_len = @intCast(copy_len),
.expiry_ns = expiry,
.tag = tag,
};
}
/// Verify a session token.
pub fn verifyToken(token: SessionToken, server_secret: [32]u8) SessionError![]const u8 {
// Check expiry
const now = std.time.nanoTimestamp();
if (now > token.expiry_ns) {
return SessionError.SessionExpired;
}
// Recompute HMAC
var mac = HmacSha256.init(&server_secret);
mac.update(&token.identity);
var expiry_bytes: [16]u8 = undefined;
std.mem.writeInt(i128, &expiry_bytes, token.expiry_ns, .little);
mac.update(&expiry_bytes);
var expected: [HmacSha256.mac_length]u8 = undefined;
mac.final(&expected);
// Constant-time comparison to prevent timing attacks
if (!timingSafeEqual(&expected, &token.tag)) {
return SessionError.InvalidSession;
}
return token.identity[0..token.identity_len];
}
/// Constant-time byte comparison to prevent timing side-channel attacks.
fn timingSafeEqual(a: []const u8, b: []const u8) bool {
if (a.len != b.len) return false;
var diff: u8 = 0;
for (a, b) |x, y| {
diff |= x ^ y;
}
return diff == 0;
}
// ── Tests ────────────────────────────────────────────────────────────────────
test "session: issue and verify" {
var secret: [32]u8 = undefined;
std.crypto.random.bytes(&secret);
const token = issueToken("abcdef1234", secret);
const identity = try verifyToken(token, secret);
try std.testing.expectEqualSlices(u8, "abcdef1234", identity);
}
test "session: wrong secret fails" {
var secret1: [32]u8 = undefined;
var secret2: [32]u8 = undefined;
std.crypto.random.bytes(&secret1);
std.crypto.random.bytes(&secret2);
const token = issueToken("alice", secret1);
try std.testing.expectError(SessionError.InvalidSession, verifyToken(token, secret2));
}
test "session: tampered identity fails" {
var secret: [32]u8 = undefined;
std.crypto.random.bytes(&secret);
var token = issueToken("alice", secret);
token.identity[0] = 'X'; // tamper
try std.testing.expectError(SessionError.InvalidSession, verifyToken(token, secret));
}
test "session: tampered expiry fails" {
var secret: [32]u8 = undefined;
std.crypto.random.bytes(&secret);
var token = issueToken("alice", secret);
token.expiry_ns += 1; // tamper
try std.testing.expectError(SessionError.InvalidSession, verifyToken(token, secret));
}