diff --git a/docs/ssh.md b/docs/ssh.md new file mode 100644 index 0000000..79df1a4 --- /dev/null +++ b/docs/ssh.md @@ -0,0 +1,100 @@ +to-do: rewrite & convert to html for docs pages + +# SSH + +MontaukOS ships an SSH-2 server, `sshd`, that gives a remote client a shell on +the machine. It speaks enough of the protocol to interoperate with stock +OpenSSH clients: + +``` +ssh dan@montauk-box interactive shell +ssh dan@montauk-box 'ls 0:/' run one command +``` + +Remote access is **off by default**. Turn it on from Settings > SSH Server, +which flips `services.ssh.enabled` in `0:/config/init.toml`; `init` starts the +daemon on the next boot. + +Two gates stand in front of a login: the account must exist in the MontaukOS +user database with a valid password, and it must be listed in the `[allow]` +table of `0:/config/ssh.toml`. A user absent from that table is refused even +with the right password. The Settings applet maintains the table; `sshd` never +writes it. + +## Layout + +``` +programs/src/sshd/ + main.cpp transport framing, key exchange, auth, session/channel loop + crypto.hpp host key storage, DH group 14 arithmetic, cipher/MAC wrappers +programs/src/sshserver/ + main.cpp Settings applet: enable the service, edit the allow table +programs/include/montauk/ssh.h + shared policy helpers used by both +``` + +The crypto primitives (SHA-256, HMAC, AES-CTR, RSA signing, the DRBG) come from +BearSSL. The modular exponentiation for Diffie-Hellman is in `crypto.hpp` +because BearSSL exposes no public bignum API. + +## What it implements + +| | | +|---|---| +| Key exchange | `diffie-hellman-group14-sha256` | +| Host key | `rsa-sha2-256`, 2048-bit, generated on first start | +| Cipher | `aes128-ctr` | +| MAC | `hmac-sha2-256` | +| Compression | none | +| Auth | password only | + +Client-initiated rekeying works, so long sessions survive OpenSSH's 1 GiB / +one-hour rekey threshold. Channel windows are tracked in both directions, so +sessions that move more than a window's worth of data keep flowing. + +## What it does not + +No public key authentication, no port or X11 forwarding, no SFTP or SCP. One +session channel per connection, and connections are served one at a time -- +there is no `fork`, so a second client waits for the first to finish. A +pre-authentication timeout keeps an idle peer from holding the daemon shut. + +`exec` requests are run by typing the command into an interactive shell rather +than executing it directly, so the prompt and the echoed command come back +mixed into the output. This is why `scp` and friends will not work as-is. + +## The host key + +Generated on first start and written to `0:/config/ssh_hostkey.toml`, separate +from the policy file so the unprivileged Settings applet -- which rewrites +`ssh.toml` whenever the allow table changes -- never round-trips private key +material. + +That separation is hygiene, not protection: **the filesystem has no permission +model**, so any local process can read the host key. Closing that hole needs +FS-level access control, at which point the key file should be restricted to +the account `sshd` runs as. A key found in an older `ssh.toml` is migrated to +the new file on first start so the host key does not change under existing +clients. + +## Debugging + +`sshd` calls `montauk::print`, which reaches the boot console only. **It has no +usable diagnostics once the desktop is up**, pending the unified syslog. + +Worth understanding, because it catches out anything written as a daemon: +`montauk::print` is `SYS_PRINT`, which writes the kernel *terminal*, not the +kernel *log*. Only in-kernel `KernelLogStream` writes raise `g_kernelLogDepth`, +and only those append to the ring buffer `SYS_KLOG` reads -- so daemon output +never shows up in `klog`. On top of that, `Sys_Print` returns early once +`g_suppressKernelLog` is set, which the desktop does at startup, so the output is +discarded outright from then on. `init` spawns services with `spawn` rather than +`spawn_redir`, so there is no stream to capture either. + +Until there is somewhere for it to go, debugging a handshake means watching the +boot console, or temporarily pointing the print calls at a file. + +Failures print a numeric code -- `key exchange failed (N)`, `authentication +failed (N)` -- where N identifies the step; see the `kex_error` assignments in +`main.cpp`. Running the client with `ssh -vvv` and lining its trace up against +those codes is usually the fastest way to find where a handshake diverged. diff --git a/programs/GNUmakefile b/programs/GNUmakefile index 36d6990..6dc104a 100644 --- a/programs/GNUmakefile +++ b/programs/GNUmakefile @@ -49,7 +49,7 @@ BINDIR := bin PROGRAMS := $(notdir $(wildcard src/*)) # Programs with custom Makefiles (built separately). -CUSTOM_BUILDS := 2048 fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display terminal klog procmgr powermgr calculator charmap desktop login shell paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs libloader crashpad +CUSTOM_BUILDS := 2048 fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display sshserver terminal klog procmgr powermgr calculator charmap desktop login shell paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs libloader crashpad sshd SYSTEM_PROGRAMS := $(filter-out $(CUSTOM_BUILDS),$(PROGRAMS)) # Build targets: system programs go to bin/os/, apps go to bin/apps//. @@ -89,9 +89,9 @@ WPDIR := data/wallpapers WPSRC := $(wildcard $(WPDIR)/*.jpg) WPDST := $(patsubst $(WPDIR)/%,$(BINDIR)/os/wallpapers/%,$(WPSRC)) -.PHONY: all clean 2048 fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display terminal klog procmgr powermgr calculator charmap login desktop shell paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs icons fonts configs bearssl libc tls libjpeg libjpegwrite install-apps libloader crashpad check-syscalls gen-syscalls +.PHONY: all clean 2048 fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display sshserver sshd terminal klog procmgr powermgr calculator charmap login desktop shell paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs icons fonts configs bearssl libc tls libjpeg libjpegwrite install-apps libloader crashpad check-syscalls gen-syscalls -all: bearssl libc libjpeg libjpegwrite tls libloader devkit $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display terminal klog procmgr powermgr calculator charmap 2048 paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs login desktop shell icons fonts install-apps crashpad $(MANDST) $(WWWDST) $(CA_CERTS) $(CONFIGDST) $(FWDST) $(LICDST) $(WPDST) +all: bearssl libc libjpeg libjpegwrite tls libloader devkit $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display sshserver terminal klog procmgr powermgr calculator charmap 2048 paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs login desktop shell sshd icons fonts install-apps crashpad $(MANDST) $(WWWDST) $(CA_CERTS) $(CONFIGDST) $(FWDST) $(LICDST) $(WPDST) # Build BearSSL static library (cross-compiled for freestanding x86_64). BEARSSL_INCLUDES := -isystem $(shell cd .. && pwd)/kernel/freestnd-c-hdrs/x86_64/include -isystem $(abspath include/libc) @@ -212,6 +212,14 @@ network: libc display: libc $(MAKE) -C src/display +# Build SSH Server settings applet. +sshserver: bearssl libc + $(MAKE) -C src/sshserver + +# Build the SSH daemon. +sshd: bearssl libc + $(MAKE) -C src/sshd + # Build terminal standalone GUI tool (depends on libc). terminal: libc $(MAKE) -C src/terminal @@ -329,7 +337,7 @@ crashpad: libc $(MAKE) -C src/crashpad # Install app bundles (manifests, icons, data files) into bin/apps//. -install-apps: 2048 paint spreadsheet wordprocessor weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer audio music video bluetooth network display terminal klog procmgr powermgr calculator charmap screenshot texteditor mandelbrot printers timezone crashpad +install-apps: 2048 paint spreadsheet wordprocessor weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer audio music video bluetooth network display sshserver terminal klog procmgr powermgr calculator charmap screenshot texteditor mandelbrot printers timezone crashpad ../scripts/install_apps.sh # Copy man pages into bin/man/ so mkramdisk.sh picks them up. diff --git a/programs/data/config/init.toml b/programs/data/config/init.toml index eb2f199..13e0919 100644 --- a/programs/data/config/init.toml +++ b/programs/data/config/init.toml @@ -27,6 +27,14 @@ enabled = true wait = false optional = true +# Remote shell access is opt-in. Enable this from Settings > SSH Server. +[services.ssh] +path = "0:/os/sshd.elf" +name = "SSH server" +enabled = false +wait = false +optional = true + [services.login] path = "0:/os/login.elf" enabled = true diff --git a/programs/data/config/ssh.toml b/programs/data/config/ssh.toml new file mode 100644 index 0000000..987955a --- /dev/null +++ b/programs/data/config/ssh.toml @@ -0,0 +1,7 @@ +# SSH server policy. The host key is generated locally on first start. +[server] +port = 22 + +# Per-user access defaults to false when a user is not listed. The SSH Server +# Settings applet maintains this table. +[allow] diff --git a/programs/include/montauk/ssh.h b/programs/include/montauk/ssh.h new file mode 100644 index 0000000..7b1c9b8 --- /dev/null +++ b/programs/include/montauk/ssh.h @@ -0,0 +1,27 @@ +/* Shared SSH server policy helpers. */ +#pragma once +#include +#include +#include +#include + +namespace montauk::ssh { + inline void allow_key(char* out, int cap, const char* username) { + snprintf(out, cap, "allow.%s", username); + } + + inline bool user_allowed(const montauk::toml::Doc& doc, const char* username) { + char key[64]; + allow_key(key, sizeof(key), username); + return doc.get_bool(key, false); + } + + inline bool is_admin(const char* username) { + user::UserInfo users[user::MAX_USERS]; + int count = user::load_users(users, user::MAX_USERS); + for (int i = 0; i < count; i++) + if (montauk::streq(users[i].username, username)) + return montauk::streq(users[i].role, "admin"); + return false; + } +} diff --git a/programs/man/sshd.1 b/programs/man/sshd.1 new file mode 100644 index 0000000..6a01395 --- /dev/null +++ b/programs/man/sshd.1 @@ -0,0 +1,65 @@ +.TH SSHD 1 +.SH NAME +sshd \- MontaukOS SSH-2 server +.SH SYNOPSIS +.B sshd +.SH DESCRIPTION +.B sshd +accepts SSH-2 connections and gives each authenticated user a MontaukOS shell. + +It is normally launched automatically by +.BR init (1) +and does not require direct user interaction. Remote access is disabled by +default; enable it from Settings > SSH Server, which sets +.I services.ssh.enabled +in +.IR 0:/config/init.toml . +The change takes effect on the next boot. +.SH AUTHENTICATION +Passwords are the only supported method, checked against the MontaukOS account +database. A user must additionally be listed in the +.I [allow] +table of +.I 0:/config/ssh.toml +before a login is accepted; users absent from that table are refused. The +Settings applet maintains this table. + +Failed passwords are delayed, and a connection is dropped after eight attempts. +.SH CONFIGURATION +.TP +.I 0:/config/ssh.toml +Listening port +.RI ( server.port , +default 22) and the per-user +.I [allow] +table. +.TP +.I 0:/config/ssh_hostkey.toml +The RSA host key, generated on first start. Note that the filesystem has no +permission model, so this file is readable by any local process; keeping it +separate from +.I ssh.toml +limits exposure but is not access control. +.SH DIAGNOSTICS +Messages go to the boot console only. They do not appear in +.BR klog (1): +.B SYS_PRINT +writes the kernel terminal rather than the kernel log ring, and is discarded +entirely once the desktop suppresses console output. There is currently nowhere +to read them from on a running desktop. +.SH PROTOCOL +Key exchange is diffie-hellman-group14-sha256 with an rsa-sha2-256 host key; +the transport uses aes128-ctr with hmac-sha2-256. Client-initiated rekeying is +supported. Only one session channel per connection is served, and connections +are handled one at a time. +.SH LIMITATIONS +Public key authentication, port forwarding, X11 forwarding, SFTP and SCP are not +implemented. + +.I exec +requests run the command by feeding it to an interactive shell, so the shell +prompt and the echoed command line appear in the output. Scripts that parse +remote command output should account for this. +.SH SEE ALSO +.BR shell (1), +.BR init (1) diff --git a/programs/src/sshd/Makefile b/programs/src/sshd/Makefile new file mode 100644 index 0000000..0d04f72 --- /dev/null +++ b/programs/src/sshd/Makefile @@ -0,0 +1,26 @@ +MAKEFLAGS += -rR +.SUFFIXES: +TOOLCHAIN_PREFIX := $(shell cd ../../.. && pwd)/toolchain/local/bin/x86_64-montauk- +CXX := $(TOOLCHAIN_PREFIX)g++ +PROG_INC := ../../include +LIBDIR := ../../lib +LINK_LD := ../../link.ld +OBJDIR := obj +TARGET := ../../bin/os/sshd.elf +CXXFLAGS := -std=gnu++20 -g -O2 -pipe -Wall -Wextra -ffreestanding \ + -fno-stack-protector -fno-stack-check -fno-rtti -fno-exceptions \ + -ffunction-sections -fdata-sections -mno-80387 -mno-mmx -mno-sse -mno-sse2 \ + -MMD -MP -I $(PROG_INC) -I $(LIBDIR)/bearssl/inc -isystem $(PROG_INC)/libc +LDFLAGS := -nostdlib -Wl,--gc-sections -T $(LINK_LD) +LIBS := $(LIBDIR)/bearssl/libbearssl.a $(LIBDIR)/libc/liblibc.a +.PHONY: all clean +all: $(TARGET) +$(TARGET): $(OBJDIR)/main.o $(LIBS) $(LINK_LD) Makefile + mkdir -p $(dir $@) + $(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJDIR)/main.o $(LIBS) -o $@ +$(OBJDIR)/main.o: main.cpp crypto.hpp Makefile + mkdir -p $(OBJDIR) + $(CXX) $(CXXFLAGS) -c main.cpp -o $@ +-include $(OBJDIR)/main.d +clean: + rm -rf $(OBJDIR) $(TARGET) diff --git a/programs/src/sshd/crypto.hpp b/programs/src/sshd/crypto.hpp new file mode 100644 index 0000000..05b9b0c --- /dev/null +++ b/programs/src/sshd/crypto.hpp @@ -0,0 +1,365 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace sshcrypto { + +static inline uint32_t be32(const uint8_t* data) { + return ((uint32_t)data[0] << 24) | + ((uint32_t)data[1] << 16) | + ((uint32_t)data[2] << 8) | + data[3]; +} + +static inline void put32(uint8_t* data, uint32_t value) { + data[0] = value >> 24; + data[1] = value >> 16; + data[2] = value >> 8; + data[3] = value; +} + +/* + * Wipe key material. Written through a volatile pointer so the optimiser + * cannot drop the store as dead when the buffer dies with the frame. + */ +static inline void zeroize(void* data, int size) { + volatile uint8_t* p = (volatile uint8_t*)data; + for (int i = 0; i < size; i++) p[i] = 0; +} + +static inline void sha256(const void* data, int size, uint8_t output[32]) { + br_sha256_context context; + br_sha256_init(&context); + br_sha256_update(&context, data, size); + br_sha256_out(&context, output); +} + +struct Rng { + br_hmac_drbg_context context; + + bool init() { + uint8_t seed[64]; + int64_t received = montauk::getrandom(seed, sizeof(seed)); + if (received != (int64_t)sizeof(seed)) { + montauk::memset(seed, 0, sizeof(seed)); + return false; + } + + br_hmac_drbg_init(&context, &br_sha256_vtable, seed, sizeof(seed)); + montauk::memset(seed, 0, sizeof(seed)); + return true; + } + + void bytes(void* output, int size) { + br_hmac_drbg_generate(&context, output, size); + } +}; + +static inline void hex(const uint8_t* input, int size, char* output) { + static const char* digits = "0123456789abcdef"; + for (int i = 0; i < size; i++) { + output[i * 2] = digits[input[i] >> 4]; + output[i * 2 + 1] = digits[input[i] & 15]; + } + output[size * 2] = 0; +} + +static inline int unhex(const char* input, uint8_t* output, int capacity) { + int size = 0; + while (input && input[0] && input[1] && size < capacity) { + int high = montauk::user::hex_char_val(input[0]); + int low = montauk::user::hex_char_val(input[1]); + if (high < 0 || low < 0) break; + output[size++] = (high << 4) | low; + input += 2; + } + return size; +} + +/* + * The host key lives in its own config file rather than in ssh.toml so that + * the unprivileged Settings applet, which rewrites ssh.toml whenever the + * per-user allow table changes, never reads or round-trips private key + * material. Note this is separation, not protection: the filesystem has no + * permission model yet, so any local process can still read the file. Real + * confidentiality needs FS-level access control. + */ +static const char HOSTKEY_CONFIG[] = "ssh_hostkey"; +static const char HOSTKEY_LEGACY_CONFIG[] = "ssh"; + +struct HostKey { + uint8_t privateBuffer[BR_RSA_KBUF_PRIV_SIZE(2048)]; + uint8_t publicBuffer[BR_RSA_KBUF_PUB_SIZE(2048)]; + br_rsa_private_key sk = {}; + br_rsa_public_key pk = {}; + + /* + * Earlier builds kept the key in ssh.toml. Move any such key into its own + * file on first start so an upgrade does not change the host key (which + * would trip every client's known-hosts check). + */ + bool load() { + if (load_from(HOSTKEY_CONFIG)) return true; + if (!load_from(HOSTKEY_LEGACY_CONFIG)) return false; + migrate_from_legacy(); + return true; + } + + bool load_from(const char* configName) { + auto config = montauk::config::load(configName); + const char* modulus = config.get_string("host_key.n", ""); + const char* exponent = config.get_string("host_key.e", ""); + const char* primeP = config.get_string("host_key.p", ""); + const char* primeQ = config.get_string("host_key.q", ""); + const char* exponentP = config.get_string("host_key.dp", ""); + const char* exponentQ = config.get_string("host_key.dq", ""); + const char* coefficient = config.get_string("host_key.iq", ""); + + int offset = 0; + pk.n = publicBuffer; + pk.nlen = unhex(modulus, publicBuffer, sizeof(publicBuffer)); + pk.e = publicBuffer + pk.nlen; + pk.elen = unhex(exponent, publicBuffer + pk.nlen, + sizeof(publicBuffer) - pk.nlen); + + sk.p = privateBuffer + offset; + sk.plen = unhex(primeP, sk.p, sizeof(privateBuffer) - offset); + offset += sk.plen; + sk.q = privateBuffer + offset; + sk.qlen = unhex(primeQ, sk.q, sizeof(privateBuffer) - offset); + offset += sk.qlen; + sk.dp = privateBuffer + offset; + sk.dplen = unhex(exponentP, sk.dp, sizeof(privateBuffer) - offset); + offset += sk.dplen; + sk.dq = privateBuffer + offset; + sk.dqlen = unhex(exponentQ, sk.dq, sizeof(privateBuffer) - offset); + offset += sk.dqlen; + sk.iq = privateBuffer + offset; + sk.iqlen = unhex(coefficient, sk.iq, sizeof(privateBuffer) - offset); + sk.n_bitlen = (uint32_t)config.get_int("host_key.bits", 2048); + config.destroy(); + + return pk.nlen >= 256 && pk.elen > 0 && + sk.plen > 0 && sk.qlen > 0 && + sk.dplen > 0 && sk.dqlen > 0 && sk.iqlen > 0; + } + + /* Rewrite the in-memory key to its own file and strip it from ssh.toml. */ + void migrate_from_legacy() { + if (save_to(HOSTKEY_CONFIG) < 0) return; + + auto legacy = montauk::config::load(HOSTKEY_LEGACY_CONFIG); + static const char* const parts[] = { + "host_key.n", "host_key.e", "host_key.p", "host_key.q", + "host_key.dp", "host_key.dq", "host_key.iq", "host_key.bits", + }; + for (unsigned i = 0; i < sizeof(parts) / sizeof(parts[0]); i++) { + montauk::config::unset(&legacy, parts[i]); + } + montauk::config::save(HOSTKEY_LEGACY_CONFIG, &legacy); + legacy.destroy(); + } + + int save_to(const char* configName) { + auto config = montauk::config::load(configName); + char encoded[520]; + +#define SAVE_PART(key, data, size) \ + do { \ + hex((const uint8_t*)(data), (int)(size), encoded); \ + montauk::config::set_string(&config, "host_key." key, encoded); \ + } while (0) + + SAVE_PART("n", pk.n, pk.nlen); + SAVE_PART("e", pk.e, pk.elen); + SAVE_PART("p", sk.p, sk.plen); + SAVE_PART("q", sk.q, sk.qlen); + SAVE_PART("dp", sk.dp, sk.dplen); + SAVE_PART("dq", sk.dq, sk.dqlen); + SAVE_PART("iq", sk.iq, sk.iqlen); + +#undef SAVE_PART + + montauk::config::set_int(&config, "host_key.bits", sk.n_bitlen); + int result = montauk::config::save(configName, &config); + config.destroy(); + montauk::memset(encoded, 0, sizeof(encoded)); + return result; + } + + bool generate(Rng& rng) { + if (!br_rsa_i31_keygen((const br_prng_class**)&rng.context, + &sk, privateBuffer, + &pk, publicBuffer, + 2048, 65537)) { + return false; + } + + return save_to(HOSTKEY_CONFIG) >= 0; + } +}; + +/* RFC 3526 MODP group 14, represented as little-endian 32-bit limbs. */ +static constexpr int NL = 64; +static const uint32_t P[NL] = { + 0xFFFFFFFF, 0xFFFFFFFF, 0x8AACAA68, 0x15728E5A, + 0x98FA0510, 0x15D22618, 0xEA956AE5, 0x3995497C, + 0x95581718, 0xDE2BCBF6, 0x6F4C52C9, 0xB5C55DF0, + 0xEC07A28F, 0x9B2783A2, 0x180E8603, 0xE39E772C, + 0x2E36CE3B, 0x32905E46, 0xCA18217C, 0xF1746C08, + 0x4ABC9804, 0x670C354E, 0x7096966D, 0x9ED52907, + 0x208552BB, 0x1C62F356, 0xDCA3AD96, 0x83655D23, + 0xFD24CF5F, 0x69163FA8, 0x1C55D39A, 0x98DA4836, + 0xA163BF05, 0xC2007CB8, 0xECE45B3D, 0x49286651, + 0x7C4B1FE6, 0xAE9F2411, 0x5A899FA5, 0xEE386BFB, + 0xF406B7ED, 0x0BFF5CB6, 0xA637ED6B, 0xF44C42E9, + 0x625E7EC6, 0xE485B576, 0x6D51C245, 0x4FE1356D, + 0xF25F1437, 0x302B0A6D, 0xCD3A431B, 0xEF9519B3, + 0x8E3404DD, 0x514A0879, 0x3B139B22, 0x020BBEA6, + 0x8A67CC74, 0x29024E08, 0x80DC1CD1, 0xC4C6628B, + 0x2168C234, 0xC90FDAA2, 0xFFFFFFFF, 0xFFFFFFFF, +}; + +static inline int cmp(const uint32_t* left, const uint32_t* right) { + for (int i = NL - 1; i >= 0; i--) { + if (left[i] != right[i]) return left[i] > right[i] ? 1 : -1; + } + return 0; +} + +static inline void subp(uint32_t* value) { + uint64_t borrow = 0; + for (int i = 0; i < NL; i++) { + uint64_t difference = (uint64_t)value[i] - P[i] - borrow; + value[i] = (uint32_t)difference; + borrow = (difference >> 63) & 1; + } +} + +static inline void dbl(uint32_t* value) { + uint64_t carry = 0; + for (int i = 0; i < NL; i++) { + uint64_t doubled = ((uint64_t)value[i] << 1) | carry; + value[i] = doubled; + carry = doubled >> 32; + } + if (carry || cmp(value, P) >= 0) subp(value); +} + +static inline void montmul(const uint32_t* left, const uint32_t* right, + uint32_t* output) { + uint32_t temporary[NL + 1] = {}; + + /* P[0] is -1, therefore -P^-1 mod 2^32 is 1. */ + for (int i = 0; i < NL; i++) { + uint64_t carry = 0; + for (int j = 0; j < NL; j++) { + uint64_t product = (uint64_t)left[j] * right[i] + + temporary[j] + carry; + temporary[j] = product; + carry = product >> 32; + } + + uint64_t sum = (uint64_t)temporary[NL] + carry; + temporary[NL] = (uint32_t)sum; + + uint32_t multiplier = temporary[0]; + carry = 0; + for (int j = 0; j < NL; j++) { + sum = (uint64_t)multiplier * P[j] + temporary[j] + carry; + if (j > 0) temporary[j - 1] = (uint32_t)sum; + carry = sum >> 32; + } + + sum = (uint64_t)temporary[NL] + carry; + temporary[NL - 1] = (uint32_t)sum; + temporary[NL] = (uint32_t)(sum >> 32); + } + + for (int i = 0; i < NL; i++) output[i] = temporary[i]; + if (temporary[NL] || cmp(output, P) >= 0) subp(output); +} + +static inline void from_be(const uint8_t* input, int size, uint32_t* output) { + montauk::memset(output, 0, NL * 4); + for (int i = 0; i < size && i < 256; i++) { + output[i / 4] |= (uint32_t)input[size - 1 - i] << ((i & 3) * 8); + } +} + +static inline void to_be(const uint32_t* input, uint8_t output[256]) { + for (int i = 0; i < 256; i++) { + output[255 - i] = (uint8_t)(input[i / 4] >> ((i & 3) * 8)); + } +} + +static inline void modexp(const uint32_t* base, const uint8_t* exponent, + int exponentSize, uint32_t* output) { + uint32_t one[NL] = {}; + uint32_t rSquared[NL]; + uint32_t result[NL]; + uint32_t montgomeryBase[NL]; + uint32_t temporary[NL]; + + one[0] = 1; + for (int i = 0; i < NL * 32; i++) dbl(one); + for (int i = 0; i < NL; i++) rSquared[i] = one[i]; + for (int i = 0; i < NL * 32; i++) dbl(rSquared); + for (int i = 0; i < NL; i++) result[i] = one[i]; + montmul(base, rSquared, montgomeryBase); + + for (int i = 0; i < exponentSize; i++) { + for (int bit = 7; bit >= 0; bit--) { + montmul(result, result, temporary); + for (int j = 0; j < NL; j++) result[j] = temporary[j]; + if ((exponent[i] >> bit) & 1) { + montmul(result, montgomeryBase, temporary); + for (int j = 0; j < NL; j++) result[j] = temporary[j]; + } + } + } + + uint32_t normal[NL] = {}; + normal[0] = 1; + montmul(result, normal, output); +} + +struct Cipher { + br_aes_ct_ctr_keys aes; + uint8_t iv[12]; + uint32_t counter = 0; + uint8_t mac[32]; + + void init(const uint8_t* key, const uint8_t* initializationVector, + const uint8_t* macKey) { + br_aes_ct_ctr_init(&aes, key, 16); + montauk::memcpy(iv, initializationVector, sizeof(iv)); + counter = be32(initializationVector + 12); + montauk::memcpy(mac, macKey, sizeof(mac)); + } + + void crypt(void* data, int size) { + counter = br_aes_ct_ctr_run(&aes, iv, counter, data, size); + } +}; + +static inline void hmac(const uint8_t key[32], const void* data, int size, + uint8_t output[32]) { + br_hmac_key_context keyContext; + br_hmac_context context; + br_hmac_key_init(&keyContext, &br_sha256_vtable, key, 32); + br_hmac_init(&context, &keyContext, 32); + br_hmac_update(&context, data, size); + br_hmac_out(&context, output); +} + +} // namespace sshcrypto diff --git a/programs/src/sshd/main.cpp b/programs/src/sshd/main.cpp new file mode 100644 index 0000000..b7948c0 --- /dev/null +++ b/programs/src/sshd/main.cpp @@ -0,0 +1,1355 @@ +/* + * main.cpp + * Minimal interoperable SSH-2 server for MontaukOS + */ + +#include "crypto.hpp" + +#include +#include + +extern "C" { +#include +} + +using namespace sshcrypto; + +static HostKey hostkey; +static Rng rng; +static const char SERVER_VERSION[] = "SSH-2.0-MontaukOS_1.0"; +static int kex_error = 0; +static int packet_error = 0; + +/* SSH message numbers used in more than one place. */ +static constexpr uint8_t MSG_DISCONNECT = 1; +static constexpr uint8_t MSG_IGNORE = 2; +static constexpr uint8_t MSG_UNIMPLEMENTED = 3; +static constexpr uint8_t MSG_DEBUG = 4; +static constexpr uint8_t MSG_KEXINIT = 20; +static constexpr uint8_t MSG_NEWKEYS = 21; +static constexpr uint8_t MSG_CHANNEL_WINDOW_ADJUST = 93; +static constexpr uint8_t MSG_CHANNEL_DATA = 94; + +/* RFC 4253 section 11.1 disconnect reason codes. */ +static constexpr uint32_t DISCONNECT_PROTOCOL_ERROR = 2; +static constexpr uint32_t DISCONNECT_KEY_EXCHANGE_FAILED = 3; +static constexpr uint32_t DISCONNECT_NO_MORE_AUTH_METHODS = 14; + +/* + * A client that completes the TCP handshake and then goes quiet must not be + * able to park the daemon forever: connections are served one at a time, so a + * single idle peer would lock everyone else out. Applies until the session + * starts; an established session is allowed to idle indefinitely. + */ +static constexpr uint64_t PREAUTH_TIMEOUT_MS = 30000; + +/* Delay after a failed password, to slow down online guessing. */ +static constexpr uint64_t AUTH_FAILURE_DELAY_MS = 1000; + +struct Buf { + uint8_t d[4096]; + int n = 0; + int p = 0; + bool ok = true; + + void byte(uint8_t value) { + if (n < (int)sizeof(d)) { + d[n++] = value; + } else { + ok = false; + } + } + + void raw(const void* data, int size) { + if (size < 0 || n + size > (int)sizeof(d)) { + ok = false; + return; + } + montauk::memcpy(d + n, data, size); + n += size; + } + + void u32(uint32_t value) { + uint8_t bytes[4]; + put32(bytes, value); + raw(bytes, sizeof(bytes)); + } + + void boolean(bool value) { + byte(value ? 1 : 0); + } + + void str(const void* data, int size) { + u32(size); + raw(data, size); + } + + void str(const char* value) { + str(value, montauk::slen(value)); + } + + void mpint(const uint8_t* data, int size) { + while (size > 0 && *data == 0) { + data++; + size--; + } + + bool needsLeadingZero = size > 0 && (data[0] & 0x80); + u32(size + (needsLeadingZero ? 1 : 0)); + if (needsLeadingZero) byte(0); + raw(data, size); + } + + uint8_t get8() { + if (p >= n) { + ok = false; + return 0; + } + return d[p++]; + } + + uint32_t get32() { + if (p + 4 > n) { + ok = false; + return 0; + } + uint32_t value = be32(d + p); + p += 4; + return value; + } + + bool getbool() { + return get8() != 0; + } + + const uint8_t* getstr(int& size) { + size = (int)get32(); + if (size < 0 || p + size > n) { + ok = false; + size = 0; + return nullptr; + } + const uint8_t* value = d + p; + p += size; + return value; + } + + void skipstr() { + int size; + getstr(size); + } +}; + +struct Conn { + int fd; + uint32_t inseq = 0; + uint32_t outseq = 0; + bool incrypt = false; + bool outcrypt = false; + Cipher ci; + Cipher co; + uint8_t H[32]; + uint8_t sid[32]; + char vc[256] = {}; + int vc_n = 0; + Buf ic; + Buf is; + bool haveSessionId = false; + /* Absolute deadline in milliseconds; 0 means block indefinitely. */ + uint64_t deadline = 0; + + void set_timeout(uint64_t milliseconds) { + deadline = montauk::get_milliseconds() + milliseconds; + } + + void clear_timeout() { + deadline = 0; + } + + /* + * Blocks until `wanted` is signalled. Returns false if the peer closed, + * the handle went away, or the connection deadline expired. + */ + bool wait_for(uint32_t wanted) { + uint64_t timeout = ~0ULL; + if (deadline) { + uint64_t now = montauk::get_milliseconds(); + if (now >= deadline) return false; + timeout = deadline - now; + } + + uint32_t signals = montauk::wait_handle( + fd, wanted | montauk::abi::IPC_SIGNAL_PEER_CLOSED, timeout); + if (signals == (uint32_t)-1) return false; + if (signals & montauk::abi::IPC_SIGNAL_PEER_CLOSED) return false; + /* A zero return means the timeout elapsed without the peer closing. */ + if (signals == 0 && deadline) return false; + return true; + } + + bool exact(void* data, int size) { + uint8_t* cursor = (uint8_t*)data; + while (size > 0) { + int received = montauk::recv(fd, cursor, size); + if (received < 0) return false; + + if (received == 0) { + if (!wait_for(montauk::abi::IPC_SIGNAL_READABLE)) return false; + continue; + } + + cursor += received; + size -= received; + } + return true; + } + + bool sendall(const void* data, int size) { + const uint8_t* cursor = (const uint8_t*)data; + while (size > 0) { + int sent = montauk::send(fd, cursor, size); + if (sent < 0) return false; + + if (sent == 0) { + if (!wait_for(montauk::abi::IPC_SIGNAL_WRITABLE)) return false; + continue; + } + + cursor += sent; + size -= sent; + } + return true; + } + + bool packet(Buf& payload) { + /* + * A payload that overflowed its buffer is truncated, not short: sending + * it would put a malformed packet on the wire under a valid MAC. + */ + if (!payload.ok) return false; + + int blockSize = outcrypt ? 16 : 8; + int paddingSize = blockSize - ((payload.n + 5) % blockSize); + if (paddingSize < 4) paddingSize += blockSize; + + int totalSize = 4 + 1 + payload.n + paddingSize; + uint8_t* wire = (uint8_t*)montauk::malloc(totalSize + 32); + if (!wire) return false; + + put32(wire, totalSize - 4); + wire[4] = paddingSize; + montauk::memcpy(wire + 5, payload.d, payload.n); + rng.bytes(wire + 5 + payload.n, paddingSize); + + uint8_t tag[32]; + if (outcrypt) { + uint8_t* macInput = (uint8_t*)montauk::malloc(totalSize + 4); + if (!macInput) { + montauk::mfree(wire); + return false; + } + + put32(macInput, outseq); + montauk::memcpy(macInput + 4, wire, totalSize); + hmac(co.mac, macInput, totalSize + 4, tag); + montauk::mfree(macInput); + co.crypt(wire, totalSize); + } + + bool success = sendall(wire, totalSize) && + (!outcrypt || sendall(tag, sizeof(tag))); + montauk::mfree(wire); + outseq++; + return success; + } + + bool recvpacket(Buf& output) { + packet_error = 0; + output.n = 0; + output.p = 0; + output.ok = true; + + uint8_t firstBlock[16]; + int firstSize = incrypt ? 16 : 4; + if (!exact(firstBlock, firstSize)) { + packet_error = 1; + return false; + } + if (incrypt) ci.crypt(firstBlock, firstSize); + + uint32_t packetSize = be32(firstBlock); + if (packetSize < 6 || packetSize > 16380) { + packet_error = 2; + return false; + } + + int totalSize = (int)packetSize + 4; + /* + * RFC 4253 section 6: the total length must be a multiple of the cipher + * block size, or 8, whichever is larger. A misaligned length would make + * the CTR keystream consume a partial block and desynchronise the + * counter for good. + */ + int blockSize = incrypt ? 16 : 8; + if (totalSize % blockSize != 0) { + packet_error = 8; + return false; + } + uint8_t* plain = (uint8_t*)montauk::malloc(totalSize); + if (!plain) { + packet_error = 7; + return false; + } + montauk::memcpy(plain, firstBlock, firstSize); + + if (totalSize < firstSize || + !exact(plain + firstSize, totalSize - firstSize)) { + montauk::mfree(plain); + packet_error = 3; + return false; + } + if (incrypt) ci.crypt(plain + firstSize, totalSize - firstSize); + + uint8_t receivedTag[32]; + uint8_t expectedTag[32]; + if (incrypt) { + if (!exact(receivedTag, sizeof(receivedTag))) { + montauk::mfree(plain); + packet_error = 4; + return false; + } + + uint8_t* macInput = (uint8_t*)montauk::malloc(totalSize + 4); + if (!macInput) { + montauk::mfree(plain); + packet_error = 7; + return false; + } + + put32(macInput, inseq); + montauk::memcpy(macInput + 4, plain, totalSize); + hmac(ci.mac, macInput, totalSize + 4, expectedTag); + montauk::mfree(macInput); + + int difference = 0; + for (int i = 0; i < 32; i++) { + difference |= receivedTag[i] ^ expectedTag[i]; + } + if (difference) { + montauk::mfree(plain); + packet_error = 5; + return false; + } + } + + int paddingSize = plain[4]; + int payloadSize = totalSize - 5 - paddingSize; + if (paddingSize < 4 || payloadSize < 1 || + payloadSize > (int)sizeof(output.d)) { + montauk::mfree(plain); + packet_error = 6; + return false; + } + + montauk::memcpy(output.d, plain + 5, payloadSize); + output.n = payloadSize; + montauk::mfree(plain); + inseq++; + return true; + } + + /* + * SSH_MSG_IGNORE, SSH_MSG_DEBUG and SSH_MSG_UNIMPLEMENTED are legal at any + * point in the protocol and must be skipped rather than treated as the + * message the caller was waiting for. SSH_MSG_DISCONNECT ends the + * connection. Every caller wanting a specific message should use this. + */ + bool recvmsg(Buf& output, bool transportOnly = false) { + for (int guard = 0; guard < 128; guard++) { + if (!recvpacket(output)) return false; + + uint8_t type = output.d[0]; + if (type == MSG_DISCONNECT) return false; + if (type == MSG_IGNORE || type == MSG_DEBUG || + type == MSG_UNIMPLEMENTED) { + continue; + } + /* + * During a key exchange the peer may send only transport-layer + * messages (types below 50), but channel data it had already put on + * the wire can still arrive. Dropping those costs a few keystrokes; + * mistaking one for the awaited kex message would kill the session. + */ + if (transportOnly && type >= 50) continue; + return true; + } + /* A peer sending nothing but no-ops is not making progress. */ + return false; + } + + void disconnect(uint32_t reason, const char* description) { + Buf message; + message.byte(MSG_DISCONNECT); + message.u32(reason); + message.str(description); + message.str(""); + packet(message); + } +}; + +static void hash_string(br_sha256_context& hash, const void* data, int size) { + uint8_t prefix[4]; + put32(prefix, size); + br_sha256_update(&hash, prefix, sizeof(prefix)); + br_sha256_update(&hash, data, size); +} + +static void hash_mpint(br_sha256_context& hash, const uint8_t* data, int size) { + while (size > 0 && *data == 0) { + data++; + size--; + } + + bool needsLeadingZero = size > 0 && (*data & 0x80); + uint8_t prefix[4]; + put32(prefix, size + (needsLeadingZero ? 1 : 0)); + br_sha256_update(&hash, prefix, sizeof(prefix)); + if (needsLeadingZero) { + uint8_t zero = 0; + br_sha256_update(&hash, &zero, 1); + } + br_sha256_update(&hash, data, size); +} + +static void host_blob(Buf& output) { + Buf key; + key.str("ssh-rsa"); + key.mpint(hostkey.pk.e, hostkey.pk.elen); + key.mpint(hostkey.pk.n, hostkey.pk.nlen); + output.str(key.d, key.n); +} + +/* + * RFC 4253 section 7.2: K1 = HASH(K || H || X || session_id). The session id + * is the exchange hash of the *first* key exchange and stays fixed for the life + * of the connection, so it is not interchangeable with H once rekeying starts. + */ +static void derive(const uint8_t* sharedSecret, int secretSize, + const uint8_t exchangeHash[32], + const uint8_t sessionId[32], char letter, + uint8_t output[32]) { + br_sha256_context hash; + br_sha256_init(&hash); + hash_mpint(hash, sharedSecret, secretSize); + br_sha256_update(&hash, exchangeHash, 32); + br_sha256_update(&hash, &letter, 1); + br_sha256_update(&hash, sessionId, 32); + br_sha256_out(&hash, output); +} + +/* True if `list`, a comma-separated SSH name-list, contains `name`. */ +static bool namelist_contains(const uint8_t* list, int size, const char* name) { + int nameSize = montauk::slen(name); + int start = 0; + for (int i = 0; i <= size; i++) { + if (i != size && list[i] != ',') continue; + if (i - start == nameSize && + memcmp(list + start, name, nameSize) == 0) { + return true; + } + start = i + 1; + } + return false; +} + +/* True if the first entry of the name-list is `name`. */ +static bool namelist_first_is(const uint8_t* list, int size, const char* name) { + int nameSize = montauk::slen(name); + int end = 0; + while (end < size && list[end] != ',') end++; + return end == nameSize && memcmp(list, name, nameSize) == 0; +} + +static bool version(Conn& connection) { + int serverVersionSize = sizeof(SERVER_VERSION) - 1; + if (!connection.sendall(SERVER_VERSION, serverVersionSize) || + !connection.sendall("\r\n", 2)) { + return false; + } + + for (int lines = 0; lines < 16; lines++) { + connection.vc_n = 0; + while (connection.vc_n < (int)sizeof(connection.vc) - 1) { + char value; + if (!connection.exact(&value, 1)) return false; + if (value == '\n') break; + if (value != '\r') connection.vc[connection.vc_n++] = value; + } + + connection.vc[connection.vc_n] = 0; + if (montauk::starts_with(connection.vc, "SSH-2.0-")) return true; + } + return false; +} + +/* The algorithms this server implements, and therefore advertises. */ +static const char KEX_ALGORITHM[] = "diffie-hellman-group14-sha256"; +static const char HOSTKEY_ALGORITHM[] = "rsa-sha2-256"; +static const char CIPHER_ALGORITHM[] = "aes128-ctr"; +static const char MAC_ALGORITHM[] = "hmac-sha2-256"; + +/* + * Verifies the client's KEXINIT offers everything this server implements, and + * reports whether it also sent a speculative KEXDH_INIT that has to be thrown + * away. Per RFC 4253 section 7.1 the guess is wrong -- and the guessed packet + * must be ignored -- unless the client's *first* kex algorithm and *first* host + * key algorithm both match ours. + */ +static bool check_client_kexinit(Buf& clientInit, bool& discardGuess) { + discardGuess = false; + clientInit.p = 0; + if (clientInit.get8() != MSG_KEXINIT) return false; + for (int i = 0; i < 16; i++) clientInit.get8(); // cookie + + static const char* const required[8] = { + KEX_ALGORITHM, HOSTKEY_ALGORITHM, + CIPHER_ALGORITHM, CIPHER_ALGORITHM, + MAC_ALGORITHM, MAC_ALGORITHM, + "none", "none", + }; + + bool kexGuessMatches = false; + bool hostKeyGuessMatches = false; + for (int i = 0; i < 10; i++) { + int size; + const uint8_t* list = clientInit.getstr(size); + if (!clientInit.ok) return false; + if (i >= 8) continue; // language lists are advisory + + if (!namelist_contains(list, size, required[i])) return false; + if (i == 0) kexGuessMatches = namelist_first_is(list, size, required[0]); + if (i == 1) { + hostKeyGuessMatches = namelist_first_is(list, size, required[1]); + } + } + + bool guessed = clientInit.getbool(); + clientInit.get32(); // reserved + if (!clientInit.ok) return false; + + discardGuess = guessed && !(kexGuessMatches && hostKeyGuessMatches); + return true; +} + +/* + * Runs a key exchange. `clientKexInitReceived` is set when the client started + * a rekey, in which case its KEXINIT is already sitting in connection.ic and + * must not be read again. + */ +static bool kex(Conn& connection, bool clientKexInitReceived = false) { + kex_error = 0; + + Buf serverInit; + serverInit.byte(MSG_KEXINIT); + uint8_t cookie[16]; + rng.bytes(cookie, sizeof(cookie)); + serverInit.raw(cookie, sizeof(cookie)); + serverInit.str(KEX_ALGORITHM); + serverInit.str(HOSTKEY_ALGORITHM); + serverInit.str(CIPHER_ALGORITHM); + serverInit.str(CIPHER_ALGORITHM); + serverInit.str(MAC_ALGORITHM); + serverInit.str(MAC_ALGORITHM); + serverInit.str("none"); + serverInit.str("none"); + serverInit.str(""); + serverInit.str(""); + serverInit.boolean(false); + serverInit.u32(0); + connection.is = serverInit; + if (!connection.packet(serverInit)) { + kex_error = 1; + return false; + } + + if (!clientKexInitReceived && !connection.recvmsg(connection.ic, true)) { + kex_error = 2; + return false; + } + + bool discardGuess = false; + if (!check_client_kexinit(connection.ic, discardGuess)) { + kex_error = 3; + connection.disconnect(DISCONNECT_KEY_EXCHANGE_FAILED, + "no matching algorithm"); + return false; + } + + Buf clientExchange; + if (discardGuess && !connection.recvmsg(clientExchange, true)) { + kex_error = 13; + return false; + } + if (!connection.recvmsg(clientExchange, true)) { + kex_error = 4; + return false; + } + if (clientExchange.get8() != 30) { + kex_error = 5; + return false; + } + + int clientPublicSize; + const uint8_t* clientPublic = clientExchange.getstr(clientPublicSize); + if (!clientExchange.ok || clientPublicSize < 1 || clientPublicSize > 257) { + kex_error = 6; + return false; + } + if (clientPublicSize == 257) { + /* Only a single sign-padding zero may push the value to 257 bytes. */ + if (*clientPublic != 0) { + kex_error = 6; + return false; + } + clientPublic++; + clientPublicSize--; + } + + uint32_t clientValue[NL]; + uint32_t serverValue[NL]; + uint32_t sharedValue[NL]; + uint32_t generator[NL] = {}; + uint32_t primeMinusOne[NL]; + from_be(clientPublic, clientPublicSize, clientValue); + generator[0] = 2; + for (int i = 0; i < NL; i++) primeMinusOne[i] = P[i]; + primeMinusOne[0]--; + if (cmp(clientValue, generator) < 0 || + cmp(clientValue, primeMinusOne) >= 0) { + kex_error = 7; + return false; + } + + uint8_t privateExponent[32]; + rng.bytes(privateExponent, sizeof(privateExponent)); + privateExponent[0] |= 0x80; + modexp(generator, privateExponent, sizeof(privateExponent), serverValue); + modexp(clientValue, privateExponent, sizeof(privateExponent), sharedValue); + + uint8_t serverPublic[256]; + uint8_t sharedSecret[256]; + to_be(serverValue, serverPublic); + to_be(sharedValue, sharedSecret); + + Buf serverKey; + host_blob(serverKey); + + br_sha256_context hash; + br_sha256_init(&hash); + hash_string(hash, connection.vc, connection.vc_n); + hash_string(hash, SERVER_VERSION, montauk::slen(SERVER_VERSION)); + hash_string(hash, connection.ic.d, connection.ic.n); + hash_string(hash, connection.is.d, connection.is.n); + int serverKeySize = be32(serverKey.d); + hash_string(hash, serverKey.d + 4, serverKeySize); + hash_mpint(hash, clientPublic, clientPublicSize); + hash_mpint(hash, serverPublic, sizeof(serverPublic)); + hash_mpint(hash, sharedSecret, sizeof(sharedSecret)); + br_sha256_out(&hash, connection.H); + /* The session id is the first exchange hash and never changes after that. */ + if (!connection.haveSessionId) { + montauk::memcpy(connection.sid, connection.H, sizeof(connection.sid)); + connection.haveSessionId = true; + } + + uint8_t digest[32]; + uint8_t rawSignature[256]; + sha256(connection.H, sizeof(connection.H), digest); + static const uint8_t sha256Oid[] = { + 9, 0x60, 0x86, 0x48, 1, 0x65, 3, 4, 2, 1, + }; + if (!br_rsa_i31_pkcs1_sign(sha256Oid, digest, sizeof(digest), + &hostkey.sk, rawSignature)) { + kex_error = 8; + return false; + } + + Buf signature; + signature.str("rsa-sha2-256"); + signature.str(rawSignature, sizeof(rawSignature)); + + Buf reply; + reply.byte(31); + host_blob(reply); + reply.mpint(serverPublic, sizeof(serverPublic)); + reply.str(signature.d, signature.n); + if (!connection.packet(reply)) { + kex_error = 9; + return false; + } + + Buf serverNewKeys; + serverNewKeys.byte(MSG_NEWKEYS); + if (!connection.packet(serverNewKeys)) { + kex_error = 10; + return false; + } + + uint8_t clientIv[32]; + uint8_t serverIv[32]; + uint8_t clientKey[32]; + uint8_t serverKeyBytes[32]; + uint8_t clientMac[32]; + uint8_t serverMac[32]; + const uint8_t* sid = connection.sid; + derive(sharedSecret, sizeof(sharedSecret), connection.H, sid, 'A', clientIv); + derive(sharedSecret, sizeof(sharedSecret), connection.H, sid, 'B', serverIv); + derive(sharedSecret, sizeof(sharedSecret), connection.H, sid, 'C', clientKey); + derive(sharedSecret, sizeof(sharedSecret), connection.H, sid, 'D', + serverKeyBytes); + derive(sharedSecret, sizeof(sharedSecret), connection.H, sid, 'E', clientMac); + derive(sharedSecret, sizeof(sharedSecret), connection.H, sid, 'F', serverMac); + + /* + * Ordering matters on a rekey. Our outbound keys take effect immediately + * after our NEWKEYS goes out, but the client's NEWKEYS is still encrypted + * under the *previous* inbound keys, so the receive cipher may only be + * replaced once that packet has been read. + */ + connection.co.init(serverKeyBytes, serverIv, serverMac); + connection.outcrypt = true; + + Buf clientNewKeys; + bool ok = connection.recvmsg(clientNewKeys, true); + if (!ok) { + kex_error = 11; + } else if (clientNewKeys.get8() != MSG_NEWKEYS) { + kex_error = 12; + ok = false; + } else { + connection.ci.init(clientKey, clientIv, clientMac); + connection.incrypt = true; + } + + zeroize(sharedSecret, sizeof(sharedSecret)); + zeroize(sharedValue, sizeof(sharedValue)); + zeroize(privateExponent, sizeof(privateExponent)); + zeroize(clientIv, sizeof(clientIv)); + zeroize(serverIv, sizeof(serverIv)); + zeroize(clientKey, sizeof(clientKey)); + zeroize(serverKeyBytes, sizeof(serverKeyBytes)); + zeroize(clientMac, sizeof(clientMac)); + zeroize(serverMac, sizeof(serverMac)); + return ok; +} + +static bool auth(Conn& connection, char user[32]) { + Buf request; + if (!connection.recvmsg(request)) { + kex_error = 20 + packet_error; + return false; + } + if (request.get8() != 5) { + kex_error = 30; + return false; + } + + int serviceSize; + const uint8_t* service = request.getstr(serviceSize); + if (!request.ok || serviceSize != 12 || + memcmp(service, "ssh-userauth", 12) != 0) { + kex_error = 31; + connection.disconnect(DISCONNECT_PROTOCOL_ERROR, + "expected a request for ssh-userauth"); + return false; + } + + Buf acceptance; + acceptance.byte(6); + acceptance.str("ssh-userauth"); + if (!connection.packet(acceptance)) { + kex_error = 32; + return false; + } + + for (int attempts = 0; attempts < 8; attempts++) { + Buf attempt; + if (!connection.recvmsg(attempt)) return false; + + uint8_t type = attempt.get8(); + if (type != 50) continue; + + int usernameSize; + int serviceNameSize; + int methodSize; + const uint8_t* username = attempt.getstr(usernameSize); + attempt.getstr(serviceNameSize); + const uint8_t* method = attempt.getstr(methodSize); + if (!attempt.ok || usernameSize < 1 || usernameSize > 31) return false; + + bool isPassword = methodSize == 8 && + memcmp(method, "password", 8) == 0; + bool accepted = false; + if (isPassword) { + attempt.getbool(); + int passwordSize; + const uint8_t* password = attempt.getstr(passwordSize); + char usernameBuffer[32]; + char passwordBuffer[256]; + + if (attempt.ok && passwordSize >= 0 && passwordSize < 256) { + montauk::memcpy(usernameBuffer, username, usernameSize); + usernameBuffer[usernameSize] = 0; + montauk::memcpy(passwordBuffer, password, passwordSize); + passwordBuffer[passwordSize] = 0; + + auto config = montauk::config::load("ssh"); + accepted = montauk::ssh::user_allowed(config, usernameBuffer) && + montauk::user::authenticate(usernameBuffer, + passwordBuffer); + config.destroy(); + zeroize(passwordBuffer, sizeof(passwordBuffer)); + if (accepted) montauk::strcpy(user, usernameBuffer); + } + } + + Buf response; + if (accepted) { + response.byte(52); + return connection.packet(response); + } + + /* + * Rate-limit guessing. Connections are served one at a time, so this + * also caps the rate at which an attacker can cycle new connections. + */ + if (isPassword) montauk::sleep_ms(AUTH_FAILURE_DELAY_MS); + + response.byte(51); + response.str("password"); + response.boolean(false); + if (!connection.packet(response)) return false; + } + + connection.disconnect(DISCONNECT_NO_MORE_AUTH_METHODS, + "too many authentication failures"); + return false; +} + +/* + * Per-session state. Channel windows are tracked in both directions: a peer + * that ignores them is a protocol violation, and OpenSSH enforces them, so a + * session that overruns its window is dropped by the client. + */ +struct Session { + uint32_t remoteChannel = 0; + int child = -1; + /* + * Process handle for the shell. Liveness via a handle signal is O(1) and + * exact; scanning the process table would both truncate at whatever bound + * the scan buffer used and cost a full table walk on every poll. + */ + int childHandle = -1; + bool opened = false; + bool ptyRequested = false; + bool lastWasCarriageReturn = false; + int columns = 80; + int rows = 25; + /* Bytes we may still send before the client must grant more window. */ + int64_t sendWindow = 0; + /* Largest CHANNEL_DATA payload the client will accept. */ + uint32_t maxPacket = 0; + /* Bytes the client may still send us before we must grant more. */ + int64_t localWindow = 0; +}; + +/* What we advertise to the client, and the point at which we top it back up. */ +static constexpr int64_t LOCAL_WINDOW = 1024 * 1024; +static constexpr int64_t LOCAL_WINDOW_REFILL = LOCAL_WINDOW / 2; +/* Upper bound on a single CHANNEL_DATA payload, set by the Buf capacity. */ +static constexpr int64_t MAX_DATA_PAYLOAD = 3000; + +/* Must not be smaller than the kernel's Sched::MaxProcesses. */ +static constexpr int PROC_SCAN_MAX = 256; + +static bool alive(const Session& session) { + if (session.child <= 0) return false; + + if (session.childHandle >= 0) { + uint32_t signals = montauk::wait_handle( + session.childHandle, montauk::abi::IPC_SIGNAL_EXITED, 0); + if (signals != (uint32_t)-1) { + return (signals & montauk::abi::IPC_SIGNAL_EXITED) == 0; + } + } + + /* + * Fallback for when proc_open failed because the kernel's process object + * pool was exhausted. Static because a full table is far too large to put + * on the stack of a function polled every 20 ms. + */ + static montauk::abi::ProcInfo processes[PROC_SCAN_MAX]; + int count = montauk::proclist(processes, PROC_SCAN_MAX); + for (int i = 0; i < count; i++) { + if (processes[i].pid == session.child) { + /* 1=Ready, 2=Running, 3=Blocked. */ + return processes[i].state >= 1 && processes[i].state <= 3; + } + } + return false; +} + +static void close_child_handle(Session& session) { + if (session.childHandle >= 0) { + montauk::close(session.childHandle); + session.childHandle = -1; + } +} + +/* + * Moves whatever the shell has produced to the client, honouring the channel + * send window and the client's maximum packet size. Returns false only if the + * connection failed; `sentAny` reports whether any data actually moved. These + * were previously conflated into one bool, which made a live-but-quiet child + * indistinguishable from a successful send. + */ +static bool send_channel_data(Conn& connection, Session& session, + bool* sentAny = nullptr) { + char childOutput[1500]; + char terminalOutput[3000]; + if (sentAny) *sentAny = false; + if (session.child <= 0) return true; + + for (int loops = 0; loops < 16; loops++) { + int64_t budget = session.sendWindow; + if (budget > (int64_t)session.maxPacket) budget = session.maxPacket; + if (budget > MAX_DATA_PAYLOAD) budget = MAX_DATA_PAYLOAD; + /* CRLF translation can double the byte count in the worst case. */ + int64_t readable = session.ptyRequested ? budget / 2 : budget; + if (readable > (int64_t)sizeof(childOutput)) { + readable = sizeof(childOutput); + } + if (readable <= 0) break; // window exhausted; the child stays buffered + + int childSize = montauk::childio_read(session.child, childOutput, + (int)readable); + if (childSize <= 0) break; + + int outputSize = 0; + for (int i = 0; i < childSize; i++) { + char value = childOutput[i]; + if (session.ptyRequested && value == '\n' && + !session.lastWasCarriageReturn) { + terminalOutput[outputSize++] = '\r'; + } + terminalOutput[outputSize++] = value; + session.lastWasCarriageReturn = value == '\r'; + } + + Buf output; + output.byte(MSG_CHANNEL_DATA); + output.u32(session.remoteChannel); + output.str(terminalOutput, outputSize); + if (!connection.packet(output)) return false; + + session.sendWindow -= outputSize; + if (sentAny) *sentAny = true; + } + return true; +} + +/* + * Grants the client more window once it has spent enough of what we advertised. + * Without this the client stops sending after LOCAL_WINDOW bytes of input. + */ +static bool refill_local_window(Conn& connection, Session& session) { + if (session.localWindow > LOCAL_WINDOW_REFILL) return true; + + uint32_t adjust = (uint32_t)(LOCAL_WINDOW - session.localWindow); + if (adjust == 0) return true; + + Buf message; + message.byte(MSG_CHANNEL_WINDOW_ADJUST); + message.u32(session.remoteChannel); + message.u32(adjust); + if (!connection.packet(message)) return false; + + session.localWindow = LOCAL_WINDOW; + return true; +} + +/* + * Translates the client's byte stream into the key events the shell reads. + * Note the shell consumes key events from the redirection mailbox, never the + * redirection stream, so every input path here must go through writekey. + */ +static void feed_child(int pid, const uint8_t* data, int size) { + for (int i = 0; i < size; i++) { + montauk::abi::KeyEvent key = {}; + key.pressed = true; + uint8_t value = data[i]; + + if (value == 0x1B && i + 2 < size && data[i + 1] == '[' && + data[i + 2] >= 'A' && data[i + 2] <= 'D') { + static const uint8_t arrowScancodes[] = { + 0x48, 0x50, 0x4D, 0x4B, + }; + key.scancode = arrowScancodes[data[i + 2] - 'A']; + i += 2; + } else if (value == 13 || value == 10) { + key.ascii = '\n'; + } else if (value == 127 || value == 8) { + key.ascii = '\b'; + } else if (value == 9) { + key.scancode = 0x0F; + } else if (value == 0x1B) { + key.scancode = 0x01; + } else if (value >= 1 && value < 32) { + /* Remaining C0 codes are the control-modified letters. */ + key.ascii = (char)('a' + value - 1); + key.ctrl = true; + } else { + key.ascii = (char)value; + } + + montauk::childio_writekey(pid, &key); + } +} + +static bool serve_session_loop(Conn& connection, const char* user, + Session& session) { + for (;;) { + if (session.child > 0) { + if (!send_channel_data(connection, session)) { + return false; + } + if (!alive(session)) { + /* + * The child has already exited, so waitpid returns its recorded + * exit code without blocking. + */ + int code = montauk::waitpid(session.child); + + Buf exitStatus; + exitStatus.byte(98); + exitStatus.u32(session.remoteChannel); + exitStatus.str("exit-status"); + exitStatus.boolean(false); + exitStatus.u32((uint32_t)code); + connection.packet(exitStatus); + + Buf eof; + eof.byte(96); + eof.u32(session.remoteChannel); + connection.packet(eof); + + Buf close; + close.byte(97); + close.u32(session.remoteChannel); + connection.packet(close); + return true; + } + } + + uint32_t signals = montauk::wait_handle( + connection.fd, + montauk::abi::IPC_SIGNAL_READABLE | + montauk::abi::IPC_SIGNAL_PEER_CLOSED, + session.child > 0 ? 20 : ~0ULL); + if (signals == (uint32_t)-1 || + (signals & montauk::abi::IPC_SIGNAL_PEER_CLOSED)) { + return false; + } + if (!(signals & montauk::abi::IPC_SIGNAL_READABLE)) continue; + + Buf request; + if (!connection.recvmsg(request)) { + return false; + } + uint8_t type = request.get8(); + + if (type == MSG_KEXINIT) { + /* + * OpenSSH rekeys after 1 GiB or one hour. Ignoring this leaves the + * client waiting for our KEXINIT forever, wedging the session. + */ + connection.ic = request; + if (!kex(connection, /*clientKexInitReceived=*/true)) { + return false; + } + continue; + } + + if (type == MSG_CHANNEL_WINDOW_ADJUST && session.opened) { + request.get32(); + uint32_t adjust = request.get32(); + if (request.ok) session.sendWindow += (int64_t)adjust; + continue; + } + + if (type == 90) { + int channelTypeSize; + const uint8_t* channelType = request.getstr(channelTypeSize); + uint32_t sender = request.get32(); + uint32_t windowSize = request.get32(); + uint32_t maximumPacket = request.get32(); + if (!request.ok) return false; + + /* Only one session channel is supported at a time. */ + if (session.opened || channelTypeSize != 7 || + memcmp(channelType, "session", 7) != 0) { + Buf failure; + failure.byte(92); + failure.u32(sender); + failure.u32(3); // SSH_OPEN_UNKNOWN_CHANNEL_TYPE + failure.str("only one session channel is supported"); + failure.str(""); + if (!connection.packet(failure)) return false; + continue; + } + + session.remoteChannel = sender; + session.sendWindow = (int64_t)windowSize; + /* + * Floor the advertised packet size: CRLF translation halves the + * usable budget, so a very small value would round down to a + * zero-byte read and stall output entirely. + */ + session.maxPacket = maximumPacket < 256 ? 256 : maximumPacket; + session.localWindow = LOCAL_WINDOW; + + Buf response; + response.byte(91); + response.u32(session.remoteChannel); + response.u32(0); + response.u32((uint32_t)LOCAL_WINDOW); + response.u32((uint32_t)MAX_DATA_PAYLOAD); + if (!connection.packet(response)) return false; + session.opened = true; + } else if (type == 98 && session.opened) { + request.get32(); + int requestNameSize; + const uint8_t* requestName = request.getstr(requestNameSize); + bool wantsReply = request.getbool(); + bool success = false; + + if (requestNameSize == 7 && + memcmp(requestName, "pty-req", 7) == 0) { + request.skipstr(); + session.columns = (int)request.get32(); + session.rows = (int)request.get32(); + request.get32(); + request.get32(); + request.skipstr(); + success = request.ok; + session.ptyRequested = success; + } else if (requestNameSize == 3 && + memcmp(requestName, "env", 3) == 0) { + request.skipstr(); + request.skipstr(); + success = request.ok; + } else if ((requestNameSize == 5 && + memcmp(requestName, "shell", 5) == 0) || + (requestNameSize == 4 && + memcmp(requestName, "exec", 4) == 0)) { + const uint8_t* command = nullptr; + int commandSize = 0; + if (requestNameSize == 4) { + command = request.getstr(commandSize); + } + + /* Only one shell per session channel. */ + if (session.child > 0) { + success = false; + } else { + char daemonUser[32] = {}; + char home[96]; + montauk::getuser(daemonUser, sizeof(daemonUser)); + montauk::user::home_dir(user, home, sizeof(home)); + montauk::setuser(montauk::getpid(), user); + montauk::chdir(home); + session.child = montauk::spawn_redir("0:/os/shell.elf"); + montauk::chdir("0:/"); + montauk::setuser(montauk::getpid(), daemonUser); + + if (session.child > 0) { + montauk::setuser(session.child, user); + session.childHandle = montauk::proc_open(session.child); + montauk::childio_settermsz(session.child, + session.columns, + session.rows); + success = true; + if (command) { + feed_child(session.child, command, commandSize); + static const uint8_t tail[] = { + '\n', 'e', 'x', 'i', 't', '\n', + }; + feed_child(session.child, tail, sizeof(tail)); + } + } + } + } else if (requestNameSize == 13 && + memcmp(requestName, "window-change", 13) == 0) { + session.columns = (int)request.get32(); + session.rows = (int)request.get32(); + request.get32(); + request.get32(); + if (session.child > 0) { + montauk::childio_settermsz(session.child, session.columns, + session.rows); + } + success = true; + } + + if (wantsReply) { + Buf response; + response.byte(success ? 99 : 100); + response.u32(session.remoteChannel); + if (!connection.packet(response)) return false; + } + + /* Give the shell a chance to emit its banner before we go idle. */ + if (session.child > 0) { + for (int i = 0; i < 20; i++) { + montauk::yield(); + bool sentAny = false; + if (!send_channel_data(connection, session, &sentAny)) { + return false; + } + if (sentAny) break; + } + } + } else if (type == MSG_CHANNEL_DATA && session.child > 0) { + request.get32(); + int dataSize; + const uint8_t* data = request.getstr(dataSize); + if (request.ok) { + session.localWindow -= dataSize; + if (!refill_local_window(connection, session)) { + return false; + } + feed_child(session.child, data, dataSize); + } + for (int i = 0; i < 8; i++) { + montauk::yield(); + if (!send_channel_data(connection, session)) { + return false; + } + } + } else if (type == 96 && session.child > 0) { + /* + * End of input. The shell reads key events from the redirection + * mailbox and never touches the redirection stream, so this has to + * go through writekey like every other input byte. + */ + static const uint8_t endOfTransmission[] = {4}; + feed_child(session.child, endOfTransmission, 1); + } else if (type == 97) { + if (session.child > 0) montauk::kill(session.child); + return true; + } else if (type == 80) { + int requestNameSize; + request.getstr(requestNameSize); + bool wantsReply = request.getbool(); + if (wantsReply) { + Buf failure; + failure.byte(82); + connection.packet(failure); + } + } + } +} + +/* + * Owns session teardown so that no exit path can leave the shell running. A + * client that simply drops the TCP connection would otherwise orphan its shell, + * which holds a process slot for the rest of the boot. + */ +static bool serve_session(Conn& connection, const char* user) { + Session session; + bool result = serve_session_loop(connection, user, session); + + if (session.child > 0 && alive(session)) montauk::kill(session.child); + close_child_handle(session); + return result; +} + +static void handle(int fd) { + Conn connection = {}; + connection.fd = fd; + char user[32] = {}; + + /* + * Bound everything up to the start of the session. Connections are served + * one at a time, so a peer that connects and then stalls would otherwise + * lock out every other client indefinitely. + */ + connection.set_timeout(PREAUTH_TIMEOUT_MS); + + if (!version(connection)) { + montauk::print("sshd: client version exchange failed\n"); + } else if (!kex(connection)) { + char message[64]; + snprintf(message, sizeof(message), + "sshd: key exchange failed (%d)\n", kex_error); + montauk::print(message); + } else if (!auth(connection, user)) { + char message[64]; + snprintf(message, sizeof(message), + "sshd: authentication failed (%d)\n", kex_error); + montauk::print(message); + } else { + /* An authenticated session is allowed to sit idle. */ + connection.clear_timeout(); + serve_session(connection, user); + } + + montauk::closesocket(fd); +} + +extern "C" void _start() { + if (!rng.init()) { + montauk::print("sshd: secure random initialization failed\n"); + montauk::exit(1); + } + + if (!hostkey.load()) { + montauk::print("sshd: generating local RSA host key...\n"); + if (!hostkey.generate(rng)) { + montauk::print("sshd: host key generation failed\n"); + montauk::exit(1); + } + } + + auto config = montauk::config::load("ssh"); + int port = (int)config.get_int("server.port", 22); + config.destroy(); + if (port < 1 || port > 65535) port = 22; + + int listener = montauk::socket(montauk::abi::SOCK_TCP); + if (listener < 0 || montauk::bind(listener, (uint16_t)port) < 0 || + montauk::listen(listener) < 0) { + montauk::print("sshd: could not listen\n"); + montauk::exit(1); + } + + char message[80]; + snprintf(message, sizeof(message), "sshd: listening on port %d\n", port); + montauk::print(message); + + for (;;) { + int fd = montauk::accept(listener); + if (fd >= 0) { + handle(fd); + continue; + } + + /* + * accept() is non-blocking, so spinning on it would burn a scheduler + * slot for the life of the boot. A listening socket reports READABLE + * once a connection is pending, so block on that instead. + */ + montauk::wait_handle(listener, montauk::abi::IPC_SIGNAL_READABLE, + ~0ULL); + } +} diff --git a/programs/src/sshserver/Makefile b/programs/src/sshserver/Makefile new file mode 100644 index 0000000..bfc7334 --- /dev/null +++ b/programs/src/sshserver/Makefile @@ -0,0 +1,29 @@ +MAKEFLAGS += -rR +.SUFFIXES: +TOOLCHAIN_PREFIX := $(shell cd ../../.. && pwd)/toolchain/local/bin/x86_64-montauk- +CXX := $(TOOLCHAIN_PREFIX)g++ +PROG_INC := ../../include +LINK_LD := ../../link.ld +BINDIR := ../../bin +OBJDIR := obj +LIBDIR := ../../lib +CXXFLAGS := -std=gnu++20 -g -O2 -pipe -Wall -Wextra -Wno-unused-parameter \ + -ffreestanding -fno-stack-protector -fno-stack-check -fno-rtti -fno-exceptions \ + -ffunction-sections -fdata-sections -msse -msse2 -MMD -MP -I $(PROG_INC) \ + -I $(LIBDIR)/bearssl/inc -isystem $(PROG_INC)/libc +LDFLAGS := -nostdlib -Wl,--gc-sections -T $(LINK_LD) +SRCS := main.cpp stb_truetype_impl.cpp +OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) +TARGET := $(BINDIR)/apps/sshserver/sshserver.elf +LIBS := $(LIBDIR)/bearssl/libbearssl.a $(LIBDIR)/libc/liblibc.a +.PHONY: all clean +all: $(TARGET) +$(TARGET): $(OBJS) $(LINK_LD) Makefile $(LIBS) + mkdir -p $(dir $@) + $(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBS) -o $@ +$(OBJDIR)/%.o: %.cpp Makefile + mkdir -p $(dir $@) + $(CXX) $(CXXFLAGS) -c $< -o $@ +-include $(OBJS:.o=.d) +clean: + rm -rf $(OBJDIR) $(TARGET) diff --git a/programs/src/sshserver/main.cpp b/programs/src/sshserver/main.cpp new file mode 100644 index 0000000..16058da --- /dev/null +++ b/programs/src/sshserver/main.cpp @@ -0,0 +1,328 @@ +/* + * main.cpp + * MontaukOS SSH Server settings applet + */ + +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +using namespace gui; + +static constexpr int WIN_W = 620; +static constexpr int WIN_H = 500; +static constexpr int PAD = 20; +static constexpr int FOOTER_H = 48; +static constexpr int USER_ROW_H = 38; +static constexpr int VISIBLE_USERS = 7; + +static WsWindow g_win; +static Color g_accent = colors::ACCENT; +static int g_mouse_x = -1; +static int g_mouse_y = -1; +static bool g_is_admin = false; +static bool g_enabled = false; +static bool g_saved_enabled = false; +static bool g_dirty = false; +static montauk::user::UserInfo g_users[montauk::user::MAX_USERS]; +static bool g_allowed[montauk::user::MAX_USERS]; +static bool g_saved_allowed[montauk::user::MAX_USERS]; +static int g_user_count = 0; +static int g_user_scroll = 0; +static char g_status[128] = {}; + +static Rect service_row() { + return {PAD, 76, WIN_W - PAD * 2, 32}; +} + +static Rect user_row(int visibleIndex) { + return {PAD, 164 + visibleIndex * USER_ROW_H, + WIN_W - PAD * 2, USER_ROW_H}; +} + +static Rect apply_button() { + return {WIN_W - PAD - 92, WIN_H - FOOTER_H + 8, 92, 31}; +} + +static Rect revert_button() { + Rect apply = apply_button(); + return {apply.x - 104, apply.y, 92, apply.h}; +} + +static mtk::Theme app_theme() { + return mtk::make_theme(g_accent); +} + +static void load_state() { + char currentUser[32] = {}; + montauk::getuser(currentUser, sizeof(currentUser)); + + auto init = montauk::config::load("init"); + g_enabled = init.get_bool("services.ssh.enabled", false); + init.destroy(); + + auto ssh = montauk::config::load("ssh"); + g_user_count = montauk::user::load_users( + g_users, montauk::user::MAX_USERS); + for (int i = 0; i < g_user_count; i++) { + g_allowed[i] = montauk::ssh::user_allowed(ssh, g_users[i].username); + } + ssh.destroy(); + + /* Reuse the table just loaded rather than parsing the user file twice. */ + g_is_admin = false; + for (int i = 0; i < g_user_count; i++) { + if (montauk::streq(g_users[i].username, currentUser)) { + g_is_admin = montauk::streq(g_users[i].role, "admin"); + break; + } + } + + g_status[0] = 0; + + g_saved_enabled = g_enabled; + for (int i = 0; i < g_user_count; i++) { + g_saved_allowed[i] = g_allowed[i]; + } + g_dirty = false; +} + +static void recompute_dirty() { + g_dirty = g_enabled != g_saved_enabled; + for (int i = 0; i < g_user_count; i++) { + if (g_allowed[i] != g_saved_allowed[i]) g_dirty = true; + } +} + +static bool save_state() { + if (!g_is_admin) { + snprintf(g_status, sizeof(g_status), + "Administrator access is required"); + return false; + } + + auto init = montauk::config::load("init"); + /* + * init discovers services by enumerating [services.] tables and needs + * a path to launch. On a system whose init.toml predates the SSH service, + * writing only the enabled flag would produce a key init cannot act on, so + * write the whole service definition and let set_* overwrite in place where + * it already exists. + */ + if (!init.get_string("services.ssh.path", nullptr)) { + montauk::config::set_string(&init, "services.ssh.path", + "0:/os/sshd.elf"); + montauk::config::set_string(&init, "services.ssh.name", "SSH server"); + montauk::config::set_bool(&init, "services.ssh.wait", false); + montauk::config::set_bool(&init, "services.ssh.optional", true); + } + montauk::config::set_bool(&init, "services.ssh.enabled", g_enabled); + int initResult = montauk::config::save("init", &init); + init.destroy(); + + auto ssh = montauk::config::load("ssh"); + for (int i = 0; i < g_user_count; i++) { + char key[64]; + montauk::ssh::allow_key(key, sizeof(key), g_users[i].username); + montauk::config::set_bool(&ssh, key, g_allowed[i]); + } + int sshResult = montauk::config::save("ssh", &ssh); + ssh.destroy(); + + if (initResult < 0 || sshResult < 0) { + snprintf(g_status, sizeof(g_status), + "Could not save SSH configuration"); + return false; + } + + g_saved_enabled = g_enabled; + for (int i = 0; i < g_user_count; i++) { + g_saved_allowed[i] = g_allowed[i]; + } + g_dirty = false; + + snprintf(g_status, sizeof(g_status), + g_enabled ? "Saved; SSH starts on the next boot" + : "Saved; SSH is disabled on the next boot"); + return true; +} + +static void render() { + mtk::StandaloneHost host(&g_win); + Canvas canvas = host.canvas(); + mtk::Theme theme = app_theme(); + canvas.fill(theme.window_bg); + + canvas.text(PAD, 20, "SSH Server", theme.text); + canvas.text(PAD, 44, + "Secure remote access to the MontaukOS shell", + theme.text_subtle); + + Rect service = service_row(); + mtk::draw_checkbox(canvas, service, + "Start the SSH server at boot", + mtk::check_state(g_enabled), theme, + g_is_admin, + service.contains(g_mouse_x, g_mouse_y)); + canvas.text(PAD + 24, 112, + "Listens on TCP port 22 and uses MontaukOS account passwords.", + theme.text_subtle); + canvas.text(PAD, 142, "USERS ALLOWED TO CONNECT", theme.text_muted); + + for (int row = 0; row < VISIBLE_USERS; row++) { + int userIndex = g_user_scroll + row; + if (userIndex >= g_user_count) break; + + Rect bounds = user_row(row); + if (userIndex & 1) { + canvas.fill_rect(bounds.x, bounds.y, bounds.w, bounds.h, + mtk::mix(theme.window_bg, theme.surface, 55)); + } + + char label[112]; + const char* displayName = g_users[userIndex].display_name[0] + ? g_users[userIndex].display_name + : g_users[userIndex].username; + snprintf(label, sizeof(label), "%s (%s)", + displayName, g_users[userIndex].username); + mtk::draw_checkbox(canvas, bounds, label, + mtk::check_state(g_allowed[userIndex]), + theme, g_is_admin, + bounds.contains(g_mouse_x, g_mouse_y)); + + if (montauk::streq(g_users[userIndex].role, "admin")) { + const char* role = "Administrator"; + canvas.text(bounds.x + bounds.w - text_width(role) - 10, + bounds.y + 10, role, theme.text_muted); + } + } + + /* Without an indicator there is nothing to show the list continues. */ + if (g_user_count > VISIBLE_USERS) { + Rect track = {WIN_W - PAD + 4, user_row(0).y, 4, + VISIBLE_USERS * USER_ROW_H}; + canvas.fill_rect(track.x, track.y, track.w, track.h, + mtk::mix(theme.window_bg, theme.surface, 120)); + + int thumbHeight = track.h * VISIBLE_USERS / g_user_count; + if (thumbHeight < 20) thumbHeight = 20; + int span = g_user_count - VISIBLE_USERS; + int thumbY = track.y + + (track.h - thumbHeight) * g_user_scroll / (span ? span : 1); + canvas.fill_rect(track.x, thumbY, track.w, thumbHeight, + theme.text_muted); + } + + if (!g_is_admin) { + canvas.text(PAD, WIN_H - FOOTER_H - 27, + "Only administrators can change system SSH settings.", + theme.danger); + } + + Rect footer = {0, WIN_H - FOOTER_H, WIN_W, FOOTER_H}; + canvas.fill_rect(footer.x, footer.y, footer.w, footer.h, theme.surface); + mtk::draw_separator(canvas, 0, footer.y, WIN_W, theme); + const char* footerStatus = g_status[0] + ? g_status + : (g_dirty ? "Unsaved SSH changes" : "SSH configuration ready"); + canvas.text(PAD, footer.y + 16, footerStatus, theme.text_subtle); + + Rect revert = revert_button(); + Rect apply = apply_button(); + mtk::draw_button( + canvas, revert, "Revert", mtk::BUTTON_SECONDARY, + mtk::widget_state(false, revert.contains(g_mouse_x, g_mouse_y), + g_dirty && g_is_admin), + theme); + mtk::draw_button( + canvas, apply, "Apply", mtk::BUTTON_PRIMARY, + mtk::widget_state(false, apply.contains(g_mouse_x, g_mouse_y), + g_dirty && g_is_admin), + theme); + + host.present(); +} + +static void handle_mouse(const montauk::abi::WinEvent& event) { + g_mouse_x = event.mouse.x; + g_mouse_y = event.mouse.y; + + bool pressed = (event.mouse.buttons & 1) && + !(event.mouse.prev_buttons & 1); + + if (event.mouse.scroll) { + g_user_scroll += event.mouse.scroll > 0 ? -1 : 1; + int maximum = g_user_count > VISIBLE_USERS + ? g_user_count - VISIBLE_USERS + : 0; + if (g_user_scroll < 0) g_user_scroll = 0; + if (g_user_scroll > maximum) g_user_scroll = maximum; + } + + if (!pressed || !g_is_admin) return; + + if (service_row().contains(g_mouse_x, g_mouse_y)) { + g_enabled = !g_enabled; + /* A stale "Saved" message would otherwise hide the unsaved state. */ + g_status[0] = 0; + } else { + for (int row = 0; row < VISIBLE_USERS; row++) { + int userIndex = g_user_scroll + row; + if (userIndex < g_user_count && + user_row(row).contains(g_mouse_x, g_mouse_y)) { + g_allowed[userIndex] = !g_allowed[userIndex]; + g_status[0] = 0; + break; + } + } + } + + if (revert_button().contains(g_mouse_x, g_mouse_y) && g_dirty) { + load_state(); + } else if (apply_button().contains(g_mouse_x, g_mouse_y) && g_dirty) { + save_state(); + } + recompute_dirty(); +} + +extern "C" void _start() { + if (!fonts::init()) montauk::exit(1); + + g_accent = mtk::load_system_accent(); + load_state(); + if (!g_win.create("SSH Server", WIN_W, WIN_H)) montauk::exit(1); + render(); + + while (g_win.id >= 0 && !g_win.closed) { + montauk::abi::WinEvent event; + int count = g_win.poll(&event); + if (count <= 0) { + montauk::yield(); + continue; + } + + if (event.type == 1) { + handle_mouse(event); + } else if (event.type == 2) { + g_win.width = event.resize.w; + g_win.height = event.resize.h; + } else if (event.type == 3) { + g_win.closed = true; + } else if (event.type == 0 && event.key.pressed && + event.key.scancode == 1) { + g_win.closed = true; + } + + render(); + } + + g_win.destroy(); + montauk::exit(0); +} diff --git a/programs/src/sshserver/manifest.toml b/programs/src/sshserver/manifest.toml new file mode 100644 index 0000000..d5f522b --- /dev/null +++ b/programs/src/sshserver/manifest.toml @@ -0,0 +1,13 @@ +[app] +id = "sshserver" +name = "SSH Server" +binary = "sshserver.elf" +icon = "utilities-terminal.svg" + +[menu] +category = "System" +visible = false + +[desktop] +section = "settings" +admin_only = false diff --git a/programs/src/sshserver/stb_truetype_impl.cpp b/programs/src/sshserver/stb_truetype_impl.cpp new file mode 100644 index 0000000..8d03b2b --- /dev/null +++ b/programs/src/sshserver/stb_truetype_impl.cpp @@ -0,0 +1,2 @@ +#define STB_TRUETYPE_IMPLEMENTATION +#include diff --git a/scripts/install_apps.sh b/scripts/install_apps.sh index acc08c7..3de3fdf 100755 --- a/scripts/install_apps.sh +++ b/scripts/install_apps.sh @@ -32,6 +32,7 @@ APPS=( "bluetooth|apps/scalable/bluetooth.svg" "network|devices/scalable/network-wired.svg" "display|apps/scalable/preferences-desktop-display.svg" + "sshserver|apps/scalable/utilities-terminal.svg" "terminal|apps/scalable/utilities-terminal.svg" "klog|apps/scalable/utilities-terminal.svg" "procmgr|apps/scalable/system-monitor.svg" diff --git a/template/sysroot/include/montauk/ssh.h b/template/sysroot/include/montauk/ssh.h new file mode 100644 index 0000000..7b1c9b8 --- /dev/null +++ b/template/sysroot/include/montauk/ssh.h @@ -0,0 +1,27 @@ +/* Shared SSH server policy helpers. */ +#pragma once +#include +#include +#include +#include + +namespace montauk::ssh { + inline void allow_key(char* out, int cap, const char* username) { + snprintf(out, cap, "allow.%s", username); + } + + inline bool user_allowed(const montauk::toml::Doc& doc, const char* username) { + char key[64]; + allow_key(key, sizeof(key), username); + return doc.get_bool(key, false); + } + + inline bool is_admin(const char* username) { + user::UserInfo users[user::MAX_USERS]; + int count = user::load_users(users, user::MAX_USERS); + for (int i = 0; i < count; i++) + if (montauk::streq(users[i].username, username)) + return montauk::streq(users[i].role, "admin"); + return false; + } +}