╭ ssh.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const hx = @import("haxy"); 3 const ssh = hx.serve_ssh; 4 const proto = hx.serve_ssh_protocol; 5 const Ed25519 = std.crypto.sign.Ed25519; 6 const X25519 = std.crypto.dh.X25519; 7 const Sha256 = std.crypto.hash.sha2.Sha256; 8 9 test "fingerprint format matches openssh layout" { 10 const seed = [_]u8{0x42} ** Ed25519.KeyPair.seed_length; 11 const keypair = try Ed25519.KeyPair.generateDeterministic(seed); 12 13 // SSH wire-format pubkey blob: string "ssh-ed25519" || string raw_pubkey. 14 const algo = "ssh-ed25519"; 15 var blob: [4 + algo.len + 4 + Ed25519.PublicKey.encoded_length]u8 = undefined; 16 std.mem.writeInt(u32, blob[0..4], algo.len, .big); 17 @memcpy(blob[4 .. 4 + algo.len], algo); 18 std.mem.writeInt(u32, blob[4 + algo.len ..][0..4], Ed25519.PublicKey.encoded_length, .big); 19 @memcpy(blob[4 + algo.len + 4 ..], &keypair.public_key.bytes); 20 21 const formatted = proto.formatFingerprint(&blob); 22 23 try std.testing.expectEqual(50, formatted.len); 24 try std.testing.expectEqualStrings("SHA256:", formatted[0..7]); 25 26 // the body must decode to the SHA256 of the input blob. 27 var decoded: [Sha256.digest_length]u8 = undefined; 28 try std.base64.standard_no_pad.Decoder.decode(&decoded, formatted[7..]); 29 var expected: [Sha256.digest_length]u8 = undefined; 30 Sha256.hash(&blob, &expected, .{}); 31 try std.testing.expectEqualSlices(u8, &expected, &decoded); 32 } 33 34 test "channel data parser rejects malformed payloads" { 35 const allocator = std.testing.allocator; 36 37 var packet = std.ArrayList(u8).empty; 38 defer packet.deinit(allocator); 39 try packet.append(allocator, proto.SSH_MSG_CHANNEL_DATA); 40 try proto.writeU32(&packet, allocator, 0); 41 try proto.writeU32(&packet, allocator, 3); 42 try packet.appendSlice(allocator, "abc"); 43 44 try std.testing.expectEqualStrings("abc", try proto.parseChannelData(packet.items, false, 0)); 45 46 // wrong recipient channel 47 packet.items[4] = 1; 48 try std.testing.expectError(error.UnknownChannel, proto.parseChannelData(packet.items, false, 0)); 49 50 // declared length larger than the packet carries 51 var too_long = std.ArrayList(u8).empty; 52 defer too_long.deinit(allocator); 53 try too_long.append(allocator, proto.SSH_MSG_CHANNEL_DATA); 54 try proto.writeU32(&too_long, allocator, 0); 55 try proto.writeU32(&too_long, allocator, 4); 56 try too_long.appendSlice(allocator, "abc"); 57 try std.testing.expectError(error.InvalidChannelData, proto.parseChannelData(too_long.items, false, 0)); 58 59 // declared length smaller than the packet carries (trailing junk) 60 var trailing = std.ArrayList(u8).empty; 61 defer trailing.deinit(allocator); 62 try trailing.append(allocator, proto.SSH_MSG_CHANNEL_DATA); 63 try proto.writeU32(&trailing, allocator, 0); 64 try proto.writeU32(&trailing, allocator, 3); 65 try trailing.appendSlice(allocator, "abcd"); 66 try std.testing.expectError(error.InvalidChannelData, proto.parseChannelData(trailing.items, false, 0)); 67 } 68 69 test "extended channel data parser accepts only stderr type" { 70 const allocator = std.testing.allocator; 71 72 var packet = std.ArrayList(u8).empty; 73 defer packet.deinit(allocator); 74 try packet.append(allocator, proto.SSH_MSG_CHANNEL_EXTENDED_DATA); 75 try proto.writeU32(&packet, allocator, 0); 76 try proto.writeU32(&packet, allocator, proto.SSH_EXTENDED_DATA_STDERR); 77 try proto.writeU32(&packet, allocator, 3); 78 try packet.appendSlice(allocator, "err"); 79 80 try std.testing.expectEqualStrings("err", try proto.parseChannelData(packet.items, true, 0)); 81 82 // any other data_type code is rejected 83 packet.items[8] = 2; 84 try std.testing.expectError(error.UnsupportedExtendedData, proto.parseChannelData(packet.items, true, 0)); 85 } 86 87 test "SSH-2 negotiation: walk through every step (banner -> KEX -> auth -> channel -> exec)" { 88 const io = std.testing.io; 89 const allocator = std.testing.allocator; 90 91 const host_key = try newHostKey(); 92 const user_kp = try newUserKeypair(); 93 94 // c2s = bytes the client sends, server reads. s2c = the other direction. 95 var c2s_buf: [4096]u8 = undefined; 96 var c2s = Pipe.init(&c2s_buf); 97 var s2c_buf: [4096]u8 = undefined; 98 var s2c = Pipe.init(&s2c_buf); 99 100 var server_rd_buf: [4096]u8 = undefined; 101 var server_wr_buf: [4096]u8 = undefined; 102 var client_rd_buf: [4096]u8 = undefined; 103 var client_wr_buf: [4096]u8 = undefined; 104 var server_pipe_reader = PipeReader.init(io, &c2s, &server_rd_buf); 105 var server_pipe_writer = PipeWriter.init(io, &s2c, &server_wr_buf); 106 var client_pipe_reader = PipeReader.init(io, &s2c, &client_rd_buf); 107 var client_pipe_writer = PipeWriter.init(io, &c2s, &client_wr_buf); 108 const cr = &client_pipe_reader.interface; 109 const cw = &client_pipe_writer.interface; 110 111 var server: ServerTask = .{ 112 .io = io, 113 .allocator = allocator, 114 .host_key = &host_key, 115 .reader = &server_pipe_reader.interface, 116 .writer = &server_pipe_writer.interface, 117 .outgoing = &s2c, 118 }; 119 var server_future = try std.Io.concurrent(io, ServerTask.run, .{&server}); 120 defer server_future.await(io); 121 defer c2s.close(io); // unblocks the server even on early test failure 122 123 // STEP 1: VERSION EXCHANGE (RFC 4253 §4.2) 124 // Each side writes one "SSH-protoversion-softwareversion\r\n" line. 125 // The exact strings get hashed into the exchange hash later, so they 126 // bind the session's identity. 127 try cw.writeAll(client_version ++ "\r\n"); 128 try cw.flush(); 129 const v_s_line = (try cr.takeDelimiter('\n')) orelse return error.UnexpectedEof; 130 const v_s = if (v_s_line.len > 0 and v_s_line[v_s_line.len - 1] == '\r') v_s_line[0 .. v_s_line.len - 1] else v_s_line; 131 try std.testing.expect(std.mem.startsWith(u8, v_s, "SSH-2.0-haxy_")); 132 const v_c = client_version; 133 134 // STEP 2: ALGORITHM NEGOTIATION (RFC 4253 §7.1) 135 // Both sides send SSH_MSG_KEXINIT listing supported algorithms, in 136 // preference order, for: key exchange, host-key signature, c->s and 137 // s->c ciphers, c->s and s->c MACs (none with chacha20-poly1305 since 138 // it's an AEAD), c->s and s->c compression, plus languages. The first 139 // algorithm in each slot that the *client* prefers and the server also 140 // offers is selected. Both raw KEXINITs are hashed into the exchange 141 // hash so tampering is caught at signature verification time. 142 const client_kex_init = try proto.buildKexInit(io, allocator, &proto.our_kex_algos, &proto.our_host_key_algos, &proto.our_ciphers); 143 defer allocator.free(client_kex_init); 144 try proto.writePlainPacket(io, cw, client_kex_init); 145 146 const server_kex_init = try proto.readPlainPacket(allocator, cr); 147 defer allocator.free(server_kex_init); 148 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_KEXINIT), server_kex_init[0]); 149 150 // STEP 3: ECDH KEY EXCHANGE (RFC 8731) 151 // Client generates a fresh X25519 keypair, sends its public ephemeral 152 // q_c in SSH_MSG_KEX_ECDH_INIT. Server: 153 // 1. generates its own ephemeral 154 // 2. computes K = X25519(server_priv, q_c) — shared secret 155 // 3. computes H = SHA256(V_C || V_S || I_C || I_S || K_S || q_c || q_s || K) 156 // (each "||" element is length-prefixed; K is an mpint) 157 // 4. signs H with its long-term host key 158 // 5. replies KEX_ECDH_REPLY with K_S (host key blob), q_s, signature 159 // 160 // Client verifies the signature against K_S — MITM defense. 161 const client_eph = try newClientEphemeral(); 162 { 163 var pkt: std.ArrayList(u8) = .empty; 164 defer pkt.deinit(allocator); 165 try pkt.append(allocator, proto.SSH_MSG_KEX_ECDH_INIT); 166 try proto.writeStringField(&pkt, allocator, &client_eph.public_key); 167 try proto.writePlainPacket(io, cw, pkt.items); 168 } 169 const ecdh_reply = try proto.readPlainPacket(allocator, cr); 170 defer allocator.free(ecdh_reply); 171 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_KEX_ECDH_REPLY), ecdh_reply[0]); 172 173 var server_eph_pub: [X25519.public_length]u8 = undefined; 174 var k_s: []u8 = undefined; 175 var sig_blob: []u8 = undefined; 176 { 177 var r = std.Io.Reader.fixed(ecdh_reply[1..]); 178 k_s = try proto.takeStringField(allocator, &r, 4096); 179 errdefer allocator.free(k_s); 180 const q_s = try proto.takeStringField(allocator, &r, X25519.public_length); 181 defer allocator.free(q_s); 182 try std.testing.expectEqual(X25519.public_length, q_s.len); 183 @memcpy(&server_eph_pub, q_s); 184 sig_blob = try proto.takeStringField(allocator, &r, 1024); 185 } 186 defer allocator.free(k_s); 187 defer allocator.free(sig_blob); 188 189 // K_S must be the host's ed25519 pubkey we configured. 190 var expected_k_s: std.ArrayList(u8) = .empty; 191 defer expected_k_s.deinit(allocator); 192 try host_key.appendPublicBlob(&expected_k_s, allocator); 193 try std.testing.expectEqualSlices(u8, expected_k_s.items, k_s); 194 195 // K = X25519(client_priv, q_s) — same scalarmult on the other side. 196 const k = try X25519.scalarmult(client_eph.secret_key, server_eph_pub); 197 198 // Recompute H independently. If the server lied about any input the 199 // signature won't verify against the trusted host key. 200 const h = try proto.computeExchangeHash(allocator, v_c, v_s, client_kex_init, server_kex_init, k_s, &client_eph.public_key, &server_eph_pub, &k); 201 202 // Verify the host signature on H. 203 { 204 var r = std.Io.Reader.fixed(sig_blob); 205 const algo = try proto.takeStringField(allocator, &r, 64); 206 defer allocator.free(algo); 207 try std.testing.expectEqualStrings("ssh-ed25519", algo); 208 const raw_sig = try proto.takeStringField(allocator, &r, Ed25519.Signature.encoded_length); 209 defer allocator.free(raw_sig); 210 var sig_bytes: [Ed25519.Signature.encoded_length]u8 = undefined; 211 @memcpy(&sig_bytes, raw_sig); 212 const sig = Ed25519.Signature.fromBytes(sig_bytes); 213 try sig.verify(&h, host_key.keypair.public_key); 214 } 215 216 // STEP 4: NEWKEYS — switch to encrypted mode (RFC 4253 §7.3) 217 // Each side sends a one-byte SSH_MSG_NEWKEYS and starts encrypting 218 // from the next packet on. chacha20-poly1305@openssh.com needs 64 219 // bytes per direction (32 for the body cipher, 32 for the length 220 // cipher); only the 'C' (c->s) and 'D' (s->c) RFC 4253 §7.2 outputs 221 // are used since the AEAD covers MAC. 222 try proto.writePlainPacket(io, cw, &[_]u8{proto.SSH_MSG_NEWKEYS}); 223 { 224 const server_newkeys = try proto.readPlainPacket(allocator, cr); 225 defer allocator.free(server_newkeys); 226 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_NEWKEYS), server_newkeys[0]); 227 } 228 229 var keys: proto.SessionKeys = undefined; 230 try proto.deriveSessionKeys(allocator, &k, &h, &h, &keys); 231 // KEXINIT/KEX_ECDH_INIT/NEWKEYS were seqno 0/1/2; first encrypted is 3. 232 var cs_cipher = proto.Cipher.init(&keys.cs_enc, 3); 233 var sc_cipher = proto.Cipher.init(&keys.sc_enc, 3); 234 235 // STEP 5: SERVICE REQUEST (RFC 4253 §10) 236 // First encrypted message: client asks for the "ssh-userauth" service. 237 // Server replies SERVICE_ACCEPT. ("ssh-connection" comes after auth.) 238 { 239 var req: std.ArrayList(u8) = .empty; 240 defer req.deinit(allocator); 241 try req.append(allocator, proto.SSH_MSG_SERVICE_REQUEST); 242 try proto.writeStringField(&req, allocator, "ssh-userauth"); 243 try cs_cipher.writePacket(io, allocator, cw, req.items); 244 } 245 { 246 const accept = try sc_cipher.readPacket(allocator, cr); 247 defer allocator.free(accept); 248 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_SERVICE_ACCEPT), accept[0]); 249 } 250 251 // STEP 6: PUBLIC-KEY USERAUTH — PROBE (RFC 4252 §7) 252 // Two-trip dance. Round 1: USERAUTH_REQUEST with method=publickey and 253 // has_signature=false, carrying just the algo + public key blob. The 254 // server says USERAUTH_PK_OK if it would accept that key (haxy 255 // accepts any ed25519) or USERAUTH_FAILURE if not — so the client 256 // doesn't waste a signature on a rejected key. 257 var user_pubkey_blob: std.ArrayList(u8) = .empty; 258 defer user_pubkey_blob.deinit(allocator); 259 try proto.writeStringField(&user_pubkey_blob, allocator, "ssh-ed25519"); 260 try proto.writeStringField(&user_pubkey_blob, allocator, &user_kp.public_key.bytes); 261 262 { 263 var req: std.ArrayList(u8) = .empty; 264 defer req.deinit(allocator); 265 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 266 try proto.writeStringField(&req, allocator, "testuser"); 267 try proto.writeStringField(&req, allocator, "ssh-connection"); 268 try proto.writeStringField(&req, allocator, "publickey"); 269 try req.append(allocator, 0); // has_signature = false 270 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 271 try proto.writeStringField(&req, allocator, user_pubkey_blob.items); 272 try cs_cipher.writePacket(io, allocator, cw, req.items); 273 } 274 { 275 const pk_ok = try sc_cipher.readPacket(allocator, cr); 276 defer allocator.free(pk_ok); 277 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_PK_OK), pk_ok[0]); 278 } 279 280 // STEP 7: PUBLIC-KEY USERAUTH — SIGNED REQUEST 281 // Round 2: same USERAUTH_REQUEST, now with has_signature=true and an 282 // ed25519 signature over the canonical signed-data block — see 283 // proto.appendPublickeySignedData for the exact byte layout. The 284 // block includes session_id (== H of the first KEX), so signatures 285 // can't be replayed across sessions. 286 var signed: std.ArrayList(u8) = .empty; 287 defer signed.deinit(allocator); 288 try proto.appendPublickeySignedData(&signed, allocator, &h, "testuser", "ssh-connection", "ssh-ed25519", user_pubkey_blob.items); 289 const sig = try user_kp.sign(signed.items, null); 290 const sig_bytes = sig.toBytes(); 291 292 var sig_wire: std.ArrayList(u8) = .empty; 293 defer sig_wire.deinit(allocator); 294 try proto.writeStringField(&sig_wire, allocator, "ssh-ed25519"); 295 try proto.writeStringField(&sig_wire, allocator, &sig_bytes); 296 297 { 298 var req: std.ArrayList(u8) = .empty; 299 defer req.deinit(allocator); 300 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 301 try proto.writeStringField(&req, allocator, "testuser"); 302 try proto.writeStringField(&req, allocator, "ssh-connection"); 303 try proto.writeStringField(&req, allocator, "publickey"); 304 try req.append(allocator, 1); 305 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 306 try proto.writeStringField(&req, allocator, user_pubkey_blob.items); 307 try proto.writeStringField(&req, allocator, sig_wire.items); 308 try cs_cipher.writePacket(io, allocator, cw, req.items); 309 } 310 { 311 const success = try sc_cipher.readPacket(allocator, cr); 312 defer allocator.free(success); 313 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_SUCCESS), success[0]); 314 } 315 316 // STEP 8: CHANNEL_OPEN (RFC 4254 §5.1) 317 // Open a "session" channel — SSH's container for shell/exec/subsystem. 318 // Client picks its own channel id and announces an initial receive 319 // window + max-packet size; server replies CHANNEL_OPEN_CONFIRMATION 320 // with its own channel id and c->s limits. Each side identifies the 321 // channel by *the other's* id when sending. 322 // 323 // haxy supports exactly one channel per connection — a second 324 // CHANNEL_OPEN here gets CHANNEL_OPEN_FAILURE (see the failure tests). 325 const client_channel_id: u32 = 42; 326 { 327 var req: std.ArrayList(u8) = .empty; 328 defer req.deinit(allocator); 329 try req.append(allocator, proto.SSH_MSG_CHANNEL_OPEN); 330 try proto.writeStringField(&req, allocator, "session"); 331 try proto.writeU32(&req, allocator, client_channel_id); 332 try proto.writeU32(&req, allocator, 1 << 20); // initial window 333 try proto.writeU32(&req, allocator, 32768); // max packet 334 try cs_cipher.writePacket(io, allocator, cw, req.items); 335 } 336 var server_channel_id: u32 = undefined; 337 { 338 const conf = try sc_cipher.readPacket(allocator, cr); 339 defer allocator.free(conf); 340 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_CHANNEL_OPEN_CONFIRMATION), conf[0]); 341 var r = std.Io.Reader.fixed(conf[1..]); 342 const recipient = try r.takeInt(u32, .big); 343 try std.testing.expectEqual(client_channel_id, recipient); 344 server_channel_id = try r.takeInt(u32, .big); 345 } 346 347 // STEP 9: CHANNEL_REQUEST exec (RFC 4254 §6.5) 348 // Ask the server to run a command on this channel. Packet is 349 // addressed to the *server's* channel id. With want_reply=true the 350 // server must respond CHANNEL_SUCCESS or CHANNEL_FAILURE before any 351 // CHANNEL_DATA flows. 352 const exec_command = "git-upload-pack 'demo-repo'"; 353 { 354 var req: std.ArrayList(u8) = .empty; 355 defer req.deinit(allocator); 356 try req.append(allocator, proto.SSH_MSG_CHANNEL_REQUEST); 357 try proto.writeU32(&req, allocator, server_channel_id); 358 try proto.writeStringField(&req, allocator, "exec"); 359 try req.append(allocator, 1); // want_reply 360 try proto.writeStringField(&req, allocator, exec_command); 361 try cs_cipher.writePacket(io, allocator, cw, req.items); 362 } 363 { 364 const success = try sc_cipher.readPacket(allocator, cr); 365 defer allocator.free(success); 366 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_CHANNEL_SUCCESS), success[0]); 367 } 368 369 // STEP 10: HANDLER RUNS, CHANNEL TEARDOWN 370 // The server is now in handleSession. Our recording handler returns 371 // immediately; runChannelLayer's wrap-up then sends: 372 // - CHANNEL_REQUEST "exit-status" (status=0, want_reply=false) 373 // - CHANNEL_EOF 374 // - CHANNEL_CLOSE 375 // …and waits for our CHANNEL_CLOSE in reply (so the underlying socket 376 // isn't closed-with-unread-data, which would surface as TCP RST). 377 var saw_exit_status = false; 378 var saw_server_close = false; 379 while (!saw_server_close) { 380 const pkt = try sc_cipher.readPacket(allocator, cr); 381 defer allocator.free(pkt); 382 switch (pkt[0]) { 383 proto.SSH_MSG_CHANNEL_REQUEST => { 384 var r = std.Io.Reader.fixed(pkt[1..]); 385 _ = try r.takeInt(u32, .big); 386 const req_type = try proto.takeStringField(allocator, &r, 64); 387 defer allocator.free(req_type); 388 if (std.mem.eql(u8, req_type, "exit-status")) { 389 _ = try r.takeByte(); 390 const status = try r.takeInt(u32, .big); 391 try std.testing.expectEqual(@as(u32, 0), status); 392 saw_exit_status = true; 393 } 394 }, 395 proto.SSH_MSG_CHANNEL_EOF => {}, 396 proto.SSH_MSG_CHANNEL_CLOSE => saw_server_close = true, 397 proto.SSH_MSG_CHANNEL_WINDOW_ADJUST => {}, 398 else => {}, 399 } 400 } 401 try std.testing.expect(saw_exit_status); 402 403 { 404 var pkt: std.ArrayList(u8) = .empty; 405 defer pkt.deinit(allocator); 406 try pkt.append(allocator, proto.SSH_MSG_CHANNEL_CLOSE); 407 try proto.writeU32(&pkt, allocator, server_channel_id); 408 try cs_cipher.writePacket(io, allocator, cw, pkt.items); 409 } 410 411 server_future.await(io); 412 try std.testing.expect(server.result == null); 413 414 // FINAL ASSERTIONS 415 // the exec command we sent on the wire should have reached the handler 416 // verbatim, and the server should have recorded our pubkey's SHA256 417 // fingerprint as the authenticated identity for this session. 418 try std.testing.expectEqualStrings(exec_command, server.captured_exec_buf[0..server.captured_exec_len]); 419 const expected_fp = proto.formatFingerprint(user_pubkey_blob.items); 420 try std.testing.expectEqualSlices(u8, &expected_fp, &server.captured_fp); 421 } 422 423 // failure modes against a single live session, in increasing severity: 424 // - publickey signed-data binding (session_id and user_name) — server 425 // replies USERAUTH_FAILURE, connection continues 426 // - second CHANNEL_OPEN on the same connection — server replies 427 // CHANNEL_OPEN_FAILURE, connection continues 428 // - MAC-tampered encrypted packet — server bails out of handleConnection 429 // with MacVerificationFailed (terminal) 430 // running all of these against one negotiated session avoids paying the 431 // KEX cost per failure case and keeps the failure tests visually together. 432 test "auth + channel layer failure modes" { 433 const io = std.testing.io; 434 const allocator = std.testing.allocator; 435 436 const host_key = try newHostKey(); 437 438 var c2s_buf: [4096]u8 = undefined; 439 var c2s = Pipe.init(&c2s_buf); 440 var s2c_buf: [4096]u8 = undefined; 441 var s2c = Pipe.init(&s2c_buf); 442 443 var server_rd_buf: [4096]u8 = undefined; 444 var server_wr_buf: [4096]u8 = undefined; 445 var client_rd_buf: [4096]u8 = undefined; 446 var client_wr_buf: [4096]u8 = undefined; 447 var server_pipe_reader = PipeReader.init(io, &c2s, &server_rd_buf); 448 var server_pipe_writer = PipeWriter.init(io, &s2c, &server_wr_buf); 449 var client_pipe_reader = PipeReader.init(io, &s2c, &client_rd_buf); 450 var client_pipe_writer = PipeWriter.init(io, &c2s, &client_wr_buf); 451 const cr = &client_pipe_reader.interface; 452 const cw = &client_pipe_writer.interface; 453 454 var server: ServerTask = .{ 455 .io = io, 456 .allocator = allocator, 457 .host_key = &host_key, 458 .reader = &server_pipe_reader.interface, 459 .writer = &server_pipe_writer.interface, 460 .outgoing = &s2c, 461 }; 462 var server_future = try std.Io.concurrent(io, ServerTask.run, .{&server}); 463 defer server_future.await(io); 464 defer c2s.close(io); 465 466 // bring the connection up to a post-NEWKEYS, ready-to-encrypt state: 467 // banner exchange, KEXINIT exchange, ECDH, NEWKEYS, key derivation. 468 var cs: proto.Cipher = undefined; 469 var sc: proto.Cipher = undefined; 470 var session_id: [Sha256.digest_length]u8 = undefined; 471 { 472 try cw.writeAll(client_version ++ "\r\n"); 473 try cw.flush(); 474 const v_s_line = (try cr.takeDelimiter('\n')) orelse return error.UnexpectedEof; 475 const v_s_trimmed = if (v_s_line.len > 0 and v_s_line[v_s_line.len - 1] == '\r') v_s_line[0 .. v_s_line.len - 1] else v_s_line; 476 const v_s = try allocator.dupe(u8, v_s_trimmed); 477 defer allocator.free(v_s); 478 479 const ckex = try proto.buildKexInit(io, allocator, &proto.our_kex_algos, &proto.our_host_key_algos, &proto.our_ciphers); 480 defer allocator.free(ckex); 481 try proto.writePlainPacket(io, cw, ckex); 482 const skex = try proto.readPlainPacket(allocator, cr); 483 defer allocator.free(skex); 484 485 const eph = try newClientEphemeral(); 486 { 487 var pkt: std.ArrayList(u8) = .empty; 488 defer pkt.deinit(allocator); 489 try pkt.append(allocator, proto.SSH_MSG_KEX_ECDH_INIT); 490 try proto.writeStringField(&pkt, allocator, &eph.public_key); 491 try proto.writePlainPacket(io, cw, pkt.items); 492 } 493 const reply = try proto.readPlainPacket(allocator, cr); 494 defer allocator.free(reply); 495 var rr = std.Io.Reader.fixed(reply[1..]); 496 const k_s = try proto.takeStringField(allocator, &rr, 4096); 497 defer allocator.free(k_s); 498 const q_s = try proto.takeStringField(allocator, &rr, X25519.public_length); 499 defer allocator.free(q_s); 500 var server_pub: [X25519.public_length]u8 = undefined; 501 @memcpy(&server_pub, q_s); 502 const sig_blob = try proto.takeStringField(allocator, &rr, 1024); 503 defer allocator.free(sig_blob); 504 const k = try X25519.scalarmult(eph.secret_key, server_pub); 505 const exchange = try proto.computeExchangeHash(allocator, client_version, v_s, ckex, skex, k_s, &eph.public_key, &server_pub, &k); 506 507 try proto.writePlainPacket(io, cw, &[_]u8{proto.SSH_MSG_NEWKEYS}); 508 { 509 const server_newkeys = try proto.readPlainPacket(allocator, cr); 510 defer allocator.free(server_newkeys); 511 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_NEWKEYS), server_newkeys[0]); 512 } 513 514 var keys: proto.SessionKeys = undefined; 515 try proto.deriveSessionKeys(allocator, &k, &exchange, &exchange, &keys); 516 cs = proto.Cipher.init(&keys.cs_enc, 3); 517 sc = proto.Cipher.init(&keys.sc_enc, 3); 518 session_id = exchange; 519 } 520 521 // SERVICE_REQUEST → SERVICE_ACCEPT, then USERAUTH_REQUEST publickey 522 // probe → PK_OK, then three signed USERAUTH_REQUEST attempts that 523 // exercise the cryptographic-binding properties of publickey auth: 524 // 1. signed with a different session_id → USERAUTH_FAILURE 525 // (replay-across-sessions defense — H is bound into the signature) 526 // 2. signed with one user_name but the request carries another → 527 // USERAUTH_FAILURE (user_name is bound into the signature) 528 // 3. legit (correct session_id and user_name) → USERAUTH_SUCCESS 529 // each USERAUTH_FAILURE is non-terminal: the server's auth loop just 530 // increments attempts and waits for the next request. 531 const user_kp = try newUserKeypair(); 532 { 533 { 534 var req: std.ArrayList(u8) = .empty; 535 defer req.deinit(allocator); 536 try req.append(allocator, proto.SSH_MSG_SERVICE_REQUEST); 537 try proto.writeStringField(&req, allocator, "ssh-userauth"); 538 try cs.writePacket(io, allocator, cw, req.items); 539 } 540 { 541 const accept = try sc.readPacket(allocator, cr); 542 defer allocator.free(accept); 543 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_SERVICE_ACCEPT), accept[0]); 544 var r = std.Io.Reader.fixed(accept[1..]); 545 const service = try proto.takeStringField(allocator, &r, 64); 546 defer allocator.free(service); 547 try std.testing.expectEqualStrings("ssh-userauth", service); 548 } 549 550 var pubkey_blob: std.ArrayList(u8) = .empty; 551 defer pubkey_blob.deinit(allocator); 552 try proto.writeStringField(&pubkey_blob, allocator, "ssh-ed25519"); 553 try proto.writeStringField(&pubkey_blob, allocator, &user_kp.public_key.bytes); 554 555 { 556 var req: std.ArrayList(u8) = .empty; 557 defer req.deinit(allocator); 558 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 559 try proto.writeStringField(&req, allocator, "testuser"); 560 try proto.writeStringField(&req, allocator, "ssh-connection"); 561 try proto.writeStringField(&req, allocator, "publickey"); 562 try req.append(allocator, 0); 563 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 564 try proto.writeStringField(&req, allocator, pubkey_blob.items); 565 try cs.writePacket(io, allocator, cw, req.items); 566 } 567 { 568 const pk_ok = try sc.readPacket(allocator, cr); 569 defer allocator.free(pk_ok); 570 try std.testing.expectEqual(@as(u8, proto.SSH_MSG_USERAUTH_PK_OK), pk_ok[0]); 571 var r = std.Io.Reader.fixed(pk_ok[1..]); 572 const algo = try proto.takeStringField(allocator, &r, 64); 573 defer allocator.free(algo); 574 try std.testing.expectEqualStrings("ssh-ed25519", algo); 575 const accepted_blob = try proto.takeStringField(allocator, &r, 4096); 576 defer allocator.free(accepted_blob); 577 try std.testing.expectEqualSlices(u8, pubkey_blob.items, accepted_blob); 578 } 579 580 const wrong_session = [_]u8{0xFF} ** Sha256.digest_length; 581 for ([_]struct { 582 sign_session: []const u8, 583 sign_user: []const u8, 584 send_user: []const u8, 585 expect: u8, 586 }{ 587 .{ .sign_session = &wrong_session, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, 588 .{ .sign_session = &session_id, .sign_user = "alice", .send_user = "bob", .expect = proto.SSH_MSG_USERAUTH_FAILURE }, 589 .{ .sign_session = &session_id, .sign_user = "testuser", .send_user = "testuser", .expect = proto.SSH_MSG_USERAUTH_SUCCESS }, 590 }) |attempt| { 591 var signed: std.ArrayList(u8) = .empty; 592 defer signed.deinit(allocator); 593 try proto.appendPublickeySignedData(&signed, allocator, attempt.sign_session, attempt.sign_user, "ssh-connection", "ssh-ed25519", pubkey_blob.items); 594 const sig = try user_kp.sign(signed.items, null); 595 const sig_bytes = sig.toBytes(); 596 var sig_wire: std.ArrayList(u8) = .empty; 597 defer sig_wire.deinit(allocator); 598 try proto.writeStringField(&sig_wire, allocator, "ssh-ed25519"); 599 try proto.writeStringField(&sig_wire, allocator, &sig_bytes); 600 601 var req: std.ArrayList(u8) = .empty; 602 defer req.deinit(allocator); 603 try req.append(allocator, proto.SSH_MSG_USERAUTH_REQUEST); 604 try proto.writeStringField(&req, allocator, attempt.send_user); 605 try proto.writeStringField(&req, allocator, "ssh-connection"); 606 try proto.writeStringField(&req, allocator, "publickey"); 607 try req.append(allocator, 1); 608 try proto.writeStringField(&req, allocator, "ssh-ed25519"); 609 try proto.writeStringField(&req, allocator, pubkey_blob.items); 610 try proto.writeStringField(&req, allocator, sig_wire.items); 611 try cs.writePacket(io, allocator, cw, req.items); 612 613 const reply = try sc.readPacket(allocator, cr); 614 defer allocator.free(reply); 615 try std.testing.expectEqual(attempt.expect, reply[0]); 616 } 617 } 618 619 // open two channels back to back: server confirms the first, then 620 // refuses the second with OPEN_FAILURE and stays running. 621 for ([_]struct { id: u32, expect: u8 }{ 622 .{ .id = 7, .expect = proto.SSH_MSG_CHANNEL_OPEN_CONFIRMATION }, 623 .{ .id = 8, .expect = proto.SSH_MSG_CHANNEL_OPEN_FAILURE }, 624 }) |case| { 625 var req: std.ArrayList(u8) = .empty; 626 defer req.deinit(allocator); 627 try req.append(allocator, proto.SSH_MSG_CHANNEL_OPEN); 628 try proto.writeStringField(&req, allocator, "session"); 629 try proto.writeU32(&req, allocator, case.id); 630 try proto.writeU32(&req, allocator, 1 << 20); 631 try proto.writeU32(&req, allocator, 32768); 632 try cs.writePacket(io, allocator, cw, req.items); 633 634 const reply = try sc.readPacket(allocator, cr); 635 defer allocator.free(reply); 636 try std.testing.expectEqual(case.expect, reply[0]); 637 } 638 639 // now break the connection: build any encrypted packet, flip one bit of 640 // its trailing Poly1305 tag, and ship the corrupted bytes. server must 641 // surface MacVerificationFailed out of handleConnection. 642 var req: std.ArrayList(u8) = .empty; 643 defer req.deinit(allocator); 644 try req.append(allocator, proto.SSH_MSG_CHANNEL_REQUEST); 645 try proto.writeU32(&req, allocator, 0); 646 try proto.writeStringField(&req, allocator, "shell"); 647 try req.append(allocator, 0); 648 649 var tmp_buf: [512]u8 = undefined; 650 var sink = std.Io.Writer.fixed(&tmp_buf); 651 try cs.writePacket(io, allocator, &sink, req.items); 652 const written = sink.buffered(); 653 tmp_buf[written.len - 1] ^= 0x01; 654 try cw.writeAll(tmp_buf[0..written.len]); 655 try cw.flush(); 656 657 server_future.await(io); 658 try std.testing.expectEqual(@as(?anyerror, error.MacVerificationFailed), server.result); 659 } 660 661 const client_version = "SSH-2.0-haxy_test"; 662 663 // in-memory bidirectional pipe — drives the server side of handleConnection 664 // without a socket. one Pipe carries bytes in one direction. 665 666 const Pipe = std.Io.Queue(u8); 667 668 const PipeReader = struct { 669 interface: std.Io.Reader, 670 io: std.Io, 671 pipe: *Pipe, 672 673 fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeReader { 674 return .{ 675 .interface = .{ 676 .vtable = &.{ .readVec = readVec, .stream = stream }, 677 .buffer = buf, 678 .seek = 0, 679 .end = 0, 680 }, 681 .io = io, 682 .pipe = pipe, 683 }; 684 } 685 686 fn stream(r: *std.Io.Reader, w: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize { 687 // tests don't drive any code path that calls stream, but the vtable 688 // requires a non-default impl. read into a small staging buffer and 689 // forward, so the implementation is correct if it ever fires. 690 const self: *PipeReader = @fieldParentPtr("interface", r); 691 var stage: [256]u8 = undefined; 692 const cap = limit.minInt(stage.len); 693 if (cap == 0) return 0; 694 const n = self.pipe.get(self.io, stage[0..cap], 1) catch |err| switch (err) { 695 error.Closed => return error.EndOfStream, 696 error.Canceled => return error.ReadFailed, 697 }; 698 return try w.write(stage[0..n]); 699 } 700 701 fn readVec(r: *std.Io.Reader, data: [][]u8) std.Io.Reader.Error!usize { 702 const self: *PipeReader = @fieldParentPtr("interface", r); 703 // vtable contract: if data[0] is empty, fill r.buffer[r.end..] instead. 704 const dest = if (data.len == 0 or data[0].len == 0) r.buffer[r.end..] else data[0]; 705 if (dest.len == 0) return 0; 706 const n = self.pipe.get(self.io, dest, 1) catch |err| switch (err) { 707 error.Closed => return error.EndOfStream, 708 error.Canceled => return error.ReadFailed, 709 }; 710 if (data.len == 0 or data[0].len == 0) { 711 r.end += n; 712 return 0; 713 } 714 return n; 715 } 716 }; 717 718 const PipeWriter = struct { 719 interface: std.Io.Writer, 720 io: std.Io, 721 pipe: *Pipe, 722 723 fn init(io: std.Io, pipe: *Pipe, buf: []u8) PipeWriter { 724 return .{ 725 .interface = .{ 726 .vtable = &.{ .drain = drain }, 727 .buffer = buf, 728 }, 729 .io = io, 730 .pipe = pipe, 731 }; 732 } 733 734 fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { 735 const self: *PipeWriter = @fieldParentPtr("interface", w); 736 const buffered = w.buffered(); 737 if (buffered.len > 0) self.pipe.putAll(self.io, buffered) catch return error.WriteFailed; 738 739 var extra: usize = 0; 740 if (data.len > 1) { 741 for (data[0 .. data.len - 1]) |chunk| { 742 if (chunk.len == 0) continue; 743 self.pipe.putAll(self.io, chunk) catch return error.WriteFailed; 744 extra += chunk.len; 745 } 746 } 747 if (data.len > 0) { 748 const pattern = data[data.len - 1]; 749 if (pattern.len > 0) { 750 var i: usize = 0; 751 while (i < splat) : (i += 1) { 752 self.pipe.putAll(self.io, pattern) catch return error.WriteFailed; 753 extra += pattern.len; 754 } 755 } 756 } 757 return w.consume(buffered.len + extra); 758 } 759 }; 760 761 // runs proto.handleConnection in a concurrent task (spawned via std.Io.concurrent). 762 // also acts as the session handler — captures the authenticated key's 763 // fingerprint and returns immediately, leaving runChannelLayer's wrap-up to 764 // send exit-status / EOF / CLOSE. records any error so the test can assert 765 // on it after awaiting the future. 766 const ServerTask = struct { 767 io: std.Io, 768 allocator: std.mem.Allocator, 769 host_key: *const proto.HostKey, 770 reader: *std.Io.Reader, 771 writer: *std.Io.Writer, 772 outgoing: *Pipe, // closed on exit so the client's reads return EOF 773 captured_fp: [proto.fingerprint_len]u8 = undefined, 774 captured_exec_buf: [256]u8 = undefined, 775 captured_exec_len: usize = 0, 776 result: ?anyerror = null, 777 778 pub fn handleSession(self: *ServerTask, sess: *proto.SessionCtx, request: proto.Request) !void { 779 @memcpy(&self.captured_fp, &sess.fingerprint); 780 switch (request) { 781 .exec => |cmd| { 782 // copy out of the protocol's stack-scoped buffer so the 783 // test can assert on it after the handler returns. 784 @memcpy(self.captured_exec_buf[0..cmd.len], cmd); 785 self.captured_exec_len = cmd.len; 786 }, 787 .shell => {}, 788 } 789 } 790 791 fn run(self: *ServerTask) void { 792 defer self.outgoing.close(self.io); 793 proto.handleConnection( 794 self.io, 795 self.allocator, 796 self.reader, 797 self.writer, 798 self.host_key, 799 self, 800 ) catch |e| { 801 self.result = e; 802 }; 803 } 804 }; 805 806 // deterministic keys so failures don't depend on RNG state. 807 808 fn newHostKey() !proto.HostKey { 809 const seed = [_]u8{0xAA} ** Ed25519.KeyPair.seed_length; 810 return .{ .keypair = try Ed25519.KeyPair.generateDeterministic(seed) }; 811 } 812 813 fn newUserKeypair() !Ed25519.KeyPair { 814 const seed = [_]u8{0xBB} ** Ed25519.KeyPair.seed_length; 815 return try Ed25519.KeyPair.generateDeterministic(seed); 816 } 817 818 fn newClientEphemeral() !X25519.KeyPair { 819 const seed = [_]u8{0xCC} ** X25519.seed_length; 820 return try X25519.KeyPair.generateDeterministic(seed); 821 } 822