Compare commits
6 Commits
e071dc1cfc
...
b4dbc6ff34
Author | SHA1 | Date |
---|---|---|
|
b4dbc6ff34 | |
|
b197445d64 | |
|
2186c1ce86 | |
|
c15c4625a9 | |
|
5ad48e716e | |
|
8838d4d26f |
|
@ -16,7 +16,7 @@ multithread = ["tokio/rt-multi-thread"]
|
|||
|
||||
[dependencies]
|
||||
keyfork-bug = { workspace = true }
|
||||
keyfork-derive-util = { workspace = true }
|
||||
keyfork-derive-util = { workspace = true, default_features = true }
|
||||
keyfork-frame = { workspace = true, features = ["async"] }
|
||||
keyfork-mnemonic = { workspace = true }
|
||||
keyfork-derive-path-data = { workspace = true }
|
||||
|
|
|
@ -8,7 +8,7 @@ use std::{
|
|||
};
|
||||
|
||||
use keyfork_prompt::default_handler;
|
||||
use keyfork_shard::{openpgp::OpenPGP, Format};
|
||||
use keyfork_shard::{openpgp::OpenPGP, Format, default_transfer};
|
||||
|
||||
type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
|
||||
|
||||
|
@ -34,11 +34,13 @@ fn run() -> Result<()> {
|
|||
|
||||
let openpgp = OpenPGP;
|
||||
let prompt_handler = default_handler()?;
|
||||
let mut transfer = default_transfer()?;
|
||||
|
||||
openpgp.decrypt_one_shard_for_transport(
|
||||
key_discovery.as_deref(),
|
||||
messages_file,
|
||||
prompt_handler,
|
||||
&mut *transfer,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use std::{env, process::ExitCode};
|
||||
|
||||
use keyfork_shard::remote_decrypt;
|
||||
use keyfork_shard::{remote_decrypt, default_transfer};
|
||||
|
||||
type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
|
||||
|
||||
|
@ -15,8 +15,10 @@ fn run() -> Result<()> {
|
|||
_ => panic!("Usage: {program_name}"),
|
||||
}
|
||||
|
||||
let mut transfer = default_transfer()?;
|
||||
|
||||
let mut bytes = vec![];
|
||||
remote_decrypt(&mut bytes)?;
|
||||
remote_decrypt(&mut bytes, &mut *transfer)?;
|
||||
print!("{}", smex::encode(bytes));
|
||||
|
||||
Ok(())
|
||||
|
|
|
@ -3,30 +3,23 @@
|
|||
use std::{
|
||||
io::{Read, Write},
|
||||
rc::Rc,
|
||||
str::FromStr,
|
||||
sync::{LazyLock, Mutex},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{consts::U12, Aead},
|
||||
Aes256Gcm, KeyInit, Nonce,
|
||||
};
|
||||
use base64::prelude::{Engine, BASE64_STANDARD};
|
||||
use blahaj::{Share, Sharks};
|
||||
use hkdf::Hkdf;
|
||||
use keyfork_bug::{bug, POISONED_MUTEX};
|
||||
use keyfork_mnemonic::{English, Mnemonic};
|
||||
use keyfork_prompt::{
|
||||
prompt_validated_wordlist,
|
||||
validators::{
|
||||
mnemonic::{MnemonicSetValidator, MnemonicValidator, WordLength},
|
||||
Validator,
|
||||
},
|
||||
Message as PromptMessage, PromptHandler, YesNo,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use keyfork_prompt::PromptHandler;
|
||||
use sha2::Sha256;
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
pub mod transfer;
|
||||
pub use transfer::{default_transfer, Transfer};
|
||||
|
||||
const PLAINTEXT_LENGTH: u8 = 32 // shard
|
||||
+ 1 // index
|
||||
+ 1 // threshold
|
||||
|
@ -34,45 +27,6 @@ const PLAINTEXT_LENGTH: u8 = 32 // shard
|
|||
+ 1; // length;
|
||||
const ENCRYPTED_LENGTH: u8 = PLAINTEXT_LENGTH + 16;
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
enum RetryScanMnemonic {
|
||||
Retry,
|
||||
Continue,
|
||||
}
|
||||
|
||||
impl keyfork_prompt::Choice for RetryScanMnemonic {
|
||||
fn identifier(&self) -> Option<char> {
|
||||
Some(match self {
|
||||
RetryScanMnemonic::Retry => 'r',
|
||||
RetryScanMnemonic::Continue => 'c',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RetryScanMnemonic {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RetryScanMnemonic::Retry => write!(f, "Retry scanning mnemonic."),
|
||||
RetryScanMnemonic::Continue => write!(f, "Continue to manual mnemonic entry."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_checksum(slice: &[u8]) -> Vec<u8> {
|
||||
// generate a verification checksum
|
||||
// this checksum should be expensive to calculate
|
||||
let mut payload = vec![];
|
||||
for _ in 0..1_000_000 {
|
||||
payload.extend(slice);
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&payload);
|
||||
let result = hasher.finalize();
|
||||
payload.clear();
|
||||
payload.extend(result);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
#[cfg(feature = "openpgp")]
|
||||
pub mod openpgp;
|
||||
|
||||
|
@ -269,6 +223,7 @@ pub trait Format {
|
|||
private_key_discovery: Option<impl KeyDiscovery<Self>>,
|
||||
reader: impl Read + Send + Sync,
|
||||
prompt: Box<dyn PromptHandler>,
|
||||
transfer: &mut dyn Transfer,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let prompt = Rc::new(Mutex::new(prompt));
|
||||
|
||||
|
@ -279,80 +234,12 @@ pub trait Format {
|
|||
let encrypted_messages = self.parse_shard_file(reader)?;
|
||||
|
||||
// establish AES-256-GCM key via ECDH
|
||||
let mut pubkey_data: Option<[u8; 32]> = None;
|
||||
|
||||
// receive remote data via scanning QR code from camera
|
||||
#[cfg(feature = "qrcode")]
|
||||
{
|
||||
prompt
|
||||
.lock()
|
||||
.expect(bug!(POISONED_MUTEX))
|
||||
.prompt_message(PromptMessage::Text(QRCODE_PROMPT.to_string()))?;
|
||||
loop {
|
||||
if let Ok(Some(qrcode_content)) =
|
||||
keyfork_qrcode::scan_camera(std::time::Duration::from_secs(*QRCODE_TIMEOUT), 0)
|
||||
{
|
||||
let decoded_data = BASE64_STANDARD
|
||||
.decode(qrcode_content)
|
||||
.expect(bug!("qrcode should contain base64 encoded data"));
|
||||
let data: [u8; 32] = decoded_data.try_into().map_err(|_| InvalidData)?;
|
||||
let checksum = calculate_checksum(&data);
|
||||
let small_sum = &checksum[..8];
|
||||
let small_mnemonic = Mnemonic::from_raw_bytes(small_sum);
|
||||
|
||||
let mut prompt = prompt.lock().expect(bug!(POISONED_MUTEX));
|
||||
let question =
|
||||
format!("Do these words match the expected words? {small_mnemonic}");
|
||||
let response = keyfork_prompt::prompt_choice(
|
||||
&mut **prompt,
|
||||
&question,
|
||||
&[YesNo::No, YesNo::Yes],
|
||||
)?;
|
||||
if response == YesNo::No {
|
||||
prompt.prompt_message(PromptMessage::Text(String::from(
|
||||
"Could not establish secure channel, exiting.",
|
||||
)))?;
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
pubkey_data = Some(data);
|
||||
break;
|
||||
} else {
|
||||
let mut prompt = prompt.lock().expect(bug!(POISONED_MUTEX));
|
||||
let choice = keyfork_prompt::prompt_choice(
|
||||
&mut **prompt,
|
||||
"A QR code could not be scanned. Retry or continue?",
|
||||
&[RetryScanMnemonic::Retry, RetryScanMnemonic::Continue],
|
||||
)?;
|
||||
if choice == RetryScanMnemonic::Continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if QR code scanning failed or was unavailable, read from a set of mnemonics
|
||||
let their_pubkey = if let Some(pubkey) = pubkey_data {
|
||||
pubkey
|
||||
} else {
|
||||
let validator = MnemonicValidator {
|
||||
word_length: Some(WordLength::Count(24)),
|
||||
};
|
||||
let mut prompt = prompt.lock().expect(bug!(POISONED_MUTEX));
|
||||
prompt_validated_wordlist::<English, _>(
|
||||
&mut **prompt,
|
||||
QRCODE_COULDNT_READ,
|
||||
3,
|
||||
&*validator.to_fn(),
|
||||
)?
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.map_err(|_| InvalidData)?
|
||||
};
|
||||
let their_pubkey =
|
||||
transfer.receive_pubkey(&mut **prompt.lock().expect(bug!(POISONED_MUTEX)))?;
|
||||
|
||||
// create our shared key
|
||||
let our_key = EphemeralSecret::random();
|
||||
let our_pubkey_mnemonic = Mnemonic::try_from_slice(PublicKey::from(&our_key).as_bytes())?;
|
||||
let our_pubkey = PublicKey::from(&our_key).to_bytes();
|
||||
let shared_secret = our_key.diffie_hellman(&PublicKey::from(their_pubkey));
|
||||
assert!(
|
||||
shared_secret.was_contributory(),
|
||||
|
@ -401,44 +288,14 @@ pub trait Format {
|
|||
ENCRYPTED_LENGTH as usize,
|
||||
bug!("encrypted bytes size != expected len"),
|
||||
);
|
||||
let mut mnemonic_bytes = [0u8; ENCRYPTED_LENGTH as usize];
|
||||
mnemonic_bytes.copy_from_slice(&encrypted_bytes);
|
||||
let mut payload_bytes = [0u8; ENCRYPTED_LENGTH as usize];
|
||||
payload_bytes.copy_from_slice(&encrypted_bytes);
|
||||
|
||||
let payload_mnemonic = Mnemonic::from_array(mnemonic_bytes);
|
||||
|
||||
#[cfg(feature = "qrcode")]
|
||||
{
|
||||
use keyfork_qrcode::{qrencode, ErrorCorrection};
|
||||
let mut qrcode_data = our_pubkey_mnemonic.to_bytes();
|
||||
qrcode_data.extend(payload_mnemonic.as_bytes());
|
||||
if let Ok(qrcode) = qrencode(
|
||||
&BASE64_STANDARD.encode(qrcode_data),
|
||||
ErrorCorrection::Highest,
|
||||
) {
|
||||
prompt
|
||||
.lock()
|
||||
.expect(bug!(POISONED_MUTEX))
|
||||
.prompt_message(PromptMessage::Text(
|
||||
concat!(
|
||||
"A QR code will be displayed after this prompt. ",
|
||||
"Send the QR code back to the operator combining the shards. ",
|
||||
"Nobody else should scan this QR code."
|
||||
)
|
||||
.to_string(),
|
||||
))?;
|
||||
prompt
|
||||
.lock()
|
||||
.expect(bug!(POISONED_MUTEX))
|
||||
.prompt_message(PromptMessage::Data(qrcode))?;
|
||||
}
|
||||
}
|
||||
|
||||
prompt
|
||||
.lock()
|
||||
.expect(bug!(POISONED_MUTEX))
|
||||
.prompt_message(PromptMessage::Text(format!(
|
||||
"Upon request, these words should be sent: {our_pubkey_mnemonic} {payload_mnemonic}"
|
||||
)))?;
|
||||
transfer.send_encrypted_payload(
|
||||
&mut **prompt.lock().expect(bug!(POISONED_MUTEX)),
|
||||
our_pubkey,
|
||||
payload_bytes,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -512,15 +369,6 @@ pub struct InvalidData;
|
|||
pub(crate) const HUNK_VERSION: u8 = 2;
|
||||
pub(crate) const HUNK_OFFSET: usize = 2;
|
||||
|
||||
const QRCODE_PROMPT: &str = "Press enter, then present QR code to camera.";
|
||||
const QRCODE_COULDNT_READ: &str = "A QR code could not be scanned. Please enter their words: ";
|
||||
static QRCODE_TIMEOUT: LazyLock<u64> = LazyLock::new(|| {
|
||||
std::env::var("KEYFORK_QRCODE_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|t| u64::from_str(&t).ok())
|
||||
.unwrap_or(60)
|
||||
});
|
||||
|
||||
/// Establish ECDH transport for remote operators, receive transport-encrypted shares, decrypt the
|
||||
/// shares, and combine them.
|
||||
///
|
||||
|
@ -534,7 +382,10 @@ static QRCODE_TIMEOUT: LazyLock<u64> = LazyLock::new(|| {
|
|||
/// The function may panic if it is given payloads generated using a version of Keyfork that is
|
||||
/// incompatible with the currently running version.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn remote_decrypt(w: &mut impl Write) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn remote_decrypt(
|
||||
w: &mut impl Write,
|
||||
transfer: &mut dyn Transfer,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut pm = keyfork_prompt::default_handler()?;
|
||||
|
||||
let mut iter_count = None;
|
||||
|
@ -546,104 +397,12 @@ pub fn remote_decrypt(w: &mut impl Write) -> Result<(), Box<dyn std::error::Erro
|
|||
while iter_count.is_none() || iter_count.is_some_and(|i| i > 0) {
|
||||
iter += 1;
|
||||
let our_key = EphemeralSecret::random();
|
||||
let key_mnemonic = Mnemonic::try_from_slice(PublicKey::from(&our_key).as_bytes())?;
|
||||
|
||||
#[cfg(feature = "qrcode")]
|
||||
{
|
||||
use keyfork_qrcode::{qrencode, ErrorCorrection};
|
||||
let qrcode_data = key_mnemonic.to_bytes();
|
||||
if let Ok(qrcode) = qrencode(
|
||||
&BASE64_STANDARD.encode(qrcode_data),
|
||||
ErrorCorrection::Highest,
|
||||
) {
|
||||
let checksum = calculate_checksum(key_mnemonic.as_bytes());
|
||||
let small_sum = &checksum[..8];
|
||||
let small_mnemonic = Mnemonic::from_raw_bytes(small_sum);
|
||||
pm.prompt_message(PromptMessage::Text(format!(
|
||||
concat!(
|
||||
"QR code #{iter} will be displayed after this prompt. ",
|
||||
"Send the QR code to the next shardholder. ",
|
||||
"Only the next shardholder should scan the QR code. ",
|
||||
),
|
||||
iter = iter,
|
||||
)))?;
|
||||
pm.prompt_message(PromptMessage::Data(qrcode))?;
|
||||
pm.prompt_message(PromptMessage::Text(format!(
|
||||
"The following should be sent to verify the QR code: {small_mnemonic}"
|
||||
)))?;
|
||||
}
|
||||
}
|
||||
|
||||
pm.prompt_message(PromptMessage::Text(format!(
|
||||
concat!(
|
||||
"Upon request, these words should be sent to the shardholder: ",
|
||||
"{key_mnemonic}"
|
||||
),
|
||||
key_mnemonic = key_mnemonic,
|
||||
)))?;
|
||||
|
||||
let mut pubkey_data: Option<[u8; 32]> = None;
|
||||
let mut payload_data = None;
|
||||
|
||||
#[cfg(feature = "qrcode")]
|
||||
{
|
||||
pm.prompt_message(PromptMessage::Text(QRCODE_PROMPT.to_string()))?;
|
||||
loop {
|
||||
if let Ok(Some(qrcode_content)) =
|
||||
keyfork_qrcode::scan_camera(std::time::Duration::from_secs(*QRCODE_TIMEOUT), 0)
|
||||
{
|
||||
let decoded_data = BASE64_STANDARD
|
||||
.decode(qrcode_content)
|
||||
.expect(bug!("qrcode should contain base64 encoded data"));
|
||||
assert_eq!(
|
||||
decoded_data.len(),
|
||||
// Include length of public key
|
||||
ENCRYPTED_LENGTH as usize + 32,
|
||||
bug!("invalid payload data")
|
||||
);
|
||||
let _ =
|
||||
pubkey_data.insert(decoded_data[..32].try_into().map_err(|_| InvalidData)?);
|
||||
let _ = payload_data.insert(decoded_data[32..].to_vec());
|
||||
break;
|
||||
} else {
|
||||
let choice = keyfork_prompt::prompt_choice(
|
||||
&mut *pm,
|
||||
"A QR code could not be scanned. Retry or continue?",
|
||||
&[RetryScanMnemonic::Retry, RetryScanMnemonic::Continue],
|
||||
)?;
|
||||
if choice == RetryScanMnemonic::Continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (pubkey, payload) = if let Some((pubkey, payload)) = pubkey_data.zip(payload_data) {
|
||||
(pubkey, payload)
|
||||
} else {
|
||||
let validator = MnemonicSetValidator {
|
||||
word_lengths: [24, 39],
|
||||
};
|
||||
|
||||
let [pubkey_mnemonic, payload_mnemonic] = prompt_validated_wordlist::<English, _>(
|
||||
&mut *pm,
|
||||
QRCODE_COULDNT_READ,
|
||||
3,
|
||||
&*validator.to_fn(),
|
||||
)?;
|
||||
let pubkey = pubkey_mnemonic
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.map_err(|_| InvalidData)?;
|
||||
let payload = payload_mnemonic.to_bytes();
|
||||
(pubkey, payload)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
payload.len(),
|
||||
ENCRYPTED_LENGTH as usize,
|
||||
bug!("invalid payload data")
|
||||
);
|
||||
let (pubkey, payload) = transfer.exchange_pubkey_for_encrypted_payload(
|
||||
&mut *pm,
|
||||
PublicKey::from(&our_key).to_bytes(),
|
||||
iter,
|
||||
)?;
|
||||
|
||||
let shared_secret = our_key.diffie_hellman(&PublicKey::from(pubkey));
|
||||
assert!(
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
//! Transfer shards between computers.
|
||||
|
||||
use keyfork_prompt::PromptHandler;
|
||||
|
||||
mod prompt;
|
||||
// mod enclave;
|
||||
|
||||
pub use prompt::PromptTransfer;
|
||||
// pub use enclave::EnclaveTransfer;
|
||||
|
||||
pub(crate) type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
|
||||
|
||||
/// An interface for facilitating the transfer of shards between systems.
|
||||
///
|
||||
/// The transfer system should be the same on each side of the operation.
|
||||
pub trait Transfer {
|
||||
/// Send a public key to a Shardholder.
|
||||
///
|
||||
/// For human transfer, this could display the public key in a human-compatible mechanism.
|
||||
/// For automatic transfer, this could wait for a client to connect to a server. Once the
|
||||
/// client has connected and authenticated the server, the server can send a public key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The method may return an error if the transfer was, for some reason, unable to finish.
|
||||
fn send_pubkey(
|
||||
&mut self,
|
||||
prompt_handler: &mut dyn PromptHandler,
|
||||
pubkey: [u8; 32],
|
||||
pubkey_index: u8,
|
||||
) -> Result<()>;
|
||||
|
||||
/// As a shardholder, receive a public key.
|
||||
///
|
||||
/// For human transfer, the shardholder should authenticate the reconstitution operator in a
|
||||
/// channel that is not necessarily spy-proof (i.e. the public key can be _seen_), but must be
|
||||
/// tamper-proof (i.e. the public key can't be _modified_). A public communication channel with
|
||||
/// authenticated messages would be permissible.
|
||||
///
|
||||
/// For automated transfer, the system should authenticate the remote system and establish a
|
||||
/// channel under the previously-discussed conditions. A TLS connection satisfies these
|
||||
/// requirements. Ideally, attestation of the remote server should be accomplished once the
|
||||
/// channel has been created.
|
||||
///
|
||||
/// Once the channel has been established, the public key can be received.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The method may return an error if the transfer was, for some reason, unable to finish.
|
||||
fn receive_pubkey(&mut self, prompt_handler: &mut dyn PromptHandler) -> Result<[u8; 32]>;
|
||||
|
||||
/// As a shardholder, send the encrypted shard payload.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The method may return an error if the transfer was, for some reason, unable to finish.
|
||||
fn send_encrypted_payload(
|
||||
&mut self,
|
||||
prompt_handler: &mut dyn PromptHandler,
|
||||
pubkey: [u8; 32],
|
||||
payload: [u8; super::ENCRYPTED_LENGTH as usize],
|
||||
) -> Result<()>;
|
||||
|
||||
/// Receive an encrypted shard payload.
|
||||
///
|
||||
/// This method should be invoked directly after [`Transfer::send_pubkey`], as the payload will
|
||||
/// be decrypted using the private key that is associated with the previously-send public key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The method may return an error if the transfer was, for some reason, unable to finish.
|
||||
fn receive_encrypted_payload(
|
||||
&mut self,
|
||||
prompt_handler: &mut dyn PromptHandler,
|
||||
) -> Result<([u8; 32], [u8; super::ENCRYPTED_LENGTH as usize])>;
|
||||
|
||||
/// Utility function to send a pubkey and receive a payload, as one operation follows the
|
||||
/// other.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The method may return an error if the transfer was, for some reason, unable to finish.
|
||||
fn exchange_pubkey_for_encrypted_payload(
|
||||
&mut self,
|
||||
prompt_handler: &mut dyn PromptHandler,
|
||||
pubkey: [u8; 32],
|
||||
pubkey_index: u8,
|
||||
) -> Result<([u8; 32], [u8; super::ENCRYPTED_LENGTH as usize])> {
|
||||
self.send_pubkey(prompt_handler, pubkey, pubkey_index)?;
|
||||
self.receive_encrypted_payload(prompt_handler)
|
||||
}
|
||||
}
|
||||
|
||||
/// An error occurred in the process of loading a default transfer mechanism.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum DefaultTransferError {
|
||||
}
|
||||
|
||||
/// Get a Transfer mechanism that is most suitable for the given environment.
|
||||
///
|
||||
/// The following handlers will be used based on the `KEYFORK_TRANSFER_TYPE` variable:
|
||||
/// * `KEYFORK_TRANSFER_TYPE=prompt`: [`PromptTransfer`]
|
||||
///
|
||||
/// Otherwise, the following heuristics are followed:
|
||||
/// * default: [`PromptTransfer`]
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The function will return an error if a specific transfer mechanism was requested but could not
|
||||
/// be constructed.
|
||||
pub fn default_transfer() -> Result<Box<dyn Transfer>, DefaultTransferError> {
|
||||
Ok(Box::new(PromptTransfer))
|
||||
}
|
|
@ -0,0 +1,292 @@
|
|||
use std::{str::FromStr, sync::LazyLock};
|
||||
|
||||
use keyfork_bug::bug;
|
||||
use keyfork_mnemonic::{English, Mnemonic};
|
||||
use keyfork_prompt::{
|
||||
prompt_validated_wordlist,
|
||||
validators::{
|
||||
mnemonic::{MnemonicSetValidator, MnemonicValidator, WordLength},
|
||||
Validator,
|
||||
},
|
||||
Message as PromptMessage, PromptHandler, YesNo,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// NOTE: Base64 is only used as the transport for QR codes.
|
||||
#[cfg(feature = "qrcode")]
|
||||
use base64::prelude::{Engine, BASE64_STANDARD};
|
||||
|
||||
use super::Result;
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
enum RetryScanMnemonic {
|
||||
Retry,
|
||||
Continue,
|
||||
}
|
||||
|
||||
impl keyfork_prompt::Choice for RetryScanMnemonic {
|
||||
fn identifier(&self) -> Option<char> {
|
||||
Some(match self {
|
||||
RetryScanMnemonic::Retry => 'r',
|
||||
RetryScanMnemonic::Continue => 'c',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RetryScanMnemonic {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RetryScanMnemonic::Retry => write!(f, "Retry scanning mnemonic."),
|
||||
RetryScanMnemonic::Continue => write!(f, "Continue to manual mnemonic entry."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_checksum(slice: &[u8]) -> Vec<u8> {
|
||||
// generate a verification checksum
|
||||
// this checksum should be expensive to calculate
|
||||
let mut payload = vec![];
|
||||
for _ in 0..1_000_000 {
|
||||
payload.extend(slice);
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&payload);
|
||||
let result = hasher.finalize();
|
||||
payload.clear();
|
||||
payload.extend(result);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
const QRCODE_PROMPT: &str = "Press enter, then present QR code to camera.";
|
||||
const QRCODE_COULDNT_READ: &str = "A QR code could not be scanned. Please enter their words: ";
|
||||
static QRCODE_TIMEOUT: LazyLock<u64> = LazyLock::new(|| {
|
||||
std::env::var("KEYFORK_QRCODE_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|t| u64::from_str(&t).ok())
|
||||
.unwrap_or(60)
|
||||
});
|
||||
|
||||
/// The mnemonic or QR code used to transport an encrypted shard did not store the correct amount
|
||||
/// of data.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[error("Mnemonic or QR code did not store enough data")]
|
||||
pub struct InvalidData;
|
||||
|
||||
/// A transfer mechanism based on prompts being sent to and from operators.
|
||||
pub struct PromptTransfer;
|
||||
|
||||
impl super::Transfer for PromptTransfer {
|
||||
fn send_pubkey(
|
||||
&mut self,
|
||||
prompt_handler: &mut dyn PromptHandler,
|
||||
pubkey: [u8; 32],
|
||||
pubkey_index: u8,
|
||||
) -> Result<()> {
|
||||
#[cfg(feature = "qrcode")]
|
||||
{
|
||||
use keyfork_qrcode::{qrencode, ErrorCorrection};
|
||||
if let Ok(qrcode) = qrencode(&BASE64_STANDARD.encode(pubkey), ErrorCorrection::Highest)
|
||||
{
|
||||
let checksum = calculate_checksum(&pubkey);
|
||||
let small_sum: [u8; 8] = checksum[..8].try_into().expect(bug!(
|
||||
"Mnemonic {checksum:?} must have at least 8 bytes",
|
||||
checksum = checksum
|
||||
));
|
||||
let small_mnemonic = Mnemonic::from_array(small_sum);
|
||||
|
||||
prompt_handler.prompt_message(PromptMessage::Text(format!(
|
||||
concat!(
|
||||
"QR code #{iter} will be displayed after this prompt. ",
|
||||
"Send the QR code to the next shardholder. ",
|
||||
"Only the next shardholder should scan the QR code.",
|
||||
),
|
||||
iter = pubkey_index
|
||||
)))?;
|
||||
|
||||
prompt_handler.prompt_message(PromptMessage::Data(qrcode))?;
|
||||
prompt_handler.prompt_message(PromptMessage::Text(format!(
|
||||
"The following should be sent to verify the QR code: {small_mnemonic}"
|
||||
)))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mnemonic = Mnemonic::from_array(pubkey);
|
||||
prompt_handler.prompt_message(PromptMessage::Text(format!(
|
||||
"Upon request, these words should be sent to the shardholder: {mnemonic}"
|
||||
)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn receive_pubkey(&mut self, prompt_handler: &mut dyn PromptHandler) -> Result<[u8; 32]> {
|
||||
let mut pubkey_data: Option<[u8; 32]> = None;
|
||||
|
||||
#[cfg(feature = "qrcode")]
|
||||
{
|
||||
prompt_handler.prompt_message(PromptMessage::Text(QRCODE_PROMPT.to_string()))?;
|
||||
loop {
|
||||
if let Ok(Some(qrcode_content)) =
|
||||
keyfork_qrcode::scan_camera(std::time::Duration::from_secs(*QRCODE_TIMEOUT), 0)
|
||||
{
|
||||
let decoded_data = BASE64_STANDARD
|
||||
.decode(qrcode_content)
|
||||
.expect(bug!("qrcode should contain base64 encoded data"));
|
||||
let data: [u8; 32] = decoded_data.try_into().map_err(|_| InvalidData)?;
|
||||
let checksum = calculate_checksum(&data);
|
||||
let small_sum = &checksum[..8];
|
||||
let small_mnemonic = Mnemonic::from_raw_bytes(small_sum);
|
||||
|
||||
let question =
|
||||
format!("Do these words match the expected words? {small_mnemonic}");
|
||||
let response = keyfork_prompt::prompt_choice(
|
||||
&mut *prompt_handler,
|
||||
&question,
|
||||
&[YesNo::No, YesNo::Yes],
|
||||
)?;
|
||||
if response == YesNo::No {
|
||||
prompt_handler.prompt_message(PromptMessage::Text(String::from(
|
||||
"Could not establish secure channel, exiting.",
|
||||
)))?;
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
pubkey_data = Some(data);
|
||||
break;
|
||||
} else {
|
||||
let choice = keyfork_prompt::prompt_choice(
|
||||
&mut *prompt_handler,
|
||||
"A QR code could not be scanned. Retry or continue?",
|
||||
&[RetryScanMnemonic::Retry, RetryScanMnemonic::Continue],
|
||||
)?;
|
||||
if choice == RetryScanMnemonic::Continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let their_pubkey = if let Some(pubkey) = pubkey_data {
|
||||
pubkey
|
||||
} else {
|
||||
let validator = MnemonicValidator {
|
||||
word_length: Some(WordLength::Count(24)),
|
||||
};
|
||||
prompt_validated_wordlist::<English, _>(
|
||||
&mut *prompt_handler,
|
||||
QRCODE_COULDNT_READ,
|
||||
3,
|
||||
&*validator.to_fn(),
|
||||
)?
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.map_err(|_| InvalidData)?
|
||||
};
|
||||
|
||||
Ok(their_pubkey)
|
||||
}
|
||||
|
||||
fn send_encrypted_payload(
|
||||
&mut self,
|
||||
prompt_handler: &mut dyn PromptHandler,
|
||||
pubkey: [u8; 32],
|
||||
payload: [u8; crate::ENCRYPTED_LENGTH as usize],
|
||||
) -> Result<()> {
|
||||
#[cfg(feature = "qrcode")]
|
||||
{
|
||||
use keyfork_qrcode::{qrencode, ErrorCorrection};
|
||||
|
||||
let mut qrcode_data = pubkey.to_vec();
|
||||
qrcode_data.extend(payload);
|
||||
if let Ok(qrcode) = qrencode(
|
||||
&BASE64_STANDARD.encode(qrcode_data),
|
||||
ErrorCorrection::Highest,
|
||||
) {
|
||||
prompt_handler.prompt_message(PromptMessage::Text(
|
||||
concat!(
|
||||
"A QR code will be displayed after this prompt. ",
|
||||
"Send the QR code back to the operator combining the shards. ",
|
||||
"Nobody else should scan this QR code."
|
||||
)
|
||||
.to_string(),
|
||||
))?;
|
||||
prompt_handler.prompt_message(PromptMessage::Data(qrcode))?;
|
||||
}
|
||||
}
|
||||
|
||||
let pubkey_mnemonic = Mnemonic::from_array(pubkey);
|
||||
let payload_mnemonic = Mnemonic::from_array(payload);
|
||||
|
||||
prompt_handler.prompt_message(PromptMessage::Text(format!(
|
||||
"Upon request, these words should be sent: {pubkey_mnemonic} {payload_mnemonic}"
|
||||
)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn receive_encrypted_payload(
|
||||
&mut self,
|
||||
prompt_handler: &mut dyn PromptHandler,
|
||||
) -> Result<([u8; 32], [u8; crate::ENCRYPTED_LENGTH as usize])> {
|
||||
let mut pubkey_data: Option<[u8; 32]> = None;
|
||||
let mut payload_data = None;
|
||||
|
||||
#[cfg(feature = "qrcode")]
|
||||
{
|
||||
prompt_handler.prompt_message(PromptMessage::Text(QRCODE_PROMPT.to_string()))?;
|
||||
loop {
|
||||
if let Ok(Some(qrcode_content)) =
|
||||
keyfork_qrcode::scan_camera(std::time::Duration::from_secs(*QRCODE_TIMEOUT), 0)
|
||||
{
|
||||
let decoded_data = BASE64_STANDARD
|
||||
.decode(qrcode_content)
|
||||
.expect(bug!("qrcode should contain base64 encoded data"));
|
||||
assert_eq!(
|
||||
decoded_data.len(),
|
||||
// Include length of public key
|
||||
crate::ENCRYPTED_LENGTH as usize + 32,
|
||||
bug!("invalid payload data")
|
||||
);
|
||||
let _ =
|
||||
pubkey_data.insert(decoded_data[..32].try_into().map_err(|_| InvalidData)?);
|
||||
let _ = payload_data.insert(decoded_data[32..].to_vec());
|
||||
break;
|
||||
} else {
|
||||
let choice = keyfork_prompt::prompt_choice(
|
||||
&mut *prompt_handler,
|
||||
"A QR code could not be scanned. Retry or continue?",
|
||||
&[RetryScanMnemonic::Retry, RetryScanMnemonic::Continue],
|
||||
)?;
|
||||
if choice == RetryScanMnemonic::Continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (pubkey, payload) = if let Some((pubkey, payload)) = pubkey_data.zip(payload_data) {
|
||||
(pubkey, payload)
|
||||
} else {
|
||||
let validator = MnemonicSetValidator {
|
||||
word_lengths: [24, 39],
|
||||
};
|
||||
|
||||
let [pubkey_mnemonic, payload_mnemonic] = prompt_validated_wordlist::<English, _>(
|
||||
&mut *prompt_handler,
|
||||
QRCODE_COULDNT_READ,
|
||||
3,
|
||||
&*validator.to_fn(),
|
||||
)?;
|
||||
let pubkey = pubkey_mnemonic
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.map_err(|_| InvalidData)?;
|
||||
let payload = payload_mnemonic.to_bytes();
|
||||
(pubkey, payload)
|
||||
};
|
||||
|
||||
let payload: [u8; crate::ENCRYPTED_LENGTH as usize] =
|
||||
payload.try_into().map_err(|_| InvalidData)?;
|
||||
|
||||
Ok((pubkey, payload))
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ workspace = true
|
|||
[features]
|
||||
default = [
|
||||
"completion",
|
||||
"qrcode-decode-backend-rqrr",
|
||||
"qrcode-decode-backend-zbar",
|
||||
"sequoia-crypto-backend-nettle",
|
||||
]
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ where
|
|||
/// A mapping between keys and values.
|
||||
pub values: HashMap<String, String>,
|
||||
|
||||
/// The first variable for the argument, such as a [`PathBuf`].
|
||||
/// The first variable for the argument, such as a [`std::path::PathBuf`].
|
||||
pub inner: T,
|
||||
}
|
||||
|
||||
|
|
|
@ -22,7 +22,6 @@ type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
|
|||
|
||||
pub trait Deriver {
|
||||
type Prv: PrivateKey + Clone;
|
||||
const DERIVATION_ALGORITHM: DerivationAlgorithm;
|
||||
|
||||
fn derivation_path(&self) -> DerivationPath;
|
||||
|
||||
|
@ -207,7 +206,6 @@ impl OpenPGP {
|
|||
|
||||
impl Deriver for OpenPGP {
|
||||
type Prv = keyfork_derive_openpgp::XPrvKey;
|
||||
const DERIVATION_ALGORITHM: DerivationAlgorithm = DerivationAlgorithm::Ed25519;
|
||||
|
||||
fn derivation_path(&self) -> DerivationPath {
|
||||
self.derivation_path.derivation_path()
|
||||
|
@ -247,7 +245,6 @@ impl Deriver for OpenPGP {
|
|||
impl Deriver for Key {
|
||||
// HACK: We're abusing that we use the same key as OpenPGP. Maybe we should use ed25519_dalek.
|
||||
type Prv = keyfork_derive_openpgp::XPrvKey;
|
||||
const DERIVATION_ALGORITHM: DerivationAlgorithm = DerivationAlgorithm::Ed25519;
|
||||
|
||||
fn derivation_path(&self) -> DerivationPath {
|
||||
DerivationPath::default().chain_push(self.slug.0.clone())
|
||||
|
|
|
@ -14,7 +14,7 @@ use keyfork_prompt::{
|
|||
Validator,
|
||||
},
|
||||
};
|
||||
use keyfork_shard::{remote_decrypt, Format};
|
||||
use keyfork_shard::{remote_decrypt, Format, default_transfer};
|
||||
|
||||
type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
|
||||
|
||||
|
@ -60,7 +60,10 @@ impl RecoverSubcommands {
|
|||
}
|
||||
RecoverSubcommands::RemoteShard {} => {
|
||||
let mut seed = vec![];
|
||||
remote_decrypt(&mut seed)?;
|
||||
|
||||
let mut transfer = default_transfer()?;
|
||||
remote_decrypt(&mut seed, &mut *transfer)?;
|
||||
|
||||
Ok(seed)
|
||||
}
|
||||
RecoverSubcommands::Mnemonic {} => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use super::Keyfork;
|
||||
use clap::{builder::PossibleValue, Parser, Subcommand, ValueEnum};
|
||||
use keyfork_prompt::default_handler;
|
||||
use keyfork_shard::Format as _;
|
||||
use keyfork_shard::{Format as _, default_transfer};
|
||||
use std::{
|
||||
io::{stdin, stdout, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
|
@ -97,7 +97,8 @@ impl ShardExec for OpenPGP {
|
|||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let openpgp = keyfork_shard::openpgp::OpenPGP;
|
||||
let prompt = default_handler()?;
|
||||
openpgp.decrypt_one_shard_for_transport(key_discovery, input, prompt)?;
|
||||
let mut transfer = default_transfer()?;
|
||||
openpgp.decrypt_one_shard_for_transport(key_discovery, input, prompt, &mut *transfer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -266,7 +267,8 @@ impl ShardSubcommands {
|
|||
}
|
||||
ShardSubcommands::RemoteCombine => {
|
||||
let mut output = vec![];
|
||||
keyfork_shard::remote_decrypt(&mut output)?;
|
||||
let mut transfer = default_transfer()?;
|
||||
keyfork_shard::remote_decrypt(&mut output, &mut *transfer)?;
|
||||
println!("{}", smex::encode(output));
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -7,50 +7,55 @@ use openpgp::{
|
|||
types::KeyFlags,
|
||||
Cert,
|
||||
};
|
||||
use keyforkd::test_util::{run_test, Panicable};
|
||||
|
||||
const KEYFORK_BIN: &str = "keyfork";
|
||||
|
||||
#[test]
|
||||
fn test() {
|
||||
let policy = StandardPolicy::new();
|
||||
run_test(b"AAAA", |_| {
|
||||
let policy = StandardPolicy::new();
|
||||
let command_output = Command::cargo_bin(KEYFORK_BIN)
|
||||
.unwrap()
|
||||
.args([
|
||||
"derive",
|
||||
"openpgp",
|
||||
"--to-stdout",
|
||||
"Ryan Heywood (RyanSquared) <ryan@distrust.co>",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let command_output = Command::cargo_bin(KEYFORK_BIN)
|
||||
.unwrap()
|
||||
.args([
|
||||
"derive",
|
||||
"openpgp",
|
||||
"Ryan Heywood (RyanSquared) <ryan@distrust.co>",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
let packets = PacketParser::from_bytes(&command_output.get_output().stdout).unwrap();
|
||||
let cert = Cert::try_from(packets).unwrap();
|
||||
|
||||
let packets = PacketParser::from_bytes(&command_output.get_output().stdout).unwrap();
|
||||
let cert = Cert::try_from(packets).unwrap();
|
||||
|
||||
// assert the cert contains _any_ secret key data
|
||||
assert!(
|
||||
cert.is_tsk(),
|
||||
"exported key should contain secret key data, indicated by the key being a TSK"
|
||||
);
|
||||
|
||||
// assert the correct keys were added in the correct order
|
||||
let mut key_formats = std::collections::HashSet::from([
|
||||
KeyFlags::empty().set_certification(),
|
||||
KeyFlags::empty().set_signing(),
|
||||
KeyFlags::empty()
|
||||
.set_transport_encryption()
|
||||
.set_storage_encryption(),
|
||||
KeyFlags::empty().set_authentication(),
|
||||
]);
|
||||
let valid_cert = cert.with_policy(&policy, None).unwrap();
|
||||
for key in valid_cert.keys() {
|
||||
let flags = key.key_flags().unwrap();
|
||||
// assert the cert contains _any_ secret key data
|
||||
assert!(
|
||||
key_formats.remove(&flags),
|
||||
"could not find key flag set: {flags:?}"
|
||||
cert.is_tsk(),
|
||||
"exported key should contain secret key data, indicated by the key being a TSK"
|
||||
);
|
||||
key.alive().expect("is live after being generated");
|
||||
key.parts_into_secret().expect("has secret keys");
|
||||
}
|
||||
assert!(key_formats.is_empty(), "remaining key formats: {key_formats:?}");
|
||||
|
||||
// assert the correct keys were added in the correct order
|
||||
let mut key_formats = std::collections::HashSet::from([
|
||||
KeyFlags::empty().set_certification(),
|
||||
KeyFlags::empty().set_signing(),
|
||||
KeyFlags::empty()
|
||||
.set_transport_encryption()
|
||||
.set_storage_encryption(),
|
||||
KeyFlags::empty().set_authentication(),
|
||||
]);
|
||||
let valid_cert = cert.with_policy(&policy, None).unwrap();
|
||||
for key in valid_cert.keys() {
|
||||
let flags = key.key_flags().unwrap();
|
||||
assert!(
|
||||
key_formats.remove(&flags),
|
||||
"could not find key flag set: {flags:?}"
|
||||
);
|
||||
key.alive().expect("is live after being generated");
|
||||
key.parts_into_secret().expect("has secret keys");
|
||||
}
|
||||
assert!(key_formats.is_empty(), "remaining key formats: {key_formats:?}");
|
||||
|
||||
Panicable::Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
|
|
@ -55,13 +55,13 @@ impl TerminalIoctl {
|
|||
|
||||
fn get_termios(&self) -> Result<Termios> {
|
||||
let mut termios = unsafe { std::mem::zeroed() };
|
||||
assert_io(unsafe { libc::tcgetattr(self.fd, &mut termios) })?;
|
||||
assert_io(unsafe { libc::tcgetattr(self.fd, &raw mut termios) })?;
|
||||
Ok(termios)
|
||||
}
|
||||
|
||||
/// Enable raw mode for the given terminal.
|
||||
///
|
||||
/// Replaces: [`crossterm::terminal::enable_raw_mode`].
|
||||
/// Replaces: `crossterm::terminal::enable_raw_mode`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
|
@ -71,8 +71,8 @@ impl TerminalIoctl {
|
|||
let mut termios = self.get_termios()?;
|
||||
let original_mode_ios = termios;
|
||||
|
||||
unsafe { libc::cfmakeraw(&mut termios) };
|
||||
assert_io(unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &termios) })?;
|
||||
unsafe { libc::cfmakeraw(&raw mut termios) };
|
||||
assert_io(unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &raw const termios) })?;
|
||||
self.stored_termios = Some(original_mode_ios);
|
||||
}
|
||||
Ok(())
|
||||
|
@ -80,21 +80,21 @@ impl TerminalIoctl {
|
|||
|
||||
/// Disable raw mode for the given terminal.
|
||||
///
|
||||
/// Replaces: [`crossterm::terminal::disable_raw_mode`].
|
||||
/// Replaces: `crossterm::terminal::disable_raw_mode`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The method may propagate errors encountered when interacting with the terminal.
|
||||
pub fn disable_raw_mode(&mut self) -> Result<()> {
|
||||
if let Some(termios) = self.stored_termios.take() {
|
||||
assert_io(unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &termios) })?;
|
||||
assert_io(unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &raw const termios) })?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the size for the given terminal.
|
||||
///
|
||||
/// Replaces: [`crossterm::terminal::size`].
|
||||
/// Replaces: `crossterm::terminal::size`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
|
@ -107,7 +107,7 @@ impl TerminalIoctl {
|
|||
ws_ypixel: 0,
|
||||
};
|
||||
|
||||
assert_io(unsafe { libc::ioctl(self.fd, libc::TIOCGWINSZ, &mut size) })?;
|
||||
assert_io(unsafe { libc::ioctl(self.fd, libc::TIOCGWINSZ, &raw mut size) })?;
|
||||
Ok((size.ws_col, size.ws_row))
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue