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