ajhahn.de
← FlashOS
Flash 367 lines
// shadow: /etc/shadow line parser + hex decoder.
//
// Pure, allocation-free, no externs. The kernel's sys_authenticate
// (src/sys.zig) reads /etc/shadow into a stack buffer and walks it line by
// line with these helpers; the host tests below pin the format so a
// consumer never drifts from the build-time generator (tools/gen_shadow.zig).
//
// Line format: `user:iterations:salt_hex:hash_hex`
//   * user        — login name (raw bytes, compared verbatim)
//   * iterations  — PBKDF2-HMAC-SHA256 round count, decimal
//   * salt_hex    — salt bytes, hex (even length, lower/upper)
//   * hash_hex    — derived key bytes, hex (even length)
//
// salt/hash stay hex in the Entry; the caller hexDecode()s them right next
// to the PBKDF2 call, so this module owns no buffers. There is deliberately
// NO uid field — uid/gid/shell live in /etc/passwd (parsed in userland by
// /bin/login); /etc/shadow holds only the verifier, mirroring real Unix.

pub const Entry = struct {
    user []u8,
    iterations u32,
    salt_hex []u8,
    hash_hex []u8,
}

// Split one shadow line (no trailing newline — the caller slices on '\n')
// into its four fields. Returns null on a missing or empty field, a 5th
// `:`-delimited field, or a non-decimal / zero / overflowing iteration
// count.
pub fn parseLine(line []u8) ?Entry {
    const c1 = indexOf(line, ':') orelse return null
    const user = line[0..c1]
    const rest1 = line[c1 + 1 ..]

    const c2 = indexOf(rest1, ':') orelse return null
    const iters_s = rest1[0..c2]
    const rest2 = rest1[c2 + 1 ..]

    const c3 = indexOf(rest2, ':') orelse return null
    const salt_hex = rest2[0..c3]
    const hash_hex = rest2[c3 + 1 ..]

    // A 5th field (another ':') is malformed.
    if (indexOf(hash_hex, ':') != null) { return null }
    if (user.len == 0) || (iters_s.len == 0) || (salt_hex.len == 0) || (hash_hex.len == 0) { return null }

    const iterations = parseDecimalU32(iters_s) orelse return null
    if (iterations == 0) { return null }

    return .{
        .user = user,
        .iterations = iterations,
        .salt_hex = salt_hex,
        .hash_hex = hash_hex,
    }
}

// Decode `inp` (hex, even length) into `out`. Returns the byte count, or
// null on odd length, a non-hex digit, or `out` too small.
pub fn hexDecode(inp []u8, out []mut u8) ?usize {
    if (inp.len % 2 != 0) { return null }
    const n = inp.len / 2
    if (out.len < n) { return null }
    var i usize = 0
    while (i < n) {
        const hi = hexNibble(inp[2 * i]) orelse return null
        const lo = hexNibble(inp[2 * i + 1]) orelse return null
        out[i] = (hi << 4) | lo
        i += 1
    }
    return n
}

// Encode `inp` bytes as lowercase hex into `out`. Returns the character
// count (2 × inp.len), or null when `out` is too small. Inverse of
// hexDecode; sys_passwd uses it to serialize the fresh salt + derived
// key back into a shadow line.
pub fn hexEncode(inp []u8, out []mut u8) ?usize {
    if (out.len < inp.len * 2) { return null }
    const digits = "0123456789abcdef"
    for b, i in inp {
        out[2 * i] = digits[b >> 4]
        out[2 * i + 1] = digits[b & 0xF]
    }
    return inp.len * 2
}

// Byte span (start inclusive, end exclusive, newline excluded) of the
// shadow line whose user field equals `user`. Lines that fail parseLine
// are skipped, mirroring the lookup loop in sys_authenticate. Returns
// null when no line matches.
pub const LineSpan = struct { start usize, end usize }

pub fn findUserLine(content []u8, user []u8) ?LineSpan {
    var line_start usize = 0
    var i usize = 0
    while (i <= content.len) {
        if (i == content.len) || (content[i] == '\n') {
            const line = content[line_start..i]
            const span_start = line_start
            line_start = i + 1
            if (line.len != 0) {
                if parseLine(line) |e| {
                    if (bytesEqual(e.user, user)) { return .{ .start = span_start, .end = i } }
                }
            }
        }
        i += 1
    }
    return null
}

// Rewrite `user`'s shadow line in place with a fresh salt + hash, keeping
// the iteration count. The same-length invariant is what makes the
// follow-up FAT32 write splice-safe: the iteration count is reused
// verbatim and salt/hash arrive as fixed-width hex, so the new line is
// byte-for-byte the same length as the old one and the file size never
// changes. Returns false when the user is absent, the old line does not
// parse, or the lengths diverge (e.g. a hand-edited shadow with a
// different salt width — refuse rather than corrupt).
pub fn rewriteLineInPlace(
    content []mut u8,
    user []u8,
    new_salt_hex []u8,
    new_hash_hex []u8
) bool {
    const span = findUserLine(content, user) orelse return false
    const old = parseLine(content[span.start..span.end]) orelse return false

    const new_len = user.len + 1 + decimalLen(old.iterations) + 1 + new_salt_hex.len + 1 + new_hash_hex.len
    if (new_len != span.end - span.start) { return false }

    var w usize = span.start
    for c in user {
        content[w] = c
        w += 1
    }
    content[w] = ':'
    w += 1
    w += writeDecimal(content[w..], old.iterations)
    content[w] = ':'
    w += 1
    for c in new_salt_hex {
        content[w] = c
        w += 1
    }
    content[w] = ':'
    w += 1
    for c in new_hash_hex {
        content[w] = c
        w += 1
    }
    return w == span.end
}

