Compare commits
4 commits
9e7d8c3494
...
0d242d16d7
Author | SHA1 | Date | |
---|---|---|---|
|
0d242d16d7 | ||
|
a73e2cd8d8 | ||
|
fe5245fc31 | ||
|
70693fe768 |
16
Cargo.toml
16
Cargo.toml
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "duralumin"
|
name = "duralumin"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
@ -9,7 +9,7 @@ path = "src/lib.rs"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["passphrase-gen", "password-gen", "ed25519", "clap", "rpassword", "base64"]
|
default = ["passphrase-gen", "password-gen", "ed25519", "clap", "rpassword", "base64"]
|
||||||
ed25519 = ["osshkeys", "sha2"]
|
ed25519 = ["sha2"]
|
||||||
passphrase-gen = []
|
passphrase-gen = []
|
||||||
password-gen = []
|
password-gen = []
|
||||||
|
|
||||||
|
@ -25,14 +25,16 @@ required-features = ["ed25519", "clap", "rpassword", "base64"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
rand_chacha = "0.3"
|
|
||||||
clap = {version = "3.0.0-beta.5", optional = true, features = ["derive"]}
|
clap = {version = "3.0.0-beta.5", optional = true, features = ["derive"]}
|
||||||
base64 = {version = "0.13", optional = true}
|
base64 = {version = "0.13", optional = true}
|
||||||
bytes = {version = "1.1", optional = true}
|
bytes = {version = "1.1", optional = true}
|
||||||
osshkeys = {git = "https://github.com/noonebtw/rust-osshkeys.git", branch = "master", optional = true}
|
|
||||||
sha2 = {version = "0.9", optional = true}
|
sha2 = {version = "0.9", optional = true}
|
||||||
rpassword = {version = "5.0", optional = true}
|
rpassword = {version = "7.0", optional = true}
|
||||||
zeroize = {version = "1.5"}
|
zeroize = {version = "1.8"}
|
||||||
rust-argon2 = "1.0"
|
argon2 = "0.5.3"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
itertools = "0.13.0"
|
||||||
|
ssh-key = {version = "0.6.6", features = ["ed25519", "encryption"]}
|
||||||
|
ed25519-dalek = "2.1.1"
|
|
@ -1,315 +1,59 @@
|
||||||
use std::{io::Write, str::FromStr};
|
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use libduralumin::ed25519::randomart;
|
|
||||||
use osshkeys::PublicParts;
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
use crate::keygen::HashType;
|
|
||||||
|
|
||||||
/// program that generates ed25519 keypairs seeded by a passphrase and an optional ID.
|
/// program that generates ed25519 keypairs seeded by a passphrase and an optional ID.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[clap(
|
#[clap(
|
||||||
name = "duralumin-keygen",
|
name = "duralumin-keygen",
|
||||||
version = "0.2.0",
|
version = "0.3.0",
|
||||||
author = "No One <noonebtw@nirgendwo.xyz>"
|
author = "No One <noonebtw@nirgendwo.xyz>"
|
||||||
)]
|
)]
|
||||||
struct Opts {
|
struct Opts {
|
||||||
#[clap(short, long, default_value = "id_ed25519")]
|
#[clap(short, long)]
|
||||||
file: String,
|
file: Option<String>,
|
||||||
}
|
|
||||||
|
|
||||||
fn fix_newline_ref(line: &mut String) {
|
|
||||||
if line.ends_with('\n') {
|
|
||||||
line.pop();
|
|
||||||
|
|
||||||
if line.ends_with('\r') {
|
|
||||||
line.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn fix_newline(mut line: String) -> String {
|
|
||||||
fix_newline_ref(&mut line);
|
|
||||||
|
|
||||||
line
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod keygen {
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use osshkeys::{error::OsshResult, keys::ed25519::Ed25519KeyPair, KeyPair};
|
|
||||||
use rand::{Rng, SeedableRng};
|
|
||||||
use sha2::Digest;
|
|
||||||
use thiserror::Error;
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum Error {
|
|
||||||
#[error("Failed to parse")]
|
|
||||||
ParseError,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum KeyType {
|
|
||||||
SSH,
|
|
||||||
PGP,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for KeyType {
|
|
||||||
type Err = Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"ssh" => Ok(Self::SSH),
|
|
||||||
"pgp" => Ok(Self::PGP),
|
|
||||||
_ => Err(Error::ParseError),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum HashType {
|
|
||||||
Sha256,
|
|
||||||
Argon2,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for HashType {
|
|
||||||
type Err = Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"sha256" => Ok(Self::Sha256),
|
|
||||||
"argon2" | "argon" => Ok(Self::Argon2),
|
|
||||||
_ => Err(Error::ParseError),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct SshKeyBuilder {
|
|
||||||
hash_type: HashType,
|
|
||||||
iterations: i16,
|
|
||||||
tag: Zeroizing<String>,
|
|
||||||
passphrase: Zeroizing<String>,
|
|
||||||
encrypt_key: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SshKeyBuilder {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
hash_type: HashType::Argon2,
|
|
||||||
iterations: 4,
|
|
||||||
tag: Default::default(),
|
|
||||||
passphrase: Default::default(),
|
|
||||||
encrypt_key: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SshKeyBuilder {
|
|
||||||
pub fn with_hash_type(mut self, hash_type: HashType) -> Self {
|
|
||||||
self.hash_type = hash_type;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_encrypt(mut self, encrypt: bool) -> Self {
|
|
||||||
self.encrypt_key = encrypt;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_hash_type_and_iterations(
|
|
||||||
self,
|
|
||||||
hash_type: HashType,
|
|
||||||
iterations: i16,
|
|
||||||
) -> Self {
|
|
||||||
self.with_hash_type(hash_type).with_iterations(iterations)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_iterations(mut self, iterations: i16) -> Self {
|
|
||||||
self.iterations = iterations;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_maybe_tag(self, tag: Option<String>) -> Self {
|
|
||||||
match tag {
|
|
||||||
Some(tag) => self.with_tag(tag),
|
|
||||||
None => self,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_tag(mut self, tag: String) -> Self {
|
|
||||||
self.tag = Zeroizing::new(tag);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_passphrase<Z: Into<Zeroizing<String>>>(
|
|
||||||
mut self,
|
|
||||||
passphrase: Z,
|
|
||||||
) -> Self {
|
|
||||||
self.passphrase = passphrase.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_keypair(
|
|
||||||
self,
|
|
||||||
) -> OsshResult<((Zeroizing<String>, String), KeyPair)> {
|
|
||||||
let hash_seed = Zeroizing::new(
|
|
||||||
self.passphrase
|
|
||||||
.chars()
|
|
||||||
.chain(self.tag.chars())
|
|
||||||
.collect::<String>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let seed = match self.hash_type {
|
|
||||||
HashType::Sha256 => {
|
|
||||||
let mut digest = sha2::Sha256::digest(hash_seed.as_bytes());
|
|
||||||
|
|
||||||
assert!(self.iterations <= 1);
|
|
||||||
for _ in 0..(self.iterations - 1) {
|
|
||||||
digest = sha2::Sha256::digest(&digest);
|
|
||||||
}
|
|
||||||
digest.to_vec()
|
|
||||||
}
|
|
||||||
HashType::Argon2 => {
|
|
||||||
// TODO: make more random salt
|
|
||||||
let salt = b"thissaltneedsupdating";
|
|
||||||
|
|
||||||
let config = argon2::Config {
|
|
||||||
variant: argon2::Variant::Argon2i,
|
|
||||||
version: argon2::Version::Version13,
|
|
||||||
mem_cost: 65536,
|
|
||||||
time_cost: self.iterations as u32,
|
|
||||||
lanes: 4,
|
|
||||||
thread_mode: argon2::ThreadMode::Parallel,
|
|
||||||
hash_length: 32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
argon2::hash_raw(hash_seed.as_bytes(), salt, &config)
|
|
||||||
.expect("hash passphrase")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.try_into()
|
|
||||||
.expect("unwrap seed into [u8; 32]");
|
|
||||||
|
|
||||||
let rng = rand_chacha::ChaChaRng::from_seed(seed).gen::<[u8; 32]>();
|
|
||||||
|
|
||||||
let keypair = Ed25519KeyPair::from_seed(&rng)
|
|
||||||
.map(|key| Into::<KeyPair>::into(key))?;
|
|
||||||
|
|
||||||
let private_key = Zeroizing::new(keypair.serialize_openssh(
|
|
||||||
self.encrypt_key.then(|| self.passphrase.as_str()),
|
|
||||||
osshkeys::cipher::Cipher::Aes256_Ctr,
|
|
||||||
)?);
|
|
||||||
|
|
||||||
Ok(((private_key, keypair.serialize_publickey()?), keypair))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_line() -> std::io::Result<String> {
|
|
||||||
let mut line = String::new();
|
|
||||||
std::io::stdin().read_line(&mut line)?;
|
|
||||||
fix_newline_ref(&mut line);
|
|
||||||
|
|
||||||
Ok(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_non_empty_line() -> std::io::Result<Option<String>> {
|
|
||||||
let line = read_line()?;
|
|
||||||
|
|
||||||
Ok(if line.is_empty() { None } else { Some(line) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let opts = Opts::parse();
|
let opts = Opts::parse();
|
||||||
println!("Generating ed25519 ssh keypair:");
|
println!("Generating ed25519 ssh keypair:");
|
||||||
|
|
||||||
let ((private_key, public_key), keypair) = keygen::SshKeyBuilder::default()
|
let desc = libduralumin::key_gen::cli::keygen_desc_from_stdin()?;
|
||||||
.with_passphrase({
|
|
||||||
print!("Enter a passphrase: ");
|
|
||||||
std::io::stdout().flush()?;
|
|
||||||
let passphrase = Zeroizing::new(rpassword::read_password()?);
|
|
||||||
print!("Re-enter the same passphrase: ");
|
|
||||||
std::io::stdout().flush()?;
|
|
||||||
let passphrase2 = Zeroizing::new(rpassword::read_password()?);
|
|
||||||
|
|
||||||
if passphrase == passphrase2 {
|
let base_path = opts.file.unwrap_or_else(|| {
|
||||||
Ok(passphrase)
|
if let Some(tag) = desc.tag.as_ref() {
|
||||||
} else {
|
format!("duralumin_{}", tag.as_str())
|
||||||
println!("passphrases do not match.");
|
} else {
|
||||||
Err(std::io::Error::new(
|
"duralumin".to_owned()
|
||||||
std::io::ErrorKind::InvalidInput,
|
}
|
||||||
"passphrase did not match.",
|
});
|
||||||
))
|
|
||||||
}
|
|
||||||
}?)
|
|
||||||
.with_maybe_tag({
|
|
||||||
print!("Enter an optional ID []: ");
|
|
||||||
std::io::stdout().flush()?;
|
|
||||||
|
|
||||||
read_non_empty_line()?
|
let keypair = libduralumin::key_gen::generate_key(desc)?;
|
||||||
})
|
|
||||||
.with_encrypt({
|
|
||||||
print!("Encrypt keypair with passphrase? [Y/n]: ");
|
|
||||||
std::io::stdout().flush()?;
|
|
||||||
|
|
||||||
let encrypting = read_line()? != "n";
|
|
||||||
if encrypting {
|
|
||||||
println!("Encrypting keypair..")
|
|
||||||
};
|
|
||||||
|
|
||||||
encrypting
|
|
||||||
})
|
|
||||||
.with_hash_type({
|
|
||||||
print!("Use hash algorithm (sha256, argon2)[argon2]: ");
|
|
||||||
std::io::stdout().flush()?;
|
|
||||||
|
|
||||||
read_non_empty_line()?
|
|
||||||
.map(|line| HashType::from_str(&line))
|
|
||||||
.unwrap_or(Ok(HashType::Argon2))?
|
|
||||||
})
|
|
||||||
.with_iterations({
|
|
||||||
print!("Number of hashing iterations [4]: ");
|
|
||||||
std::io::stdout().flush()?;
|
|
||||||
|
|
||||||
read_non_empty_line()?
|
|
||||||
.map(|line| i16::from_str(&line))
|
|
||||||
.unwrap_or(Ok(4))?
|
|
||||||
})
|
|
||||||
.build_keypair()?;
|
|
||||||
|
|
||||||
let fingerprint =
|
|
||||||
keypair.fingerprint(osshkeys::keys::FingerprintHash::SHA256)?;
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Your key fingerprint is: Sha256:{}",
|
"Your key fingerprint is: Sha256:{}",
|
||||||
base64::encode(&fingerprint)
|
keypair.fingerprint_base64()
|
||||||
);
|
);
|
||||||
|
|
||||||
let randomart = randomart::RandomArt::from_digest(&fingerprint)
|
println!(
|
||||||
.render("ED25519 256", "SHA256")?;
|
"RandomArt:\n{}",
|
||||||
|
keypair.randomart().render("ED25519 256", "SHA256")?
|
||||||
|
);
|
||||||
|
|
||||||
println!("RandomArt:\n{}", randomart);
|
let private_path = base_path.clone();
|
||||||
|
let public_path = base_path.clone() + ".pub";
|
||||||
let private_path = opts.file.clone();
|
|
||||||
let public_path = opts.file.clone() + ".pub";
|
|
||||||
|
|
||||||
|
let (private_key, public_key) = keypair.encode_keys()?;
|
||||||
std::fs::write(&private_path, private_key)?;
|
std::fs::write(&private_path, private_key)?;
|
||||||
|
|
||||||
std::fs::write(&public_path, public_key)?;
|
std::fs::write(&public_path, public_key)?;
|
||||||
|
|
||||||
#[cfg(target_family = "unix")]
|
#[cfg(target_family = "unix")]
|
||||||
use std::fs::Permissions;
|
{
|
||||||
#[cfg(target_family = "unix")]
|
use std::fs::Permissions;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
#[cfg(target_family = "unix")]
|
std::fs::set_permissions(private_path, Permissions::from_mode(0o0600))?;
|
||||||
std::fs::set_permissions(private_path, Permissions::from_mode(0o0600))?;
|
std::fs::set_permissions(public_path, Permissions::from_mode(0o0600))?;
|
||||||
#[cfg(target_family = "unix")]
|
}
|
||||||
std::fs::set_permissions(public_path, Permissions::from_mode(0o0600))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
176
src/ed25519.rs
176
src/ed25519.rs
|
@ -1,176 +0,0 @@
|
||||||
use osshkeys::{error::OsshResult, keys::ed25519::Ed25519KeyPair, KeyPair};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
pub fn generate_ed25519_keypair<S1, S2>(
|
|
||||||
passphrase: S1,
|
|
||||||
id: Option<S2>,
|
|
||||||
) -> OsshResult<KeyPair>
|
|
||||||
where
|
|
||||||
S1: AsRef<str>,
|
|
||||||
S2: AsRef<str>,
|
|
||||||
{
|
|
||||||
let hash = {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(passphrase.as_ref());
|
|
||||||
if let Some(id) = id {
|
|
||||||
hasher.update(id.as_ref());
|
|
||||||
}
|
|
||||||
|
|
||||||
hasher.finalize()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ed25519KeyPair::from_seed(&hash).map(|key| Into::<KeyPair>::into(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod randomart {
|
|
||||||
use std::fmt::Write;
|
|
||||||
|
|
||||||
const WIDTH: usize = 17;
|
|
||||||
const HEIGHT: usize = 9;
|
|
||||||
const TILES: &[char] = &[
|
|
||||||
' ', '.', 'o', '+', '=', '*', 'B', 'O', 'X', '@', '%', '&', '#', '/',
|
|
||||||
'^',
|
|
||||||
];
|
|
||||||
|
|
||||||
pub struct RandomArt {
|
|
||||||
tiles: [[i8; WIDTH]; HEIGHT],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RandomArt {
|
|
||||||
pub fn render(
|
|
||||||
&self,
|
|
||||||
title: &str,
|
|
||||||
subtitle: &str,
|
|
||||||
) -> Result<String, std::fmt::Error> {
|
|
||||||
let mut string = String::new();
|
|
||||||
|
|
||||||
string
|
|
||||||
.write_str(&format!("+{:-^17}+\n", format!("[{}]", title)))?;
|
|
||||||
|
|
||||||
for row in self.tiles {
|
|
||||||
string.write_char('|')?;
|
|
||||||
for cell in row {
|
|
||||||
if cell == -1 {
|
|
||||||
string.write_char('S')?;
|
|
||||||
} else if cell == -2 {
|
|
||||||
string.write_char('E')?;
|
|
||||||
} else {
|
|
||||||
let cell = (TILES.len() - 1).min(cell as usize);
|
|
||||||
string.write_char(TILES[cell])?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
string.write_char('|')?;
|
|
||||||
string.write_char('\n')?;
|
|
||||||
}
|
|
||||||
|
|
||||||
string.write_str(&format!(
|
|
||||||
"+{:-^17}+\n",
|
|
||||||
format!("[{}]", subtitle)
|
|
||||||
))?;
|
|
||||||
|
|
||||||
Ok(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_digest(digest: &[u8]) -> Self {
|
|
||||||
let mut tiles = [[0i8; WIDTH]; HEIGHT];
|
|
||||||
let mut x = WIDTH / 2;
|
|
||||||
let mut y = HEIGHT / 2;
|
|
||||||
|
|
||||||
tiles[y][x] = -1;
|
|
||||||
|
|
||||||
for byte in digest.iter() {
|
|
||||||
for i in 0..4 {
|
|
||||||
let b = (*byte >> (i * 2)) & 3;
|
|
||||||
match b {
|
|
||||||
0 | 1 => {
|
|
||||||
if y > 0 {
|
|
||||||
y -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2 | 3 => {
|
|
||||||
if y < HEIGHT - 1 {
|
|
||||||
y += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
match b {
|
|
||||||
0 | 2 => {
|
|
||||||
if x > 0 {
|
|
||||||
x -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1 | 3 => {
|
|
||||||
if x < WIDTH - 1 {
|
|
||||||
x += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tiles[y][x] >= 0 {
|
|
||||||
tiles[y][x] += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tiles[y][x] = -2;
|
|
||||||
|
|
||||||
Self { tiles }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const FINGERPRINT: &str =
|
|
||||||
"L5N7A2PETaGegW5P3qh/Vjd8AW6Mn4B+VB2SHK+eZCY=";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render() {
|
|
||||||
let randomart =
|
|
||||||
RandomArt::from_digest(&base64::decode(FINGERPRINT).unwrap())
|
|
||||||
.render("Title", "Subtitle")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
println!("{}", randomart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod tests {
|
|
||||||
#![allow(dead_code)]
|
|
||||||
#![allow(unused_imports)]
|
|
||||||
use osshkeys::{
|
|
||||||
keys::ed25519::Ed25519KeyPair, KeyPair, PrivateParts, PublicParts,
|
|
||||||
};
|
|
||||||
use sha2::Digest;
|
|
||||||
|
|
||||||
use super::randomart::RandomArt;
|
|
||||||
|
|
||||||
const PASSPHRASE: &str = "the spice must flow";
|
|
||||||
|
|
||||||
const DATA: &str = "I’d just like to interject for a moment. What you’re refering to as Linux, is in fact, GNU/Linux, or as I’ve recently taken to calling it, GNU plus Linux. Linux is not an operating system unto itself, but rather another free component of a fully functioning GNU system made useful by the GNU corelibs, shell utilities and vital system components comprising a full OS as defined by POSIX.";
|
|
||||||
|
|
||||||
const SIGNATURE: &[u8] = &[
|
|
||||||
115, 18, 156, 87, 192, 149, 107, 105, 13, 87, 219, 90, 26, 146, 41,
|
|
||||||
114, 20, 143, 253, 206, 216, 236, 222, 66, 252, 136, 38, 216, 184, 127,
|
|
||||||
94, 255, 68, 246, 64, 228, 141, 64, 63, 64, 236, 222, 184, 214, 3, 157,
|
|
||||||
73, 186, 73, 156, 20, 100, 76, 241, 113, 81, 38, 131, 174, 31, 103,
|
|
||||||
181, 220, 11,
|
|
||||||
];
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test() {
|
|
||||||
let mut hasher = sha2::Sha256::new();
|
|
||||||
hasher.update(PASSPHRASE);
|
|
||||||
|
|
||||||
let hash = hasher.finalize();
|
|
||||||
|
|
||||||
let keypair: KeyPair = Ed25519KeyPair::from_seed(&hash).unwrap().into();
|
|
||||||
|
|
||||||
let asdf = keypair.sign(DATA.as_bytes()).unwrap();
|
|
||||||
assert_eq!(keypair.verify(DATA.as_bytes(), SIGNATURE).unwrap(), true);
|
|
||||||
}
|
|
||||||
}
|
|
377
src/key_gen.rs
Normal file
377
src/key_gen.rs
Normal file
|
@ -0,0 +1,377 @@
|
||||||
|
use sha2::Digest;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::randomart;
|
||||||
|
|
||||||
|
pub mod cli {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::key_gen::{HashDesc, KeygenDesc};
|
||||||
|
|
||||||
|
fn fix_newline_ref(line: &mut String) {
|
||||||
|
if line.ends_with('\n') {
|
||||||
|
line.pop();
|
||||||
|
|
||||||
|
if line.ends_with('\r') {
|
||||||
|
line.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn fix_newline(mut line: String) -> String {
|
||||||
|
fix_newline_ref(&mut line);
|
||||||
|
|
||||||
|
line
|
||||||
|
}
|
||||||
|
pub fn read_line() -> std::io::Result<String> {
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line)?;
|
||||||
|
fix_newline_ref(&mut line);
|
||||||
|
|
||||||
|
Ok(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_non_empty_line() -> std::io::Result<Option<String>> {
|
||||||
|
let line = read_line()?;
|
||||||
|
|
||||||
|
Ok(if line.is_empty() { None } else { Some(line) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
ParseInt(#[from] std::num::ParseIntError),
|
||||||
|
#[error("Mismatching secret.")]
|
||||||
|
MismatchingSecret,
|
||||||
|
#[error("Invalid hash method.")]
|
||||||
|
InvalidHashMethod,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
fn read_passphrase() -> Result<Zeroizing<String>> {
|
||||||
|
let passphrase = Zeroizing::new(rpassword::prompt_password("Enter a passphrase: ")?);
|
||||||
|
let passphrase2 = Zeroizing::new(rpassword::prompt_password("Re-enter your passphrase: ")?);
|
||||||
|
std::io::stdout().flush().expect("flush stdout");
|
||||||
|
|
||||||
|
if passphrase == passphrase2 {
|
||||||
|
println!(
|
||||||
|
"Passphrase entropy (rough estimate): {:.2}",
|
||||||
|
crate::entropy(&passphrase)
|
||||||
|
);
|
||||||
|
Ok(passphrase)
|
||||||
|
} else {
|
||||||
|
Err(Error::MismatchingSecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_argon_desc() -> Result<HashDesc> {
|
||||||
|
print!("Use argon2 variant (argon2i, argon2d, argon2id) [argon2id]: ");
|
||||||
|
|
||||||
|
let variant = match read_non_empty_line()?.as_ref().map(|s| s.as_str()) {
|
||||||
|
Some("argon2i") => argon2::Algorithm::Argon2i,
|
||||||
|
Some("argon2d") => argon2::Algorithm::Argon2d,
|
||||||
|
Some("argon2id") | None => argon2::Algorithm::Argon2id,
|
||||||
|
_ => {
|
||||||
|
return Err(Error::InvalidHashMethod);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
print!("Use argon2 version (16,19) [19]: ");
|
||||||
|
let version = match read_non_empty_line()?.as_ref().map(|s| s.as_str()) {
|
||||||
|
Some("16") | Some("10") => argon2::Version::V0x10,
|
||||||
|
Some("19") | Some("13") | None => argon2::Version::V0x13,
|
||||||
|
_ => {
|
||||||
|
return Err(Error::InvalidHashMethod);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
print!("memory size (memory cost) (KiB if no unit specified) (min 19 MiB) [64 MiB]: ");
|
||||||
|
let memory = 'mem: {
|
||||||
|
let Some(line) = read_non_empty_line()? else {
|
||||||
|
break 'mem 64 * 1024;
|
||||||
|
};
|
||||||
|
let line = line.trim();
|
||||||
|
let chars = line.char_indices();
|
||||||
|
let digits_end = chars
|
||||||
|
.take_while(|(_, c)| ('0'..='9').contains(c))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.last()
|
||||||
|
.unwrap_or(line.len());
|
||||||
|
let digits = &line[..digits_end];
|
||||||
|
let unit = &line[digits_end..];
|
||||||
|
|
||||||
|
let number = digits.parse::<u32>()?;
|
||||||
|
let factor = match unit {
|
||||||
|
"KiB" | "kib" | "" => 1,
|
||||||
|
"MiB" | "mib" => 1024,
|
||||||
|
_ => {
|
||||||
|
return Err(Error::InvalidHashMethod);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
number * factor
|
||||||
|
};
|
||||||
|
|
||||||
|
print!("Iterations (time cost) [4]: ");
|
||||||
|
let iterations = read_non_empty_line()?
|
||||||
|
.map(|s| s.parse::<u16>())
|
||||||
|
.unwrap_or(Ok(4))?;
|
||||||
|
|
||||||
|
print!("Lanes (parallelism cost) [1]: ");
|
||||||
|
let lanes = read_non_empty_line()?
|
||||||
|
.map(|s| s.parse::<u16>())
|
||||||
|
.unwrap_or(Ok(1))?;
|
||||||
|
|
||||||
|
Ok(HashDesc::Argon {
|
||||||
|
variant,
|
||||||
|
version,
|
||||||
|
memory,
|
||||||
|
time: iterations as u32,
|
||||||
|
lanes: lanes as u32,
|
||||||
|
hash_length: 32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn keygen_desc_from_stdin() -> Result<super::KeygenDesc> {
|
||||||
|
let passphrase = read_passphrase()?;
|
||||||
|
|
||||||
|
print!("Enter an optional ID []: ");
|
||||||
|
let tag = read_non_empty_line()?.map(|s| Zeroizing::new(s));
|
||||||
|
|
||||||
|
print!("Encrypt keypair with passphrase? [Y/n]: ");
|
||||||
|
let encrypt = read_line()? != "n";
|
||||||
|
if encrypt {
|
||||||
|
println!("Will encrypt keypair.");
|
||||||
|
}
|
||||||
|
|
||||||
|
print!("Use hash algorithm (sha256, argon2) [argon2]: ");
|
||||||
|
let hash = match read_non_empty_line()?.as_ref().map(|s| s.as_str()) {
|
||||||
|
Some("argon2") | None => read_argon_desc()?,
|
||||||
|
Some("sha256") => {
|
||||||
|
print!("Iterations [4]: ");
|
||||||
|
let iterations = read_non_empty_line()?
|
||||||
|
.map(|s| s.parse::<u16>())
|
||||||
|
.unwrap_or(Ok(4))?;
|
||||||
|
HashDesc::Sha256 {
|
||||||
|
iterations: iterations as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(Error::InvalidHashMethod),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(KeygenDesc {
|
||||||
|
hash,
|
||||||
|
salt: KeygenDesc::SALT.to_vec(),
|
||||||
|
tag,
|
||||||
|
passphrase,
|
||||||
|
encrypt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Argon2 Error: {0}")]
|
||||||
|
Argon2(argon2::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
SshKeys(#[from] ssh_key::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<argon2::Error> for Error {
|
||||||
|
fn from(value: argon2::Error) -> Self {
|
||||||
|
Self::Argon2(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
pub struct KeyPair {
|
||||||
|
pub passphrase: Option<Zeroizing<String>>,
|
||||||
|
pub inner: ssh_key::private::Ed25519Keypair,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyPair {
|
||||||
|
fn public_key(&self) -> &[u8; 32] {
|
||||||
|
&self.inner.public.0
|
||||||
|
}
|
||||||
|
fn private_key(&self) -> [u8; 32] {
|
||||||
|
self.inner.private.to_bytes()
|
||||||
|
}
|
||||||
|
pub fn fingerprint(&self) -> Vec<u8> {
|
||||||
|
sha2::Sha256::digest(self.public_key()).to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fingerprint_base64(&self) -> String {
|
||||||
|
base64::encode(&self.fingerprint())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn randomart(&self) -> randomart::RandomArt {
|
||||||
|
randomart::RandomArt::from_digest(&self.fingerprint())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_keys(&self) -> Result<(Zeroizing<String>, String)> {
|
||||||
|
let keydata = ssh_key::private::KeypairData::Ed25519(self.inner.clone());
|
||||||
|
|
||||||
|
let keypair = match &self.passphrase {
|
||||||
|
Some(passphrase) => ssh_key::PrivateKey::new(keydata, "")?
|
||||||
|
.encrypt(&mut rand::rngs::OsRng, passphrase.as_bytes())?,
|
||||||
|
None => ssh_key::PrivateKey::new(keydata, "")?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let public_key = keypair.public_key();
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
keypair.to_openssh(ssh_key::LineEnding::LF)?,
|
||||||
|
public_key.to_openssh()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_key(desc: KeygenDesc) -> Result<KeyPair> {
|
||||||
|
let seed = Zeroizing::new(
|
||||||
|
desc.passphrase
|
||||||
|
.chars()
|
||||||
|
.chain(desc.tag.unwrap_or_default().chars())
|
||||||
|
.collect::<String>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut hash = [0u8; 32];
|
||||||
|
match desc.hash {
|
||||||
|
HashDesc::Sha256 { iterations } => {
|
||||||
|
let mut hasher = sha2::Sha256::new();
|
||||||
|
hasher.update(seed.as_bytes());
|
||||||
|
hasher.update(desc.salt.as_slice());
|
||||||
|
|
||||||
|
let mut digest = hasher.finalize();
|
||||||
|
|
||||||
|
for _ in 0..(iterations - 1) {
|
||||||
|
digest = sha2::Sha256::digest(digest.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
hash.copy_from_slice(&digest[..32]);
|
||||||
|
}
|
||||||
|
HashDesc::Argon {
|
||||||
|
variant,
|
||||||
|
version,
|
||||||
|
memory,
|
||||||
|
time,
|
||||||
|
lanes,
|
||||||
|
hash_length,
|
||||||
|
} => {
|
||||||
|
let argon = argon2::Argon2::new(
|
||||||
|
variant,
|
||||||
|
version,
|
||||||
|
argon2::Params::new(memory, time, lanes, Some(hash_length as usize))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
argon.hash_password_into(seed.as_bytes(), &desc.salt, &mut hash)?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// let hash = rand_chacha::ChaChaRng::from_seed(hash).gen::<[u8; 32]>();
|
||||||
|
let private_key = ed25519_dalek::SigningKey::from_bytes(&hash);
|
||||||
|
let public_key = private_key.verifying_key();
|
||||||
|
|
||||||
|
let keypair = ssh_key::private::Ed25519Keypair {
|
||||||
|
public: ssh_key::public::Ed25519PublicKey(public_key.to_bytes()),
|
||||||
|
private: ssh_key::private::Ed25519PrivateKey::from_bytes(private_key.as_bytes()),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(KeyPair {
|
||||||
|
passphrase: desc.encrypt.then_some(desc.passphrase),
|
||||||
|
inner: keypair,
|
||||||
|
});
|
||||||
|
// let keypair = russh_keys::key::KeyPair::Ed25519(private_key);
|
||||||
|
// let public_key = keypair.clone_public_key().expect("pubkey");
|
||||||
|
// let fingerprint = public_key.fingerprint();
|
||||||
|
|
||||||
|
// let pubbytes = public_key.public_key_bytes();
|
||||||
|
|
||||||
|
// let randomart = randomart::RandomArt::from_digest(sha2::Sha256::digest(&pubbytes).as_slice())
|
||||||
|
// .render("ED25519 256", "SHA256")
|
||||||
|
// .expect("randomart");
|
||||||
|
|
||||||
|
// let mut private_key = Vec::new();
|
||||||
|
// if desc.encrypt {
|
||||||
|
// russh_keys::encode_pkcs8_pem_encrypted(
|
||||||
|
// &keypair,
|
||||||
|
// desc.passphrase.as_bytes(),
|
||||||
|
// 1,
|
||||||
|
// &mut private_key,
|
||||||
|
// )
|
||||||
|
// .expect("writing private key");
|
||||||
|
// } else {
|
||||||
|
// russh_keys::encode_pkcs8_pem(&keypair, &mut private_key).expect("writing private key");
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct KeygenDesc {
|
||||||
|
pub hash: HashDesc,
|
||||||
|
pub salt: Vec<u8>,
|
||||||
|
pub tag: Option<Zeroizing<String>>,
|
||||||
|
pub passphrase: Zeroizing<String>,
|
||||||
|
pub encrypt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeygenDesc {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hash: Default::default(),
|
||||||
|
salt: Self::SALT.to_vec(),
|
||||||
|
tag: None,
|
||||||
|
passphrase: Zeroizing::new("password123".to_string()),
|
||||||
|
encrypt: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeygenDesc {
|
||||||
|
/// known random salt.
|
||||||
|
pub const SALT: &'static [u8; 32] = b"10ru4YBD5wvdQZc2Mw7Brx45jCxGUM3F";
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum HashDesc {
|
||||||
|
Sha256 {
|
||||||
|
iterations: u32,
|
||||||
|
},
|
||||||
|
Argon {
|
||||||
|
variant: argon2::Algorithm,
|
||||||
|
version: argon2::Version,
|
||||||
|
memory: u32,
|
||||||
|
time: u32,
|
||||||
|
lanes: u32,
|
||||||
|
hash_length: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HashDesc {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Argon {
|
||||||
|
variant: argon2::Algorithm::Argon2id,
|
||||||
|
version: argon2::Version::V0x13,
|
||||||
|
memory: 65536,
|
||||||
|
time: 4,
|
||||||
|
lanes: 1,
|
||||||
|
hash_length: 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{generate_key, KeygenDesc};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edkey() {
|
||||||
|
generate_key(KeygenDesc::default());
|
||||||
|
}
|
||||||
|
}
|
52
src/lib.rs
52
src/lib.rs
|
@ -1,8 +1,58 @@
|
||||||
|
use itertools::Itertools;
|
||||||
|
|
||||||
#[cfg(feature = "passphrase-gen")]
|
#[cfg(feature = "passphrase-gen")]
|
||||||
pub mod passphrase_gen;
|
pub mod passphrase_gen;
|
||||||
|
|
||||||
#[cfg(feature = "password-gen")]
|
#[cfg(feature = "password-gen")]
|
||||||
pub mod password_gen;
|
pub mod password_gen;
|
||||||
|
|
||||||
|
pub mod randomart;
|
||||||
|
|
||||||
#[cfg(feature = "ed25519")]
|
#[cfg(feature = "ed25519")]
|
||||||
pub mod ed25519;
|
pub mod key_gen;
|
||||||
|
|
||||||
|
fn entropy(pass: &str) -> f32 {
|
||||||
|
let mut r = 0;
|
||||||
|
if pass.chars().any(|c| c.is_ascii_lowercase()) {
|
||||||
|
r += 26;
|
||||||
|
}
|
||||||
|
if pass.chars().any(|c| c.is_ascii_uppercase()) {
|
||||||
|
r += 26;
|
||||||
|
}
|
||||||
|
if pass.chars().any(|c| c.is_ascii_punctuation()) {
|
||||||
|
r += 36;
|
||||||
|
}
|
||||||
|
if pass.chars().any(|c| c.is_ascii_digit()) {
|
||||||
|
r += 10;
|
||||||
|
}
|
||||||
|
let len = pass.chars().count();
|
||||||
|
let other = pass
|
||||||
|
.chars()
|
||||||
|
.filter(|c| {
|
||||||
|
!(c.is_ascii_lowercase()
|
||||||
|
|| c.is_ascii_uppercase()
|
||||||
|
|| c.is_ascii_digit()
|
||||||
|
|| c.is_ascii_punctuation())
|
||||||
|
})
|
||||||
|
.dedup()
|
||||||
|
.count();
|
||||||
|
|
||||||
|
((r + other) as f32).log2() * len as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::entropy;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entropy() {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
entropy("this is a long passphrase with quite a few words!")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
entropy("This Is A Long Passphrase With Quite A Few Words!")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
103
src/randomart.rs
Normal file
103
src/randomart.rs
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
const WIDTH: usize = 17;
|
||||||
|
const HEIGHT: usize = 9;
|
||||||
|
const TILES: &[char] = &[
|
||||||
|
' ', '.', 'o', '+', '=', '*', 'B', 'O', 'X', '@', '%', '&', '#', '/', '^',
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct RandomArt {
|
||||||
|
tiles: [[i8; WIDTH]; HEIGHT],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RandomArt {
|
||||||
|
pub fn render(&self, title: &str, subtitle: &str) -> Result<String, std::fmt::Error> {
|
||||||
|
let mut string = String::new();
|
||||||
|
|
||||||
|
string.write_str(&format!("+{:-^17}+\n", format!("[{}]", title)))?;
|
||||||
|
|
||||||
|
for row in self.tiles {
|
||||||
|
string.write_char('|')?;
|
||||||
|
for cell in row {
|
||||||
|
if cell == -1 {
|
||||||
|
string.write_char('S')?;
|
||||||
|
} else if cell == -2 {
|
||||||
|
string.write_char('E')?;
|
||||||
|
} else {
|
||||||
|
let cell = (TILES.len() - 1).min(cell as usize);
|
||||||
|
string.write_char(TILES[cell])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
string.write_char('|')?;
|
||||||
|
string.write_char('\n')?;
|
||||||
|
}
|
||||||
|
|
||||||
|
string.write_str(&format!("+{:-^17}+\n", format!("[{}]", subtitle)))?;
|
||||||
|
|
||||||
|
Ok(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_digest(digest: &[u8]) -> Self {
|
||||||
|
let mut tiles = [[0i8; WIDTH]; HEIGHT];
|
||||||
|
let mut x = WIDTH / 2;
|
||||||
|
let mut y = HEIGHT / 2;
|
||||||
|
|
||||||
|
tiles[y][x] = -1;
|
||||||
|
|
||||||
|
for byte in digest.iter() {
|
||||||
|
for i in 0..4 {
|
||||||
|
let b = (*byte >> (i * 2)) & 3;
|
||||||
|
match b {
|
||||||
|
0 | 1 => {
|
||||||
|
if y > 0 {
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 | 3 => {
|
||||||
|
if y < HEIGHT - 1 {
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match b {
|
||||||
|
0 | 2 => {
|
||||||
|
if x > 0 {
|
||||||
|
x -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 | 3 => {
|
||||||
|
if x < WIDTH - 1 {
|
||||||
|
x += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tiles[y][x] >= 0 {
|
||||||
|
tiles[y][x] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tiles[y][x] = -2;
|
||||||
|
|
||||||
|
Self { tiles }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FINGERPRINT: &str = "L5N7A2PETaGegW5P3qh/Vjd8AW6Mn4B+VB2SHK+eZCY=";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render() {
|
||||||
|
let randomart = RandomArt::from_digest(&base64::decode(FINGERPRINT).unwrap())
|
||||||
|
.render("Title", "Subtitle")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("{}", randomart);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue