diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
| commit | 0f5169d44682a6070573f1d9359ed550e8b99caf (patch) | |
| tree | 87c1a6f69856000c0752f0b2c7b6de1096b02174 /src | |
| parent | 6f4d4fe59b4e4167c7718feb9088748234029a2d (diff) | |
access: SSH signatures
access::sshsig: OpenSSH's SSHSIG, verified and made natively (Ed25519,
sha512/sha256), checked against OpenSSH's own test vector.
Auth plan M7.
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/mod.rs | 4 | ||||
| -rw-r--r-- | src/access/sshsig.rs | 256 |
2 files changed, 260 insertions, 0 deletions
diff --git a/src/access/mod.rs b/src/access/mod.rs index 4d96f9b..2ed19a5 100644 --- a/src/access/mod.rs +++ b/src/access/mod.rs @@ -18,6 +18,10 @@ pub(crate) mod policy; pub(crate) mod registry; pub(crate) mod rules; pub(crate) mod screen; +// Users' keys and signatures: what signed values and logins by key use; until then, +// the tests. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) mod sshsig; pub(crate) mod token; /// What a capability lets its holder do, at its path and below. diff --git a/src/access/sshsig.rs b/src/access/sshsig.rs new file mode 100644 index 0000000..1f01d21 --- /dev/null +++ b/src/access/sshsig.rs @@ -0,0 +1,256 @@ +//! Users' keys as SSH has them: Ed25519 public keys as an `authorized_keys` line has +//! them (`PublicKey`), and signatures as `ssh-keygen -Y sign` makes them (OpenSSH's +//! `PROTOCOL.sshsig`), checked (`verify`) and made (`sign`) here, so a user signs with +//! the keys and the tools it has, and a client of ours can sign alike without them. +//! +//! A signature is armored (`-----BEGIN SSH SIGNATURE-----`) around `SSHSIG`, its +//! version (1), the public key, a namespace (what it is for: a signature for one can't +//! be taken for another), a reserved string, the hash algorithm (`sha512` or `sha256`) +//! and the signature, over `SSHSIG`, the namespace, the reserved string, the algorithm +//! and the message's hash. Strings are SSH's: a big-endian `u32` length, then the bytes. + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use sha2::{Digest, Sha256, Sha512}; + +const MAGIC: &[u8] = b"SSHSIG"; +const KEY_TYPE: &str = "ssh-ed25519"; +const BEGIN: &str = "-----BEGIN SSH SIGNATURE-----"; +const END: &str = "-----END SSH SIGNATURE-----"; + +/// An Ed25519 public key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PublicKey(pub(crate) [u8; 32]); + +impl PublicKey { + /// From an `authorized_keys` line: `ssh-ed25519 <base64> [comment]`. + pub(crate) fn parse(line: &str) -> Result<Self, String> { + let mut parts = line.split_whitespace(); + let (Some(kind), Some(encoded)) = (parts.next(), parts.next()) else { + return Err("not `ssh-ed25519 <base64> [comment]`".to_owned()); + }; + if kind != KEY_TYPE { + return Err(format!("a {kind} key: only {KEY_TYPE} keys are taken")); + } + let blob = BASE64 + .decode(encoded) + .map_err(|error| format!("its base64 doesn't decode: {error}"))?; + Self::from_blob(&blob) + } + + /// From SSH's encoding: the key type, then the key, each a string. + fn from_blob(blob: &[u8]) -> Result<Self, String> { + let mut reader = Reader(blob); + let kind = reader.string()?; + if kind != KEY_TYPE.as_bytes() { + return Err(format!("not an {KEY_TYPE} key")); + } + let key: [u8; 32] = reader + .string()? + .try_into() + .map_err(|_| "a key that isn't 32 bytes".to_owned())?; + reader.end()?; + Ok(PublicKey(key)) + } + + fn blob(&self) -> Vec<u8> { + let mut blob = Vec::new(); + string(&mut blob, KEY_TYPE.as_bytes()); + string(&mut blob, &self.0); + blob + } + + /// As an `authorized_keys` line has it, without a comment. + pub(crate) fn line(&self) -> String { + format!("{KEY_TYPE} {}", BASE64.encode(self.blob())) + } + + /// Its fingerprint, as `ssh-keygen -l` shows it: `SHA256:` and the base64 of its + /// encoding's SHA-256, unpadded. + pub(crate) fn fingerprint(&self) -> String { + let digest = Sha256::digest(self.blob()); + format!( + "SHA256:{}", + base64::engine::general_purpose::STANDARD_NO_PAD.encode(digest) + ) + } +} + +/// The key that made `armored`, a signature of `message` for `namespace`, if it is +/// one: valid, of an Ed25519 key, for that namespace. +pub(crate) fn verify(armored: &str, namespace: &str, message: &[u8]) -> Result<PublicKey, String> { + let body: String = armored + .trim() + .strip_prefix(BEGIN) + .and_then(|rest| rest.trim_end().strip_suffix(END)) + .ok_or("not an armored SSH signature")? + .split_whitespace() + .collect(); + let blob = BASE64 + .decode(body) + .map_err(|error| format!("its base64 doesn't decode: {error}"))?; + let mut reader = Reader( + blob.strip_prefix(MAGIC) + .ok_or("not an SSH signature (no SSHSIG)")?, + ); + if reader.u32()? != 1 { + return Err("an SSH signature of another version".to_owned()); + } + let key = PublicKey::from_blob(reader.string()?)?; + let signed_for = reader.string()?; + if signed_for != namespace.as_bytes() { + return Err(format!( + "signed for {:?}, not {namespace:?}", + String::from_utf8_lossy(signed_for) + )); + } + let reserved = reader.string()?; + let algorithm = reader.string()?; + let hash = match algorithm { + b"sha512" => Sha512::digest(message).to_vec(), + b"sha256" => Sha256::digest(message).to_vec(), + other => { + return Err(format!("hashed with {}", String::from_utf8_lossy(other))); + } + }; + let mut signature = Reader(reader.string()?); + reader.end()?; + if signature.string()? != KEY_TYPE.as_bytes() { + return Err(format!("not an {KEY_TYPE} signature")); + } + let bytes: [u8; 64] = signature + .string()? + .try_into() + .map_err(|_| "a signature that isn't 64 bytes".to_owned())?; + signature.end()?; + let signed = signed_data(signed_for, reserved, algorithm, &hash); + VerifyingKey::from_bytes(&key.0) + .map_err(|_| "not a valid Ed25519 key".to_owned())? + .verify_strict(&signed, &Signature::from_bytes(&bytes)) + .map_err(|_| "the signature doesn't match".to_owned())?; + Ok(key) +} + +/// `message` signed with `key` for `namespace`, armored, as `ssh-keygen -Y sign` makes +/// it (SHA-512). +pub(crate) fn sign(key: &SigningKey, namespace: &str, message: &[u8]) -> String { + let public = PublicKey(key.verifying_key().to_bytes()); + let hash = Sha512::digest(message); + let signed = signed_data(namespace.as_bytes(), b"", b"sha512", &hash); + let mut signature = Vec::new(); + string(&mut signature, KEY_TYPE.as_bytes()); + string(&mut signature, &key.sign(&signed).to_bytes()); + let mut blob = MAGIC.to_vec(); + blob.extend_from_slice(&1u32.to_be_bytes()); + string(&mut blob, &public.blob()); + string(&mut blob, namespace.as_bytes()); + string(&mut blob, b""); + string(&mut blob, b"sha512"); + string(&mut blob, &signature); + let encoded = BASE64.encode(blob); + let mut armored = format!("{BEGIN}\n"); + for line in encoded.as_bytes().chunks(70) { + armored.push_str(std::str::from_utf8(line).expect("base64 is ASCII")); + armored.push('\n'); + } + armored.push_str(END); + armored.push('\n'); + armored +} + +/// What a signature is made over. +fn signed_data(namespace: &[u8], reserved: &[u8], algorithm: &[u8], hash: &[u8]) -> Vec<u8> { + let mut signed = MAGIC.to_vec(); + string(&mut signed, namespace); + string(&mut signed, reserved); + string(&mut signed, algorithm); + string(&mut signed, hash); + signed +} + +fn string(out: &mut Vec<u8>, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); +} + +/// Reads SSH's encoding. +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + fn u32(&mut self) -> Result<u32, String> { + let (bytes, rest) = self.0.split_first_chunk::<4>().ok_or("cut short")?; + self.0 = rest; + Ok(u32::from_be_bytes(*bytes)) + } + + fn string(&mut self) -> Result<&'a [u8], String> { + let length = self.u32()? as usize; + if self.0.len() < length { + return Err("cut short".to_owned()); + } + let (bytes, rest) = self.0.split_at(length); + self.0 = rest; + Ok(bytes) + } + + fn end(&self) -> Result<(), String> { + if self.0.is_empty() { + Ok(()) + } else { + Err("more than it should hold".to_owned()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // OpenSSH's test data (regress/unittests/sshsig/testdata: ed25519.pub, ed25519.sig, + // signed-data, namespace), made by `ssh-keygen -Y sign`. + const OPENSSH_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIlizSIN3DQV78VPNjVvvwjgf+P5HqbPYCYu3BOMdjAE ED25519 test key"; + const OPENSSH_SIGNATURE: &str = "-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgiWLNIg3cNBXvxU82NW+/COB/4/ +keps9gJi7cE4x2MAQAAAAIdW5pdHRlc3QAAAAAAAAABnNoYTUxMgAAAFMAAAALc3NoLWVk +MjU1MTkAAABAihQsbUzuNEFflk5Tw1+H9aLS7tZQk0RG8KW1DtOmDYYnWe3D3UKiG3fcJa +DNg4vBWp1j1gLRiBMOF+gwYNegDg== +-----END SSH SIGNATURE----- +"; + const OPENSSH_MESSAGE: &[u8] = b"This is a test, this is only a test"; + + /// A signature `ssh-keygen -Y sign` made checks, by its key, for its namespace and + /// message only. + #[test] + fn what_ssh_keygen_signs_checks() { + let key = PublicKey::parse(OPENSSH_KEY).unwrap(); + assert_eq!( + verify(OPENSSH_SIGNATURE, "unittest", OPENSSH_MESSAGE), + Ok(key) + ); + assert!(verify(OPENSSH_SIGNATURE, "continuum-value", OPENSSH_MESSAGE).is_err()); + assert!(verify(OPENSSH_SIGNATURE, "unittest", b"This is a test").is_err()); + assert_eq!(PublicKey::parse(&key.line()), Ok(key)); + assert!(key.fingerprint().starts_with("SHA256:")); + } + + /// What `sign` makes checks, and reads as `ssh-keygen` writes it. + #[test] + fn signed_here_checks() { + let key = SigningKey::from_bytes(&[7; 32]); + let armored = sign(&key, "continuum-value", b"a value"); + assert!(armored.starts_with(BEGIN)); + assert_eq!( + verify(&armored, "continuum-value", b"a value"), + Ok(PublicKey(key.verifying_key().to_bytes())) + ); + assert!(verify(&armored, "continuum-value", b"another").is_err()); + } + + #[test] + fn other_keys_are_refused() { + assert!(PublicKey::parse("ssh-rsa AAAAB3NzaC1yc2E= someone").is_err()); + assert!(PublicKey::parse("ssh-ed25519 !!!").is_err()); + assert!(PublicKey::parse("").is_err()); + assert!(verify("not a signature", "unittest", b"").is_err()); + } +} |
