From 9329dbc52b82137a4666e1a65184b507cfbaae00 Mon Sep 17 00:00:00 2001 From: Christian Baars Date: Tue, 6 May 2025 10:48:50 +0200 Subject: [PATCH] add SSH demo driver to berry examples (#23378) --- tasmota/berry/drivers/ssh.be | 1307 ++++++++++++++++++++++++++++++++++ 1 file changed, 1307 insertions(+) create mode 100644 tasmota/berry/drivers/ssh.be diff --git a/tasmota/berry/drivers/ssh.be b/tasmota/berry/drivers/ssh.be new file mode 100644 index 000000000..af7f9c227 --- /dev/null +++ b/tasmota/berry/drivers/ssh.be @@ -0,0 +1,1307 @@ +#- Simple SSH server in Berry by Christian Baars +# includes rudimentary terminal and SFTP server +# this is demo code and not intended for production use +# written from scratch, no libraries used +# DO NOT OPEN A GH ISSUE, but feel free to use GH discussions +# needs some crypto primitives: + #define USE_BERRY_CRYPTO_EC_C25519 + #define USE_BERRY_CRYPTO_CHACHA_POLY + #define USE_BERRY_CRYPTO_ED25519 +-# + +class SSH_MSG + static DISCONNECT = 1 + static IGNORE = 2 + static SERVICE_REQUEST = 5 + static SERVICE_ACCEPT = 6 + static KEXINIT = 20 + static NEWKEYS = 21 + static KEXDH_INIT = 30 + static KEX_ECDH_REPLY = 31 + static USERAUTH_REQUEST = 50 + static USERAUTH_FAILURE = 51 + static USERAUTH_SUCCESS = 52 + static USERAUTH_BANNER = 53 + static GLOBAL_REQUEST = 80 + static REQUEST_SUCCESS = 81 + static REQUEST_FAILURE = 82 + static CHANNEL_OPEN = 90 + static CHANNEL_OPEN_CONFIRMATION = 91 + static CHANNEL_OPEN_FAILURE = 92 + static CHANNEL_WINDOW_ADJUST = 93 + static CHANNEL_DATA = 94 + static CHANNEL_EXTENDED_DATA = 95 + static CHANNEL_EOF = 96 + static CHANNEL_CLOSE = 97 + static CHANNEL_REQUEST = 98 + static CHANNEL_SUCCESS = 99 + static CHANNEL_FAILURE =100 + + static def get_name_list(buffer, index, length) + import string + if length == 0 || length > (size(buffer) - 5) + return nil + end + var names = buffer[index + 4 .. index + 3 + length] + return string.split(names.asstring(),",") + end + + static def get_string(buffer, index, length) + import string + if length == 0 || length > (size(buffer) - 5) + return nil + end + var name = buffer[index + 4 .. index + 3 + length] + return name.asstring() + end + + static def get_bytes(buffer, index, length) + import string + if length == 0 || length > (size(buffer) - 5) + return nil + end + var b = buffer[index + 4 .. index + 3 + length] + return b + end + + static def get_item_length(buf) + return buf.geti(0,-4) + end + + static def add_string(buf, str_entry) + buf.add(size(str_entry),-4) + buf .. str_entry + end + + static def add_mpint(buf, entry) + if entry[0] & 128 != 0 + entry = bytes("00") + entry + end + buf.add(size(entry),-4) + buf .. entry + end + + static def make_mpint(buf) + var mpint = bytes(size(buf) + 5) + if buf[0] & 128 != 0 + buf = bytes("00") + buf + end + mpint.add(size(buf),-4) + mpint .. buf + return mpint + end +end + +class TERMINAL + var in_buf, session + + def init(session) + self.session = session + self.in_buf = bytes(64) + end + + def process(data) + self.in_buf .. data + if data == bytes("0d") + var c = self.in_buf.asstring() + var r = tasmota.cmd(f"{c}") + self.in_buf.clear() + if r + return "\r\n" + r.tostring() + "\r\n> " + else + return "\r\n>" + end + else + return data.asstring() + end + return "" + end +end + +class PATH # helper class to hold the current directory + var p # path components in a list + + def init() + import string + self.p = [] + end + + def set(p) + import string + import path + + if path.isdir(p) != true + return false + end + + var new = string.split(p,"/") + self.p = [] + for c:new + if c != "" + self.p.push(c) + end + end + return true + end + + def dir_up() + if size(self.p) > 0 + self.p.pop() + end + end + + def get_url() + var url = "/" + for c:self.p + if c != "" + url += f"{c}/" + end + end + return url + end +end + +class SFTP_FILE + var url, file, length, written, is_writing, is_reading, id + var append_flag, chunk_limit + + #define SSH_FXF_READ 0x00000001 + #define SSH_FXF_WRITE 0x00000002 + #define SSH_FXF_APPEND 0x00000004 + #define SSH_FXF_CREAT 0x00000008 + #define SSH_FXF_TRUNC 0x00000010 + #define SSH_FXF_EXCL 0x00000020 + + def init(url, pflags) + import path + if path.exists(url) != true + if pflags&1 == false && pflags&4 == false + return nil + end + end + if pflags&1 + self.file = open(url,"r") + log(f"SFTP: open file for read {url}",4) + end + if pflags&2 + self.file = open(url,"w") + log(f"SFTP: open file for write {log}",4) + end + if pflags&4 + self.append_flag = true + log(f"SFTP: open file for append {log}",4) + else + self.append_flag = false + end + self.url = url + self.is_writing = false + self.chunk_limit = 4096 + end + + def deinit() + self.close() + end + + def write(data, offset, id) + log(f"SFTP: write file {data} at position {offset}",3) + if self.append_flag == false + self.file.seek(offset) + end + + self.length = data.geti(0,-4) + log(f"SFTP: file length {self.length}", 3) + + self.id = id + self.written = size(data) - 4 + if self.written < self.length + self.is_writing = true + end + return self.file.write(data[4..]) # skip length + end + + def append(data) + if self.file + self.written += size(data) + if self.written == self.length + self.is_writing = false + end + return self.file.write(data) + end + end + + def read(len, offset, id) + self.file.seek(offset) + if len > self.chunk_limit # stay below 4096 max packet size in the end + len = self.chunk_limit + end + if self.file + var b = self.file.readbytes(len) + return b + end + return nil + end + + def close() + log(f"SFTP: close file {self.url}",3) + if self.file + self.file.close() + end + end +end + +class SFTP + static INIT = 1 + static VERSION = 2 + static OPEN = 3 + static CLOSE = 4 + static READ = 5 + static WRITE = 6 + static LSTAT = 7 + static FSETSTAT = 10 + static OPENDIR = 11 + static READDIR = 12 + static REALPATH = 16 + static STAT = 17 + static STATUS = 101 + static DATA = 103 + static NAME = 104 + static ATTRS = 105 + + var session, dir_list, dir, file + + def init(session) + self.session = session + self.dir = PATH() + log("SFTP started .. very incomplete!",1) + end + + def fsize(url) + import path + if path.exists(url) == true && path.isdir(url) == false + var f = open(url,"r") + var sz = f.size() + f.close() + return sz + end + return 0 + end + + def fdate(url) + import path + if path.exists(url) == true + return path.last_modified(url) + end + return 0 + end + + def long_name(url) + var date = self.fdate(url) + var sz = self.fsize(url) + var m = tasmota.strftime("%B", date)[0..2] + var dt = tasmota.strftime("%d %H:%M", date) + var pre = "-" + if sz ==0 pre = "d" end # TODO: really check if dir + return f"{pre}rwxrwxr-x 1 admin all {sz:8i} {m} {dt} {url}" + end + + def read_dir(url, id) + if size(self.dir_list) == 0 + return self.status(id, 1) # EOF + end + var r = bytes("00000000") # size + r .. SFTP.NAME + r .. id + r.add(size(self.dir_list),-4) # count + for i:self.dir_list + SSH_MSG.add_string(r,i) + SSH_MSG.add_string(r,self.long_name(i)) + r .. self.attribs(i) # file attributes + end + r.seti(0,size(r)-4,-4) + self.dir_list = [] + return r + end + + def attribs(url) + import path + var date = self.fdate(url) + var sz = self.fsize(url) + var perms = 777 + var a = bytes("0800000f") # flags for extended size|uid|perm|time + a.add(0, -4) # high bytes of size + a.add(sz,-4) # is uint64 + a.add(0,-4) # uid - superuser + a.add(0,-4) # gid - superuser + if path.isdir(url) + a.add(perms|40000, -4) # permissions for dir + else + a.add(perms|100000, -4) # permissions for file + end + a.add(date,-4) + a.add(date,-4) + return a + end + + def status(id,code) + var s = bytes("0000000065") # packet type SSH_FXP_STATUS 101 + s .. id + s.add(code,-4) + s .. bytes(-8) # two empty strings + s.seti(0,size(s)-4,-4) + log(f"SFTP: status {code} for {id}",4) + return s + end + + def handle(id,url) + var h = bytes("0000000066") # packet type SSH_FXP_HANDLE 102 + h .. id + SSH_MSG.add_string(h,url) + h.seti(0,size(h)-4,-4) + return h + end + + def stat_for_url(id, url) + import path + if path.exists(url) + var r = bytes("00000000") # size + r .. SFTP.ATTRS + r..id + r .. self.attribs(url) # file attributes + r.seti(0,size(r)-4,-4) + return r + end + return self.status(id, 2) # NO_SUCH_FILE + end + + def open_file(id,url,pflags,attr) + self.file = SFTP_FILE(url,pflags) + if self.file + return self.handle(id,url) + end + return self.status(id, 2) # NO_SUCH_FILE + end + + def path_name(url,id) + var r = bytes("00000000") # size + r .. SFTP.NAME + r .. id + r.add(1,-4) # count + SSH_MSG.add_string(r,url) + SSH_MSG.add_string(r,"") + r .. self.attribs(url) # file attributes + r.seti(0,size(r)-4,-4) + return r + end + + def process(d) + log(f"SFTP: process SFTP __________________________",3) + var r = bytes() + var unfinished = true + var ptype, id + if self.file + log(f"SFTP: file is open {self.file.url} {self.file.written} {self.file.length} {self.file.is_writing}",4) + if self.file.is_writing == true + log(f"SFTP: append {d}",3) + self.file.append(d) + if self.file.is_writing == false + return self.status(self.file.id, 0) # SSH_FX_OK + end + return "" # will resolve later to MSG_IGNORE + end + end + if self.file + var cmds = size(d)/32 + if cmds == 0 + cmds = 1 + end + self.file.chunk_limit = 4096/cmds # read command 32 bytes + log(f"SSH: multiple commands: {cmds}",3) + end + while unfinished == true + ptype = d[4] # https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02#section-3 + id = d[5..8] + log(f"SFTP: type {ptype}, id {id}, data {d}", 3) + if ptype == SFTP.INIT + r = bytes('000000050200000003') # no extended data support, ver 3 + elif ptype == SFTP.LSTAT + var url = d[13..].asstring() + log(f"SFTP LSTAT for: {url}",3) + r = self.stat_for_url(id,url) + elif ptype == SFTP.STAT + var url = d[13..].asstring() + log(f"SFTP STAT for: {url}",3) + r = self.stat_for_url(id,url) + elif ptype == SFTP.OPEN + var next_index = 9 + var next_length = SSH_MSG.get_item_length(d[next_index..]) + var url = SSH_MSG.get_string(d, next_index, next_length) + next_index += next_length + 4 + var pflags = d.geti(next_index,-4) + next_index += 4 + var attr = d[next_index..] + log(f"SFTP OPEN: {url} with {pflags} and {attr}",3) + r = self.open_file(id,url,pflags,attr) + elif ptype == SFTP.READ + var next_index = 9 + var next_length = SSH_MSG.get_item_length(d[next_index..]) + var url = SSH_MSG.get_string(d, next_index, next_length) + next_index += next_length + 8 + var offset = d.geti(next_index,-4) # uint64 + next_index += 4 + var len = d.geti(next_index,-4) # uint32 + next_index += 4 + log(f"SFTP READ: {url} - {len} bytes from {offset}",3) + var fbytes = self.file.read(len,offset,id) + if size(fbytes) == 0 + r = self.status(id, 1) # FX_EOF 1 + else + var _r = bytes("00000000") # size + _r .. SFTP.DATA + _r .. id + SSH_MSG.add_string(_r, fbytes) + _r.seti(0,size(_r)-4,-4) + r .. _r + end + if next_index < size(d) - 9 + unfinished = true + d = d[next_index..] + continue + end + elif ptype == SFTP.WRITE + var next_index = 9 + var next_length = SSH_MSG.get_item_length(d[next_index..]) + var url = SSH_MSG.get_string(d, next_index, next_length) + next_index += next_length + 8 + var offset = d.geti(next_index,-4) # uint64 + next_index += 4 + var data = d[next_index..] + log(f"SFTP WRITE: {url}",3) + self.file.write(data,offset, id) # Todo: check success + if self.file.is_writing == false + r = self.status(self.file.id, 0) # SSH_FX_OK + else + r = "" # -> MSG_IGNORE + end + elif ptype == SFTP.OPENDIR + var url = d[13..].asstring() + if url == "" + url = "/" + end + log(f"SFTP OPENDIR: {url}",3) + if self.dir.set(url) + import path + self.dir_list = path.listdir(self.dir.get_url()) + r = self.handle(id,url) + else + r = self.status(id, 2) # NO_SUCH_FILE + end + elif ptype == SFTP.READDIR + var url = d[13..].asstring() + log(f"SFTP READDIR: {url}",3) + r = self.read_dir(url,id) + elif ptype == SFTP.CLOSE + log("SFTP CLOSE",3) + r = self.status(id, 0) # SSH_FX_OK + self.file = nil + elif ptype == SFTP.REALPATH + log("SFTP REALPATH",3) + var url = d[13..].asstring() + if url == "." + url = "/" + end + r = self.path_name(url,id) + elif ptype == SFTP.FSETSTAT + log("SFTP FSETSTAT",3) + #ignore for now + self.file.close() + r = self.status(id, 0) # SSH_FX_OK + else + log(f"SFTP: unknown packet type {ptype}", 2) + r = self.status(id,8) #OP_UNSUPPORTED + end + unfinished = false + end + return r + end +end + +class BIN_PACKET + var packet_length, padding_length, payload, payload_length, padding, mac, mac_length + var expected_length + var complete, session, encrypted, buf, overrun_buf + + def init(buf, session, encrypted) + self.session = session + self.packet_length = buf.geti(0,-4) + self.expected_length = self.packet_length + 4 + if encrypted == true + self.packet_length = self.get_length(buf) + log(f"SSH: new bin_packet with {self.packet_length} bytes",3) + self.expected_length = self.packet_length + 4 + 16 # mac + end + if self.expected_length > 32768 + log(f"SSH: Unusual high packet length {self.expected_length} - assume decoding error!!",1) + self.expected_length = size(buf) + self.packet_length = size(buf) - 20 + end + self.buf = bytes(self.expected_length) + self.encrypted = encrypted + self.append(buf) + end + + def get_length(packet) + import crypto + var c = crypto.CHACHA20_POLY1305() + var length = packet[0..3] + var iv = bytes(-12) + iv.seti(8,self.session.seq_nr_rx,-4) + c.chacha_run(self.session.KEY_C_S_header,iv,0,length) + return length.geti(0,-4) + end + + def check_packet() + import crypto + var c = crypto.CHACHA20_POLY1305() + var iv = bytes(-12) + iv.seti(8,self.session.seq_nr_rx,-4) + var data = self.buf[0.. self.packet_length + 3] + var poly_key = bytes(-32) + c.chacha_run(self.session.KEY_C_S_main, iv, 0 ,poly_key) + var given_mac = self.buf[self.packet_length+4..self.packet_length+19] + var mac = c.poly_run(data,poly_key) + if mac != given_mac + #TODO: disconect + log(f"SSH: MAC MISMATCH!! {mac} - {given_mac} ", 1) + end + end + + def decrypt() + import crypto + var c = crypto.CHACHA20_POLY1305() + var iv = bytes(-12) + iv.seti(8,self.session.seq_nr_rx,-4) + var data = self.buf[4..-17] + c.chacha_run(self.session.KEY_C_S_main, iv, 1, data) + self.buf.setbytes(4,data) + # print(self.buf, size(self.buf)) + return + end + + def decode() + self.padding_length = self.buf[4] + self.payload_length = self.packet_length - self.padding_length - 1 + # print(self.packet_length, self.padding_length, self.payload_length) + self.payload = self.buf[5 .. 5 + self.payload_length - 1] + self.padding = self.buf[5 + self.payload_length .. 5 + self.payload_length + self.padding_length - 1] + # print(self.payload) + end + + def append(buf) + self.buf .. buf + if size(self.buf) > self.expected_length + log(f"must split TCP packet:{self.expected_length} _ {size(self.buf) - self.expected_length} ",4) + self.session.overrun_buf = self.buf[self.expected_length ..] + end + if size(self.buf) >= self.expected_length + log(f"SSH: got complete packet: {self.expected_length} _ {size(self.buf)}",4) + self.complete = true + if self.encrypted == true + self.check_packet() + self.decrypt() + end + self.decode() + else + self.complete = false + end + end + + def encrypt(packet) + # print(packet) + import crypto + var c = crypto.CHACHA20_POLY1305() + var iv = bytes(-12) + iv.seti(8,self.session.seq_nr_tx,-4) + var length = packet[0..3] + c.chacha_run(self.session.KEY_S_C_header,iv,0,length) + var data = packet[4..] + c.chacha_run(self.session.KEY_S_C_main, iv, 1, data) + var enc_packet = length + data + + var poly_key = bytes(-32) + c.chacha_run(self.session.KEY_S_C_main,iv,0,poly_key) + var mac = c.poly_run(enc_packet,poly_key) + + return enc_packet + mac + end + + def create(payload, encrypted) + import crypto + var paylength = size(payload) + var padlength = 8-((5 + paylength)%8) + if encrypted == true + padlength -= 4 + end + if padlength < 5 + padlength += 16 + end + var padding = crypto.random(padlength) + var bin = bytes(256) + bin.add(1 + paylength + padlength, -4) + bin .. padlength + bin .. payload + bin .. padding + if encrypted == true + return self.encrypt(bin) + end + return bin + end +end + + +class HANDSHAKE + var state, bin_packet, session + + var V_C # client's identification string (CR and LF excluded) + static V_S = "SSH-2.0-TasmotaSSH_0.1" # server's identification string (CR and LF excluded) + var I_C # payload of the client's SSH_MSG_KEXINIT + var I_S # payload of the server's SSH_MSG_KEXINIT + var K_S # server's public host key + var Q_C # client's ephemeral public key octet string + var Q_S # server's ephemeral public key octet string + var K # shared secret + + var H # hash of above + + var host_key # server's secret host key bytes + + + def init(session) + self.state = 0 + self.create_host_keys() + self.session = session + end + + def create_host_keys() + import crypto + var ed = crypto.ED25519() + var example_seed = bytes("a60c6c7107be5da01ba7f7bc6a08e1d0faa27e1db9327514823fdac5f8e750dd") # could be any crypto.random(32) + self.host_key = ed.secret_key(example_seed) #bytes("a60c6c7107be5da01ba7f7bc6a08e1d0faa27e1db9327514823fdac5f8e750dd") + var pk = bytes(64) + SSH_MSG.add_string(pk, "ssh-ed25519") + SSH_MSG.add_string(pk,self.host_key[-32..]) # public key is simply the last 32 bytes of the secret key + self.K_S = pk + end + + def kexinit_to_client() + import crypto + var cookie = crypto.random(16) + var kex_algorithms = "curve25519-sha256,kex-strict-s-v00@openssh.com,kex-strict-s" + var server_host_key_algorithms = "ssh-ed25519" + var encryption_algorithms_client_to_server = "chacha20-poly1305@openssh.com" + var encryption_algorithms_server_to_client = "chacha20-poly1305@openssh.com" + var mac_algorithms_client_to_server = "" + var mac_algorithms_server_to_client = "" + var compression_algorithms_client_to_server = "none" + var compression_algorithms_server_to_client = "none" + var languages_client_to_server = "" + var languages_server_to_client = "" + + var payload = bytes(256) + payload .. SSH_MSG.KEXINIT + payload .. cookie + SSH_MSG.add_string(payload,kex_algorithms) + SSH_MSG.add_string(payload,server_host_key_algorithms) + SSH_MSG.add_string(payload,encryption_algorithms_client_to_server) + SSH_MSG.add_string(payload,encryption_algorithms_server_to_client) + SSH_MSG.add_string(payload,mac_algorithms_client_to_server) + SSH_MSG.add_string(payload,mac_algorithms_server_to_client) + SSH_MSG.add_string(payload,compression_algorithms_client_to_server) + SSH_MSG.add_string(payload,compression_algorithms_client_to_server) + SSH_MSG.add_string(payload,languages_client_to_server) + SSH_MSG.add_string(payload,languages_server_to_client) + payload .. 0 # false - first_kex_follows + payload.add(0,-4) # reserved + self.I_S = payload.copy() + return self.bin_packet.create(payload) + end + + def create_KEX_ECDH_REPLY() + import crypto + var hash = bytes(2048) + SSH_MSG.add_string(hash, self.V_C) + SSH_MSG.add_string(hash, self.V_S) + SSH_MSG.add_string(hash, self.I_C) + SSH_MSG.add_string(hash, self.I_S) + SSH_MSG.add_string(hash, self.K_S) + SSH_MSG.add_string(hash, self.Q_C) + SSH_MSG.add_string(hash, self.Q_S) + SSH_MSG.add_mpint(hash, self.K) + + var sha256 = crypto.SHA256() + sha256.update(hash) + self.H = sha256.out() + + var eddsa25519 = crypto.ED25519() + var SIG = eddsa25519.sign(self.H,self.host_key) + # print(SIG) + + var payload = bytes(256) + payload .. SSH_MSG.KEX_ECDH_REPLY + # print(self.K_S, size(self.K_S), self.Q_S, size(self.Q_S), H, size(H) ) + SSH_MSG.add_string(payload, self.K_S) + SSH_MSG.add_string(payload, self.Q_S) + var HS = bytes(128) + SSH_MSG.add_string(HS, "ssh-ed25519") + SSH_MSG.add_string(HS,SIG) + SSH_MSG.add_string(payload, HS) + return self.bin_packet.create(payload) + end + + def create_ephemeral(payload) + log("SSH: create ephemeral keys",3) + import crypto + var ephem_key = crypto.random(32) + self.Q_S = (crypto.EC_C25519().public_key(ephem_key)) + self.Q_C = payload[5..] + self.K = (crypto.EC_C25519().shared_key(ephem_key, self.Q_C)) + # print(ephem_key self.Q_S, self.K) + return self.create_KEX_ECDH_REPLY() + end + + def kexinit_from_client() # mainly logging function + import string + var buf = self.bin_packet.payload + var k = {} + log(f"cookie: {buf[1..16].tohex()}",3) + var next_index = 17 + var next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"kex_algorithms: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + for i:SSH_MSG.get_name_list(buf, next_index, next_length) + if string.find(i, "kex-strict-c") >= 0 self.session.strict_mode = true end + end + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"server_host_key_algorithms: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"encryption_algorithms_client_to_server: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"encryption_algorithms_server_to_client: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"mac_algorithms_client_to_server: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"mac_algorithms_server_to_client: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"compression_algorithms_client_to_server: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"compression_algorithms_server_to_client: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"languages_client_to_server: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + log(f"languages_server_to_client: {SSH_MSG.get_name_list(buf, next_index, next_length)}",3) + next_index += next_length + 4 + log(f"first_kex_packet_follows: {buf[next_index]}",3) + end + + def send_NEWKEYS() + log("SSH: send new keys",2) + var payload = bytes(-1) + payload[0] = SSH_MSG.NEWKEYS + self.session.prepare(self.K,self.H) + return self.bin_packet.create(payload) + end + + def process(buf) + var response = bytes() + if self.state == 0 + self.state = 1 + self.V_C = buf[0..-3].asstring() # strip LF + return f"{self.V_S}\r\n" + elif self.state == 1 + if self.bin_packet + self.bin_packet.append(buf) + else + self.bin_packet = BIN_PACKET(buf,self.session, false) + end + if self.bin_packet.complete == true + if self.bin_packet.payload[0] == SSH_MSG.KEXINIT + self.I_C = self.bin_packet.payload.copy() + self.kexinit_from_client() + response = self.kexinit_to_client() + elif self.bin_packet.payload[0] == SSH_MSG.KEXDH_INIT + response = self.create_ephemeral(self.bin_packet.payload) + elif self.bin_packet.payload[0] == SSH_MSG.NEWKEYS + response = self.send_NEWKEYS() + self.state = 2 + elif self.bin_packet.payload[0] == SSH_MSG.DISCONNECT + log("SSH: client did disconnect",1) + else + log("SSH: unknown packet type: {self.bin_packet.payload[0]}", 1) + end + self.bin_packet = nil + end + return response + elif self.state == 2 + + end + log("SSH: unknown packet",1) + return "" + end +end + +class SESSION + var up, strict_mode, client_pub_key + var H, K, ID + var bin_packet + var KEY_C_S_main, KEY_S_C_main, KEY_C_S_header, KEY_S_C_header + var seq_nr_rx, seq_nr_tx, channel_nr + var send_queue, overrun_buf + var type # terminal or SFTP + + static MAX_PACKET_SIZE = 4096 # we must process the whole packet (crypt, auth, etc) + + static user = "admin" + static password = "1234" + + static banner = " / \\ Secure Wireless Serial Interface\n" + "/ /|\\ \\ SSH Terminal Server on %s\n" + " \\_/ Copyright (C) 2025 Tasmota %s\n" + + def init() + self.up = false + self.seq_nr_rx = -2 # very unsure about this!!! + self.seq_nr_tx = -1 + self.send_queue = [] + self.strict_mode = false # support by client + end + + def deinit() + self.type = nil + self.bin_packet = nil + end + + def send_banner() + var r = bytes() + r .. SSH_MSG.USERAUTH_BANNER + var s2 = tasmota.cmd("status 2")["StatusFWR"] + var hw = s2["Hardware"] + var vs = s2["Version"] + var strict_mode = "" + if self.strict_mode == false + strict_mode = "\n\r WARNING: outdated SSH-client, connection is vulnerable to Terrapin!!!\r\n" + end + SSH_MSG.add_string(r,format(self.banner,hw,vs) + strict_mode) + SSH_MSG.add_string(r,"") # language + var p = BIN_PACKET(bytes(-32),self,false) + self.overrun_buf = nil + return p.create(r ,true) + end + + def check_pub_key() + import persist + var r = bytes(32) + if persist.known_hosts == nil + persist.known_hosts = [] + end + for key:persist.known_hosts + if key == self.client_pub_key + log("SSH: known client",2) + r .. SSH_MSG.USERAUTH_SUCCESS + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + end + r .. SSH_MSG.USERAUTH_FAILURE + SSH_MSG.add_string(r,"password") + r .. 0 + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + + def handle_service_request() + var name = SSH_MSG.get_string(self.bin_packet.payload, 1, SSH_MSG.get_item_length(self.bin_packet.payload[1..])) + log(f"SSH: service request: {name}",2) + if name == "ssh-userauth" + var r = bytes(64) + r .. SSH_MSG.SERVICE_ACCEPT + SSH_MSG.add_string(r,name) + var enc_r = self.bin_packet.create(r ,true) + self.send_queue.push(/->self.send_banner()) + return enc_r + end + var r = bytes(64) + r .. SSH_MSG.USERAUTH_SUCCESS + log(f"SSH: unhandled request {r}",1) + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + + + def handle_userauth_request() + var r = bytes(32) + var buf = self.bin_packet.payload + var next_index = 1 + var next_length = SSH_MSG.get_item_length(buf[next_index..]) + var user_name = SSH_MSG.get_string(buf, next_index, next_length) + if user_name != self.user + r .. SSH_MSG.USERAUTH_FAILURE + SSH_MSG.add_string(r,"unknown user") + r .. 0 + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + var service_name = SSH_MSG.get_string(buf, next_index, next_length) + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + var method_name = SSH_MSG.get_string(buf, next_index, next_length) + if method_name == "none" + r .. SSH_MSG.USERAUTH_FAILURE + SSH_MSG.add_string(r,"publickey,password") + r .. 0 + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + next_index += next_length + 4 + var bool_field = buf[next_index] + next_index += 1 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + var key_algo = SSH_MSG.get_string(buf, next_index, next_length) #var name is "context sensitive" + if method_name == "password" + if key_algo != self.password + r .. SSH_MSG.USERAUTH_FAILURE + SSH_MSG.add_string(r,"wrong password") + r .. 0 + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + end + next_index += next_length + 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + var algo_blob = SSH_MSG.get_bytes(buf, next_index, next_length) #var name is "context sensitive" + if method_name == "publickey" + log(f"SSH: public key auth: {key_algo}",2) + self.client_pub_key = algo_blob[-32..].tohex() + return self.check_pub_key() + end + # print(user_name,service_name,method_name,bool_field,key_algo,size(algo_blob),algo_blob) + r .. SSH_MSG.USERAUTH_SUCCESS + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + + def handle_channel_open() + var buf = self.bin_packet.payload + var next_index = 1 + var next_length = SSH_MSG.get_item_length(buf[next_index..]) + var channel_type = SSH_MSG.get_string(buf, next_index, next_length) + next_index += next_length + 4 + self.channel_nr = buf.geti(next_index,-4) + next_index += 4 + var window_size = buf.geti(next_index,-4) + next_index += 4 + var packet_size = buf.geti(next_index,-4) + log(f"SSH: type {channel_type}, nr{self.channel_nr}, window size {window_size}, packet size {packet_size}",2) + var r = bytes(64) + r .. SSH_MSG.CHANNEL_OPEN_CONFIRMATION + r.add(self.channel_nr,-4) + r.add(self.channel_nr,-4) + r.add(window_size,-4) + r.add(SESSION.MAX_PACKET_SIZE,-4) + # print(r) + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + + def handle_channel_request() + var buf = self.bin_packet.payload + var next_index = 1 + var channel = buf.geti(next_index,4) + next_index += 4 + var next_length = SSH_MSG.get_item_length(buf[next_index..]) + var req_type_type = SSH_MSG.get_string(buf, next_index, next_length) + next_index += next_length + 4 + var want_reply = buf[next_index] + next_index += 1 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + var term = SSH_MSG.get_string(buf, next_index, next_length) + next_index += next_length + 4 + var width_c = buf.geti(next_index,-4) + next_index += 4 + var height_c = buf.geti(next_index,-4) + next_index += 4 + var width_p = buf.geti(next_index,-4) + next_index += 4 + var height_p = buf.geti(next_index,-4) + next_index += 4 + next_length = SSH_MSG.get_item_length(buf[next_index..]) + var terminal_modes = SSH_MSG.get_string(buf, next_index, next_length) + log(f"{channel},{req_type_type},{want_reply},{term,width_c},{height_c},{width_p},{height_p}",3) + if req_type_type == "shell" + self.type = TERMINAL() + elif req_type_type == "subsystem" && term == "sftp" + self.type = SFTP() + end + var r = bytes(64) + if want_reply + r .. SSH_MSG.CHANNEL_SUCCESS # TODO: may really check + else + r .. SSH_MSG.IGNORE + end + r.add(self.channel_nr,-4) + # print(r) + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + + def handle_channel_data() + var buf = self.bin_packet.payload + var next_index = 1 + var channel = buf.geti(next_index,-4) + next_index += 4 + var next_length = SSH_MSG.get_item_length(buf[next_index..]) + var data = SSH_MSG.get_bytes(buf, next_index, next_length) + log(f"SSH: ch {channel} data {next_length} {data}",3) + var t_r = self.type.process(data) + if t_r == "" + # self.seq_nr_rx -= 1 # pending write job or something else + var r = bytes() + r .. SSH_MSG.IGNORE + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + var r = bytes() + r .. SSH_MSG.CHANNEL_DATA + r.add(self.channel_nr,-4) + SSH_MSG.add_string(r,t_r) + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + + def close_channel() + var r = bytes(16) + r .. SSH_MSG.CHANNEL_CLOSE + r.add(self.channel_nr,-4) + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + + def process(data) + var r = bytes() + if self.bin_packet + self.bin_packet.append(data) + else + self.bin_packet = BIN_PACKET(data, self ,true) + end + if self.bin_packet.complete == true + if self.bin_packet.payload[0] == SSH_MSG.SERVICE_REQUEST + return self.handle_service_request() + elif self.bin_packet.payload[0] == SSH_MSG.USERAUTH_REQUEST + log("USERAUTH_REQUEST",3) + return self.handle_userauth_request() + elif self.bin_packet.payload[0] == SSH_MSG.CHANNEL_OPEN + log("CHANNEL_OPEN__REQUEST",3) + return self.handle_channel_open() + elif self.bin_packet.payload[0] == SSH_MSG.CHANNEL_REQUEST + log("CHANNEL_REQUEST",3) + return self.handle_channel_request() + elif self.bin_packet.payload[0] == SSH_MSG.CHANNEL_DATA + log("CHANNEL_DATA",3) + return self.handle_channel_data() + elif self.bin_packet.payload[0] == SSH_MSG.CHANNEL_EOF + log("CHANNEL_EOF",3) + return self.close_channel() + elif self.bin_packet.payload[0] == SSH_MSG.CHANNEL_CLOSE + log("CHANNEL_CLOSE",3) + return self.close_channel() + elif self.bin_packet.payload[0] == SSH_MSG.DISCONNECT + log("SSH: client did disconnect",1) + return "" + else + log(f"SSH: unhandled session message type: {self.bin_packet.payload[0]}", 2) + end + else + self.seq_nr_rx -= 1 # TODO: check + return "" + end + r .. SSH_MSG.IGNORE + var enc_r = self.bin_packet.create(r ,true) + return enc_r + end + + def generate_keys(K,H,third,id) + import crypto + var sha256 = crypto.SHA256() + sha256.update(SSH_MSG.make_mpint(K)) + sha256.update(H) + if classof(third) != bytes + sha256.update(bytes().fromstring(third)) + else + sha256.update(third) + end + if id != nil + sha256.update(id) + end + return sha256.out() + end + + def prepare(K,H) + self.K = K + self.H = H + self.ID = H.copy() + self.KEY_C_S_main = self.generate_keys(K,H,"C",H) + self.KEY_C_S_header = self.generate_keys(K,H,self.KEY_C_S_main) + self.KEY_S_C_main = self.generate_keys(K,H,"D",H) + self.KEY_S_C_header = self.generate_keys(K,H,self.KEY_S_C_main) + log("SSH: session keys created",3) + # print(self.KEY_C_S_main, self.KEY_C_S_header, self.KEY_S_C_main, self.KEY_S_C_header) + self.up = true + if self.strict_mode == true + self.seq_nr_rx = -1 # reset to handle Terrapin attack + self.seq_nr_tx = -1 + end + end +end + +class SSH : Driver + + var connection, server, client + var handshake, session, loop + static port = 22 + + def init() + self.server = tcpserver(self.port) # connection for control data + self.connection = false + tasmota.add_driver(self) + log(f"SSH: init server on port {self.port}",1) + end + + def every_50ms() + if self.connection == true + self.loop() + elif self.server.hasclient() + self.client = self.server.acceptasync() + self.session = SESSION() + self.handshake = HANDSHAKE(self.session) + self.loop = /->self.run_loop() + self.connection = true + self.pubClientInfo() + else + self.handshake = nil + self.connection = false + end + end + + def every_second() + if self.client && self.connection != false + if self.client.connected() == false + self.pubClientInfo() + self.connection = false + self.session = nil + self.client = nil + end + end + end + + def pubClientInfo() + import mqtt + var payload = self.client.info().tostring() + mqtt.publish("SSH",format("{'server':%s}", payload)) + end + + def run_loop() + if self.connection == true + self.handleConnection() + end + end + + def send(packet) + if self.client.listening() == false + log("SSH: client not listening",3) + self.loop = /->self.send(packet) + return # back to Tasmota + end + var written = self.client.write(packet) + while written < size(packet) + log(f"SSH: written only {written} of {size(packet)}",1) + self.loop = /->self.send(packet[written..]) + return # back to Tasmota + end + self.session.seq_nr_tx += 1 + self.loop = /->self.run_loop() + end + + def sendResponse(resp) + var session = self.session + var bin = session.bin_packet + session.bin_packet = nil + self.send(resp) + if size(session.send_queue) != 0 + self.send(session.send_queue.pop()()) + end + log(f"SSH: {self.session.seq_nr_tx} >>> {resp} _ {size(resp)} bytes",3) + end + + def handleConnection() # main loop for incoming commands + var response + var d + if self.session.overrun_buf + d = self.session.overrun_buf.copy() + self.session.overrun_buf = nil + log(f"SSH: got overrun packet: {size(d)}",3) + else + d = self.client.readbytes() + end + if size(d) == 0 + return + end + self.session.seq_nr_rx += 1 + log(f"SSH: {self.session.seq_nr_rx} <<< {d} _ {size(d)} bytes",3) + if self.session.up == true + response = self.session.process(d) + if response != "" + self.sendResponse(response) + end + elif self.handshake + response = self.handshake.process(d) + if size(response) != 0 + self.sendResponse(response) + if size(response) > 5 && response[5] == SSH_MSG.NEWKEYS + self.handshake = nil + end + end + end + end + + def key_save() + if self.session + if self.session.client_pub_key + import persist + if persist.known_hosts == nil + persist.known_hosts = [] + end + for key:persist.known_hosts + if key == self.session.client_pub_key + tasmota.resp_cmnd_str("SSH: key already known") + return + end + end + persist.known_hosts.push(self.session.client_pub_key) + persist.save(true) + tasmota.resp_cmnd_str("SSH: key saved") + end + end + end +end + +var ssh = SSH() + +tasmota.add_cmd("ssh_key_save", /->ssh.key_save())