Skip to content

Commit 4ad6f8b

Browse files
authored
ssh-key: add "randomart" fingerprint visualizations (#77)
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]-----+
1 parent 6526bd4 commit 4ad6f8b

3 files changed

Lines changed: 163 additions & 10 deletions

File tree

ssh-key/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ respective SSH key algorithm.
4444
- [x] Private key generation support: DSA, Ed25519, ECDSA (P-256+P-384), and RSA
4545
- [x] FIDO/U2F key support (`sk-*`) as specified in [PROTOCOL.u2f]
4646
- [x] Fingerprint support
47+
- [x] "randomart" fingerprint visualizations
4748
- [x] `no_std` support including support for "heapless" (no-`alloc`) targets
4849
- [x] Parsing `authorized_keys` files
4950
- [x] Parsing `known_hosts` files

ssh-key/src/fingerprint.rs

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
//! SSH public key fingerprints.
22
3+
mod randomart;
4+
5+
use self::randomart::Randomart;
36
use crate::{public, Error, HashAlg, Result};
47
use core::{
58
fmt::{self, Display},
@@ -14,11 +17,11 @@ use sha2::{Digest, Sha256, Sha512};
1417
/// Fingerprint encoding error message.
1518
const FINGERPRINT_ERR_MSG: &str = "fingerprint encoding error";
1619

20+
#[cfg(feature = "alloc")]
21+
use alloc::string::{String, ToString};
22+
1723
#[cfg(all(feature = "alloc", feature = "serde"))]
18-
use {
19-
alloc::string::{String, ToString},
20-
serde::{de, ser, Deserialize, Serialize},
21-
};
24+
use serde::{de, ser, Deserialize, Serialize};
2225

2326
/// SSH public key fingerprints.
2427
///
@@ -79,6 +82,22 @@ impl Fingerprint {
7982
}
8083
}
8184

