Skip to content

Regenerating bundled moduli

SIMNOS bundles a Diffie-Hellman Group Exchange (DH-GEX) moduli file at simnos/plugins/servers/moduli so that the paramiko-based SSH server can support GEX key exchange on hosts that lack a system moduli file (typically Windows / macOS). This page documents why the file exists, how often it should be regenerated, and the exact procedure.

Why SIMNOS bundles moduli

paramiko itself does not ship a moduli file. It expects the host system to provide one at /etc/ssh/moduli or /usr/local/etc/moduli, which Windows and macOS hosts typically do not have. Without a moduli file, paramiko's GEX server-mode hits a stale-snapshot bug (paramiko issue 2126) which forces SIMNOS to disable all GEX algorithms server-side as a workaround. Combined with the SHA-1 KEX removal in paramiko 5.0, this produces an empty KEX overlap with SHA-1-leaning legacy clients such as netmiko.fortinet.FortinetSSH, leading to deterministic connection failures on Windows / macOS.

Bundling our own moduli file lets the server advertise gex-sha256 again on those platforms, restoring connectivity without re-introducing SHA-1 KEX. See the follow-up comment on paramiko issue 2126 for the full root-cause analysis.

Why not rely on paramiko's bundled moduli?

paramiko does not ship one (verified: no moduli file exists inside the installed paramiko package). By bundling our own, SIMNOS becomes self-contained on platforms without an OpenSSH server installed.

Why not copy OpenSSH's openssh-portable moduli directly?

