Blob: tests/auth.rs
Raw · Blame
use crate::auth;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use auth::{
AuthError, SshPublicKey, format_keyless_login_challenge, verify_keyless_login_challenge,
};
use tempfile::TempDir;
const ISSUED_AT: u64 = 1_750_000_000;
const LIFETIME: u64 = 120;
const DSA_PUBLIC_KEY: &str = "ssh-dss AAAAB3NzaC1kc3MAAACBANw9iSUO2UYhFMssjUgW46URqv8bBrDgHeF8HLBOWBvKuXF2Rx2J/XyhgX48SOLMuv0hcPaejlyLarabnF9F2V4dkpPpZSJ+7luHmxEjNxwhsdtg8UteXAWkeCzrQ6MvRJZHcDBjYh56KGvslbFnJsGLXlI4PQCyl6awNImwYGilAAAAFQCJGBU3hZf+QtP9Jh/nbfNlhFu7hwAAAIBHObOQioQVRm3HsVb7mOy3FVKhcLoLO3qoG9gTkd4KeuehtFAC3+rckiX7xSCnE/5BBKdL7VP9WRXac2Nlr9Pwl3e7zPut96wrCHt/TZX6vkfXKkbpUIj5zSqfvyNrWKaYJkfzwAQwrXNS1Hol676Ud/DDEn2oatdEhkS3beWHXAAAAIBgQqaz/YYTRMshzMzYcZ4lqgvgmA55y6v0h39e8HH2A5dwNS6sPUw2jyna+le0dceNRJifFld1J+WYM0vmquSr11DDavgEidOSaXwfMvPPPJqLmbzdtT16N+Gij9U9STQTHPQcQ3xnNNHgQAStzZJbhLOVbDDDo5BO7LMUALDfSA==";
#[test]
fn normalizes_supported_openssh_keys_and_matches_stock_fingerprints() {
let directory = TempDir::new().expect("create a key directory");
for fixture in [KeyFixture::Ed25519, KeyFixture::EcdsaP256] {
let private_key = directory.path().join(fixture.name());
generate_key(&private_key, fixture);
let public_key_path = public_key_path(&private_key);
let encoded = fs::read_to_string(&public_key_path).expect("read the public key");
let key = SshPublicKey::parse(&encoded).expect("normalize the public key");
assert_eq!(key.canonical().split_whitespace().count(), 2);
assert_eq!(key.fingerprint(), stock_fingerprint(&public_key_path));
assert_eq!(
SshPublicKey::parse(key.canonical())
.expect("parse the normalized key")
.canonical(),
key.canonical()
);
}
}
#[test]
fn rejects_unsupported_and_undersized_keys() {
let directory = TempDir::new().expect("create a key directory");
assert!(matches!(
SshPublicKey::parse(&"x".repeat(16 * 1024 + 1)),
Err(AuthError::InputTooLarge("SSH public key"))
));
assert!(matches!(
SshPublicKey::parse(DSA_PUBLIC_KEY),
Err(AuthError::UnsupportedKeyAlgorithm)
));
let ecdsa_p384 = directory.path().join("ecdsa-p384");
generate_key(&ecdsa_p384, KeyFixture::EcdsaP384);
assert!(matches!(
parse_public_key(&ecdsa_p384),
Err(AuthError::UnsupportedKeyAlgorithm)
));
let rsa_2048 = directory.path().join("rsa-2048");
generate_key(&rsa_2048, KeyFixture::Rsa2048);
assert!(matches!(
parse_public_key(&rsa_2048),
Err(AuthError::UndersizedRsa {
actual: 2_048,
minimum: 3_072
})
));
let rsa_3072 = directory.path().join("rsa-3072");
generate_key(&rsa_3072, KeyFixture::Rsa3072);
assert!(matches!(
parse_public_key(&rsa_3072),
Err(AuthError::UnsupportedKeyAlgorithm)
));
assert!(SshPublicKey::parse("not a key").is_err());
}
#[test]
fn derives_the_key_from_a_keyless_stock_sshsig_envelope() {
let directory = TempDir::new().expect("create a key directory");
let private_key = directory.path().join("keyless");
generate_key(&private_key, KeyFixture::Ed25519);
let key = parse_public_key(&private_key).expect("parse the public key");
let nonce = [7_u8; 32];
let challenge = format_keyless_login_challenge(
"https://tit.example",
"alice",
&nonce,
ISSUED_AT,
ISSUED_AT + LIFETIME,
);
let signature = sign(&directory, "keyless", &private_key, "tit-auth", &challenge);
let verified = verify_keyless_login_challenge(
"https://tit.example",
&challenge,
&signature,
"alice",
ISSUED_AT + 1,
)
.expect("verify a keyless challenge");
assert_eq!(verified.fingerprint, key.fingerprint());
}
#[test]
fn rejects_invalid_keyless_login_challenges() {
let directory = TempDir::new().expect("create a key directory");
let private_key = directory.path().join("ed25519");
generate_key(&private_key, KeyFixture::Ed25519);
let challenge = format_keyless_login_challenge(
"https://tit.example",
"alice",
&[7_u8; 32],
ISSUED_AT,
ISSUED_AT + LIFETIME,
);
let signature = sign(&directory, "valid", &private_key, "tit-auth", &challenge);
assert!(matches!(
verify_keyless_login_challenge(
"https://other.example",
&challenge,
&signature,
"alice",
ISSUED_AT + 1
),
Err(AuthError::WrongOrigin)
));
assert!(matches!(
verify_keyless_login_challenge(
"https://tit.example",
&challenge,
&signature,
"bob",
ISSUED_AT + 1,
),
Err(AuthError::WrongUsername)
));
assert!(matches!(
verify_keyless_login_challenge(
"https://tit.example",
&challenge,
&signature,
"alice",
ISSUED_AT + LIFETIME + 1,
),
Err(AuthError::ExpiredChallenge)
));
let wrong_namespace = sign(&directory, "namespace", &private_key, "other", &challenge);
assert!(matches!(
verify_keyless_login_challenge(
"https://tit.example",
&challenge,
&wrong_namespace,
"alice",
ISSUED_AT + 1,
),
Err(AuthError::SignatureVerification(_))
));
assert!(matches!(
verify_keyless_login_challenge(
"https://tit.example",
&challenge,
"not an SSHSIG envelope",
"alice",
ISSUED_AT + 1,
),
Err(AuthError::SignatureEnvelope(_))
));
assert!(matches!(
verify_keyless_login_challenge(
"https://tit.example",
&"x".repeat(4 * 1024 + 1),
"signature",
"alice",
ISSUED_AT + 1,
),
Err(AuthError::InputTooLarge("login challenge"))
));
assert!(matches!(
verify_keyless_login_challenge(
"https://tit.example",
&challenge,
&"x".repeat(16 * 1024 + 1),
"alice",
ISSUED_AT + 1,
),
Err(AuthError::InputTooLarge("SSHSIG envelope"))
));
}
fn parse_public_key(private_key: &Path) -> Result<SshPublicKey, AuthError> {
let encoded = fs::read_to_string(public_key_path(private_key)).expect("read the public key");
SshPublicKey::parse(&encoded)
}
fn public_key_path(private_key: &Path) -> PathBuf {
let mut path = private_key.as_os_str().to_owned();
path.push(".pub");
PathBuf::from(path)
}
fn stock_fingerprint(public_key: &Path) -> String {
let output = Command::new("ssh-keygen")
.args(["-l", "-E", "sha256", "-f"])
.arg(public_key)
.output()
.expect("run ssh-keygen fingerprint");
assert!(output.status.success());
String::from_utf8(output.stdout)
.expect("read the fingerprint output")
.split_whitespace()
.nth(1)
.expect("find the fingerprint")
.to_owned()
}
fn sign(
directory: &TempDir,
name: &str,
private_key: &Path,
namespace: &str,
message: &str,
) -> String {
let message_path = directory.path().join(format!("{name}.challenge"));
fs::write(&message_path, message).expect("write the challenge");
let output = Command::new("ssh-keygen")
.args(["-q", "-Y", "sign", "-f"])
.arg(private_key)
.args(["-n", namespace])
.arg(&message_path)
.output()
.expect("run ssh-keygen sign");
assert!(
output.status.success(),
"sign the challenge: {}",
String::from_utf8_lossy(&output.stderr)
);
let mut signature_path = message_path.into_os_string();
signature_path.push(".sig");
fs::read_to_string(signature_path).expect("read the SSHSIG envelope")
}
#[derive(Clone, Copy)]
enum KeyFixture {
Ed25519,
EcdsaP256,
EcdsaP384,
Rsa2048,
Rsa3072,
}
impl KeyFixture {
fn name(self) -> &'static str {
match self {
Self::Ed25519 => "ed25519",
Self::EcdsaP256 => "ecdsa-p256",
Self::EcdsaP384 => "ecdsa-p384",
Self::Rsa2048 => "rsa-2048",
Self::Rsa3072 => "rsa-3072",
}
}
}
fn generate_key(path: &Path, fixture: KeyFixture) {
let mut command = Command::new("ssh-keygen");
command.args(["-q", "-N", "", "-C", "test comment", "-f"]);
command.arg(path);
match fixture {
KeyFixture::Ed25519 => {
command.args(["-t", "ed25519"]);
}
KeyFixture::EcdsaP256 => {
command.args(["-t", "ecdsa", "-b", "256"]);
}
KeyFixture::EcdsaP384 => {
command.args(["-t", "ecdsa", "-b", "384"]);
}
KeyFixture::Rsa2048 => {
command.args(["-t", "rsa", "-b", "2048"]);
}
KeyFixture::Rsa3072 => {
command.args(["-t", "rsa", "-b", "3072"]);
}
}
let output = command.output().expect("run ssh-keygen key generation");
assert!(
output.status.success(),
"generate {}: {}",
fixture.name(),
String::from_utf8_lossy(&output.stderr)
);
}