85+
/// Get the name of the hash algorithm (upper case e.g. "SHA256").
86+
pub fn prefix(self) -> &'static str {
87+
match self.algorithm() {
88+
HashAlg::Sha256 => "SHA256",
89+
HashAlg::Sha512 => "SHA512",
90+
}
91+
}
92+
93+
/// Get the bracketed hash algorithm footer for use in "randomart".
94+
fn footer(self) -> &'static str {
95+
match self.algorithm() {
96+
HashAlg::Sha256 => "[SHA256]",
97+
HashAlg::Sha512 => "[SHA512]",
98+
}
99+
}
100+
82101
/// Get the raw digest output for the fingerprint as bytes.
83102
pub fn as_bytes(&self) -> &[u8] {
84103
match self {
@@ -112,6 +131,31 @@ impl Fingerprint {
112131
pub fn is_sha512(self) -> bool {
113132
matches!(self, Self::Sha512(_))
114133
}
134+
135+
/// Format "randomart" for this fingerprint using the provided formatter.
136+
pub fn fmt_randomart(self, header: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137+
Randomart::new(header, self).fmt(f)
138+
}
139+
140+
/// Render "randomart" hash visualization for this fingerprint as a string.
141+
///
142+
/// ```text
143+
/// +--[ED25519 256]--+
144+
/// |o+oO==+ o.. |
145+
/// |.o++Eo+o.. |
146+
/// |. +.oO.o . . |
147+
/// | . o..B.. . . |
148+
/// | ...+ .S. o |
149+
/// | .o. . . . . |
150+
/// | o.. o |
151+
/// | B . |
152+
/// | .o* |
153+
/// +----[SHA256]-----+
154+
/// ```
155+
#[cfg(feature = "alloc")]
156+
pub fn to_randomart(self, header: &str) -> String {
157+
Randomart::new(header, self).to_string()
158+
}
115159
}
116160

117161
impl AsRef<[u8]> for Fingerprint {
@@ -122,16 +166,12 @@ impl AsRef<[u8]> for Fingerprint {
122166

123167
impl Display for Fingerprint {
124168
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125-
// Fingerprints use a special upper-case hash algorithm encoding.
126-
let algorithm = match self.algorithm() {
127-
HashAlg::Sha256 => "SHA256",
128-
HashAlg::Sha512 => "SHA512",
129-
};
169+
let prefix = self.prefix();
130170

131171
// Buffer size is the largest digest size of of any supported hash function
132172
let mut buf = [0u8; Self::SHA512_BASE64_SIZE];
133173
let base64 = Base64Unpadded::encode(self.as_bytes(), &mut buf).map_err(|_| fmt::Error)?;
134-
write!(f, "{algorithm}:{base64}")
174+
write!(f, "{prefix}:{base64}")
135175
}
136176
}
137177

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//! Support for the "drunken bishop" fingerprint algorithm, a.k.a. "randomart".
2+
//!
3+
//! The algorithm is described in the paper:
4+
//!
5+
//! "The drunken bishop: An analysis of the OpenSSH fingerprint visualization algorithm"
6+
//!
7+
//! <http://www.dirk-loss.de/sshvis/drunken_bishop.pdf>
8+
9+
use super::Fingerprint;
10+
use core::fmt;
11+
12+
const WIDTH: usize = 17;
13+
const HEIGHT: usize = 9;
14+
const VALUES: &[u8; 17] = b" .o+=*BOX@%&#/^SE";
15+
const NVALUES: usize = VALUES.len() - 1;
16+
17+
type Field = [[u8; WIDTH]; HEIGHT];
18+
19+
/// "randomart" renderer.
20+
pub(super) struct Randomart<'a> {
21+
header: &'a str,
22+
field: Field,
23+
footer: &'static str,
24+
}
25+
26+
impl<'a> Randomart<'a> {
27+
/// Create new "randomart" from the given fingerprint.
28+
#[allow(clippy::integer_arithmetic)]
29+
pub(super) fn new(header: &'a str, fingerprint: Fingerprint) -> Self {
30+
let mut field = Field::default();
31+
let mut x = WIDTH / 2;
32+
let mut y = HEIGHT / 2;
33+
34+
for mut byte in fingerprint.as_bytes().iter().copied() {
35+
for _ in 0..4 {
36+
if byte & 0x1 == 0 {
37+
x = x.saturating_sub(1);
38+
} else {
39+
x = x.saturating_add(1);
40+
}
41+
42+
if byte & 0x2 == 0 {
43+
y = y.saturating_sub(1);
44+
} else {
45+
y = y.saturating_add(1);
46+
}
47+
48+
x = x.min(WIDTH.saturating_sub(1));
49+
y = y.min(HEIGHT.saturating_sub(1));
50+
51+
if field[y][x] < NVALUES as u8 - 2 {
52+
field[y][x] += 1;
53+
}
54+
55+
byte >>= 2;
56+
}
57+
}
58+
59+
field[HEIGHT / 2][WIDTH / 2] = NVALUES as u8 - 1;
60+
field[y][x] = NVALUES as u8;
61+
62+
Self {
63+
header,
64+
field,
65+
footer: fingerprint.footer(),
66+
}
67+
}
68+
}
69+
70+
impl fmt::Display for Randomart<'_> {
71+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72+
writeln!(f, "+{:-^width$}+", self.header, width = WIDTH)?;
73+
74+
for row in self.field {
75+
write!(f, "|")?;
76+
77+
for c in row {
78+
write!(f, "{}", VALUES[c as usize] as char)?;
79+
}
80+
81+
writeln!(f, "|")?;
82+
}
83+
84+
write!(f, "+{:-^width$}+", self.footer, width = WIDTH)
85+
}
86+
}
87+
88+
#[cfg(all(test, feature = "alloc"))]
89+
mod tests {
90+
use super::Fingerprint;
91+
92+
const EXAMPLE_FINGERPRINT: &str = "SHA256:UCUiLr7Pjs9wFFJMDByLgc3NrtdU344OgUM45wZPcIQ";
93+
const EXAMPLE_RANDOMART: &str = "\
94+
+--[ED25519 256]--+
95+
|o+oO==+ o.. |
96+
|.o++Eo+o.. |
97+
|. +.oO.o . . |
98+
| . o..B.. . . |
99+
| ...+ .S. o |
100+
| .o. . . . . |
101+
| o.. o |
102+
| B . |
103+
| .o* |
104+
+----[SHA256]-----+";
105+
106+
#[test]
107+
fn generation() {
108+
let fingerprint = EXAMPLE_FINGERPRINT.parse::<Fingerprint>().unwrap();
109+
let randomart = fingerprint.to_randomart("[ED25519 256]");
110+
assert_eq!(EXAMPLE_RANDOMART, randomart);
111+
}
112+
}

0 commit comments

Comments
 (0)