A Rust reimplementation of the Tangled knotserver.
0

Configure Feed

Select the types of activity you want to include in your feed.

ssh: Proptest SSH public-key canonicalization

The auth path stores keys as their OpenSSH text encoding and looks
them up by exact-string match. That contract relies on
`ssh_key::PublicKey::to_openssh()` being:

1. Deterministic — calling it twice on the same key produces
byte-identical output
2. Roundtrip-stable — encode → parse → re-encode is byte-
identical

If either breaks, a registered key offered by a client could
miss the lookup silently and auth fails with no useful
diagnostic. These proptests pin both properties across the
algorithms we expect in practice (Ed25519, ECDSA P-256).

Sanity proptest added as a third case: two independent random
keypairs of the same algorithm must produce distinct public
keys. Guards against a regression where seeding accidentally
collapses to a fixed key — that would make all clients
authenticate as the same principal, which would be very bad.

RSA omitted from the algorithm strategy: key generation is
multi-second per key and the serialization-stability property
is algorithm-independent for our exact-match use case. If we
ever see RSA-specific drift in the wild, add it back with a
dedicated low-case-count branch.

64 cases per property × 3 properties × ~2 algorithms = ~384
verified cases. Test runs in 0.25s on a warm build — Ed25519
and ECDSA generation are both cheap.

What's NOT covered here: pack-over-SSH parity (the SSH-transport
equivalent of protocol_push_roundtrip's proptest). That needs
genuine test infrastructure — a generic git+openssh-client
testcontainer for hermetic git invocation, host.docker.internal
routing back to our SSH server — flagged as a separate slice
worth tackling when there's appetite, not bundled in here.

author
Isaac Corbrey
date (May 15, 2026, 5:18 PM -0400) commit da7a5b13 parent 17b235cd change-id vsqzmmlr
+94
+1
Cargo.lock
··· 4062 4062 "http-body-util", 4063 4063 "mnemosyne_postgres", 4064 4064 "mnemosyne_protocol", 4065 + "proptest", 4065 4066 "rand 0.10.1", 4066 4067 "russh", 4067 4068 "sqlx",
+1
mnemosyne_ssh/Cargo.toml
··· 23 23 thiserror.workspace = true 24 24 25 25 [dev-dependencies] 26 + proptest.workspace = true 26 27 # Pinned to match russh's transitive rand dep — russh's forked ssh-key 27 28 # expects its own version of `signature::rand_core::CryptoRng`, which 28 29 # is satisfied by rand 0.10's ThreadRng but not 0.9's.
+92
mnemosyne_ssh/tests/key_canonicalization.rs
··· 1 + //! Proptests for SSH public-key canonicalization. 2 + //! 3 + //! The auth path in `ServerHandler::auth_publickey` stores keys as 4 + //! their OpenSSH text encoding and looks them up via exact-string 5 + //! match against the `public_keys` table. That's correct iff the 6 + //! encoding is *stable*: 7 + //! 8 + //! 1. The same key must serialize to the same bytes every time 9 + //! (`to_openssh` is deterministic). 10 + //! 2. Encode → decode → re-encode must round-trip byte-identical 11 + //! (so a key parsed from its OpenSSH form re-emits the same form). 12 + //! 13 + //! If either property breaks, a registered key offered by a client 14 + //! could miss the lookup silently and auth would fail with no 15 + //! diagnostic. These proptests pin both — parameterized over the 16 + //! algorithms we expect to see in practice. 17 + 18 + use mnemosyne_ssh::keys::ssh_key::{EcdsaCurve, PublicKey}; 19 + use mnemosyne_ssh::keys::{Algorithm, PrivateKey}; 20 + use proptest::prelude::*; 21 + 22 + fn algorithm_strategy() -> impl Strategy<Value = Algorithm> { 23 + // RSA omitted — generation is multi-second per key, and the 24 + // serialization-stability property is algorithm-independent for 25 + // our exact-match use case. If we ever store RSA keys in the 26 + // wild and hit a real round-trip bug specific to RSA, add it 27 + // here with a dedicated low-case-count branch. 28 + prop_oneof![ 29 + Just(Algorithm::Ed25519), 30 + Just(Algorithm::Ecdsa { 31 + curve: EcdsaCurve::NistP256 32 + }), 33 + ] 34 + } 35 + 36 + proptest! { 37 + #![proptest_config(ProptestConfig { 38 + // Key generation is cheap for Ed25519 and ECDSA P-256 39 + // (microseconds). 64 cases × ~2 algorithms exercises both 40 + // surfaces meaningfully without bloating test time. 41 + cases: 64, 42 + ..ProptestConfig::default() 43 + })] 44 + 45 + /// `to_openssh()` called twice on the same key must yield byte- 46 + /// identical output. This is the load-bearing property for our 47 + /// "encode at storage time, encode again at lookup time, compare 48 + /// strings" auth flow — if it ever drifts, lookup misses 49 + /// silently. 50 + #[test] 51 + fn pubkey_openssh_serialization_is_stable(algorithm in algorithm_strategy()) { 52 + let private = PrivateKey::random(&mut rand::rng(), algorithm) 53 + .expect("generate keypair"); 54 + let public = private.public_key(); 55 + let a = public.to_openssh().expect("encode 1"); 56 + let b = public.to_openssh().expect("encode 2"); 57 + prop_assert_eq!(a, b); 58 + } 59 + 60 + /// Encode → decode → re-encode is the round-trip that the auth 61 + /// path implicitly relies on: a key generated and stored on one 62 + /// path will be presented by a client whose russh layer parsed 63 + /// it from its OpenSSH form. Both sides must agree on the bytes. 64 + #[test] 65 + fn pubkey_openssh_roundtrip_is_byte_stable(algorithm in algorithm_strategy()) { 66 + let private = PrivateKey::random(&mut rand::rng(), algorithm) 67 + .expect("generate keypair"); 68 + let public = private.public_key(); 69 + let encoded = public.to_openssh().expect("encode"); 70 + let decoded = PublicKey::from_openssh(&encoded).expect("decode"); 71 + let re_encoded = decoded.to_openssh().expect("re-encode"); 72 + prop_assert_eq!(encoded, re_encoded); 73 + } 74 + 75 + /// Two `PrivateKey::random` calls with the same algorithm must 76 + /// produce distinct public keys. Sanity check — guards against 77 + /// a regression where some seeding shortcut accidentally 78 + /// collapses to a fixed key. 79 + #[test] 80 + fn different_random_calls_produce_distinct_pubkeys(algorithm in algorithm_strategy()) { 81 + let a = PrivateKey::random(&mut rand::rng(), algorithm.clone()) 82 + .expect("generate a"); 83 + let b = PrivateKey::random(&mut rand::rng(), algorithm) 84 + .expect("generate b"); 85 + let a_enc = a.public_key().to_openssh().expect("encode a"); 86 + let b_enc = b.public_key().to_openssh().expect("encode b"); 87 + prop_assert_ne!( 88 + a_enc, b_enc, 89 + "two independent random keypairs must not collapse to the same public key", 90 + ); 91 + } 92 + }