ajhahn.de
← FlashOS
Flash 250 lines
// login — interactive credential gate + session supervisor.
//
// PID-1 execs /bin/login instead of the shell. login prompts for a
// username (echoed) and a password (masked with '*' via
// SYS_SET_CONSOLE_MODE), asks the kernel to verify the password against
// the active shadow database (sys_authenticate — the KDF lives in the
// kernel), looks the user up in /etc/passwd for the uid / gid / shell,
// and then runs the session as a CHILD process: the child drops
// privilege (setgid + setuid) and execs the shell; login itself stays
// root, waits, reaps, and prompts again. `exit` in the shell therefore
// returns to the `login:` prompt instead of ending the boot — the
// re-prompt lifecycle.
//
// The privilege drop MUST live in the child: setuid is one-way for a
// non-root process, so a login that dropped itself could never
// authenticate a second session. The parent staying root is what makes
// it a supervisor.
//
// argv[1] (optional) is a decimal session limit: login exits cleanly
// after that many completed sessions. The [TEST] auth scenario drives a
// full login->shell->exit->login cycle through this real binary with limit
// "2" and then reaps it for the free-page baseline check. No argv (the
// real boot) means loop forever. A non-numeric argv[1] is ignored.
//
// Under the CI boot watchdog PID-1 console-injects the test credentials
// so this real path authenticates unattended; on hardware the user types
// them. Same coreutil recipe as dmesg / ls (flibc _start shim, single
// PT_LOAD, no heap allocator — only fixed stack buffers).

use flibc
use pwfile

link "flibc_start"
link "flibc_mem"

const PASSWD_PATH cstr = "/etc/passwd"

fn emit(s []u8) void {
    _ = flibc.sys.write_fd(1, s.ptr, s.len)
}

// Read a masked secret from fd 0 into `buf`. Drives flibc's pure, host-tested
// line-editor `step` so backspace actually pops a byte, but echoes one '*' per
// accepted byte and the rubout "\x08 \x08" on backspace instead of the byte
// itself — the secret never reaches the serial console. Submits on CR / LF,
// stops on EOF, drops the line on ^C. Returns the byte count, excluding the
// terminator. The caller leaves the console in echo-off mode, so this loop is
// the only echo (no kernel double-echo).
fn readMasked(buf []mut u8) usize {
    var state = flibc.readline_mod.State.init(buf)
    var ch [1]u8 = undefined
    while true {
        if flibc.sys.read(0, &ch, 1) <= 0 {
            break
        }
        switch flibc.readline_mod.step(&state, ch[0]) {
            .echo => emit("*"),
            .backspace => emit("\x08 \x08"),
            .submit, .eof => { break },
            .abandon => {
                state.len = 0
                break
            },
            .none, .complete => {},
        }
    }
    return state.len
}

fn strLen(s cstr) usize {
    var n usize = 0
    while s[n] != 0 {
        n += 1
    }
    return n
}

fn parseU32(s []u8) ?u32 {
    if s.len == 0 {
        return null
    }
    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)
}

// One authenticated session: fork; the child drops privilege and execs
// the user's shell; the parent waits for it to exit (logout). Returns
// true when a session actually ran (a fork/exec failure returns false so
// the caller does not count it against the session limit).
fn runSession(uid u32, gid u32, shell_z cstr) bool {
    pid := flibc.fork()
    if pid == 0 {
        // Child: drop privilege — gid first (while still root), then uid —
        // and become the shell. Credentials are inherited by everything
        // the shell forks.
        if flibc.sys.setgid(gid) != 0 || flibc.sys.setuid(uid) != 0 {
            emit("login: cannot drop privilege\n")
            flibc.exit()
        }
        sh_argv := [_:null]?cstr{ shell_z }
        _ = flibc.sys.exec_path(shell_z, &sh_argv)
        // exec_path only returns on failure; the child must die, not loop.
        emit("login: exec failed\n")
        flibc.exit()
    }
    if pid < 0 {
        emit("login: fork failed\n")
        return false
    }
    // Parent (still root): the wait returning is the logout event.
    _ = flibc.wait()
    return true
}

