361 lines
11 KiB
Lua
361 lines
11 KiB
Lua
local program_name = "BlueSSH"
|
|
local program_version = "1.0"
|
|
local program_string = program_name .. "_" .. program_version
|
|
|
|
local component = require("component")
|
|
local internet = component.internet
|
|
|
|
local function lazy_require(name)
|
|
local this = {}
|
|
local module
|
|
setmetatable(this, {
|
|
__index = function(_, key)
|
|
module = module or require(name)
|
|
return module[key]
|
|
end,
|
|
__call = function(_, ...)
|
|
module = module or require(name)
|
|
return module(...)
|
|
end
|
|
})
|
|
return this
|
|
end
|
|
|
|
local x25519 = lazy_require("x25519")
|
|
local sha2 = lazy_require("sha2")
|
|
|
|
local function parse_ssh_version(str)
|
|
local proto, software, comments = str:match("^SSH%-(%d+%.%d+)%-(%S+)%s?(.*)\r\n$")
|
|
if not proto then return nil, "invalid SSH version string" end
|
|
return proto, software, comments or nil
|
|
end
|
|
|
|
local function encode_ssh_version(proto, software, comments)
|
|
return "SSH-" .. proto .. "-" .. software .. (comments and (" " .. comments) or "") .. "\r\n"
|
|
end
|
|
|
|
local random
|
|
|
|
if not component.isAvailable("data") or component.data.random == nil then
|
|
terminal.write("ssh: WARNING: THIS PROGRAM IS NOT CRYPTOGRAPHICALLY SAFE WITHOUT A TIER 2 DATA CARD, DO NOT CONNECT TO A REAL SERVER WITH THIS\n")
|
|
random = function(n)
|
|
local parts = {}
|
|
for i = 1, n do parts[i] = math.random(0, 255) end
|
|
return table.concat(parts)
|
|
end
|
|
else
|
|
random = component.data.random
|
|
end
|
|
|
|
local SSH_MSG_DISCONNECT = 1
|
|
local SSH_MSG_IGNORE = 2
|
|
local SSH_MSG_UNIMPLEMENTED = 3
|
|
local SSH_MSG_DEBUG = 4
|
|
local SSH_MSG_SERVICE_REQUEST = 5
|
|
local SSH_MSG_SERVICE_ACCEPT = 6
|
|
local SSH_MSG_KEXINIT = 20
|
|
local SSH_MSG_NEWKEYS = 21
|
|
|
|
-- RFC 4253 7.1: first entry in client list that also appears in server_list wins
|
|
local function negotiate_one(client_list, server_list)
|
|
local server_set = {}
|
|
for _, name in ipairs(server_list) do server_set[name] = true end
|
|
for _, name in ipairs(client_list) do
|
|
if server_set[name] then return name end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function negotiate(client_algs, server_kex)
|
|
local chosen = {}
|
|
for field, _ in pairs(client_algs) do
|
|
chosen[field] = negotiate_one(client_algs[field] or {}, server_kex[field] or {})
|
|
local is_language_field = field:find("^languages_")
|
|
if not chosen[field] and not is_language_field then
|
|
return nil, "no agreement on " .. field
|
|
end
|
|
end
|
|
return chosen
|
|
end
|
|
|
|
local function xor_with_byte(s, b)
|
|
local out = {}
|
|
for i = 1, #s do
|
|
out[i] = string.char(string.byte(s, i) ~ b)
|
|
end
|
|
return table.concat(out)
|
|
end
|
|
|
|
local function hmac(hash_fn, block_size, key, message)
|
|
if #key > block_size then key = hash_fn(key) end
|
|
if #key < block_size then key = key .. string.rep('\0', block_size - #key) end
|
|
return hash_fn(xor_with_byte(key, 0x5c) .. hash_fn(xor_with_byte(key, 0x36) .. message))
|
|
end
|
|
|
|
local mac_algorithms_priority = {
|
|
"hmac-sha2-256",
|
|
"hmac-sha2-512",
|
|
--"hmac-sha1"
|
|
}
|
|
|
|
local mac_algorithms = {
|
|
["hmac-sha2-256"] = function(key, message) return hmac(sha2.sha256, 64, key, message) end,
|
|
["hmac-sha2-512"] = function(key, message) return hmac(sha2.sha512, 128, key, message) end,
|
|
--["hmac-sha1"] = function(key, message) return hmac(sha1, 64, key, message) end,
|
|
}
|
|
|
|
local encryption_algorithms_priority = {
|
|
"chacha20-poly1305@openssh.com",
|
|
-- aes128-gcm@openssh.com
|
|
-- aes256-gcm@openssh.com
|
|
-- aes128-ctr
|
|
-- aes192-ctr
|
|
-- aes256-ctr
|
|
}
|
|
local encryption_algorithms = {
|
|
["chacha20-poly1305@openssh.com"] = nil
|
|
}
|
|
|
|
local server_host_key_algorithms_priority = {
|
|
--[[
|
|
"rsa-sha2-512",
|
|
"rsa-sha2-256",
|
|
"ecdsa-sha2-nistp256",
|
|
"ssh-ed25519"
|
|
]]
|
|
}
|
|
|
|
local server_host_key_algorithms = {
|
|
|
|
}
|
|
|
|
local function socket()
|
|
local this = {}
|
|
local _sock
|
|
local _host
|
|
local _port
|
|
|
|
function this.connect(host, port)
|
|
_host = host; _port = port
|
|
_sock = internet.connect(host, port)
|
|
|
|
local ok = false
|
|
for _ = 1, 50 do
|
|
local err
|
|
ok, err = _sock.finishConnect()
|
|
if ok == nil then
|
|
return nil, err
|
|
end
|
|
if ok == true then
|
|
return true
|
|
end
|
|
coroutine.yield()
|
|
end
|
|
return nil, "timed out"
|
|
end
|
|
|
|
local _read_buf = ""
|
|
|
|
function this.read(n)
|
|
while #_read_buf < n do
|
|
local chunk, err = _sock.read(n - #_read_buf)
|
|
if chunk == nil then return nil, err end
|
|
if chunk == "" then
|
|
coroutine.yield()
|
|
else
|
|
_read_buf = _read_buf .. chunk
|
|
end
|
|
end
|
|
local to_return = _read_buf:sub(1, n);
|
|
_read_buf = _read_buf:sub(n + 1, -1)
|
|
return to_return
|
|
end
|
|
|
|
function this.read_until_crlf(n)
|
|
local chunk, err = _sock.read(n)
|
|
if not chunk then return nil, err end
|
|
_read_buf = _read_buf .. chunk
|
|
|
|
local crlf_pos = _read_buf:find("\r\n", 1, true)
|
|
if crlf_pos == nil then return nil end
|
|
|
|
local to_return = _read_buf:sub(1, crlf_pos + 1)
|
|
_read_buf = _read_buf:sub(crlf_pos + 2, -1)
|
|
|
|
return to_return
|
|
end
|
|
|
|
function this.read_uint8()
|
|
local x, err = this.read(1)
|
|
if x == nil then return nil, err end
|
|
return string.unpack(">I1", x)
|
|
end
|
|
|
|
function this.read_uint32()
|
|
local x, err = this.read(4)
|
|
if x == nil then return nil, err end
|
|
return string.unpack(">I4", x)
|
|
end
|
|
|
|
function this.read_packet(mac_length)
|
|
mac_length = mac_length or 0
|
|
|
|
local packet_length, err = this.read_uint32()
|
|
if not packet_length then return nil, err end
|
|
|
|
local padding_length, err = this.read_uint8()
|
|
if not padding_length then return nil, err end
|
|
|
|
local payload, err = this.read(packet_length - padding_length - 1)
|
|
if not payload then return nil, err end
|
|
|
|
local random_padding, err = this.read(padding_length)
|
|
if not random_padding then return nil, err end
|
|
|
|
local mac, err = this.read(mac_length)
|
|
if not mac then return nil, err end
|
|
|
|
return payload
|
|
end
|
|
|
|
function this.write_packet(payload, block_size)
|
|
block_size = block_size or 8
|
|
local min_padding = 4
|
|
|
|
local padding_length = block_size - ((5 + #payload) % block_size)
|
|
if padding_length < min_padding then
|
|
padding_length = padding_length + block_size
|
|
end
|
|
|
|
local padding = random(padding_length)
|
|
|
|
local packet_length = 1 + #payload + padding_length
|
|
local packet = string.pack(">I4", packet_length)
|
|
.. string.char(padding_length)
|
|
.. payload
|
|
.. padding
|
|
|
|
return this.write(packet)
|
|
end
|
|
|
|
local function parse_namelist(payload, pos)
|
|
local len = string.unpack(">I4", payload, pos)
|
|
local s = payload:sub(pos + 4, pos + 4 + len - 1)
|
|
local list = {}
|
|
if #s > 0 then
|
|
for name in s:gmatch("[^,]+") do
|
|
list[#list + 1] = name
|
|
end
|
|
end
|
|
return list, pos + 4 + len
|
|
end
|
|
|
|
local function encode_namelist(list)
|
|
return string.pack(">s4", table.concat(list or {}, ","))
|
|
end
|
|
|
|
function this.parse_kexinit(payload)
|
|
local packet_type = payload:byte(1)
|
|
if packet_type ~= SSH_MSG_KEXINIT then return nil, "unknown server side error" end
|
|
|
|
local pos = 2
|
|
local kex = {}
|
|
kex.cookie = payload:sub(pos, pos + 15)
|
|
pos = pos + 16
|
|
|
|
kex.kex_algorithms, pos = parse_namelist(payload, pos)
|
|
kex.server_host_key_algorithms, pos = parse_namelist(payload, pos)
|
|
kex.encryption_algorithms_client_to_server, pos = parse_namelist(payload, pos)
|
|
kex.encryption_algorithms_server_to_client, pos = parse_namelist(payload, pos)
|
|
kex.mac_algorithms_client_to_server, pos = parse_namelist(payload, pos)
|
|
kex.mac_algorithms_server_to_client, pos = parse_namelist(payload, pos)
|
|
kex.compression_algorithms_client_to_server, pos = parse_namelist(payload, pos)
|
|
kex.compression_algorithms_server_to_client, pos = parse_namelist(payload, pos)
|
|
kex.languages_client_to_server, pos = parse_namelist(payload, pos)
|
|
kex.languages_server_to_client, pos = parse_namelist(payload, pos)
|
|
|
|
kex.first_kex_packet_follows = payload:byte(pos) ~= 0
|
|
pos = pos + 1
|
|
|
|
-- next 4 bytes are reserved (uint32 == 0)
|
|
return kex
|
|
end
|
|
|
|
function this.build_kexinit(algs)
|
|
return string.char(SSH_MSG_KEXINIT) ..
|
|
random(16) ..
|
|
encode_namelist(algs.kex_algorithms) ..
|
|
encode_namelist(algs.server_host_key_algorithms) ..
|
|
encode_namelist(algs.encryption_algorithms_client_to_server) ..
|
|
encode_namelist(algs.encryption_algorithms_server_to_client) ..
|
|
encode_namelist(algs.mac_algorithms_client_to_server) ..
|
|
encode_namelist(algs.mac_algorithms_server_to_client) ..
|
|
encode_namelist(algs.compression_algorithms_client_to_server) ..
|
|
encode_namelist(algs.compression_algorithms_server_to_client) ..
|
|
encode_namelist(algs.languages_client_to_server) ..
|
|
encode_namelist(algs.languages_server_to_client) ..
|
|
'\0' .. -- first_kex_packet_follows
|
|
'\0\0\0\0' -- reserved
|
|
end
|
|
|
|
function this.write(x)
|
|
local written = 0
|
|
while written < #x do
|
|
local bytes, err = _sock.write(x:sub(written + 1, -1))
|
|
if not bytes then return nil, err end
|
|
written = written + bytes
|
|
end
|
|
return written
|
|
end
|
|
|
|
return this
|
|
end
|
|
|
|
local sock = socket()
|
|
sock.connect("localhost", 22)
|
|
-- RFC 4253: The maximum length of the string is 255 characters
|
|
local server_version_raw = sock.read_until_crlf(255)
|
|
local server_protoversion, server_softwareversion, server_comments = parse_ssh_version(server_version_raw)
|
|
if server_protoversion ~= "2.0" then
|
|
return
|
|
end
|
|
|
|
local client_version_line = encode_ssh_version("2.0", program_string)
|
|
sock.write(client_version_line)
|
|
|
|
local V_C = client_version_line:gsub("\r\n$", "")
|
|
local V_S = server_version_raw:gsub("\r\n$", "")
|
|
|
|
local I_S, kerr = sock.read_packet()
|
|
if not I_S then
|
|
print("failed to read server KEXINIT: " .. tostring(kerr))
|
|
return
|
|
end
|
|
local server_kex, perr = sock.parse_kexinit(I_S)
|
|
if not server_kex then
|
|
print("failed to parse server KEXINIT: " .. tostring(perr))
|
|
return
|
|
end
|
|
|
|
local my_algs = {
|
|
kex_algorithms = { "curve25519-sha256" },
|
|
server_host_key_algorithms = server_host_key_algorithms,
|
|
encryption_algorithms_client_to_server = encryption_algorithms_priority,
|
|
encryption_algorithms_server_to_client = encryption_algorithms_priority,
|
|
mac_algorithms_client_to_server = mac_algorithms_priority,
|
|
mac_algorithms_server_to_client = mac_algorithms_priority,
|
|
compression_algorithms_client_to_server = { "none" },
|
|
compression_algorithms_server_to_client = { "none" },
|
|
languages_client_to_server = {},
|
|
languages_server_to_client = {},
|
|
}
|
|
|
|
local I_C = sock.build_kexinit(my_algs)
|
|
sock.write_packet(I_C)
|
|
|
|
local chosen, nerr = negotiate(my_algs, server_kex)
|
|
if not chosen then
|
|
print("algorithm negotiation failed: " .. tostring(nerr))
|
|
return
|
|
end
|