Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions build/ssh/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ license = "MIT OR Apache-2.0"
publish = false

[dependencies]
# Every primitive the one algorithm set needs: X25519, Ed25519 sign and
# verify, `chacha20-poly1305@openssh.com` and SHA-256. Its randomness is the
# Every primitive the one algorithm set needs: ML-KEM-768 and its SSH
# combiner, X25519, Ed25519 sign and verify, `chacha20-poly1305@openssh.com`
# and SHA-256. Its randomness is the
# caller's.
toyos-crypto = { path = "../../lib/crypto", features = ["sign"] }
# The one `SHA256:` fingerprint a key is named by, the one the package
Expand Down
23 changes: 15 additions & 8 deletions build/ssh/src/kex.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
//! Algorithm negotiation (RFC 4253 §7.1), the curve25519-sha256 exchange hash
//! (RFC 8731 §3) and key derivation (RFC 4253 §7.2).
//! Algorithm negotiation (RFC 4253 §7.1), the exchange hash of
//! `mlkem768x25519-sha256` (RFC 10042 §2.5) and `curve25519-sha256` (RFC 8731
//! §3), and key derivation (RFC 4253 §7.2).
//!
//! **One algorithm per slot, and the key exchange a list.** The server offers
//! `ssh-ed25519`, `chacha20-poly1305@openssh.com` and no compression; the
//! cipher is an AEAD, so the MAC lists are empty and never negotiated. The key
//! exchange methods are [`METHODS`], chosen in the client's order, so a second
//! exchange methods are [`METHODS`], chosen in the client's order, so another
//! method is one more entry and one more arm of [`Method`].

use toyos_crypto::hash::Sha256;
Expand All @@ -15,19 +16,21 @@ use crate::wire::{put_bool, put_string, put_u32, NameList, Reader, Refusal};
/// A key exchange method this server runs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Method {
MlKem768X25519Sha256,
Curve25519Sha256,
}

impl Method {
fn name(self) -> &'static str {
match self {
Self::MlKem768X25519Sha256 => "mlkem768x25519-sha256",
Self::Curve25519Sha256 => "curve25519-sha256",
}
}
}

/// The methods offered, most preferred first.
pub(crate) const METHODS: &[Method] = &[Method::Curve25519Sha256];
/// The methods offered, the post-quantum hybrid first.
pub(crate) const METHODS: &[Method] = &[Method::MlKem768X25519Sha256, Method::Curve25519Sha256];

/// Strict key exchange (OpenSSH `PROTOCOL` §1.10): the server's marker, and the
/// client's, without which no session runs.
Expand Down Expand Up @@ -111,16 +114,19 @@ pub(crate) fn negotiate(payload: &[u8], first: bool) -> Result<Negotiated, Refus
Ok(Negotiated { method, ext_info: first && kex.contains(EXT_INFO_CLIENT) })
}

/// The inputs of the exchange hash, in RFC 8731 §3's order.
/// The inputs of the exchange hash, in RFC 8731 §3's and RFC 10042 §2.5's
/// order; both methods hash with SHA-256.
pub(crate) struct Exchange<'a> {
pub(crate) client_version: &'a [u8],
pub(crate) server_version: &'a [u8],
pub(crate) client_kexinit: &'a [u8],
pub(crate) server_kexinit: &'a [u8],
pub(crate) host_key: &'a [u8],
/// `Q_C`, or the hybrid's `C_INIT`.
pub(crate) client_public: &'a [u8],
/// `Q_S`, or the hybrid's `S_REPLY`.
pub(crate) server_public: &'a [u8],
/// `K` as an `mpint`, length included.
/// `K` encoded as its method says, length included.
pub(crate) secret: &'a [u8],
}

Expand All @@ -145,7 +151,8 @@ impl Exchange<'_> {
}

