From d8774be5eb08f2b448dc088d8f5af5a3bc0df411 Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Mon, 6 Mar 2023 22:32:52 -0700 Subject: [PATCH] ssh-key: add "randomart" fingerprint visualizations Implements the "drunken bishop" algorithm described in this paper: http://www.dirk-loss.de/sshvis/drunken_bishop.pdf This algorithm produces hash visualizations like the following: +--[ED25519 256]--+ |o+oO==+ o.. | |.o++Eo+o.. | |. +.oO.o . . | | . o..B.. . . | | ...+ .S. o | | .o. . . . . | | o.. o | | B . | | .o* | +----[SHA256]-----+ --- ssh-key/README.md | 1 + ssh-key/src/fingerprint.rs | 60 +++++++++++--- ssh-key/src/fingerprint/randomart.rs | 112 +++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 ssh-key/src/fingerprint/randomart.rs diff --git a/ssh-key/README.md b/ssh-key/README.md index ca7b654f..257548f6 100644 --- a/ssh-key/README.md +++ b/ssh-key/README.md @@ -44,6 +44,7 @@ respective SSH key algorithm. - [x] Private key generation support: DSA, Ed25519, ECDSA (P-256+P-384), and RSA - [x] FIDO/U2F key support (`sk-*`) as specified in [PROTOCOL.u2f] - [x] Fingerprint support + - [x] "randomart" fingerprint visualizations - [x] `no_std` support including support for "heapless" (no-`alloc`) targets - [x] Parsing `authorized_keys` files - [x] Parsing `known_hosts` files diff --git a/ssh-key/src/fingerprint.rs b/ssh-key/src/fingerprint.rs index a4c8d9f3..fa2fe714 100644 --- a/ssh-key/src/fingerprint.rs +++ b/ssh-key/src/fingerprint.rs @@ -1,5 +1,8 @@ //! SSH public key fingerprints. +mod randomart; + +use self::randomart::Randomart; use crate::{public, Error, HashAlg, Result}; use core::{ fmt::{self, Display}, @@ -14,11 +17,11 @@ use sha2::{Digest, Sha256, Sha512}; /// Fingerprint encoding error message. const FINGERPRINT_ERR_MSG: &str = "fingerprint encoding error"; +#[cfg(feature = "alloc")] +use alloc::string::{String, ToString}; + #[cfg(all(feature = "alloc", feature = "serde"))] -use { - alloc::string::{String, ToString}, - serde::{de, ser, Deserialize, Serialize}, -}; +use serde::{de, ser, Deserialize, Serialize}; /// SSH public key fingerprints. /// @@ -79,6 +82,22 @@ impl Fingerprint { } } + /// Get the name of the hash algorithm (upper case e.g. "SHA256"). + pub fn prefix(self) -> &'static str { + match self.algorithm() { + HashAlg::Sha256 => "SHA256", + HashAlg::Sha512 => "SHA512", + } + } + + /// Get the bracketed hash algorithm footer for use in "randomart". + fn footer(self) -> &'static str { + match self.algorithm() { + HashAlg::Sha256 => "[SHA256]", + HashAlg::Sha512 => "[SHA512]", + } + } + /// Get the raw digest output for the fingerprint as bytes. pub fn as_bytes(&self) -> &[u8] { match self { @@ -112,6 +131,31 @@ impl Fingerprint { pub fn is_sha512(self) -> bool { matches!(self, Self::Sha512(_)) } + + /// Format "randomart" for this fingerprint using the provided formatter. + pub fn fmt_randomart(self, header: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Randomart::new(header, self).fmt(f) + } + + /// Render "randomart" hash visualization for this fingerprint as a string. + /// + /// ```text + /// +--[ED25519 256]--+ + /// |o+oO==+ o.. | + /// |.o++Eo+o.. | + /// |. +.oO.o . . | + /// | . o..B.. . . | + /// | ...+ .S. o | + /// | .o. . . . . | + /// | o.. o | + /// | B . | + /// | .o* | + /// +----[SHA256]-----+ + /// ``` + #[cfg(feature = "alloc")] + pub fn to_randomart(self, header: &str) -> String { + Randomart::new(header, self).to_string() + } } impl AsRef<[u8]> for Fingerprint { @@ -122,16 +166,12 @@ impl AsRef<[u8]> for Fingerprint { impl Display for Fingerprint { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Fingerprints use a special upper-case hash algorithm encoding. - let algorithm = match self.algorithm() { - HashAlg::Sha256 => "SHA256", - HashAlg::Sha512 => "SHA512", - }; + let prefix = self.prefix(); // Buffer size is the largest digest size of of any supported hash function let mut buf = [0u8; Self::SHA512_BASE64_SIZE]; let base64 = Base64Unpadded::encode(self.as_bytes(), &mut buf).map_err(|_| fmt::Error)?; - write!(f, "{algorithm}:{base64}") + write!(f, "{prefix}:{base64}") } } diff --git a/ssh-key/src/fingerprint/randomart.rs b/ssh-key/src/fingerprint/randomart.rs new file mode 100644 index 00000000..9db7a72e --- /dev/null +++ b/ssh-key/src/fingerprint/randomart.rs @@ -0,0 +1,112 @@ +//! Support for the "drunken bishop" fingerprint algorithm, a.k.a. "randomart". +//! +//! The algorithm is described in the paper: +//! +//! "The drunken bishop: An analysis of the OpenSSH fingerprint visualization algorithm" +//! +//! + +use super::Fingerprint; +use core::fmt; + +const WIDTH: usize = 17; +const HEIGHT: usize = 9; +const VALUES: &[u8; 17] = b" .o+=*BOX@%&#/^SE"; +const NVALUES: usize = VALUES.len() - 1; + +type Field = [[u8; WIDTH]; HEIGHT]; + +/// "randomart" renderer. +pub(super) struct Randomart<'a> { + header: &'a str, + field: Field, + footer: &'static str, +} + +impl<'a> Randomart<'a> { + /// Create new "randomart" from the given fingerprint. + #[allow(clippy::integer_arithmetic)] + pub(super) fn new(header: &'a str, fingerprint: Fingerprint) -> Self { + let mut field = Field::default(); + let mut x = WIDTH / 2; + let mut y = HEIGHT / 2; + + for mut byte in fingerprint.as_bytes().iter().copied() { + for _ in 0..4 { + if byte & 0x1 == 0 { + x = x.saturating_sub(1); + } else { + x = x.saturating_add(1); + } + + if byte & 0x2 == 0 { + y = y.saturating_sub(1); + } else { + y = y.saturating_add(1); + } + + x = x.min(WIDTH.saturating_sub(1)); + y = y.min(HEIGHT.saturating_sub(1)); + + if field[y][x] < NVALUES as u8 - 2 { + field[y][x] += 1; + } + + byte >>= 2; + } + } + + field[HEIGHT / 2][WIDTH / 2] = NVALUES as u8 - 1; + field[y][x] = NVALUES as u8; + + Self { + header, + field, + footer: fingerprint.footer(), + } + } +} + +impl fmt::Display for Randomart<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "+{:-^width$}+", self.header, width = WIDTH)?; + + for row in self.field { + write!(f, "|")?; + + for c in row { + write!(f, "{}", VALUES[c as usize] as char)?; + } + + writeln!(f, "|")?; + } + + write!(f, "+{:-^width$}+", self.footer, width = WIDTH) + } +} + +#[cfg(all(test, feature = "alloc"))] +mod tests { + use super::Fingerprint; + + const EXAMPLE_FINGERPRINT: &str = "SHA256:UCUiLr7Pjs9wFFJMDByLgc3NrtdU344OgUM45wZPcIQ"; + const EXAMPLE_RANDOMART: &str = "\ ++--[ED25519 256]--+ +|o+oO==+ o.. | +|.o++Eo+o.. | +|. +.oO.o . . | +| . o..B.. . . | +| ...+ .S. o | +| .o. . . . . | +| o.. o | +| B . | +| .o* | ++----[SHA256]-----+"; + + #[test] + fn generation() { + let fingerprint = EXAMPLE_FINGERPRINT.parse::().unwrap(); + let randomart = fingerprint.to_randomart("[ED25519 256]"); + assert_eq!(EXAMPLE_RANDOMART, randomart); + } +}