fn bytesEqual(a []u8, b []u8) bool {
    if (a.len != b.len) { return false }
    var i usize = 0
    while (i < a.len) {
        if (a[i] != b[i]) { return false }
        i += 1
    }
    return true
}

// Digit count of `v` in decimal (v == 0 -> 1).
fn decimalLen(v u32) usize {
    var n usize = 1
    var x = v / 10
    while (x != 0) {
        n += 1
        x /= 10
    }
    return n
}

// Write `v` in decimal at out[0..]; returns the digit count. The caller
// guarantees capacity (rewriteLineInPlace checked the total length).
fn writeDecimal(out []mut u8, v u32) usize {
    const n = decimalLen(v)
    var x = v
    var i = n
    while (i > 0) {
        i -= 1
        out[i] = '0' + #as(u8, #intCast(x % 10))
        x /= 10
    }
    return n
}

fn indexOf(haystack []u8, needle u8) ?usize {
    for c, i in haystack {
        if (c == needle) { return i }
    }
    return null
}

// Decimal u32 parse, exact (no sign, no whitespace). A u64 accumulator
// catches overflow past u32 without depending on std.math.
fn parseDecimalU32(s []u8) ?u32 {
    var v u64 = 0
    for c in s {
        if (c < '0') || (c > '9') { return null }
        v = v * 10 + (c - '0')
        if (v > 0xffff_ffff) { return null }
    }
    return #intCast(v)
}

fn hexNibble(c u8) ?u8 {
    return switch c {
        '0'...'9' => c - '0',
        'a'...'f' => c - 'a' + 10,
        'A'...'F' => c - 'A' + 10,
        else => null,
    }
}

// ---- Host tests ----
const std = #import("std")

