bad apple but it's ssh keys
Date: Message-Id: https://www.5snb.club/posts/2025/bad-apple-but-its-ssh-keys/
itβs fucking bad apple. but ssh keys.
credit to https://blog.benjojo.co.uk/post/ssh-randomart-how-does-it-work-art for inspiration.
π³οΈββ§οΈ meow :3c
Note that the output cast file is big here. Maybe donβt play this on a mobile connection. Sorry. Not much I can do about it, itβs a SSH key per frame for a 3 minute video.
The badly written code is here. Bit hacky, but you get the gist of it. PRs not welcome. This code is not a place of honor.
use rand::RngCore;
use rand::SeedableRng;
use ssh_key::{Algorithm, PrivateKey};
use std::fmt::Write as _;
use std::io::Write as _;
use std::sync::mpsc::{channel, Sender};
const FRAME_COUNT: usize = 6572;
fn compare(a: &str, b: &str) -> usize {
let mut ct = 0;
for (l, r) in a.bytes().zip(b.bytes()) {
if (l == b' ') != (r == b' ') {
ct += 1000;
}
if l != r {
ct += 1;
}
}
return ct;
}
struct Rng(rand::rngs::SmallRng);
impl ssh_key::rand_core::CryptoRng for Rng {}
impl ssh_key::rand_core::RngCore for Rng {
fn next_u32(&mut self) -> u32 {
self.0.next_u32()
}
fn next_u64(&mut self) -> u64 {
self.0.next_u64()
}
fn fill_bytes(&mut self, buf: &mut [u8]) {
self.0.fill_bytes(buf)
}
fn try_fill_bytes(&mut self, buf: &mut [u8]) -> Result<(), ssh_key::rand_core::Error> {
self.0.fill_bytes(buf);
Ok(())
}
}
fn worker_thread(mut wanted: Vec<BestKnown>, results: Sender<(usize, String, PrivateKey)>) {
let mut r = Rng(rand::rngs::SmallRng::from_os_rng());
loop {
let private_key = PrivateKey::random(&mut r, Algorithm::Ed25519).unwrap();
let pubkey = private_key.public_key();
let fingerprint = pubkey.fingerprint(Default::default());
let fp = fingerprint.to_randomart("[ED25519 256]");
for (idx, w) in wanted.iter_mut().enumerate() {
if w.attempt(&fp) {
results
.send((idx, fp.clone(), private_key.clone()))
.unwrap();
// Strictly speaking we *could* send this multiple times
// however that makes consecutive frames look the same
// which looks bad in playback.
break;
}
}
}
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct BestKnown {
wanted: String,
fingerprint: String,
difference: usize,
}
impl BestKnown {
fn new(wanted: String) -> Self {
Self {
wanted,
fingerprint: String::new(),
difference: 5000 * 250,
}
}
fn attempt(&mut self, new: &str) -> bool {
let comp = compare(&self.wanted, new);
if comp < self.difference {
// yipee, we found a better match :3c
self.fingerprint = new.to_string();
self.difference = comp;
return true;
}
return false;
}
}
fn main() {
render_rendered();
return;
let mut wanted: Vec<BestKnown> = if std::fs::exists("state.json").ok() == Some(true) {
let f = std::fs::File::open("state.json").unwrap();
serde_json::from_reader(f).unwrap()
} else {
let mut w = Vec::new();
for frame_number in 1..=FRAME_COUNT {
let rendered = frame_to_wanted(frame_number);
w.push(BestKnown::new(rendered));
}
w
};
let (sender, receiver) = channel();
let mut threads = Vec::new();
for _ in 0..14 {
threads.push(std::thread::spawn({
let sender = sender.clone();
let wanted = wanted.clone();
|| worker_thread(wanted, sender)
}));
}
let mut saved_age = std::time::Instant::now();
loop {
let (idx, art, privkey) = receiver.recv().unwrap();
let pubkey = privkey.public_key().clone();
let old = wanted[idx].difference;
if wanted[idx].attempt(&art) {
// println!("improved from {} to {}", old, wanted[idx].difference);
let mut output = String::new();
writeln!(output, "{}", wanted[idx].fingerprint).unwrap();
write!(
output,
"{}",
&*privkey.to_openssh(Default::default()).unwrap()
)
.unwrap();
writeln!(output, "{}", pubkey.to_openssh().unwrap()).unwrap();
std::fs::write(format!("rendered/frame{idx:04}.txt"), &output).unwrap();
// println!("{output}");
if saved_age.elapsed().as_secs() > 30 {
let f = std::fs::File::create_new("state-new.json").unwrap();
serde_json::to_writer(&f, &wanted.clone()).unwrap();
f.sync_all().unwrap();
drop(f);
std::fs::rename("state-new.json", "state.json").unwrap();
println!("Saved!");
println!(
"Total distance: {}",
wanted.iter().map(|x| x.difference).sum::<usize>()
);
saved_age = std::time::Instant::now();
}
}
}
}
fn frame_to_wanted(idx: usize) -> String {
use image::imageops::{grayscale, resize, FilterType};
const WIDTH: u32 = 17;
const HEIGHT: u32 = 9;
let fname = format!("frames/frame{idx:04}.png");
dbg!(&fname);
let image = image::open(&fname).unwrap();
let resized = resize(&image, WIDTH, HEIGHT, FilterType::Gaussian);
let grayscaled = grayscale(&resized);
// not actually the real thing, but it looks better like this
let wanted: &[u8; 15] = b" .o+=^/*BOX@%&#";
let mut out = String::new();
writeln!(out, "+--[ED25519 256]--+").unwrap();
for line in grayscaled.rows() {
write!(out, "|").unwrap();
for pixel in line {
let luma: u8 = pixel[0];
let c = wanted[usize::from(luma / 18)];
write!(out, "{}", c as char).unwrap();
}
write!(out, "|").unwrap();
writeln!(out).unwrap();
}
writeln!(out, "+----[SHA256]-----+").unwrap();
out
}
fn render_rendered() {
let mut output_file = std::fs::File::create_new("output.cast").unwrap();
writeln!(
output_file,
r#"{{"version": 2, "width": 90, "height": 24}}"#
)
.unwrap();
const FPS: usize = 30;
for frame in 0..=6571 {
let mut data = std::fs::read_to_string(format!("rendered/frame{frame:04}.txt")).unwrap();
// lmao, hacky as shit
// cold path, :shrug:
data = "\x1b[2J\x1b[;H".to_string() + &data.replace("\n", "\r\n");
let timestamp = (frame as f64) / (FPS as f64);
let item = serde_json::json!([timestamp, "o", data]);
serde_json::to_writer(&mut output_file, &item).unwrap();
writeln!(output_file).unwrap();
}
}
// TODO:
//
// * Download video file
// * Split into frames
// https://superuser.com/questions/1758192/how-can-i-split-a-video-into-frames-and-then-reassemble-it-with-the-audio-too
// * Convert each frame into ssh random art characters
// (look at the average brightness of the cell, convert linearly to the character set)
// (close enough...)
// * Adjust best_known to be a Vec of frames
// * For each key, iterate over every best known, and see if the current key is a better fit.
//
// * Persist best_known to disk every minute or so
// * Also load it from disk on start
//
// * Finally, *recombine* the frames (rendered black on white)
// maybe use asciinema for this? their format seems very simple