╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │38 │ │39 pub const server_version = "SSH-2.0-haxy_0.0"; │ │40 pub const host_key_file_name = "ssh_host_ed25519_key"; │ │41 +const max_host_key_file_len = 4096; │ │42 │ │43 const max_packet_len: u32 = 35000; // RFC 4253 §6.1 — minimum implementations must support│ │44 pub const packet_buffer_size = 4 + max_packet_len + Poly1305.mac_length; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │124 defer allocator.free(path); │ │125 │ │126 const cwd = std.Io.Dir.cwd(); │ │126 - if (cwd.openFile(io, path, .{ .mode = .read_only })) |file| { │ │127 - defer file.close(io); │ │128 - var buf: [Ed25519.SecretKey.encoded_length]u8 = undefined;│ │129 - var read_storage: [128]u8 = undefined; │ │130 - var file_reader = file.reader(io, &read_storage); │ │131 - try file_reader.interface.readSliceAll(&buf); │ │132 - const sk = try Ed25519.SecretKey.fromBytes(buf); │ │133 - const keypair = try Ed25519.KeyPair.fromSecretKey(sk); │ │134 - return .{ .keypair = keypair }; │ │127 + var text_buf: [max_host_key_file_len]u8 = undefined; │ │128 + defer std.crypto.secureZero(u8, &text_buf); │ │129 + if (cwd.readFile(io, path, &text_buf)) |text| { │ │130 + return parseOpenssh(text); │ │131 } else |err| switch (err) { │ │132 error.FileNotFound => {}, │ │133 else => return err, │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │134 } │ │135 │ │140 - const keypair = Ed25519.KeyPair.generate(io); │ │136 + const host_key: HostKey = .{ .keypair = Ed25519.KeyPair.generate(io) }; │ │137 + const text = try host_key.formatOpenssh(allocator); │ │138 + defer allocator.free(text); │ │139 + defer std.crypto.secureZero(u8, text); │ │140 // owner-only from the start — never a window where the key is readable │ │141 const permissions: std.Io.File.Permissions = if (builtin.os.tag == .windows) .default_file else @enumFromInt(0o600);│ │142 // linked into place only once complete, and never over an existing key │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │143 var atomic_file = try cwd.createFileAtomic(io, path, .{ .permissions = permissions }); │ │144 defer atomic_file.deinit(io); │ │146 - try atomic_file.file.writeStreamingAll(io, &keypair.secret_key.bytes); │ │145 + try atomic_file.file.writeStreamingAll(io, text); │ │146 try atomic_file.link(io); │ │147 + return host_key; │ │148 + } │ │149 + │ │150 + const openssh_magic = "openssh-key-v1\x00"; │ │151 + const openssh_begin = "-----BEGIN OPENSSH PRIVATE KEY-----"; │ │152 + const openssh_end = "-----END OPENSSH PRIVATE KEY-----"; │ │153 + │ │154 + /// the unencrypted openssh-key-v1 file that `ssh-keygen` writes and reads │ │155 + fn formatOpenssh(self: HostKey, allocator: std.mem.Allocator) ![]u8 { │ │156 + var public_blob: std.ArrayList(u8) = .empty; │ │157 + defer public_blob.deinit(allocator); │ │158 + try self.appendPublicBlob(&public_blob, allocator); │ │159 + │ │160 + // two equal check ints show a reader that the section decrypted │ │161 + // properly, a formality without a cipher │ │162 + var private: std.ArrayList(u8) = .empty; │ │163 + defer private.deinit(allocator); │ │164 + defer std.crypto.secureZero(u8, private.items); │ │165 + // every buffer that holds the secret is reserved up front, so a │ │166 + // reallocation can't leave a copy behind, and wiped before it's freed │ │167 + const private_capacity = 256; │ │168 + try private.ensureTotalCapacityPrecise(allocator, private_capacity); │ │169 + try writeU32(&private, allocator, 0); │ │170 + try writeU32(&private, allocator, 0); │ │171 + try writeStringField(&private, allocator, "ssh-ed25519"); │ │172 + try writeStringField(&private, allocator, &self.keypair.public_key.bytes); │ │173 + try writeStringField(&private, allocator, &self.keypair.secret_key.bytes); │ │174 + try writeStringField(&private, allocator, "haxy"); // comment │ │175 + // padded with 1, 2, 3 and so on to the cipher's block size, 8 for none │ │176 + var pad: u8 = 1; │ │177 + while (private.items.len % 8 != 0) : (pad += 1) try private.append(allocator, pad); │ │178 + std.debug.assert(private.items.len <= private_capacity); │ │179 + │ │180 + var blob: std.ArrayList(u8) = .empty; │ │181 + defer blob.deinit(allocator); │ │182 + defer std.crypto.secureZero(u8, blob.items); │ │183 + const blob_capacity = 128 + public_blob.items.len + private.items.len; │ │184 + try blob.ensureTotalCapacityPrecise(allocator, blob_capacity); │ │185 + try blob.appendSlice(allocator, openssh_magic); │ │186 + try writeStringField(&blob, allocator, "none"); // cipher │ │187 + try writeStringField(&blob, allocator, "none"); // kdf │ │188 + try writeStringField(&blob, allocator, ""); // kdf options │ │189 + try writeU32(&blob, allocator, 1); // number of keys │ │190 + try writeStringField(&blob, allocator, public_blob.items); │ │191 + try writeStringField(&blob, allocator, private.items); │ │192 + std.debug.assert(blob.items.len <= blob_capacity); │ │193 + │ │194 + const encoder = std.base64.standard.Encoder; │ │195 + const encoded = try allocator.alloc(u8, encoder.calcSize(blob.items.len)); │ │196 + defer allocator.free(encoded); │ │197 + defer std.crypto.secureZero(u8, encoded); │ │198 + _ = encoder.encode(encoded, blob.items); │ │199 + │ │200 + // sized exactly, so toOwnedSlice hands over the allocation as it is │ │201 + const line_len = 70; │ │202 + const line_count = std.math.divCeil(usize, encoded.len, line_len) catch unreachable; │ │203 + var text: std.ArrayList(u8) = .empty; │ │204 + errdefer text.deinit(allocator); │ │205 + errdefer std.crypto.secureZero(u8, text.items); │ │206 + try text.ensureTotalCapacityPrecise(allocator, openssh_begin.len + 1 + encoded.len + line_count + openssh_end.len + 1); │ │207 + try text.appendSlice(allocator, openssh_begin ++ "\n"); │ │208 + var lines = std.mem.window(u8, encoded, line_len, line_len); │ │209 + while (lines.next()) |line| { │ │210 + try text.appendSlice(allocator, line); │ │211 + try text.append(allocator, '\n'); │ │212 + } │ │213 + try text.appendSlice(allocator, openssh_end ++ "\n"); │ │214 + return try text.toOwnedSlice(allocator); │ │215 + } │ │216 + │ │217 + fn parseOpenssh(text: []const u8) !HostKey { │ │218 + const trimmed = std.mem.trim(u8, text, " \t\r\n"); │ │219 + if (!std.mem.startsWith(u8, trimmed, openssh_begin) or !std.mem.endsWith(u8, trimmed, openssh_end)) return error.InvalidHostKey;│ │220 + const encoded = trimmed[openssh_begin.len .. trimmed.len - openssh_end.len]; │ │221 + │ │222 + var blob_buf: [max_host_key_file_len]u8 = undefined; │ │223 + defer std.crypto.secureZero(u8, &blob_buf); │ │224 + const decoder = std.base64.standard.decoderWithIgnore(" \t\r\n"); │ │225 + const blob_len = decoder.decode(&blob_buf, encoded) catch return error.InvalidHostKey; │ │226 + return parseOpensshBlob(blob_buf[0..blob_len]) catch |err| switch (err) { │ │227 + error.EncryptedHostKey, error.UnsupportedHostKeyType => |e| e, │ │228 + else => error.InvalidHostKey, │ │229 + }; │ │230 + } │ │231 + │ │232 + fn parseOpensshBlob(blob: []const u8) !HostKey { │ │233 + var r = std.Io.Reader.fixed(blob); │ │234 + if (!std.mem.eql(u8, try r.take(openssh_magic.len), openssh_magic)) return error.InvalidHostKey; │ │235 + const cipher_name = try takeString(&r, 64); │ │236 + const kdf_name = try takeString(&r, 64); │ │237 + _ = try takeString(&r, 1024); // kdf options │ │238 + if (!std.mem.eql(u8, cipher_name, "none") or !std.mem.eql(u8, kdf_name, "none")) return error.EncryptedHostKey; │ │239 + if (try r.takeInt(u32, .big) != 1) return error.InvalidHostKey; │ │240 + var outer = std.Io.Reader.fixed(try takeString(&r, 1024)); │ │241 + const private_section = try takeString(&r, 2048); │ │242 + if (r.bufferedLen() != 0) return error.InvalidHostKey; │ │243 + │ │244 + var private = std.Io.Reader.fixed(private_section); │ │245 + if (try private.takeInt(u32, .big) != try private.takeInt(u32, .big)) return error.InvalidHostKey; │ │246 + if (!std.mem.eql(u8, try takeString(&private, 64), "ssh-ed25519")) return error.UnsupportedHostKeyType; │ │247 + const public = try takeString(&private, 64); │ │248 + const secret = try takeString(&private, 128); │ │249 + if (secret.len != Ed25519.SecretKey.encoded_length) return error.InvalidHostKey; │ │250 + _ = try takeString(&private, 1024); // comment │ │251 + │ │252 + // padded with 1, 2, 3 and so on to the cipher's block size, 8 for none │ │253 + if (private_section.len % 8 != 0) return error.InvalidHostKey; │ │254 + for (private.buffered(), 1..) |byte, i| { │ │255 + if (byte != i) return error.InvalidHostKey; │ │256 + } │ │257 + │ │258 + // the outer public key must be the same key │ │259 + if (!std.mem.eql(u8, try takeString(&outer, 64), "ssh-ed25519") or │ │260 + !std.mem.eql(u8, try takeString(&outer, 64), public) or │ │261 + outer.bufferedLen() != 0) return error.InvalidHostKey; │ │262 + │ │263 + // the secret is seed || public. rebuild it from the seed so a file │ │264 + // whose halves disagree is rejected in every build mode. │ │265 + const keypair = try Ed25519.KeyPair.generateDeterministic(secret[0..Ed25519.KeyPair.seed_length].*); │ │266 + if (!std.mem.eql(u8, secret, &keypair.secret_key.bytes) or │ │267 + !std.mem.eql(u8, public, &keypair.public_key.bytes)) return error.InvalidHostKey; │ │268 return .{ .keypair = keypair }; │ │269 } │ │270 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