test "parseLine: well-formed line" {
    const e = parseLine("flash:4096:0011aabb:deadbeef").?
    try std.testing.expectEqualStrings("flash", e.user)
    try std.testing.expectEqual(#as(u32, 4096), e.iterations)
    try std.testing.expectEqualStrings("0011aabb", e.salt_hex)
    try std.testing.expectEqualStrings("deadbeef", e.hash_hex)
}

test "parseLine: rejects missing fields" {
    try std.testing.expectEqual(#as(?Entry, null), parseLine("flash:4096:0011aabb"))
    try std.testing.expectEqual(#as(?Entry, null), parseLine("flash:4096"))
    try std.testing.expectEqual(#as(?Entry, null), parseLine("flash"))
    try std.testing.expectEqual(#as(?Entry, null), parseLine(""))
}

test "parseLine: rejects a 5th field" {
    try std.testing.expectEqual(#as(?Entry, null), parseLine("a:1:bb:cc:extra"))
}

test "parseLine: rejects empty user / non-decimal / zero iters" {
    try std.testing.expectEqual(#as(?Entry, null), parseLine(":4096:bb:cc"))
    try std.testing.expectEqual(#as(?Entry, null), parseLine("flash:40x6:bb:cc"))
    try std.testing.expectEqual(#as(?Entry, null), parseLine("flash:0:bb:cc"))
}

test "parseLine: rejects iteration overflow past u32" {
    try std.testing.expectEqual(#as(?Entry, null), parseLine("flash:99999999999:bb:cc"))
}

test "hexDecode: round-trips bytes" {
    var out [4]u8 = undefined
    const n = hexDecode("0011aabb", &out).?
    try std.testing.expectEqual(#as(usize, 4), n)
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0x00, 0x11, 0xAA, 0xBB }, out[0..n])
}

test "hexDecode: accepts uppercase" {
    var out [2]u8 = undefined
    const n = hexDecode("DEAD", &out).?
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0xDE, 0xAD }, out[0..n])
}

test "hexDecode: rejects odd length / bad digit / small out" {
    var out [4]u8 = undefined
    try std.testing.expectEqual(#as(?usize, null), hexDecode("abc", &out))
    try std.testing.expectEqual(#as(?usize, null), hexDecode("zz", &out))
    var small [1]u8 = undefined
    try std.testing.expectEqual(#as(?usize, null), hexDecode("aabb", &small))
}

test "hexEncode: lowercase round-trip with hexDecode" {
    const bytes = [_]u8{ 0x00, 0x11, 0xAA, 0xBB, 0xDE, 0xAD }
    var hex [12]u8 = undefined
    const n = hexEncode(&bytes, &hex).?
    try std.testing.expectEqual(#as(usize, 12), n)
    try std.testing.expectEqualStrings("0011aabbdead", hex[0..n])

    var back [6]u8 = undefined
    const m = hexDecode(hex[0..n], &back).?
    try std.testing.expectEqualSlices(u8, &bytes, back[0..m])
}

test "hexEncode: rejects an undersized output buffer" {
    const bytes = [_]u8{ 0x01, 0x02 }
    var small [3]u8 = undefined
    try std.testing.expectEqual(#as(?usize, null), hexEncode(&bytes, &small))
}

// Two-line fixture mirroring the gen_shadow output shape: 16-byte salts
// (32 hex chars) and 32-byte derived keys (64 hex chars).
const REWRITE_FIXTURE =
    "root:4096:" ++ ("aa" ** 16) ++ ":" ++ ("bb" ** 32) ++ "\n" ++
    "flash:4096:" ++ ("cc" ** 16) ++ ":" ++ ("dd" ** 32) ++ "\n"

test "findUserLine: locates first, last, and absent users" {
    const root_span = findUserLine(REWRITE_FIXTURE, "root").?
    try std.testing.expectEqual(#as(usize, 0), root_span.start)
    const root_line = REWRITE_FIXTURE[root_span.start..root_span.end]
    try std.testing.expectEqualStrings("root", parseLine(root_line).?.user)

    const flash_span = findUserLine(REWRITE_FIXTURE, "flash").?
    const flash_line = REWRITE_FIXTURE[flash_span.start..flash_span.end]
    try std.testing.expectEqualStrings("flash", parseLine(flash_line).?.user)
    // The span excludes the trailing newline.
    try std.testing.expectEqual(#as(u8, '\n'), REWRITE_FIXTURE[flash_span.end])

    try std.testing.expectEqual(#as(?LineSpan, null), findUserLine(REWRITE_FIXTURE, "anton"))
    // A prefix of an existing user must not match.
    try std.testing.expectEqual(#as(?LineSpan, null), findUserLine(REWRITE_FIXTURE, "fla"))
}

test "findUserLine: works without a trailing newline on the last line" {
    const fixture = "root:4096:" ++ ("aa" ** 16) ++ ":" ++ ("bb" ** 32)
    const span = findUserLine(fixture, "root").?
    try std.testing.expectEqual(fixture.len, span.end)
}

test "rewriteLineInPlace: same-length rewrite keeps neighbours and size intact" {
    var buf [REWRITE_FIXTURE.len]u8 = undefined
    #memcpy(&buf, REWRITE_FIXTURE)

    const new_salt = "0123456789abcdef0123456789abcdef" // 32 hex chars
    const new_hash = "f0" ** 32 // 64 hex chars
    try std.testing.expect(rewriteLineInPlace(&buf, "flash", new_salt, new_hash))

    // The flash line carries the new salt + hash and still parses.
    const span = findUserLine(&buf, "flash").?
    const e = parseLine(buf[span.start..span.end]).?
    try std.testing.expectEqual(#as(u32, 4096), e.iterations)
    try std.testing.expectEqualStrings(new_salt, e.salt_hex)
    try std.testing.expectEqualStrings(new_hash, e.hash_hex)

    // The root line is byte-identical (no bleed across the rewrite).
    const root_span = findUserLine(&buf, "root").?
    try std.testing.expectEqualStrings(
        REWRITE_FIXTURE[root_span.start..root_span.end],
        buf[root_span.start..root_span.end]
    )
    // Total content length is unchanged by construction (in-place).
}

test "rewriteLineInPlace: round-trips through PBKDF2-style fresh values twice" {
    // Two consecutive rewrites (the [TEST] passwd change + restore shape)
    // keep the file stable: same length, both parseable, order preserved.
    var buf [REWRITE_FIXTURE.len]u8 = undefined
    #memcpy(&buf, REWRITE_FIXTURE)

    try std.testing.expect(rewriteLineInPlace(&buf, "flash", "11" ** 16, "22" ** 32))
    try std.testing.expect(rewriteLineInPlace(&buf, "flash", "cc" ** 16, "dd" ** 32))
    try std.testing.expectEqualStrings(REWRITE_FIXTURE, &buf)
}

test "rewriteLineInPlace: refuses absent user and diverging lengths" {
    var buf [REWRITE_FIXTURE.len]u8 = undefined
    #memcpy(&buf, REWRITE_FIXTURE)

    // Absent user.
    try std.testing.expect(!rewriteLineInPlace(&buf, "anton", "aa" ** 16, "bb" ** 32))
    // Shorter salt (8 bytes hex = 16 chars) would shrink the line — refused.
    try std.testing.expect(!rewriteLineInPlace(&buf, "flash", "aa" ** 8, "bb" ** 32))
    // Longer hash would grow the line — refused.
    try std.testing.expect(!rewriteLineInPlace(&buf, "flash", "aa" ** 16, "bb" ** 33))
    // The refusals left the content untouched.
    try std.testing.expectEqualStrings(REWRITE_FIXTURE, &buf)
}