Subject:

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.

download output.cast (4.9MB)

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