╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │16 // test mode serves repos directly by path instead of resolving them as │ │17 // <owner>/<repo> through the event store │ │18 is_test: bool, │ │19 + err: *std.Io.Writer, │ │20 │ │21 pub fn handleSession(self: *const SessionHandler, sess: *ssh.SessionCtx, request: ssh.Request) !void { │ │21 - std.debug.print("ssh session: kind={s} key={s}\n", .{ @tagName(request), sess.fingerprint }); │ │22 + serve_common.logError(self.err, "ssh session: kind={s} key={s}\n", .{ @tagName(request), sess.fingerprint });│ │23 switch (request) { │ │24 .shell => |pty_maybe| { │ │25 const pty = pty_maybe orelse { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │90 // runs as the function unwinds — so any error it returns leaves the TUI torn │ │91 // down and we can surface the failure on the restored screen before exiting. │ │92 runTui(handler, sess, pty) catch |err| { │ │92 - std.debug.print("ssh tui session failed: {s}\n", .{@errorName(err)}); │ │93 + serve_common.logError(handler.err, "ssh tui session failed: {s}\n", .{@errorName(err)}); │ │94 var buf: [256]u8 = undefined; │ │95 const msg = std.fmt.bufPrint(&buf, "haxy ssh: {s}\r\n", .{@errorName(err)}) catch "haxy ssh: internal error\r\n";│ │96 sess.writeBytes(msg) catch {}; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │215 const create_if_missing = parsed.service == .receive_pack; │ │216 const any_repo_opts: rp.AnyRepoOpts(.xit) = .{}; │ │217 │ │218 + // authenticate pushes: the authenticated key must be registered to the │ │219 + // repo's owner. skipped in test mode, which serves repos directly by path. │ │220 + if (create_if_missing and !handler.is_test) { │ │221 + const owner_repo = evt.parseOwnerRepoPath(parsed.dir) orelse return writeError(sess, "repo path must be <owner>/<repo>"); │ │222 + if (!try isKeyAuthorized(io, allocator, handler.admin_repo_path, owner_repo.owner, &sess.fingerprint)) │ │223 + return writeError(sess, "unauthorized: this SSH key is not registered to the repo owner"); │ │224 + } │ │225 + │ │226 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, handler.is_test, handler.repo_root_path, handler.admin_repo_path, parsed.dir, create_if_missing)) {│ │227 .ok => |p| p, │ │228 .invalid => return writeError(sess, "repo path must be <owner>/<repo>"), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │323 return error.UnsupportedService; │ │324 │ │325 return .{ .service = service, .dir = try allocator.dupe(u8, dir_token) }; │ │326 +} │ │327 + │ │328 +// true if `fingerprint` matches one of the named user's registered SSH keys │ │329 +fn isKeyAuthorized( │ │330 + io: std.Io, │ │331 + allocator: std.mem.Allocator, │ │332 + admin_repo_path: []const u8, │ │333 + owner_name: []const u8, │ │334 + fingerprint: *const [ssh.fingerprint_len]u8, │ │335 +) !bool { │ │336 + var arena = std.heap.ArenaAllocator.init(allocator); │ │337 + defer arena.deinit(); │ │338 + │ │339 + const user = (try evt.User.readByName(io, allocator, admin_repo_path, &arena, owner_name)) orelse return false;│ │340 + │ │341 + var it = std.mem.splitScalar(u8, user.ssh_keys, '\n'); │ │342 + while (it.next()) |line| { │ │343 + const fp = fingerprintOfAuthorizedKey(line) orelse continue; │ │344 + if (std.mem.eql(u8, &fp, fingerprint)) return true; │ │345 + } │ │346 + return false; │ │347 } │ │348 │ │349 +// the SHA256 fingerprint of one authorized_keys line, or null if the line is │ │350 +// blank or not a parseable "<type> <base64> [comment]" entry │ │351 +fn fingerprintOfAuthorizedKey(line: []const u8) ?[ssh.fingerprint_len]u8 { │ │352 + var it = std.mem.tokenizeAny(u8, line, " \t\r"); │ │353 + _ = it.next() orelse return null; // key type │ │354 + const blob_b64 = it.next() orelse return null; │ │355 + │ │356 + const decoder = std.base64.standard.Decoder; │ │357 + const blob_len = decoder.calcSizeForSlice(blob_b64) catch return null; │ │358 + var blob_buf: [4096]u8 = undefined; │ │359 + if (blob_len > blob_buf.len) return null; │ │360 + decoder.decode(blob_buf[0..blob_len], blob_b64) catch return null; │ │361 + │ │362 + return ssh.formatFingerprint(blob_buf[0..blob_len]); │ │363 +} │ │364 + │ │365 // write an error to the client and exit non-zero │ │366 fn writeError(sess: *ssh.SessionCtx, msg: []const u8) !void { │ │367 var buf: [256]u8 = undefined; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