Possible (license is BSD-style, compatible with the SIMNOS MIT license), but generating our own via ssh-keygen gives us:

  • A known generation date (recorded in the file by the ssh-keygen # Time header).
  • A clear rotation responsibility (we control when to regenerate).
  • Independence from OpenSSH's release cadence.

The cost is just a one-time multi-hour generation on a maintainer machine.

Note: moduli is public information

DH primes are sent to the client during every KEX, so they are not secret. OpenSSH itself ships moduli in the openssh-portable public repository. Bundling the moduli file in this public repo follows the same practice — there is no security implication.

Rotation policy

  • Every 3 years (next recommended: 2029-05 for the current bundle, computed from the ssh-keygen generation timestamp in the first column of each moduli line — YYYYMMDDHHMMSS format — plus 3 years).
  • Ad-hoc if a new logjam-class precomputation attack is reported against 2048-bit-or-larger DH-GEX.

The rotation cadence is intentionally relaxed — DH primes of 2048 bits or larger remain outside the practical reach of precomputation attacks, and SIMNOS is primarily used as a test simulator rather than a production SSH server. The 3-year baseline mirrors the typical refresh interval used by major Linux distributions.

Procedure

Run on a Linux maintainer machine. The -M screen step is CPU-bound and single-threaded. On a physical host the total runtime is in the hours; inside a VM it may run much longer, so the 3072-bit and 4096-bit steps are split across multiple parallel ssh-keygen processes via split -n l/N. The file is generated once and committed; CI does not regenerate it.

The current bundle contains 2048-bit, 3072-bit, and 4096-bit primes. The 4096-bit batch was generated on a Windows host (see the Alternative: Windows PowerShell section below) because ssh-keygen -M screen for 4096-bit candidates is prohibitively slow inside the VM host used for the 2048/3072 batch.

# 1. Generate candidates for each bit size (fast — seconds to minutes)
ssh-keygen -M generate -O bits=2048 moduli-2048.candidates
ssh-keygen -M generate -O bits=3072 moduli-3072.candidates
ssh-keygen -M generate -O bits=4096 moduli-4096.candidates

# 2a. Screen the 2048-bit candidates in a single process (~30 min - 3 h)
ssh-keygen -M screen -f moduli-2048.candidates moduli-2048

# 2b. Screen the 3072-bit candidates by splitting the candidate file and
#     running multiple ssh-keygen processes in parallel (one per available
#     CPU). Adjust `N` (= 8 below) to your physical / virtual core count,
#     which you can inspect with `nproc` on Linux.
split -n l/8 moduli-3072.candidates moduli-3072.chunk.
for chunk in moduli-3072.chunk.*; do
  (ssh-keygen -M screen -f "$chunk" "${chunk}.screened") &
done
wait
cat moduli-3072.chunk.*.screened > moduli-3072

# 2c. Same parallel pattern for 4096-bit candidates (see 2b for the
#     `N` tuning note — adjust to your `nproc`). Single-process
#     screening for 4096 typically runs 10 hours or more on a VM, so
#     parallel split is strongly recommended (a bare-metal 8-core box
#     finishes the screened batch in roughly 1 hour).
split -n l/8 moduli-4096.candidates moduli-4096.chunk.
for chunk in moduli-4096.chunk.*; do
  (ssh-keygen -M screen -f "$chunk" "${chunk}.screened") &
done
wait
cat moduli-4096.chunk.*.screened > moduli-4096

# 3. Concatenate into the bundled file (file name has no extension,
#    matching the OpenSSH convention)
cat moduli-2048 moduli-3072 moduli-4096 > simnos/plugins/servers/moduli

# 4. Clean up intermediates (`-f` to ignore missing files on partial runs)
rm -f moduli-*.candidates moduli-2048 moduli-3072 moduli-4096 \
      moduli-3072.chunk.* moduli-3072.chunk.*.screened \
      moduli-4096.chunk.* moduli-4096.chunk.*.screened

# 5. Verify (should be a few hundred to a few thousand lines).
# Each line starts with the ssh-keygen generation timestamp in
# YYYYMMDDHHMMSS format, e.g. `20260516054136 2 6 100 2047 2 D5AC...`
wc -l simnos/plugins/servers/moduli
head -1 simnos/plugins/servers/moduli

These commands write only to the current directory; the system's /etc/ssh/moduli is not touched and no root privileges are required.

Alternative: Windows PowerShell

If the maintainer's Linux host is a VM, the 4096-bit screening step can take 10+ hours. Running directly on a Windows host with PowerShell 7 (using the built-in OpenSSH client) avoids the VM overhead and finishes much faster — roughly 1 hour for 4096-bit on a bare-metal 8-core machine.

# Run from any working directory (writes to cwd only).
# Requires: Windows 10/11 with OpenSSH Client enabled and PowerShell 7+
#          (ForEach-Object -Parallel is unavailable on PowerShell 5.1).

# 1. Generate candidates (single-threaded, ~5-15 min for 4096-bit)
ssh-keygen -M generate -O bits=4096 candidates-4096.txt

# 2. Split candidates into 8 chunks (adjust 8 to your physical core count)
#    `Select-Object -Skip / -First` is used instead of `$lines[$start..$end]`
#    because PowerShell's `..` range operator reverses (rather than yielding
#    an empty array) when $start > $end — this would silently duplicate
#    rows in tiny test inputs.
$lines = Get-Content candidates-4096.txt
$chunkSize = [Math]::Ceiling($lines.Count / 8)
0..7 | ForEach-Object {
    $start = $_ * $chunkSize
    $lines | Select-Object -Skip $start -First $chunkSize |
        Set-Content "candidates-4096.chunk$_.txt"
}

# 3. Screen in parallel (-ThrottleLimit = chunk count from step 2).
#    Note: ForEach-Object -Parallel silently swallows ssh-keygen failures;
#    after this step verify every `moduli-4096.chunk*.txt` is non-empty
#    before merging (e.g. `Get-ChildItem moduli-4096.chunk*.txt | Where-Object Length -eq 0`).
0..7 | ForEach-Object -Parallel {
    ssh-keygen -M screen -f "candidates-4096.chunk$_.txt" "moduli-4096.chunk$_.txt"
} -ThrottleLimit 8

# 4. Merge chunks. Set-Content writes CRLF on Windows; the moduli file
#    must be LF-only, so re-write via [IO.File]::WriteAllText.
$content = (0..7 | ForEach-Object { Get-Content "moduli-4096.chunk$_.txt" -Raw }) -join ""
$content = $content -replace "`r`n", "`n"
[IO.File]::WriteAllText((Join-Path $pwd 'moduli-4096-lf.txt'), $content)

# 5. Transfer moduli-4096-lf.txt to the Linux maintainer machine, then
#    concatenate with the existing 2048 / 3072 bundle:
#      cat moduli-4096-lf.txt >> simnos/plugins/servers/moduli

Note: ssh-keygen on Windows shares the same -M generate / -M screen flags as the Unix build, so the output format is identical and can be concatenated directly with batches produced on Linux.

Verifying the wheel includes the moduli file

After committing the new file, build the wheel locally and confirm the moduli file is shipped inside it:

uv build
unzip -l dist/simnos-*.whl | grep moduli
tar tzf dist/simnos-*.tar.gz | grep moduli

Both commands should print the moduli path. uv_build (current backend) includes every non-Python file under the package directory by default, so no pyproject.toml change is needed for the current build. The check above exists to catch a future regression in that default behaviour.

If a future uv_build version (or a different backend) ever drops the file, the fallback is to add an explicit include:

[tool.uv.build-backend]
source-include = ["simnos/plugins/servers/moduli"]

The release CI workflow (.github/workflows/pypi-publish.yml) also asserts the moduli file is present in both the built wheel and sdist before publishing.

Commit and PR

  • Commit message: chore: regenerate bundled moduli (#NNN)
  • PR description: record the new "next recommended rotation" date (generation date + 3 years).
  • Include a brief note of which logjam-class trigger (if any) prompted the regeneration.