/// The 64 bytes of key `letter` (RFC 4253 §7.2): `HASH(K || H || letter ||
/// session_id)`, extended by `HASH(K || H || K1)`. `secret` is `K` as an `mpint`.
/// session_id)`, extended by `HASH(K || H || K1)`. `secret` is `K` encoded as
/// in the exchange hash.
pub(crate) fn derive(secret: &[u8], hash: &[u8; 32], letter: u8, session_id: &[u8; 32]) -> [u8; 64] {
let first = sha256(&[secret, hash, &[letter], session_id]);
let second = sha256(&[secret, hash, &first]);
Expand Down
6 changes: 4 additions & 2 deletions build/ssh/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
//! [`Event`]s for the driver out. No socket, no thread and no clock; the
//! randomness is the caller's [`Random`].
//!
//! **One algorithm set, and nothing else offered**: `curve25519-sha256` with
//! strict key exchange, an `ssh-ed25519` host key,
//! **One algorithm set, and nothing else offered**: `mlkem768x25519-sha256`,
//! then `curve25519-sha256`, with strict key exchange; an `ssh-ed25519` host key,
//! `chacha20-poly1305@openssh.com` both ways, no compression; `publickey`
//! authentication with `ssh-ed25519` keys; `session` channels serving `exec`.
//!
Expand Down Expand Up @@ -49,6 +49,8 @@ mod msg {
pub(crate) const EXT_INFO: u8 = 7;
pub(crate) const KEXINIT: u8 = 20;
pub(crate) const NEWKEYS: u8 = 21;
/// `SSH_MSG_KEX_ECDH_INIT` (RFC 5656 §7.1) and `SSH_MSG_KEX_HYBRID_INIT`
/// (RFC 10042 §2.2) share their numbers, as do the replies.
pub(crate) const KEX_ECDH_INIT: u8 = 30;
pub(crate) const KEX_ECDH_REPLY: u8 = 31;
/// The first number of the layers above the transport.
Expand Down
68 changes: 51 additions & 17 deletions build/ssh/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@
//! NEWKEYS is held, and sent under the new keys (RFC 4253 §7.1).

use toyos_crypto::ssh::{OpeningKey, SealingKey};
use toyos_crypto::{x25519, Random};
use toyos_crypto::{hybrid, mlkem, x25519, Random};

use crate::hostkey::HostKey;
use crate::kex::{self, Exchange, Method, Negotiated};
use crate::msg;
use crate::wire::{put_mpint, put_string, put_u32, Reader, Refusal};

/// An ML-KEM-768 encapsulation key (FIPS 203 §8).
const MLKEM768_KEY_LEN: usize = 1184;

/// The server's identification (RFC 4253 §4.2), without its CR LF.
const VERSION: &[u8] = b"SSH-2.0-ToyOS";

Expand Down Expand Up @@ -60,7 +63,7 @@ enum Kex {
/// The server's KEXINIT is sent and the client's awaited: the session's
/// start, so it is the client's first packet.
Started,
/// Both KEXINITs are exchanged and the client's `KEX_ECDH_INIT` is awaited.
/// Both KEXINITs are exchanged and the client's message 30 is awaited.
Negotiated { negotiated: Negotiated, client_kexinit: Vec<u8> },
/// The server's NEWKEYS is sent; the client's is awaited, after which its
/// packets open with `opening`.
Expand Down Expand Up @@ -149,7 +152,7 @@ impl<R: Random> Transport<R> {
let reason = match refusal {
Refusal::Integrity | Refusal::SealedLength { .. } | Refusal::SequenceExhausted | Refusal::Ended => return,
Refusal::TooManyAttempts => msg::DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE,
Refusal::NoCommonAlgorithm(_) | Refusal::NotStrict | Refusal::KeyAgreement => msg::DISCONNECT_KEY_EXCHANGE_FAILED,
Refusal::NoCommonAlgorithm(_) | Refusal::NotStrict | Refusal::KeyExchange(_) => msg::DISCONNECT_KEY_EXCHANGE_FAILED,
Refusal::Truncated(_)
| Refusal::TooLong { .. }
| Refusal::Trailing(_)
Expand Down Expand Up @@ -205,7 +208,7 @@ impl<R: Random> Transport<R> {
match (std::mem::replace(&mut self.kex, Kex::Idle), kind) {
(Kex::Started, msg::KEXINIT) => self.on_kexinit(payload)?,
(Kex::Negotiated { negotiated, client_kexinit }, msg::KEX_ECDH_INIT) => {
self.on_ecdh_init(&payload, &negotiated, &client_kexinit)?;
self.on_kex_init(&payload, &negotiated, &client_kexinit)?;
}
(Kex::NewKeys { opening }, msg::NEWKEYS) => {
Reader::new(&payload[1..]).end("NEWKEYS")?;
Expand Down Expand Up @@ -329,27 +332,31 @@ impl<R: Random> Transport<R> {
Ok(())
}

fn on_ecdh_init(&mut self, payload: &[u8], negotiated: &Negotiated, client_kexinit: &[u8]) -> Result<(), Refusal> {
/// The client's message 30: the server's half of the exchange, its reply,
/// NEWKEYS and the keys.
fn on_kex_init(&mut self, payload: &[u8], negotiated: &Negotiated, client_kexinit: &[u8]) -> Result<(), Refusal> {
let mut r = Reader::new(payload);
r.byte("KEX_ECDH_INIT")?;
match negotiated.method {
Method::Curve25519Sha256 => {}
}
let client_public: [u8; 32] = r.fixed("Q_C")?;
r.end("KEX_ECDH_INIT")?;
let private = x25519::SecretKey::generate(&mut self.rng).expect("the caller's randomness");
let server_public = private.public_key().0;
let shared = private.agree(&x25519::PublicKey(client_public)).map_err(|_| Refusal::KeyAgreement)?;
let mut secret = Vec::new();
put_mpint(&mut secret, shared.as_bytes());
r.byte("message 30")?;
let client_public = r.string("Q_C or C_INIT", r.rest_len())?;
r.end("message 30")?;
let (server_public, secret) = match negotiated.method {
Method::MlKem768X25519Sha256 => self.hybrid(client_public)?,
Method::Curve25519Sha256 => {
let client = <[u8; 32]>::try_from(client_public).map_err(|_| Refusal::KeyExchange("a Q_C that is not 32 bytes"))?;
let (server, shared) = self.x25519(client)?;
let mut secret = Vec::new();
put_mpint(&mut secret, shared.as_bytes());
(server.to_vec(), secret)
}
};
let host_key = self.host.blob();
let hash = Exchange {
client_version: self.client_version.as_deref().unwrap_or_default(),
server_version: VERSION,
client_kexinit,
server_kexinit: &self.server_kexinit,
host_key: &host_key,
client_public: &client_public,
client_public,
server_public: &server_public,
secret: &secret,
}
Expand Down Expand Up @@ -377,4 +384,31 @@ impl<R: Random> Transport<R> {
self.kex = Kex::NewKeys { opening: Box::new(OpeningKey::new(key(b'C'))) };
Ok(())
}

/// `mlkem768x25519-sha256` (RFC 10042 §2): `S_REPLY`, the ciphertext and
/// the server's X25519 key; and `K = SHA-256(K_PQ || K_CL)` as a `string`.
fn hybrid(&mut self, init: &[u8]) -> Result<(Vec<u8>, Vec<u8>), Refusal> {
let (kem_key, ecdh_key) = init
.split_at_checked(MLKEM768_KEY_LEN)
.and_then(|(kem, ecdh)| Some((kem, <[u8; 32]>::try_from(ecdh).ok()?)))
.ok_or(Refusal::KeyExchange("a C_INIT that is not an ML-KEM-768 key and an X25519 key"))?;
let kem_key = mlkem::EncapsulationKey::from_bytes(kem_key)
.map_err(|_| Refusal::KeyExchange("an ML-KEM-768 encapsulation key that fails FIPS 203's check"))?;
let (server, ecdh) = self.x25519(ecdh_key)?;
let (ciphertext, kem) = kem_key.encapsulate(&mut self.rng).expect("the caller's randomness");
let mut secret = Vec::new();
put_string(&mut secret, hybrid::for_ssh(kem, ecdh).as_bytes());
Ok(([&ciphertext[..], &server].concat(), secret))
}

/// The server's ephemeral X25519 key and the secret it shares with
/// `client`, refused when that secret is zero (RFC 8731 §3).
fn x25519(&mut self, client: [u8; 32]) -> Result<([u8; 32], x25519::SharedSecret), Refusal> {
let private = x25519::SecretKey::generate(&mut self.rng).expect("the caller's randomness");
let server = private.public_key().0;
let shared = private
.agree(&x25519::PublicKey(client))
.map_err(|_| Refusal::KeyExchange("an X25519 key that makes the zero secret"))?;
Ok((server, shared))
}
}
9 changes: 5 additions & 4 deletions build/ssh/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ pub enum Refusal {
/// The client's first KEXINIT does not offer `kex-strict-c-v00@openssh.com`,
/// and no session runs without the sequence-number reset (CVE-2023-48795).
NotStrict,
/// The client's X25519 key, refused because the secret it makes is zero
/// (RFC 7748 §6.1).
KeyAgreement,
/// The client's key exchange value, refused for the reason named: its
/// length (RFC 8731 §3, RFC 10042 §2.1), FIPS 203 §7.2's encapsulation
/// key check, or an X25519 key that makes the zero secret (RFC 7748 §6.1).
KeyExchange(&'static str),
/// A packet whose tag does not verify. The peer is told nothing.
Integrity,
/// A sealed packet's length, decrypted but not yet verified, above the
Expand All @@ -53,7 +54,7 @@ impl fmt::Display for Refusal {
Self::Unexpected { phase, message } => write!(f, "message {message} during {phase}"),
Self::NoCommonAlgorithm(list) => write!(f, "no algorithm in {list} this server offers"),
Self::NotStrict => f.write_str("the client does not offer kex-strict-c-v00@openssh.com"),
Self::KeyAgreement => f.write_str("the client's X25519 key makes a zero secret"),
Self::KeyExchange(why) => f.write_str(why),
Self::Integrity => f.write_str("a packet whose tag does not verify"),
Self::SealedLength { len, cap } => {
write!(f, "a sealed packet_length of {len}, above the cap of {cap} or off the block, before its tag")
Expand Down
65 changes: 52 additions & 13 deletions build/ssh/tests/common/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ use std::sync::OnceLock;
use toyos_crypto::ed25519::{self, SigningKey};
use toyos_crypto::hash::Sha256;
use toyos_crypto::ssh::{OpeningKey, SealingKey};
use toyos_crypto::x25519;
use toyos_crypto::{mlkem, x25519};

use toyos_ssh::{Event, Random, Refusal, Server};

use super::driver::Keys;

pub const STRICT: &str = "kex-strict-c-v00@openssh.com";

pub const HYBRID: &str = "mlkem768x25519-sha256";
pub const CURVE25519: &str = "curve25519-sha256";

/// The Poly1305 tag after every sealed packet.
pub const TAG_LEN: usize = 16;

Expand Down Expand Up @@ -44,22 +47,33 @@ pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
c.finalize()
}

/// RFC 8731 §3's `H`.
/// `K` as RFC 8731 §3 encodes the X25519 secret: an `mpint`.
pub fn curve25519_k(shared: &[u8; 32]) -> Vec<u8> {
mpint(shared)
}

/// `K` as RFC 10042 §2.4 and §2.5 make it: `SHA-256(K_PQ || K_CL)`, a `string`.
pub fn hybrid_k(k_pq: &[u8; 32], k_cl: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new();
string(&mut out, &sha256(&[k_pq, k_cl]));
out
}

/// RFC 8731 §3's and RFC 10042 §2.5's `H`; `k` is encoded.
#[allow(clippy::too_many_arguments)]
pub fn exchange_hash(v_c: &[u8], v_s: &[u8], i_c: &[u8], i_s: &[u8], k_s: &[u8], q_c: &[u8], q_s: &[u8], k: &[u8]) -> [u8; 32] {
let mut data = Vec::new();
for field in [v_c, v_s, i_c, i_s, k_s, q_c, q_s] {
string(&mut data, field);
}
data.extend_from_slice(&mpint(k));
data.extend_from_slice(k);
sha256(&[&data])
}

/// RFC 4253 §7.2, 64 bytes.
/// RFC 4253 §7.2, 64 bytes; `k` is encoded.
pub fn derive(k: &[u8], h: &[u8; 32], letter: u8, session_id: &[u8; 32]) -> [u8; 64] {
let k = mpint(k);
let k1 = sha256(&[&k, h, &[letter], session_id]);
let k2 = sha256(&[&k, h, &k1]);
let k1 = sha256(&[k, h, &[letter], session_id]);
let k2 = sha256(&[k, h, &k1]);
[k1, k2].concat().try_into().unwrap()
}

Expand Down Expand Up @@ -163,12 +177,26 @@ pub struct Client<R> {
/// A key exchange the client has started.
pub struct Exchange {
i_c: Vec<u8>,
/// The client's ML-KEM-768 key when the server picks the hybrid: the first
/// of the client's methods it runs.
kem: Option<mlkem::DecapsulationKey>,
private: x25519::SecretKey,
}

/// The client's ephemeral X25519 key, the same in every exchange.
const EPHEMERAL: [u8; 32] = [0x3c; 32];

/// The seeds of the client's ephemeral ML-KEM-768 key, the same in every
/// exchange.
struct KemSeed;

impl Random for KemSeed {
fn fill(&mut self, destination: &mut [u8]) -> Result<(), toyos_crypto::Error> {
destination.fill(0x5e);
Ok(())
}
}

impl<R: Random> Client<R> {
/// A client that has sent its identification line and read the server's.
pub fn new(server: Server<Keys, R>) -> Result<Self, Refusal> {
Expand Down Expand Up @@ -230,25 +258,36 @@ impl<R: Random> Client<R> {
pub fn start_exchange(&mut self, kex: &str) -> Result<Exchange, Refusal> {
let i_c = kexinit(kex);
self.send(&i_c)?;
Ok(Exchange { i_c, private: x25519::SecretKey::from_bytes(EPHEMERAL) })
let hybrid = kex.split(',').find(|name| [HYBRID, CURVE25519].contains(name)) == Some(HYBRID);
let kem = hybrid.then(|| mlkem::DecapsulationKey::generate(&mut KemSeed).unwrap());
Ok(Exchange { i_c, kem, private: x25519::SecretKey::from_bytes(EPHEMERAL) })
}

/// The rest of the exchange as OpenSSH runs it: the server's KEXINIT is
/// the next packet, and its signature over `H` is verified here.
pub fn finish_exchange(&mut self, exchange: Exchange) -> Result<(), Refusal> {
let q_c = exchange.private.public_key().0;
let x25519_public = exchange.private.public_key().0;
let q_c = match &exchange.kem {
Some(kem) => [&kem.public_key().as_bytes()[..], &x25519_public].concat(),
None => x25519_public.to_vec(),
};
let mut init = vec![30];
string(&mut init, &q_c);
self.send(&init)?;
let i_s = self.next().expect("the server's KEXINIT");
assert_eq!(i_s[0], 20);
let reply = self.next().expect("KEX_ECDH_REPLY");
let reply = self.next().expect("the exchange's reply");
let mut f = Fields(&reply);
assert_eq!(f.byte(), 31);
let (k_s, q_s, signature) = (f.string().to_vec(), f.string().to_vec(), f.string().to_vec());
assert_eq!(self.next().expect("NEWKEYS"), [21]);
let k = exchange.private.agree(&x25519::PublicKey(q_s[..].try_into().unwrap())).unwrap();
let k = k.as_bytes();
let (ciphertext, server_x25519) = q_s.split_at(q_s.len() - 32);
let k_cl = exchange.private.agree(&x25519::PublicKey(server_x25519.try_into().unwrap())).unwrap();
let k = match exchange.kem {
Some(kem) => hybrid_k(kem.decapsulate(ciphertext).unwrap().as_bytes(), k_cl.as_bytes()),
None => curve25519_k(k_cl.as_bytes()),
};
let k = &k[..];
let h = exchange_hash(VERSION, &self.server_version, &exchange.i_c, &i_s, &k_s, &q_c, &q_s, k);
let host = Fields(&k_s).string_pair();
let sig = Fields(&signature).string_pair();
Expand All @@ -272,7 +311,7 @@ impl<R: Random> Client<R> {
}

pub fn handshake(server: Server<Keys, R>) -> Result<Self, Refusal> {
Self::handshake_with(server, &format!("curve25519-sha256,ext-info-c,{STRICT}"))
Self::handshake_with(server, &format!("{HYBRID},{CURVE25519},ext-info-c,{STRICT}"))
}

pub fn session_id(&self) -> [u8; 32] {
Expand Down
Loading