Ssh session

   1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
use "net"
use "../ssh_error"
use "../ssh_crypto"
use "../ssh_auth"
use "../ssh_connection"

type _SshBridge is (SshClientTcpBridge tag | SshServerTcpBridge tag)

class val SshClientConfig
  """
  Immutable configuration for an outbound client session: the host and port to
  connect to, the username, the authentication methods to try in order, and the
  algorithm preferences (defaulting to the implemented set).
  """
  let host: String val
  let port: String val
  let username: String val
  let auth_methods: Array[SshAuthMethod val] val
  let algorithms: SshAlgorithmPreferences val

  new val create(host': String val, port': String val,
    username': String val = "",
    auth_methods': Array[SshAuthMethod val] val =
      recover val Array[SshAuthMethod val] end,
    algorithms': SshAlgorithmPreferences val =
      SshDefaultAlgorithms.preferences())
  =>
    host = host'
    port = port'
    username = username'
    auth_methods = auth_methods'
    algorithms = algorithms'

class val SshServerConfig
  """
  Immutable server settings. Use MakeSshServerConfig to load the host key PEM
  and require a channel receive window of at least 256 bytes before listening.
  """
  let host_key_pem: Array[U8] val
  let listen_host: String val
  let listen_port: String val
  let algorithms: SshAlgorithmPreferences val
  let channel_window: U32

  new val _create(host_key_pem': Array[U8] val,
    listen_host': String val, listen_port': String val,
    algorithms': SshAlgorithmPreferences val, channel_window': U32)
  =>
    host_key_pem = host_key_pem'
    listen_host = listen_host'
    listen_port = listen_port'
    algorithms = algorithms'
    channel_window = channel_window'

