// 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) }