export fn main(argc usize, argv argv) noreturn {
    var user_buf [64]u8 = undefined
    var pass_buf [128]u8 = undefined
    var pw_buf [512]u8 = undefined
    var shell_buf [64]u8 = undefined

    // login mints a session by dropping privilege from root, and setuid is
    // one-way — so a login that is not already root can only ever re-grant the
    // euid it inherited. Run as a normal command from a privilege-dropped shell
    // it would still authenticate (the kernel verifier does not gate on the
    // caller's uid) and only then fail the drop with a misleading "cannot drop
    // privilege". Refuse up front: minting sessions is the PID-1 supervisor's
    // job, reached as root via initramfs exec. The proper user-switch is
    // `logout` back to that supervisor, then log in as the other account.
    // (Matches Unix, where login is getty/PID-1-only with no user entry point;
    // FlashOS has no setuid-root bit to make a user-invoked login safe.)
    if flibc.sys.geteuid() != 0 {
        emit("login: must be root\n")
        flibc.exit()
    }

    // Optional session limit (argv[1], decimal). 0 = loop forever.
    var max_sessions u32 = 0
    if argc >= 2 {
        if argv[1] |arg| {
            if parseU32(arg[0..strLen(arg)]) |n| {
                max_sessions = n
            }
        }
    }
    var sessions_done u32 = 0

    // Blank line before the first `login:` prompt, separating it from the
    // kernel's last boot status line (or the -Dboot-selftest tally).
    emit("\n")

    while true {
        // Username — echo off so login owns the echo through flibc's line
        // editor: it echoes each byte and rubs out a backspace, so a typo is
        // correctable. The kernel's raw echo could not erase a mistake, which
        // made a single slip uncorrectable.
        _ = flibc.sys.set_console_mode(0)
        emit("login: ")
        ulen := switch flibc.readline(&user_buf) {
            .line => |l| l.len,
            .eof, .abandoned => 0,
        }
        emit("\n")

        // A bare Enter / empty username re-prompts silently, getty-style:
        // no password challenge, no "Login incorrect". This also absorbs a
        // stray newline left in the console RX at boot (e.g. a residual byte
        // from the [TEST] login scenario's scripted sessions), so the first
        // real prompt is a clean `login:` instead of a phantom failed attempt.
        if ulen == 0 {
            continue
        }

        // Password — still echo off; readMasked owns the echo, printing one
        // '*' per accepted byte and rubbing it out on backspace, so the secret
        // stays hidden on the serial console yet a typo is correctable. The
        // console stays echo-off straight into the shell, where fsh's own
        // readline owns the echo (no mask leak, no double-echo).
        emit("Password: ")
        plen := readMasked(&pass_buf)
        emit("\n")

        if flibc.sys.authenticate(&user_buf, ulen, &pass_buf, plen) != 0 {
            emit("Login incorrect\n")
            continue
        }

        // Pull uid / gid / shell from /etc/passwd (fresh read per session).
        fd := flibc.sys.open(PASSWD_PATH)
        if fd < 0 {
            emit("login: /etc/passwd missing\n")
            continue
        }
        var pn usize = 0
        while pn < pw_buf.len {
            r := flibc.sys.read(fd, pw_buf[pn..].ptr, pw_buf.len - pn)
            if r <= 0 {
                break
            }
            pn += #intCast(r)
        }
        _ = flibc.sys.close(fd)

        // (orelse with a multi-statement block handler is not expressible; an
        // explicit null check yields the same divergent re-prompt.)
        maybe_entry := pwfile.lookupByName(pw_buf[0..pn], user_buf[0..ulen])
        if maybe_entry == null {
            emit("login: no passwd entry\n")
            continue
        }
        entry := maybe_entry.?

        // Copy + NUL-terminate the shell path for execve.
        if entry.shell.len == 0 || entry.shell.len >= shell_buf.len {
            emit("login: bad shell\n")
            continue
        }
        var si usize = 0
        while si < entry.shell.len {
            shell_buf[si] = entry.shell[si]
            si += 1
        }
        shell_buf[si] = 0
        const shell_z cstr = #ptrCast(&shell_buf)

        // Blank line separating the password prompt from the shell's
        // homescreen, which the session child execs into next.
        emit("\n")

        if !runSession(entry.uid, entry.gid, shell_z) {
            continue
        }

        // Logout: the session child has been reaped. Honour the session
        // limit (the [TEST] auth hook), then fall through to re-prompt.
        sessions_done += 1
        if max_sessions != 0 && sessions_done >= max_sessions {
            flibc.exit()
        }
    }
}