primitive MakeSshServerConfig
  """Load server host key PEM and check the channel receive window."""
  fun apply(host_key_pem': Array[U8] val,
    listen_host': String val = "127.0.0.1",
    listen_port': String val = "22",
    algorithms': SshAlgorithmPreferences val =
      SshDefaultAlgorithms.preferences(),
    channel_window': U32 = SshChannelWindow.initial()):
    (SshServerConfig val | SshServerConfigError)
  =>
    """An invalid host key takes precedence when both inputs are invalid."""
    try SshHostKeyPair.create(host_key_pem')?
    else return SshServerHostKeyLoadFailed end

    let minimum = SshChannelLimits.min_max_packet()
    if channel_window' < minimum then
      return SshServerChannelWindowTooSmall(channel_window', minimum)
    end
    SshServerConfig._create(host_key_pem', listen_host', listen_port',
      algorithms', channel_window')

actor SshSession
  """
  Owns one SSH connection: the protocol state machine, the TCP bridge, packet
  framing/crypto, key exchange (including rekey), authentication, and channel
  multiplexing. Consumers do not construct this directly — use SshConnector
  (client) or SshListener (server) — and interact with it through the tag passed
  to their SshClientNotify / SshServerNotify callbacks.
  """
  let _role: SshRole
  let _client_notify: (SshClientNotify tag | None)
  let _server_notify: (SshServerNotify tag | None)
  var _bridge: (_SshBridge | None) = None
  var _state: SshSessionState = SshStateHandshake
  let _context: SshSessionContext = SshSessionContext
  let _reader: SshPacketReader = SshPacketReader
  let _writer: SshPacketWriter = SshPacketWriter
  let _channel_manager: SshChannelManager
  var _prefs: SshAlgorithmPreferences val = SshDefaultAlgorithms.preferences()
  var _kex: (SshKexStateMachine | None) = None
  var _auth: (SshAuthStateMachine | None) = None
  var _host_key: (SshHostKeyPair | None) = None
  var _our_kexinit: (Array[U8] val | None) = None
  // Set when the peer's KEXINIT for the initial key exchange has been accepted.
  // A rekey's KEXINIT is tracked separately on its SshRekeyContext, so this is
  // never reset: it guards the one exchange that can be replaced mid-flight.
  var _kexinit_received: Bool = false
  var _encrypted: Bool = false
  // The host the client was configured to connect to, passed to the host-key
  // verification callback so the consumer can bind the key to a hostname
  // (known_hosts / TOFU). Empty on the server side.
  var _remote_host: String val = ""
  // Set once teardown has begun, so the per-packet sequence-limit check does
  // not re-enter while the DISCONNECT packet is itself being sent.
  var _shutting_down: Bool = false
  // Rekey (RFC 4253 §9). _rekey holds an in-progress re-exchange while the
  // session stays Connected. From our KEXINIT until our NEWKEYS we may send
  // only key-exchange traffic, so other outgoing packets are deferred in
  // _pending_sends and flushed once our NEWKEYS is on the wire.
  // _write_baseline/_read_baseline record the sequence number at which each
  // direction's current key was installed, bounding how many packets (and thus
  // nonces) a single key sees before the next rekey.
  var _rekey: (SshRekeyContext | None) = None
  var _send_blackout: Bool = false
  // Strict key exchange (OpenSSH extension, Terrapin / CVE-2023-48795
  // mitigation). Set true once the peer's first KEXINIT advertises the marker
  // (we always advertise ours, so this means both sides do). While true the
  // packet sequence number is reset to zero at every NEWKEYS, and during the
  // initial key exchange only key-exchange packets are tolerated.
  var _strict_kex: Bool = false
  let _pending_sends: Array[Array[U8] val] = Array[Array[U8] val]
  var _pending_bytes: USize = 0
  var _pending_packet_limit: USize = 1024
  var _flow_observer: (_SshFlowObserver tag | None) = None
  var _hard_packet_limit_override: (U32 | None) = None
  var _write_baseline: U32 = 0
  var _read_baseline: U32 = 0
  // Set the first time the session enters its terminal state, so teardown runs
  // once: the consumer sees a single ssh_disconnected and no duplicate
  // DISCONNECT is sent into an already-closed bridge.
  var _terminated: Bool = false
  // Failed authentication attempts on this session. Without a cap an
  // unauthenticated peer gets unlimited guesses down one connection at line
  // rate, with no lockout and no connection churn for an operator to notice.
  var _auth_failures: U32 = 0

  let _version_string: String val = "SSH-2.0-ponyssh_0.1"

  // --- Factory constructors ---

  new create_client(auth: TCPConnectAuth, config: SshClientConfig val,
    notify: SshClientNotify tag)
  =>
    _role = SshRoleClient
    _channel_manager = SshChannelManager
    _client_notify = notify
    _server_notify = None
    _prefs = config.algorithms
    _kex = SshKexStateMachine(SshRoleClient)
    _auth = SshAuthStateMachine(config.username, config.auth_methods)
    _remote_host = config.host
    _bridge = SshClientTcpBridge(auth, config.host, config.port, this)

  new create_server(config: SshServerConfig val, notify: SshServerNotify tag) =>
    _role = SshRoleServer
    _channel_manager = SshChannelManager(config.channel_window)
    _client_notify = None
    _server_notify = notify
    _prefs = config.algorithms
    _kex = SshKexStateMachine(SshRoleServer)
    _auth = None
    try _host_key = SshHostKeyPair.create(config.host_key_pem)? end
    // The server bridge is wired immediately after construction by SshListener
    // via set_server_bridge (the bridge needs the session as its notify target,
    // so the session must exist first).

  new _flow_test(notify: SshServerNotify tag,
    observer: _SshFlowObserver tag, remote_window: U32,
    max_packet: U32, pending_limit: USize,
    authorized: Bool = true)
  =>
    _role = SshRoleServer
    _channel_manager = SshChannelManager
    _client_notify = None
    _server_notify = notify
    _kex = SshKexStateMachine(SshRoleServer)
    _auth = None
    _state = SshStateConnected(recover val Array[U8] end)
    let channel_id = _channel_manager.open_channel("session")
    if authorized then
      _channel_manager.confirm_channel(channel_id, 7, remote_window,
        max_packet)
    end
    _send_blackout = true
    _pending_packet_limit = pending_limit
    _flow_observer = observer

  be _flow_dispatch_grants(grants: Array[U32] val,
    observer: _SshFlowObserver tag)
  =>
    for bytes in grants.values() do
      _dispatch_packet(SshChannelMessages.channel_window_adjust(0, bytes))
    end
    observer._flow_barrier()

  be _flow_dispatch_peer_open(max_packet: U32,
    observer: _SshFlowObserver tag)
  =>
    _dispatch_packet(SshChannelMessages.channel_open(
      "session", 9, 1024, max_packet))
    observer._flow_barrier()

  be _flow_dispatch_peer_confirmation(max_packet: U32,
    observer: _SshFlowObserver tag)
  =>
    _dispatch_packet(SshChannelMessages.channel_open_confirmation(
      0, 7, 1024, max_packet))
    observer._flow_barrier()

  be _flow_snapshot(observer: _SshFlowObserver tag) =>
    match _channel_manager.get(0)
    | let ch: SshChannelState =>
      observer._flow_snapshot_result(_pending_sends.size(), _pending_bytes,
        ch.send_blocked, ch.remote_window, _terminated)
    | None =>
      observer._flow_snapshot_result(_pending_sends.size(), _pending_bytes,
        false, 0, _terminated)
    end

  be _flow_stop_after_next_packet(channel_id: U32) =>
    _hard_packet_limit_override = _packets_since_rekey() + 1
    match _channel_manager.get(channel_id)
    | let ch: SshChannelState => ch.max_packet_size = 256
    end

  be _flow_terminate(kind: U8) =>
    match kind
    | 1 => _peer_disconnected()
    | 2 => _handle_tcp_closed()
    | 3 => _handle_tcp_connection_failed()
    | 4 => _disconnect_with_error(SshConnectionLost)
    end

  be _flow_end_blackout() =>
    _send_blackout = false

  be _flow_flush_without_bridge() =>
    _flush_pending_sends()

  be set_server_bridge(bridge: SshServerTcpBridge tag) =>
    """
    Wire the TCP bridge for a server session. Accepted only once, right after
    construction; a later call (e.g. from consumer code) cannot replace the
    bridge of a live session.
    """
    match _bridge
    | None => _bridge = bridge
    end

  // --- Public behaviors (called by consumers) ---

  be open_channel(channel_type: String val = "session") =>
    match _state
    | let _: SshStateConnected =>
      let local_id = _channel_manager.open_channel(channel_type)
      _send_packet(SshChannelMessages.channel_open(channel_type, local_id,
        _channel_manager.initial_window, 0x8000))
    end

  be channel_send(channel_id: U32, data: Array[U8] val) =>
    """
    Submit at most 32768 bytes of channel data. Every call reports the original
    array and the length of its locally admitted prefix through
    ssh_channel_send_result. Admission means a packet was handed to the TCP
    bridge or the bounded rekey queue; it does not mean the peer received it.
    A blocked caller can retry data.trim(accepted) after
    ssh_channel_window_available. The hint reserves no window credit.
    """
    match _state
    | let _: SshStateConnected => _channel_send_segmented(channel_id, data)
    else
      _notify_send_result(channel_id, data, 0, SshSendClosed)
    end

  fun ref _channel_send_segmented(channel_id: U32, data: Array[U8] val) =>
    let ch = match _channel_manager.get(channel_id)
    | let c: SshChannelState => c
    | None =>
      _notify_send_result(channel_id, data, 0, SshSendClosed)
      return
    end
    if not ch.open then
      _notify_send_result(channel_id, data, 0, SshSendClosed)
      return
    end
    if not ch.authorized then
      _notify_send_result(channel_id, data, 0, SshSendNotReady)
      return
    end
    if data.size() > 32768 then
      _notify_send_result(channel_id, data, 0, SshSendTooLarge)
      return
    end
    let max_packet = ch.max_packet_size.usize()
    var offset: USize = 0
    while offset < data.size() do
      let window = ch.remote_window.usize()
      if window == 0 then
        ch.send_blocked = true
        _notify_send_result(channel_id, data, offset, SshSendWindowBlocked)
        return
      end
      let seg_len = (data.size() - offset).min(max_packet).min(window)
      let segment = recover val
        let b = Array[U8].create(seg_len)
        b.copy_from(data, offset, 0, seg_len)
        b
      end
      if not _send_packet(SshChannelMessages.channel_data(
        ch.remote_id, segment))
      then
        _notify_send_result(channel_id, data, offset, SshSendClosed)
        return
      end
      match _channel_manager.channel_data_admitted(channel_id, seg_len)
      | None => None
      | let _: SshChannelError => _Unreachable()
      end
      offset = offset + seg_len
      if _terminated then
        _notify_send_result(channel_id, data, offset, SshSendClosed)
        return
      end
    end
    _notify_send_result(channel_id, data, offset, SshSendComplete)

  be channel_request_shell(channel_id: U32, want_reply: Bool = true) =>
    """
    Ask the peer to start a login shell on a channel we opened (RFC 4254 §6.5).
    The channel's output arrives via ssh_channel_data. A no-op unless the
    session is Connected and the channel is open and authorized.
    """
    match _state
    | let _: SshStateConnected =>
      match _channel_manager.get(channel_id)
      | let ch: SshChannelState =>
        if ch.open and ch.authorized then
          let sent = _send_packet(SshChannelMessages.channel_request_shell(
            ch.remote_id, want_reply))
          if sent and want_reply then
            ch.pending_request_replies = ch.pending_request_replies + 1
          end
        end
      end
    end

  be channel_request_exec(channel_id: U32, command: String val,
    want_reply: Bool = true)
  =>
    """
    Ask the peer to run a single command on a channel we opened (RFC 4254 §6.5).
    The command's output arrives via ssh_channel_data. A no-op unless the
    session is Connected and the channel is open and authorized.
    """
    match _state
    | let _: SshStateConnected =>
      match _channel_manager.get(channel_id)
      | let ch: SshChannelState =>
        if ch.open and ch.authorized then
          let sent = _send_packet(SshChannelMessages.channel_request_exec(
            ch.remote_id, command, want_reply))
          if sent and want_reply then
            ch.pending_request_replies = ch.pending_request_replies + 1
          end
        end
      end
    end

  be channel_close(channel_id: U32) =>
    match _state
    | let _: SshStateConnected =>
      match _channel_manager.channel_remote_id_if_open(channel_id)
      | let remote_id: U32 =>
        _send_packet(SshChannelMessages.channel_eof(remote_id))
        _send_packet(SshChannelMessages.channel_close(remote_id))
        _channel_manager.close_channel(channel_id)
        _notify_channel_closed(channel_id)
      | let _: SshChannelError => None  // Already closed
      end
    end

  be accept_host_key() =>
    // The consumer has approved the server's host key. Clear the gate; if the
    // server's NEWKEYS has already arrived (auth was held pending approval),
    // begin authentication now. Encryption was activated when NEWKEYS arrived.
    match _state
    | let s: SshStateKeyExchange =>
      s.awaiting_host_key_verification = false
      if s.server_newkeys_received then
        _begin_authentication()
      end
    end

  be reject_host_key() =>
    // The consumer rejected the server's host key. Tear down before any
    // credentials are sent.
    _disconnect_with_error(SshKexFailed(SshKeyInvalid))

  be disconnect(msg: String val = "") =>
    """Clean disconnect initiated by consumer."""
    if _terminated then return end
    _terminated = true
    _send_packet(SshMessages.disconnect(
      SshDisconnectCodes.by_application(), msg))
    _finish_terminal()
    _close_bridge()
    _state = SshStateDisconnected(None)
    _notify_disconnected()

  be rekey() =>
    """
    Request a key re-exchange. A no-op unless the session is Connected and no
    rekey is already in progress. The session also rekeys automatically as the
    per-key packet count grows, so consumers rarely need this.
    """
    _start_rekey()

  be auth_accept() =>
    match _state
    | let _: SshStateAuth =>
      _send_packet(SshAuthMessages.userauth_success())
      let session_id = match _context.session_id
      | let id: Array[U8] val => id
      else
        recover val Array[U8] end
      end
      _state = SshStateConnected(session_id)
      match _server_notify
      | let n: SshServerNotify tag => n.ssh_session_ready(this)
      end
    end

  be auth_pk_ok(algorithm: String val, public_key: Array[U8] val) =>
    match _state
    | let _: SshStateAuth =>
      _send_packet(SshAuthMessages.userauth_pk_ok(algorithm, public_key))
    end

  be auth_reject(remaining: Array[String val] val) =>
    match _state
    | let _: SshStateAuth => _auth_failed(remaining)
    end

  be accept_channel(channel_id: U32) =>
    match _state
    | let _: SshStateConnected =>
      match _channel_manager.get(channel_id)
      | let ch: SshChannelState =>
        // This is the authorization decision for a channel the peer asked us to
        // open. Until it lands, requests and data naming the channel are refused.
        ch.authorized = true
        _send_packet(SshChannelMessages.channel_open_confirmation(
          ch.remote_id, ch.local_id, _channel_manager.initial_window,
          0x8000))
      end
    end

  be reject_channel(channel_id: U32, reason: U32) =>
    match _state
    | let _: SshStateConnected =>
      match _channel_manager.get(channel_id)
      | let ch: SshChannelState =>
        _send_packet(SshChannelMessages.channel_open_failure(
          ch.remote_id, reason, "rejected"))
        _channel_manager.close_channel(channel_id)
      end
    end

  be accept_request(channel_id: U32) =>
    match _state
    | let _: SshStateConnected =>
      match _channel_manager.get(channel_id)
      | let ch: SshChannelState =>
        ch.pty_pending = false
        _send_packet(SshChannelMessages.channel_success(ch.remote_id))
      end
    end

  be reject_request(channel_id: U32) =>
    match _state
    | let _: SshStateConnected =>
      match _channel_manager.get(channel_id)
      | let ch: SshChannelState =>
        if ch.pty_pending then
          ch.pty = None
          ch.pty_pending = false
        end
        _send_packet(SshChannelMessages.channel_failure(ch.remote_id))
      end
    end

  // --- Internal behaviors (called by TCP bridge) ---

  be _tcp_connected() =>
    _send_version()

  be _tcp_received(data: Array[U8] iso) =>
    _reader.append(consume data)
    _process_packets()

  be _tcp_closed() =>
    _handle_tcp_closed()

  fun ref _handle_tcp_closed() =>
    if _terminated then return end
    _terminated = true
    _finish_terminal()
    _state = SshStateDisconnected(SshConnectionLost)
    _notify_disconnected()

  be _tcp_connection_failed() =>
    _handle_tcp_connection_failed()

  fun ref _handle_tcp_connection_failed() =>
    if _terminated then return end
    _terminated = true
    _finish_terminal()
    _state = SshStateDisconnected(SshConnectionLost)
    _notify_error(SshConnectionLost)
    _notify_disconnected()

  // --- Private methods ---

  fun ref _send_version() =>
    """Send SSH version string."""
    match _bridge
    | let b: SshClientTcpBridge tag =>
      b.write(_version_string + "\r\n")
    | let b: SshServerTcpBridge tag =>
      b.write(_version_string + "\r\n")
    end

  fun ref _process_packets() =>
    """Read and dispatch packets from the reader."""
    match _state
    | let s: SshStateHandshake =>
      _handle_version_exchange(s)
      return
    end

    while true do
      match _reader.read()
      | let payload: Array[U8] val =>
        _dispatch_packet(payload)
        // Bound per-key packet/nonce counts (rekey or, as a backstop, tear
        // down) before the sequence number approaches the 2^32 nonce wrap.
        if _check_packet_limits() then return end
      | let err: SshTransportError =>
        _disconnect_with_error(err)
        return
      | None => return
      end
    end

  fun ref _handle_version_exchange(state: SshStateHandshake) =>
    """
    Parse the remote version string from the buffered data.
    Version exchange is raw line-based text before packet framing begins.
    Lines not starting with "SSH-" are pre-auth banners (ignored per RFC 4253).
    The version line must start with "SSH-2.0-".
    """
    while true do
      match _reader.read_line()
      | let line: String val =>
        if line.substring(0, 4) == "SSH-" then
          // This is the version string
          if line.substring(0, 8) != "SSH-2.0-" then
            _disconnect_with_error(SshProtocolVersionMismatch)
            return
          end
          _context.remote_version = line
          // Send our KEXINIT and transition to key exchange
          match _kex
          | let kex: SshKexStateMachine =>
            // The first KEXINIT advertises the strict-KEX marker; rekey
            // KEXINITs must not (the marker is only valid in the first).
            let our_kexinit =
              try kex.generate_kexinit(_prefs where include_strict_marker = true)?
              else
                _disconnect_with_error(SshKexFailed(SshKeyInvalid))
                return
              end
            _our_kexinit = our_kexinit
            _send_packet(our_kexinit)
            // Transition to KeyExchange state. We don't yet have their KEXINIT,
            // so create the state with placeholder empty arrays and negotiated.
            // The real negotiation happens when we receive their KEXINIT.
            let empty: Array[U8] val = recover val Array[U8] end
            let placeholder_neg = SshNegotiatedAlgorithms("", "", "", "", "", "")
            _state = SshStateKeyExchange(our_kexinit, empty, placeholder_neg)
          end
          // Try processing any remaining buffered data as packets
          _process_packets()
          return
        end
        // Non-SSH lines are pre-auth banners, skip them
      | None =>
        // Bound the pre-handshake buffer. RFC 4253 caps the version line at
        // 255 bytes; we allow generous room for optional preceding banner
        // lines. Without this an unauthenticated peer that never sends a line
        // terminator could grow the buffer without bound (memory DoS).
        if _reader.buffered_size() > _max_version_exchange_bytes() then
          _disconnect_with_error(SshProtocolVersionMismatch)
        end
        return  // Not enough data yet
      end
    end

  fun _max_version_exchange_bytes(): USize => 8192

  fun ref _dispatch_packet(payload: Array[U8] val) =>
    """Route a decrypted payload to the appropriate handler based on state."""
    try
      let msg_type = payload(0)?

      // Strict KEX (Terrapin / CVE-2023-48795): during the initial key exchange
      // the only legal packets are key-exchange messages. Anything else — even
      // IGNORE/DEBUG/UNIMPLEMENTED, which are otherwise always allowed — is an
      // injection attempt that desynchronises the transcript; tear down. A peer
      // DISCONNECT is still honoured below. Enforced only once we know the peer
      // also negotiated strict KEX, and only until the first NEWKEYS brings
      // encryption up (after which EXT_INFO and the like are legitimate).
      if _strict_kex and (not _encrypted)
        and (not _is_kex_message(msg_type))
        and (msg_type != SshMsgTypes.disconnect())
      then
        _disconnect_with_error(SshStrictKexViolation)
        return
      end

      // Global messages handled in any state (RFC 4253 §11)
      match msg_type
      | SshMsgTypes.disconnect() =>
        _peer_disconnected()
        return
      | SshMsgTypes.ignore() => return    // silently discard
      | SshMsgTypes.debug() => return     // silently discard
      | SshMsgTypes.unimplemented() => return  // peer couldn't handle something
      | SshMsgTypes.ext_info() => return  // extensions info, safe to ignore
      end

      // Rekey (RFC 4253 §9). Once connected, key-exchange traffic drives a
      // re-exchange rather than the session state machine. A KEXINIT with no
      // rekey yet in progress is the peer initiating one; we start ours and
      // process theirs. Subsequent KEX messages route to the active rekey.
      if _is_kex_message(msg_type) then
        match _rekey
        | let rk: SshRekeyContext => _handle_rekey(rk, msg_type, payload); return
        | None =>
          match _state
          | let _: SshStateConnected =>
            if msg_type == SshMsgTypes.kexinit() then
              match _start_rekey()
              | let rk: SshRekeyContext => _handle_rekey(rk, msg_type, payload)
              end
              return
            end
          end
        end
      end

      // State-specific routing
      match _state
      | let _: SshStateHandshake => None
      | let _: SshStateKeyExchange => _handle_kex(msg_type, payload)
      | let _: SshStateAuth => _handle_auth(msg_type, payload)
      | let s: SshStateConnected => _handle_connected(msg_type, payload, s)
      | let _: SshStateDisconnected => None
      end
    end

  fun ref _handle_kex(msg_type: U8, payload: Array[U8] val) =>
    """Handle messages during key exchange."""
    match msg_type
    | SshMsgTypes.kexinit() =>
      // RFC 4253 §7.1: each side sends exactly one KEXINIT per key exchange. A
      // second one replaces the whole SshStateKeyExchange, discarding an
      // in-flight exchange along with any host-key approval the consumer is
      // still answering — the approval would then be applied to whichever key
      // the *next* exchange presented. Reject rather than reset.
      if _kexinit_received then
        _disconnect_with_error(SshUnexpectedMessage)
        return
      end
      _kexinit_received = true
      match _kex
      | let kex: SshKexStateMachine =>
        match kex.receive_kexinit(payload, _prefs)
        | let neg: SshNegotiatedAlgorithms val =>
          _context.negotiated_algorithms = neg
          // Strict KEX is in effect iff the peer also advertised the marker in
          // this (first) KEXINIT. Detected once, here, during the initial KEX.
          _strict_kex = SshStrictKex.peer_advertised(payload, _role)
          // When strict KEX is in effect the KEXINIT must be the first binary
          // packet received; a packet injected before it shifts the transcript
          // (the pre-KEXINIT half of Terrapin). The guard above this match only
          // covers packets *after* _strict_kex is known, so close the earlier
          // window here. Only enforced when both peers negotiated strict KEX, so
          // a legitimate non-strict peer is unaffected.
          if _strict_kex
            and (not SshStrictKex.initial_kexinit_position_ok(
              _reader.sequence_number()))
          then
            _disconnect_with_error(SshStrictKexViolation)
            return
          end
          // Update state with their KEXINIT and negotiated algorithms
          let our_ki = match _our_kexinit
          | let ki: Array[U8] val => ki
          else
            recover val Array[U8] end
          end
          _state = SshStateKeyExchange(our_ki, payload, neg)
          // Client initiates ECDH after receiving server's KEXINIT
          match _role
          | SshRoleClient =>
            try
              let client_kex = SshKexCurve25519.create()?
              let client_pub = client_kex.public_key()
              // Store kex object in state for later use
              match _state
              | let s: SshStateKeyExchange =>
                s.our_kex = client_kex
              end
              _send_packet(SshMessages.kex_ecdh_init(client_pub))
            else
              _disconnect_with_error(SshKexFailed(SshKeyInvalid))
            end
          end
        | let err: SshTransportError =>
          _disconnect_with_error(err)
        end
      end
    | SshMsgTypes.kex_ecdh_init() =>
      // Server receives client's public key
      match _role
      | SshRoleServer =>
        match _state
        | let s: SshStateKeyExchange =>
          // One ECDH round per exchange. Without this an unauthenticated peer
          // can stream KEX_ECDH_INITs down a single connection and make the
          // server run an X25519 keygen, an X25519 derive and an Ed25519
          // signature for each ~48-byte packet.
          if s.ecdh_completed then
            _disconnect_with_error(SshUnexpectedMessage)
            return
          end
          try
            let r = SshWireReader(payload)
            r.read_byte()?  // msg type
            let client_pub = r.read_string()?
            _server_handle_ecdh_init(s, client_pub)
          else
            _disconnect_with_error(SshKexFailed(SshKeyInvalid))
          end
        end
      end
    | SshMsgTypes.kex_ecdh_reply() =>
      // Client receives server's reply
      match _role
      | SshRoleClient =>
        match _state
        | let s: SshStateKeyExchange =>
          // One ECDH round per exchange. The session stays in KeyExchange with
          // encryption already active while the consumer answers the host-key
          // callback; a second reply arriving in that window would re-derive,
          // re-arm host-key verification against a different key, and reset the
          // writer's sequence number — the ChaCha20 nonce — to zero under the
          // key already in use.
          if s.ecdh_completed then
            _disconnect_with_error(SshUnexpectedMessage)
            return
          end
          try
            let r = SshWireReader(payload)
            r.read_byte()?  // msg type
            let host_key_blob = r.read_string()?
            let server_pub = r.read_string()?
            let signature = r.read_string()?
            _client_handle_ecdh_reply(s, host_key_blob, server_pub, signature)
          else
            _disconnect_with_error(SshKexFailed(SshKeyInvalid))
          end
        end
      end
    | SshMsgTypes.newkeys() =>
      // Strict KEX: our incoming sequence number resets to zero at the peer's
      // NEWKEYS (the NEWKEYS packet itself has already been counted), so the
      // first packet under the new key is parsed at sequence 0. Done before
      // _activate_encryption so the reader's per-key baseline starts at 0.
      if _strict_kex then _reader.reset_sequence_number() end
      let session_id = match _context.session_id
      | let id: Array[U8] val => id
      else
        recover val Array[U8] end
      end
      // Activate encryption using stored key exchange results. Only advance to
      // authentication once a cipher is actually installed; on failure
      // _activate_encryption has already torn the connection down.
      match _state
      | let kex_s: SshStateKeyExchange =>
        if _activate_encryption(kex_s, session_id) then
          // A client must not begin authentication — which puts its
          // credentials on the wire — until the consumer has approved the
          // server's host key. If approval is still pending, hold here;
          // accept_host_key() resumes via _begin_authentication.
          if kex_s.awaiting_host_key_verification then
            kex_s.server_newkeys_received = true
          else
            _begin_authentication()
          end
        end
      end
    end

  fun ref _server_handle_ecdh_init(s: SshStateKeyExchange,
    client_pub: Array[U8] val)
  =>
    """
    Server handles SSH_MSG_KEX_ECDH_INIT: generate keypair, compute shared
    secret, compute exchange hash per RFC 4253 section 8, sign it,
    send reply + NEWKEYS.
    """
    try
      let server_kex = SshKexCurve25519.create()?
      let server_pub = server_kex.public_key()

      match server_kex.derive_shared_secret(client_pub)
      | let shared_secret: Array[U8] val =>
        // Build host key blob first (needed for exchange hash)
        match _host_key
        | let hk: SshHostKeyPair =>
          let pub_key = hk.public_key()
          let host_key_blob = recover val
            let w = SshWireWriter
            w.write_string_from_str(pub_key.algorithm)
            w.write_string(pub_key.public_key_data)
            w.val_bytes()
          end

          // Compute full exchange hash per RFC 4253 section 8 / RFC 8731:
          // H = SHA256(string(V_C) || string(V_S) || string(I_C) || string(I_S)
          //           || string(K_S) || string(Q_C) || string(Q_S) || mpint(K))
          let client_version = match _context.remote_version
          | let v: String val => v
          else ""
          end
          let server_version: String val = _version_string
          let exchange_hash = _compute_exchange_hash(
            client_version, server_version,
            s.their_kexinit, s.our_kexinit,
            host_key_blob, client_pub, server_pub, shared_secret)?

          // Set session_id (first exchange hash per RFC 4253)
          if _context.session_id is None then
            _context.session_id = exchange_hash
          end

          // Store shared secret and exchange hash for key derivation after NEWKEYS
          s.shared_secret = shared_secret
          s.exchange_hash = exchange_hash
          s.ecdh_completed = true

          match hk.sign(exchange_hash)
          | let raw_sig: Array[U8] val =>
            let sig_blob = recover val
              let w = SshWireWriter
              w.write_string_from_str(pub_key.algorithm)
              w.write_string(raw_sig)
              w.val_bytes()
            end

            _send_packet(SshMessages.kex_ecdh_reply(host_key_blob, server_pub,
              sig_blob))
            _send_packet(SshMessages.newkeys())
            // Strict KEX: our outgoing sequence number resets to zero at our
            // NEWKEYS, so the first packet under the new key is sequence 0.
            if _strict_kex then _writer.reset_sequence_number() end
          | let err: SshCryptoError =>
            _disconnect_with_error(SshKexFailed(err))
          end
        else
          _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        end
      | let err: SshCryptoError =>
        _disconnect_with_error(SshKexFailed(err))
      end
    else
      _disconnect_with_error(SshKexFailed(SshKeyInvalid))
    end

  fun ref _client_handle_ecdh_reply(s: SshStateKeyExchange,
    host_key_blob: Array[U8] val, server_pub: Array[U8] val,
    signature: Array[U8] val)
  =>
    """
    Client handles SSH_MSG_KEX_ECDH_REPLY: compute shared secret,
    compute exchange hash per RFC 4253 section 8, verify signature,
    request host key verification.
    """
    match s.our_kex
    | let client_kex: SshKexCurve25519 =>
      let client_pub = client_kex.public_key()
      match client_kex.derive_shared_secret(server_pub)
      | let shared_secret: Array[U8] val =>
        // Compute full exchange hash per RFC 4253 section 8 / RFC 8731
        let client_version: String val = _version_string
        let server_version = match _context.remote_version
        | let v: String val => v
        else ""
        end
        // Parse host key blob to get SshHostKey
        try
          let exchange_hash = _compute_exchange_hash(
            client_version, server_version,
            s.our_kexinit, s.their_kexinit,
            host_key_blob, client_pub, server_pub, shared_secret)?

          // Set session_id
          if _context.session_id is None then
            _context.session_id = exchange_hash
          end

          // Store shared secret and exchange hash for key derivation after NEWKEYS
          s.shared_secret = shared_secret
          s.exchange_hash = exchange_hash

          let kr = SshWireReader(host_key_blob)
          let algo = kr.read_string_as_str()?
          let pk_data = kr.read_string()?
          let host_key = SshHostKey(algo, pk_data)
          _context.server_host_key = host_key

          // Verify the signature on the exchange hash
          let sr = SshWireReader(signature)
          let sig_algo = sr.read_string_as_str()?
          let raw_sig = sr.read_string()?

          match SshHostKeyVerify.verify(host_key, raw_sig, exchange_hash)
          | true =>
            s.ecdh_completed = true
            // Signature valid, send NEWKEYS immediately
            _send_packet(SshMessages.newkeys())
            // Strict KEX: our outgoing sequence number resets to zero at our
            // NEWKEYS, so the first packet under the new key is sequence 0.
            if _strict_kex then _writer.reset_sequence_number() end
            // Notify consumer for host key verification (accept/reject
            // controls whether auth proceeds after we receive server NEWKEYS)
            s.awaiting_host_key_verification = true
            match _client_notify
            | let n: SshClientNotify tag =>
              n.ssh_verify_host_key(this, _remote_host, host_key)
            end
          | let err: SshCryptoError =>
            _disconnect_with_error(SshKexFailed(err))
          end
        else
          _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        end
      | let err: SshCryptoError =>
        _disconnect_with_error(SshKexFailed(err))
      end
    else
      _disconnect_with_error(SshKexFailed(SshKeyInvalid))
    end

  fun _is_kex_message(msg_type: U8): Bool =>
    (msg_type == SshMsgTypes.kexinit())
      or (msg_type == SshMsgTypes.kex_ecdh_init())
      or (msg_type == SshMsgTypes.kex_ecdh_reply())
      or (msg_type == SshMsgTypes.newkeys())

  fun ref _start_rekey(): (SshRekeyContext | None) =>
    """
    Begin a key re-exchange, or return the one already in progress. Sends our
    KEXINIT and enters the send-blackout. Only meaningful once Connected.
    """
    match _rekey
    | let r: SshRekeyContext => return r
    end
    match _state
    | let _: SshStateConnected => None
    else
      return None
    end
    match _kex
    | let kex: SshKexStateMachine =>
      let ki =
        try kex.generate_kexinit(_prefs)?
        else
          _disconnect_with_error(SshKexFailed(SshKeyInvalid))
          return None
        end
      let rk = SshRekeyContext(ki)
      _rekey = rk
      // From our KEXINIT until our NEWKEYS we may send only key-exchange
      // traffic (RFC 4253 §9); defer everything else until the blackout ends.
      _send_blackout = true
      _send_kex_packet(ki)
      rk
    else
      None
    end

  fun ref _handle_rekey(rk: SshRekeyContext, msg_type: U8,
    payload: Array[U8] val)
  =>
    """Route a key-exchange message belonging to an in-progress rekey."""
    match msg_type
    | SshMsgTypes.kexinit() =>
      // Negotiate from the peer's KEXINIT and, as client, drive ECDH. A second
      // KEXINIT is ignored.
      match rk.their_kexinit
      | None =>
        match _kex
        | let kex: SshKexStateMachine =>
          match kex.receive_kexinit(payload, _prefs)
          | let neg: SshNegotiatedAlgorithms val =>
            rk.their_kexinit = payload
            rk.negotiated = neg
            match _role
            | SshRoleClient =>
              try
                let ck = SshKexCurve25519.create()?
                rk.our_kex = ck
                _send_kex_packet(SshMessages.kex_ecdh_init(ck.public_key()))
              else
                _disconnect_with_error(SshKexFailed(SshKeyInvalid))
              end
            end
          | let err: SshTransportError =>
            _disconnect_with_error(err)
          end
        end
      end
    | SshMsgTypes.kex_ecdh_init() =>
      // rk.derived is set once this rekey's ECDH round has been answered, so a
      // repeat would re-run the same public-key work the initial-exchange guard
      // rejects.
      if rk.derived isnt None then
        _disconnect_with_error(SshUnexpectedMessage)
        return
      end
      match _role
      | SshRoleServer => _rekey_server_ecdh(rk, payload)
      end
    | SshMsgTypes.kex_ecdh_reply() =>
      if rk.derived isnt None then
        _disconnect_with_error(SshUnexpectedMessage)
        return
      end
      match _role
      | SshRoleClient => _rekey_client_ecdh(rk, payload)
      end
    | SshMsgTypes.newkeys() =>
      _rekey_recv_newkeys(rk)
    end

  fun ref _rekey_server_ecdh(rk: SshRekeyContext, payload: Array[U8] val) =>
    """
    Server side of a rekey: derive the new shared secret and keys, sign the new
    exchange hash (bound to the unchanged session id), and send KEX_ECDH_REPLY
    followed by NEWKEYS.
    """
    (let neg: SshNegotiatedAlgorithms val, let their_ki: Array[U8] val) =
      match (rk.negotiated, rk.their_kexinit)
      | (let n: SshNegotiatedAlgorithms val, let tk: Array[U8] val) => (n, tk)
      else
        _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        return
      end
    let kex = match _kex
      | let k: SshKexStateMachine => k
      else _disconnect_with_error(SshKexFailed(SshKeyInvalid)); return
      end
    let session_id = match _context.session_id
      | let id: Array[U8] val => id
      else _disconnect_with_error(SshKexFailed(SshKeyInvalid)); return
      end
    try
      let r = SshWireReader(payload)
      r.read_byte()?  // msg type
      let client_pub = r.read_string()?
      let server_kex = SshKexCurve25519.create()?
      let server_pub = server_kex.public_key()
      match server_kex.derive_shared_secret(client_pub)
      | let shared: Array[U8] val =>
        match _host_key
        | let hk: SshHostKeyPair =>
          let pub_key = hk.public_key()
          let host_key_blob = recover val
            let w = SshWireWriter
            w.write_string_from_str(pub_key.algorithm)
            w.write_string(pub_key.public_key_data)
            w.val_bytes()
          end
          let client_version = match _context.remote_version
            | let v: String val => v else "" end
          let exchange_hash = _compute_exchange_hash(client_version,
            _version_string, their_ki, rk.our_kexinit, host_key_blob,
            client_pub, server_pub, shared)?
          // The session id never changes; the new keys bind to the original.
          let keys = kex.derive_keys(shared, exchange_hash, session_id, neg)?
          match hk.sign(exchange_hash)
          | let raw_sig: Array[U8] val =>
            let sig_blob = recover val
              let w = SshWireWriter
              w.write_string_from_str(pub_key.algorithm)
              w.write_string(raw_sig)
              w.val_bytes()
            end
            _send_kex_packet(SshMessages.kex_ecdh_reply(host_key_blob,
              server_pub, sig_blob))
            rk.derived = keys
            _rekey_send_newkeys(rk)
          | let err: SshCryptoError =>
            _disconnect_with_error(SshKexFailed(err))
          end
        else
          _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        end
      | let err: SshCryptoError =>
        _disconnect_with_error(SshKexFailed(err))
      end
    else
      _disconnect_with_error(SshKexFailed(SshKeyInvalid))
    end

  fun ref _rekey_client_ecdh(rk: SshRekeyContext, payload: Array[U8] val) =>
    """
    Client side of a rekey: verify the server presents the same host key and a
    valid signature over the new exchange hash, derive the new keys, and send
    NEWKEYS. The consumer is not re-prompted — the key was approved already.
    """
    (let neg: SshNegotiatedAlgorithms val, let their_ki: Array[U8] val) =
      match (rk.negotiated, rk.their_kexinit)
      | (let n: SshNegotiatedAlgorithms val, let tk: Array[U8] val) => (n, tk)
      else
        _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        return
      end
    let client_kex = match rk.our_kex
      | let k: SshKexCurve25519 => k
      else _disconnect_with_error(SshKexFailed(SshKeyInvalid)); return
      end
    let kex = match _kex
      | let k: SshKexStateMachine => k
      else _disconnect_with_error(SshKexFailed(SshKeyInvalid)); return
      end
    let session_id = match _context.session_id
      | let id: Array[U8] val => id
      else _disconnect_with_error(SshKexFailed(SshKeyInvalid)); return
      end
    try
      let r = SshWireReader(payload)
      r.read_byte()?  // msg type
      let host_key_blob = r.read_string()?
      let server_pub = r.read_string()?
      let signature = r.read_string()?
      let client_pub = client_kex.public_key()
      match client_kex.derive_shared_secret(server_pub)
      | let shared: Array[U8] val =>
        let server_version = match _context.remote_version
          | let v: String val => v else "" end
        let exchange_hash = _compute_exchange_hash(_version_string,
          server_version, rk.our_kexinit, their_ki, host_key_blob, client_pub,
          server_pub, shared)?
        let kr = SshWireReader(host_key_blob)
        let algo = kr.read_string_as_str()?
        let pk_data = kr.read_string()?
        let host_key = SshHostKey(algo, pk_data)
        // The server must present the same host key it did at the initial
        // exchange; a change at rekey would be a MITM swapping identities.
        match _context.server_host_key
        | let prev: SshHostKey val =>
          if not _same_host_key(prev, host_key) then
            _disconnect_with_error(SshKexFailed(SshKeyInvalid))
            return
          end
        end
        let sr = SshWireReader(signature)
        sr.read_string_as_str()?  // signature algorithm
        let raw_sig = sr.read_string()?
        match SshHostKeyVerify.verify(host_key, raw_sig, exchange_hash)
        | true =>
          let keys = kex.derive_keys(shared, exchange_hash, session_id, neg)?
          rk.derived = keys
          _rekey_send_newkeys(rk)
        | let err: SshCryptoError =>
          _disconnect_with_error(SshKexFailed(err))
        end
      | let err: SshCryptoError =>
        _disconnect_with_error(SshKexFailed(err))
      end
    else
      _disconnect_with_error(SshKexFailed(SshKeyInvalid))
    end

  fun _same_host_key(a: SshHostKey val, b: SshHostKey val): Bool =>
    if a.algorithm != b.algorithm then return false end
    if a.public_key_data.size() != b.public_key_data.size() then return false end
    var i: USize = 0
    while i < a.public_key_data.size() do
      if (try a.public_key_data(i)? else return false end)
        != (try b.public_key_data(i)? else return false end)
      then
        return false
      end
      i = i + 1
    end
    true

  fun ref _rekey_send_newkeys(rk: SshRekeyContext) =>
    """
    Send our NEWKEYS (under the old key), switch our outgoing direction to the
    new key, and end the send-blackout — flushing any deferred packets.
    """
    _send_kex_packet(SshMessages.newkeys())
    // Strict KEX persists for the connection: reset our outgoing sequence
    // number at every NEWKEYS, so _install_writer baselines this key at 0.
    if _strict_kex then _writer.reset_sequence_number() end
    match (rk.derived, rk.negotiated)
    | (let keys: SshDerivedKeys val, let neg: SshNegotiatedAlgorithms val) =>
      if not _install_writer(keys, neg) then return end
    else
      _disconnect_with_error(SshKexFailed(SshKeyInvalid))
      return
    end
    rk.sent_newkeys = true
    _send_blackout = false
    if not _flush_pending_sends() then return end
    _maybe_finish_rekey(rk)

  fun ref _rekey_recv_newkeys(rk: SshRekeyContext) =>
    """Peer NEWKEYS received: switch our incoming direction to the new key."""
    // Strict KEX persists for the connection: reset our incoming sequence
    // number at every NEWKEYS, so _install_reader baselines this key at 0.
    if _strict_kex then _reader.reset_sequence_number() end
    match (rk.derived, rk.negotiated)
    | (let keys: SshDerivedKeys val, let neg: SshNegotiatedAlgorithms val) =>
      if not _install_reader(keys, neg) then return end
    else
      _disconnect_with_error(SshKexFailed(SshKeyInvalid))
      return
    end
    rk.recv_newkeys = true
    _maybe_finish_rekey(rk)

  fun ref _maybe_finish_rekey(rk: SshRekeyContext) =>
    """Once both directions have switched to the new keys, the rekey is done."""
    if rk.sent_newkeys and rk.recv_newkeys then
      _rekey = None
    end

  fun _compute_exchange_hash(
    client_version: String val, server_version: String val,
    client_kexinit: Array[U8] val, server_kexinit: Array[U8] val,
    host_key_blob: Array[U8] val,
    client_pub: Array[U8] val, server_pub: Array[U8] val,
    shared_secret: Array[U8] val): Array[U8] val ?
  =>
    """
    Compute the full exchange hash per RFC 4253 section 8 / RFC 8731:
    H = SHA256(string(V_C) || string(V_S) || string(I_C) || string(I_S)
              || string(K_S) || string(Q_C) || string(Q_S) || mpint(K))
    Errors if the SHA-256 computation fails.
    """
    let hash_input = recover val
      let w = SshWireWriter
      w.write_string_from_str(client_version)
      w.write_string_from_str(server_version)
      w.write_string(client_kexinit)
      w.write_string(server_kexinit)
      w.write_string(host_key_blob)
      w.write_string(client_pub)
      w.write_string(server_pub)
      w.write_mpint(shared_secret)
      w.val_bytes()
    end
    SshHash.sha256(hash_input)?

  fun ref _activate_encryption(s: SshStateKeyExchange, session_id: Array[U8] val):
    Bool
  =>
    """
    Derive keys from the completed key exchange and activate the negotiated
    cipher on the reader and writer. Returns true once a cipher is installed.
    Returns false — after tearing the connection down — when key material is
    missing or the negotiated cipher is unsupported, so the session is never
    marked encrypted without a cipher actually in place.
    """
    (let shared: Array[U8] val, let hash: Array[U8] val) =
      match (s.shared_secret, s.exchange_hash)
      | (let sh: Array[U8] val, let h: Array[U8] val) => (sh, h)
      else
        _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        return false
      end

    let kex = match _kex
    | let k: SshKexStateMachine => k
    else
      _disconnect_with_error(SshKexFailed(SshKeyInvalid))
      return false
    end

    let keys =
      try
        kex.derive_keys(shared, hash, session_id, s.negotiated)?
      else
        _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        return false
      end

    // Install both directions at once for the initial handshake (no encrypted
    // data flows before NEWKEYS, so the directional timing is moot here). Rekey
    // installs each direction separately at its own NEWKEYS boundary.
    if not _install_writer(keys, s.negotiated) then return false end
    if not _install_reader(keys, s.negotiated) then return false end

    _encrypted = true
    true

  fun ref _install_writer(keys: SshDerivedKeys val,
    neg: SshNegotiatedAlgorithms val): Bool
  =>
    """Configure the writer for our outgoing direction from derived key data."""
    // The writer encrypts our outgoing direction: c2s for a client, s2c for a
    // server. SSH negotiates the cipher/MAC for each direction independently.
    (let cipher: String val, let mac: String val) =
      match _role
      | SshRoleClient => (neg.cipher_c2s, neg.mac_c2s)
      | SshRoleServer => (neg.cipher_s2c, neg.mac_s2c)
      end
    (let key: Array[U8] val, let iv: Array[U8] val, let mac_key: Array[U8] val) =
      match _role
      | SshRoleClient => (keys.enc_key_c2s, keys.iv_c2s, keys.mac_key_c2s)
      | SshRoleServer => (keys.enc_key_s2c, keys.iv_s2c, keys.mac_key_s2c)
      end
    if _install_write_cipher(cipher, mac, key, iv, mac_key) then
      // Baseline the per-key packet count for this direction. For chacha the
      // nonce is the sequence number, so this bounds how many nonces a single
      // key sees before the next rekey.
      _write_baseline = _writer.sequence_number()
      true
    else
      false
    end

  fun ref _install_reader(keys: SshDerivedKeys val,
    neg: SshNegotiatedAlgorithms val): Bool
  =>
    """Configure the reader for the peer's incoming direction."""
    (let cipher: String val, let mac: String val) =
      match _role
      | SshRoleClient => (neg.cipher_s2c, neg.mac_s2c)
      | SshRoleServer => (neg.cipher_c2s, neg.mac_c2s)
      end
    (let key: Array[U8] val, let iv: Array[U8] val, let mac_key: Array[U8] val) =
      match _role
      | SshRoleClient => (keys.enc_key_s2c, keys.iv_s2c, keys.mac_key_s2c)
      | SshRoleServer => (keys.enc_key_c2s, keys.iv_c2s, keys.mac_key_c2s)
      end
    if _install_read_cipher(cipher, mac, key, iv, mac_key) then
      _read_baseline = _reader.sequence_number()
      true
    else
      false
    end

  fun ref _install_write_cipher(cipher_name: String val, mac_name: String val,
    key: Array[U8] val, iv: Array[U8] val, mac_key: Array[U8] val): Bool
  =>
    """
    Configure the packet writer for our outgoing direction. Returns true once a
    cipher is installed; on failure tears the connection down and returns false.
    """
    if cipher_name == "chacha20-poly1305@openssh.com" then
      // 64 bytes of key material: main_key(32) || header_key(32). No separate
      // IV or MAC key — poly1305 is the MAC, the nonce is the sequence number.
      try
        _writer.set_chacha20_poly1305(
          SshChacha20Poly1305Context(_first_bytes(key, 64))?)
        true
      else
        _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        false
      end
    elseif (cipher_name == "aes256-gcm@openssh.com")
      or (cipher_name == "aes128-gcm@openssh.com")
    then
      let key_len: USize =
        if cipher_name == "aes128-gcm@openssh.com" then 16 else 32 end
      _writer.set_gcm_params(_first_bytes(key, key_len), _first_bytes(iv, 12))
      true
    elseif cipher_name == "aes256-ctr" then
      let use_sha512 = mac_name == "hmac-sha2-512"
      let mac_len: USize = if use_sha512 then 64 else 32 end
      try
        let ctx = SshCipherContext.aes_256_ctr(key, _first_bytes(iv, 16), true)?
        _writer.set_stream_cipher(ctx, _first_bytes(mac_key, mac_len), mac_len,
          use_sha512)
        true
      else
        _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        false
      end
    else
      // Negotiated a cipher the transport cannot apply. Fail closed rather
      // than fall through and send plaintext on a session marked encrypted.
      _disconnect_with_error(SshAlgorithmNegotiationFailed)
      false
    end

  fun ref _install_read_cipher(cipher_name: String val, mac_name: String val,
    key: Array[U8] val, iv: Array[U8] val, mac_key: Array[U8] val): Bool
  =>
    """
    Configure the packet reader for the peer's incoming direction. Returns true
    once a cipher is installed; on failure tears the connection down.
    """
    if cipher_name == "chacha20-poly1305@openssh.com" then
      try
        _reader.set_chacha20_poly1305(
          SshChacha20Poly1305Context(_first_bytes(key, 64))?)
        true
      else
        _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        false
      end
    elseif (cipher_name == "aes256-gcm@openssh.com")
      or (cipher_name == "aes128-gcm@openssh.com")
    then
      let key_len: USize =
        if cipher_name == "aes128-gcm@openssh.com" then 16 else 32 end
      _reader.set_gcm_params(_first_bytes(key, key_len), _first_bytes(iv, 12))
      true
    elseif cipher_name == "aes256-ctr" then
      let use_sha512 = mac_name == "hmac-sha2-512"
      let mac_len: USize = if use_sha512 then 64 else 32 end
      try
        let ctx = SshCipherContext.aes_256_ctr(key, _first_bytes(iv, 16), false)?
        _reader.set_stream_cipher(ctx, _first_bytes(mac_key, mac_len), mac_len,
          16, use_sha512)
        true
      else
        _disconnect_with_error(SshKexFailed(SshKeyInvalid))
        false
      end
    else
      _disconnect_with_error(SshAlgorithmNegotiationFailed)
      false
    end

  fun _first_bytes(src: Array[U8] val, n: USize): Array[U8] val =>
    """
    Return a copy of the first n bytes of src. src is expected to hold at
    least n bytes; a shorter src yields a shorter result, which downstream
    construction (e.g. the chacha context) rejects.
    """
    recover val
      let b = Array[U8].create(n)
      var i: USize = 0
      while i < n do
        try b.push(src(i)?) end
        i = i + 1
      end
      b
    end

  fun ref _handle_auth(msg_type: U8, payload: Array[U8] val) =>
    """Handle messages during authentication."""
    match msg_type
    | SshAuthMsgTypes.userauth_success() =>
      // Only a client acts on USERAUTH_SUCCESS. A server that receives this
      // message has authenticated nothing; acting on it would let a client
      // jump straight to Connected, bypassing authentication entirely.
      match _role
      | SshRoleClient =>
        let session_id = match _context.session_id
        | let id: Array[U8] val => id
        else
          recover val Array[U8] end
        end
        _state = SshStateConnected(session_id)
        match _client_notify
        | let n: SshClientNotify tag => n.ssh_ready(this)
        end
      end
    | SshAuthMsgTypes.userauth_failure() =>
      match _auth
      | let auth_sm: SshAuthStateMachine =>
        match auth_sm.handle_failure()
        | let req: Array[U8] val =>
          _send_packet(req)
        | SshAuthRejected =>
          match _client_notify
          | let n: SshClientNotify tag =>
            n.ssh_auth_failed(this, SshAuthRejected)
          end
          _disconnect_with_error(SshConnectionLost)
        end
      end
    | SshAuthMsgTypes.userauth_request() =>
      match _role
      | SshRoleServer =>
        try
          let r = SshWireReader(payload)
          r.read_byte()?  // msg type
          let username = r.read_string_as_str()?
          let service = r.read_string_as_str()?
          let method = r.read_string_as_str()?
          // RFC 4252: a userauth request targets the "ssh-connection" service.
          // Enforce it so the service value bound into a publickey signature is
          // fixed rather than attacker-chosen.
          if service != "ssh-connection" then
            _auth_failed(["publickey"; "password"])
            return
          end
          let method_data: SshAuthMethodData val = match method
          | "none" => SshAuthNoneData
          | "password" =>
            r.read_bool()?  // new_password flag (ignore)
            SshAuthPasswordData(r.read_string_as_str()?)
          | "publickey" =>
            let has_sig = r.read_bool()?
            let algo = r.read_string_as_str()?
            let pk = r.read_string()?
            let sig = if has_sig then r.read_string()? else None end
            SshAuthPublicKeyData(algo, pk, sig)
          else
            SshAuthNoneData  // Unknown method treated as none
          end
          // For a signed publickey request, prove possession of the private
          // key before consulting the consumer. An invalid signature is
          // rejected here, so the consumer is only ever asked to authorize a
          // key whose ownership has been cryptographically established.
          match method_data
          | let pkd: SshAuthPublicKeyData val =>
            match pkd.signature
            | let _: Array[U8] val =>
              if not _verify_publickey_signature(username, service, pkd) then
                _auth_failed(["publickey"; "password"])
                return
              end
            end
          end

          let request = SshAuthRequest(username, method, method_data)
          match _server_notify
          | let n: SshServerNotify tag => n.ssh_auth_request(this, request)
          end
        else
          // A malformed/truncated auth request is a protocol violation; tear
          // down rather than silently drop it and leave the peer waiting.
          _disconnect_with_error(SshPacketCorrupt)
        end
      end
    | SshAuthMsgTypes.userauth_pk_ok() =>
      // Server accepted our public key query. Send actual auth with signature.
      match _role
      | SshRoleClient =>
        match _auth
        | let auth_sm: SshAuthStateMachine =>
          let session_id = match _context.session_id
          | let id: Array[U8] val => id
          else recover val Array[U8] end
          end
          match auth_sm.handle_pk_ok(session_id)
          | let req: Array[U8] val =>
            _send_packet(req)
          | SshAuthRejected =>
            match _client_notify
            | let n: SshClientNotify tag =>
              n.ssh_auth_failed(this, SshAuthRejected)
            end
            _disconnect_with_error(SshConnectionLost)
          end
        end
      end
    | SshAuthMsgTypes.service_accept() =>
      // Client received service_accept, send first auth request
      match _role
      | SshRoleClient =>
        match _auth
        | let auth_sm: SshAuthStateMachine =>
          match auth_sm.next_request()
          | let req: Array[U8] val =>
            _send_packet(req)
          | SshAuthRejected =>
            match _client_notify
            | let n: SshClientNotify tag =>
              n.ssh_auth_failed(this, SshAuthRejected)
            end
            _disconnect_with_error(SshConnectionLost)
          end
        end
      end
    | SshAuthMsgTypes.service_request() =>
      match _role
      | SshRoleServer =>
        try
          let r = SshWireReader(payload)
          r.read_byte()?  // msg type
          let service = r.read_string_as_str()?
          // The only service available before authentication is ssh-userauth
          // (RFC 4253 §10). Reject any other name with a clean disconnect rather
          // than SERVICE_ACCEPTing an arbitrary service we do not provide.
          if service != "ssh-userauth" then
            _disconnect_with_error(SshPacketCorrupt)
            return
          end
          let w = SshWireWriter
          w.write_byte(SshAuthMsgTypes.service_accept())
          w.write_string_from_str(service)
          _send_packet(w.val_bytes())
        else
          // A truncated SERVICE_REQUEST is a protocol violation; tear down
          // instead of silently dropping it and leaving the peer waiting.
          _disconnect_with_error(SshPacketCorrupt)
        end
      end
    end

  fun ref _handle_connected(msg_type: U8, payload: Array[U8] val,
    state: SshStateConnected)
  =>
    """Handle messages when connected (channel operations + rekey)."""
    match msg_type
    | SshChannelMsgTypes.channel_open() =>
      try
        let r = SshWireReader(payload)
        r.read_byte()?  // msg type
        let ch_type = r.read_string_as_str()?
        let sender_channel = r.read_u32()?
        let initial_window = r.read_u32()?
        let max_packet = r.read_u32()?
        match _server_notify
        | let n: SshServerNotify tag =>
          // Cap concurrent channels before allocating state so a peer cannot
          // grow _channels without bound by flooding CHANNEL_OPEN.
          // RFC 4254 §5.1 reason 4 = SSH_OPEN_RESOURCE_SHORTAGE.
          if _channel_manager.at_capacity() then
            _send_packet(SshChannelMessages.channel_open_failure(
              sender_channel, 4, "too many open channels"))
          else
            match _channel_manager.accept_channel(
              0, sender_channel, initial_window, max_packet, ch_type)
            | let local_id: U32 =>
              n.ssh_channel_open_request(this, local_id, ch_type)
            | let _: SshChannelUnsupportedPacketSize =>
              _send_packet(SshChannelMessages.channel_open_failure(
                sender_channel, 1, "unsupported peer packet size"))
            end
          end
        | None =>
          // A client never accepts inbound channels. Reject without allocating
          // state: there is no client-side authorization callback, so an
          // accepted channel would be orphaned and never removable — a memory
          // DoS driven by a malicious server. RFC 4254 §5.1 reason 1 =
          // SSH_OPEN_ADMINISTRATIVELY_PROHIBITED.
          _send_packet(SshChannelMessages.channel_open_failure(
            sender_channel, 1, "channels not accepted"))
        end
      end
    | SshChannelMsgTypes.channel_open_confirmation() =>
      try
        let r = SshWireReader(payload)
        r.read_byte()?  // msg type
        let recipient_channel = r.read_u32()?
        let sender_channel = r.read_u32()?
        let initial_window = r.read_u32()?
        let max_packet = r.read_u32()?
        // recipient_channel must name a channel we actually opened.
        if _channel_manager.get(recipient_channel) is None then error end
        match _channel_manager.confirm_channel(recipient_channel,
          sender_channel, initial_window, max_packet)
        | None => _notify_channel_opened(recipient_channel)
        | let err: SshChannelUnsupportedPacketSize =>
          _send_packet(SshChannelMessages.channel_close(sender_channel))
          _channel_manager.close_channel(recipient_channel)
          _notify_channel_error(recipient_channel, err)
        | let _: SshChannelError => error
        end
      end
    | SshChannelMsgTypes.channel_open_failure() =>
      try
        let r = SshWireReader(payload)
        r.read_byte()?  // msg type
        let recipient_channel = r.read_u32()?
        let reason_code = r.read_u32()?
        let description = r.read_string_as_str()?
        if _channel_manager.get(recipient_channel) is None then error end
        _channel_manager.close_channel(recipient_channel)
        _notify_channel_error(recipient_channel,
          SshChannelOpenFailed(reason_code, description))
      end
    | SshChannelMsgTypes.channel_data() =>
      try
        let r = SshWireReader(payload)
        r.read_byte()?  // msg type
        let recipient_channel = r.read_u32()?
        let data = r.read_string()?
        // Drop data for a channel we never opened. recipient_channel is
        // attacker-controlled; without this a peer can inject data on, and
        // surface to the consumer, channel ids that never existed.
        let ch = match _channel_manager.get(recipient_channel)
          | let c: SshChannelState => c
          | None => error
          end
        // A channel existing is not the same as its open having been
        // authorized: state is allocated when the peer's CHANNEL_OPEN arrives,
        // but the consumer's decision is an asynchronous behavior, and a whole
        // TCP segment is dispatched before it can run. Data pipelined behind
        // CHANNEL_OPEN would otherwise reach the consumer for a channel the
        // consumer goes on to reject.
        if not ch.authorized then error end
        // Enforce the receive window we advertised. A peer that overruns it is
        // violating flow control; close the channel rather than deliver
        // unbounded data.
        match _channel_manager.channel_data_received(recipient_channel,
          data.size())
        | let _: SshChannelError =>
          _send_packet(SshChannelMessages.channel_close(ch.remote_id))
          _channel_manager.close_channel(recipient_channel)
          _notify_channel_error(recipient_channel, SshWindowExhausted)
        | let _: U32 =>
          let transformed = match ch.pty
            | let pty: SshPtyState val => pty.transform(data)
            | None => data
            end
          _notify_channel_data(recipient_channel, transformed)
          // Replenish the receive window so the peer can keep sending; data
          // handed to the consumer is treated as consumed.
          match _channel_manager.replenish_local_window(recipient_channel)
          | let inc: U32 =>
            _send_packet(SshChannelMessages.channel_window_adjust(
              ch.remote_id, inc))
          end
        end
      end
    | SshChannelMsgTypes.channel_window_adjust() =>
      try
        let r = SshWireReader(payload)
        r.read_byte()?  // msg type
        let recipient_channel = r.read_u32()?
        let bytes_to_add = r.read_u32()?
        // Same gate as CHANNEL_DATA: a window grant on a channel whose open has
        // not been authorized is not accounted for.
        match _channel_manager.get(recipient_channel)
        | let ch: SshChannelState =>
          if ch.authorized then
            let was_blocked = ch.send_blocked and (ch.remote_window == 0)
            _channel_manager.window_adjust(recipient_channel, bytes_to_add)
            if was_blocked and (ch.remote_window > 0) then
              ch.send_blocked = false
              _notify_window_available(recipient_channel)
            end
          end
        end
      end
    | SshChannelMsgTypes.channel_eof() =>
      None
    | SshChannelMsgTypes.channel_close() =>
      try
        let r = SshWireReader(payload)
        r.read_byte()?  // msg type
        let recipient_channel = r.read_u32()?
        if _channel_manager.get(recipient_channel) is None then error end
        _channel_manager.close_channel(recipient_channel)
        _notify_channel_closed(recipient_channel)
      end
    | SshChannelMsgTypes.channel_request() =>
      let r = SshWireReader(payload)
      // Parse the fixed header first. If even this fails we do not know which
      // channel to answer, so the packet is simply dropped.
      let header =
        try
          r.read_byte()?  // msg type
          (r.read_u32()?, r.read_string_as_str()?, r.read_bool()?)
        end
      match header
      | (let recipient_channel: U32, let request_type: String val,
         let want_reply: Bool) =>
        // A shell/exec/pty-req on a channel whose open has not been authorized
        // would drive the consumer's handlers for a channel it is about to
        // reject — the peer can pipeline the request behind CHANNEL_OPEN, and
        // the whole segment is dispatched before the consumer's asynchronous
        // decision runs. Answer it as a failure instead of dispatching it.
        match _channel_manager.get(recipient_channel)
        | let ch: SshChannelState =>
          if not ch.authorized then
            if want_reply then
              _send_packet(SshChannelMessages.channel_failure(ch.remote_id))
            end
            return
          end
        | None => return
        end
        // Parse and dispatch the request-specific body. A truncated body errors
        // out of this try and leaves dispatched = false.
        let dispatched =
          try
            match _server_notify
            | let n: SshServerNotify tag =>
              if request_type == "pty-req" then
                let term = r.read_string_as_str()?
                let width_chars = r.read_u32()?
                let height_rows = r.read_u32()?
                let width_pixels = r.read_u32()?
                let height_pixels = r.read_u32()?
                let mode_data = r.read_string()?
                let modes = SshTerminalModes.parse_modes(mode_data)?
                let pty = SshPtyState(term, width_chars, height_rows,
                  width_pixels, height_pixels, modes)
                // Store optimistically; mark pending until accept/reject
                match _channel_manager.get(recipient_channel)
                | let ch: SshChannelState =>
                  ch.pty = pty
                  ch.pty_pending = true
                end
                n.ssh_pty_request(this, recipient_channel, pty, want_reply)
              elseif request_type == "shell" then
                n.ssh_shell_request(this, recipient_channel, want_reply)
              elseif request_type == "window-change" then
                let width_chars = r.read_u32()?
                let height_rows = r.read_u32()?
                let width_pixels = r.read_u32()?
                let height_pixels = r.read_u32()?
                match _channel_manager.get(recipient_channel)
                | let ch: SshChannelState =>
                  match ch.pty
                  | let old_pty: SshPtyState val =>
                    ch.pty = SshPtyState.with_dimensions(old_pty,
                      width_chars, height_rows, width_pixels, height_pixels)
                  end
                end
                n.ssh_window_change(this, recipient_channel,
                  width_chars, height_rows, width_pixels, height_pixels)
              else
                n.ssh_channel_request(this, recipient_channel, request_type,
                  want_reply)
              end
            end
            true
          else
            false
          end
        // A malformed request body that asked for a reply must still get a
        // CHANNEL_FAILURE, or the peer blocks waiting for a response that the
        // silently-dropped request would never produce.
        if (not dispatched) and want_reply then
          match _channel_manager.get(recipient_channel)
          | let ch: SshChannelState =>
            _send_packet(SshChannelMessages.channel_failure(ch.remote_id))
          end
        end
      end
    | SshChannelMsgTypes.channel_success() =>
      _handle_channel_request_result(payload, true)
    | SshChannelMsgTypes.channel_failure() =>
      _handle_channel_request_result(payload, false)
    | SshMsgTypes.disconnect() =>
      _peer_disconnected()
    else
      // RFC 4253 §11.4: respond to an unrecognized message with
      // SSH_MSG_UNIMPLEMENTED carrying the receive sequence number of the
      // offending packet. The reader has already advanced past it, so that
      // packet's number is one less than the reader's current sequence number.
      _send_packet(SshMessages.unimplemented(_reader.sequence_number() - 1))
    end

  fun ref _handle_channel_request_result(payload: Array[U8] val,
    accepted: Bool)
  =>
    if payload.size() != 5 then return end
    try
      let r = SshWireReader(payload)
      r.read_byte()?
      let channel_id = r.read_u32()?
      match _channel_manager.get(channel_id)
      | let ch: SshChannelState =>
        if ch.authorized and (ch.pending_request_replies > 0) then
          ch.pending_request_replies = ch.pending_request_replies - 1
          match _client_notify
          | let n: SshClientNotify tag =>
            n.ssh_channel_request_result(this, channel_id, accepted)
          end
        end
      end
    end

  fun ref _send_packet(payload: Array[U8] val): Bool =>
    """
    Frame and send a packet. While a rekey is in its send-blackout (from our
    KEXINIT to our NEWKEYS) non-key-exchange packets are deferred so we emit
    only key-exchange traffic, as RFC 4253 §9 requires.
    """
    if _send_blackout then
      // The peer opens the blackout with its KEXINIT and closes it by driving
      // the exchange to NEWKEYS. A peer that opens one and then stops turns
      // every packet we would have sent — including the replies its own traffic
      // provokes — into a queued allocation that is never drained, so the queue
      // is bounded and a peer that exceeds it loses the session.
      if (_pending_sends.size() >= _max_pending_sends())
        or ((_pending_bytes + payload.size()) > _max_pending_bytes())
      then
        _disconnect_with_error(SshPendingSendsExceeded)
        return false
      end
      _pending_sends.push(payload)
      _pending_bytes = _pending_bytes + payload.size()
      match _flow_observer
      | let observer: _SshFlowObserver tag => observer._flow_admitted(payload)
      end
      return true
    end
    if not _frame_and_send(payload) then
      _disconnect_with_error(SshConnectionLost)
      return false
    end
    _check_packet_limits()
    true

  fun _max_pending_sends(): USize =>
    """Packets deferrable during a rekey send-blackout before we give up."""
    _pending_packet_limit

  fun _max_pending_bytes(): USize =>
    """Bytes deferrable during a rekey send-blackout before we give up."""
    4 * 1024 * 1024

  fun ref _send_kex_packet(payload: Array[U8] val) =>
    """Send a key-exchange/transport packet, bypassing the rekey send-blackout."""
    if not _frame_and_send(payload) then
      _disconnect_with_error(SshConnectionLost)
    end

  fun ref _frame_and_send(payload: Array[U8] val): Bool =>
    match _bridge
    | let b: SshClientTcpBridge tag =>
      b.write(_writer.write(payload, _current_block_size()))
      true
    | let b: SshServerTcpBridge tag =>
      b.write(_writer.write(payload, _current_block_size()))
      true
    | None => false
    end

  fun ref _flush_pending_sends(): Bool =>
    """Send packets deferred during the rekey send-blackout, in order."""
    while _pending_sends.size() > 0 do
      try
        if not _frame_and_send(_pending_sends(0)?) then
          _disconnect_with_error(SshConnectionLost)
          return false
        end
        _pending_sends.shift()?
      else
        _Unreachable()
      end
    end
    _pending_bytes = 0
    true

  fun _rekey_packet_limit(): U32 => 0x4000_0000  // 2^30: initiate rekey
  fun _hard_packet_limit(): U32 =>
    match _hard_packet_limit_override
    | let limit: U32 => limit
    | None => 0x7000_0000  // backstop, well under 2^31
    end

  fun _packets_since_rekey(): U32 =>
    """
    The larger of the two directions' packet counts since their current key was
    installed. U32 subtraction is modular, so this stays correct across a
    sequence-number wrap as long as the true distance is below 2^32 (it is — we
    rekey every 2^30).
    """
    (_writer.sequence_number() - _write_baseline)
      .max(_reader.sequence_number() - _read_baseline)

  fun ref _check_packet_limits(): Bool =>
    """
    Keep each key's packet (and thus nonce) count bounded. Initiate a rekey at
    2^30 packets so the session can continue indefinitely; as a backstop, if a
    rekey never completes, fail closed well before the 2^32 nonce wrap. Returns
    true once teardown has been initiated.
    """
    if _shutting_down then return false end
    let since = _packets_since_rekey()
    if since >= _hard_packet_limit() then
      _shutting_down = true
      _disconnect_with_error(SshRekeyUnsupported)
      return true
    end
    if (since >= _rekey_packet_limit()) and (_rekey is None) then
      _start_rekey()
    end
    false

  fun _current_block_size(): USize =>
    """Return cipher block size, or 8 for plaintext."""
    if _encrypted then 16 else 8 end

  fun ref _disconnect_with_error(err: SshTransportError) =>
    """Send SSH_MSG_DISCONNECT and transition to Disconnected."""
    if _terminated then return end
    _terminated = true
    _send_packet(SshMessages.disconnect(
      SshDisconnectCodes.protocol_error(), err.string()))
    _finish_terminal()
    _close_bridge()
    _state = SshStateDisconnected(err)
    _notify_error(err)
    _notify_disconnected()

  fun ref _peer_disconnected() =>
    """React once to a peer-initiated DISCONNECT message."""
    if _terminated then return end
    _terminated = true
    _finish_terminal()
    _state = SshStateDisconnected(SshConnectionLost)
    _notify_disconnected()

  fun ref _finish_terminal() =>
    _pending_sends.clear()
    _pending_bytes = 0
    _channel_manager.clear_send_blocked()

  fun ref _close_bridge() =>
    """Hard-close the TCP bridge to ensure immediate resource cleanup."""
    match _bridge
    | let b: SshClientTcpBridge tag => b.dispose()
    | let b: SshServerTcpBridge tag => b.dispose()
    end

  fun ref _begin_authentication() =>
    """
    Transition to the authentication phase. Called once encryption is active
    and, on a client, the server's host key has been approved. A client kicks
    off authentication by requesting the ssh-userauth service; a server waits
    for the client to do so.
    """
    let session_id = match _context.session_id
    | let id: Array[U8] val => id
    else
      recover val Array[U8] end
    end
    _state = SshStateAuth(session_id)
    match _role
    | SshRoleClient =>
      _send_packet(SshAuthMessages.service_request("ssh-userauth"))
    end

  fun _max_auth_attempts(): U32 =>
    """
    Failed authentication attempts tolerated on one session before it is torn
    down. Matches OpenSSH's MaxAuthTries default.
    """
    6

  fun ref _auth_failed(remaining: Array[String val] val) =>
    """
    Answer a rejected authentication attempt and count it, disconnecting once
    the cap is reached. Every rejection path routes through here: a path that
    sent USERAUTH_FAILURE directly would hand the peer an uncounted guess.
    """
    _auth_failures = _auth_failures + 1
    _send_packet(SshAuthMessages.userauth_failure(remaining, false))
    if _auth_failures >= _max_auth_attempts() then
      _disconnect_with_error(SshTooManyAuthAttempts)
    end

  fun _verify_publickey_signature(username: String val, service: String val,
    pk: SshAuthPublicKeyData val): Bool
  =>
    """
    Verify a publickey userauth signature against the established session id.
    Delegates to SshPublicKeyVerifier; without a session id (which is always
    set by the time auth runs) verification fails closed.
    """
    match _context.session_id
    | let id: Array[U8] val =>
      SshPublicKeyVerifier.verify(id, username, service, pk)
    else
      false
    end

  // --- Notification helpers ---

  fun ref _notify_error(err: SshTransportError) =>
    match _client_notify
    | let n: SshClientNotify tag => n.ssh_error(this, err)
    end
    match _server_notify
    | let n: SshServerNotify tag => n.ssh_error(this, err)
    end

  fun ref _notify_disconnected() =>
    match _client_notify
    | let n: SshClientNotify tag => n.ssh_disconnected(this)
    end
    match _server_notify
    | let n: SshServerNotify tag => n.ssh_disconnected(this)
    end

  fun ref _notify_channel_opened(channel_id: U32) =>
    match _client_notify
    | let n: SshClientNotify tag => n.ssh_channel_opened(this, channel_id)
    end

  fun ref _notify_channel_data(channel_id: U32, data: Array[U8] val) =>
    match _client_notify
    | let n: SshClientNotify tag => n.ssh_channel_data(this, channel_id, data)
    end
    match _server_notify
    | let n: SshServerNotify tag => n.ssh_channel_data(this, channel_id, data)
    end

  fun ref _notify_channel_error(channel_id: U32, err: SshChannelError) =>
    match _client_notify
    | let n: SshClientNotify tag => n.ssh_channel_error(this, channel_id, err)
    end
    match _server_notify
    | let n: SshServerNotify tag => n.ssh_channel_error(this, channel_id, err)
    end

  fun ref _notify_send_result(channel_id: U32, data: Array[U8] val,
    accepted: USize, outcome: SshSendOutcome)
  =>
    match _client_notify
    | let n: SshClientNotify tag =>
      n.ssh_channel_send_result(this, channel_id, data, accepted, outcome)
    end
    match _server_notify
    | let n: SshServerNotify tag =>
      n.ssh_channel_send_result(this, channel_id, data, accepted, outcome)
    end

  fun ref _notify_window_available(channel_id: U32) =>
    match _client_notify
    | let n: SshClientNotify tag =>
      n.ssh_channel_window_available(this, channel_id)
    end
    match _server_notify
    | let n: SshServerNotify tag =>
      n.ssh_channel_window_available(this, channel_id)
    end

  fun ref _notify_channel_closed(channel_id: U32) =>
    match _client_notify
    | let n: SshClientNotify tag => n.ssh_channel_closed(this, channel_id)
    end
    match _server_notify
    | let n: SshServerNotify tag => n.ssh_channel_closed(this, channel_id)
    end