Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 12 additions & 69 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ use clap::{Args, Parser, Subcommand};
use std::fs::read;
use std::io::{Write as _, stdin, stdout};
use std::path::{Path, PathBuf};
use vial_core::crypto::{
decrypt_with_password, decrypt_with_random_key, encrypt_with_password, encrypt_with_random_key,
};
use vial_core::crypto::{decrypt, encrypt_with_password, encrypt_with_random_key};
use vial_shared::config::Config;
use vial_shared::{
CreateSecretRequest, EncryptedPayload, FullSecretV1, Payload, SecretFile, SecretFileV1,
Expand Down Expand Up @@ -74,28 +72,8 @@ enum Command {
///
/// This may be a raw secret ID (e.g. abc123)
/// or a full URL returned by the `send` command.
#[arg(long, value_name = "ID|URL")]
#[arg(value_name = "ID|URL")]
source: String,

/// Decrypt using a user-provided password
///
/// If the provided source does not contain a '#<key>'
/// fragment, you will be prompted for a password and
/// the secret will be decrypted using the password-based
/// encryption scheme.
///
/// This is the default behavior when no key is found.
#[arg(short = 'p', long)]
password: bool,

/// Decrypt using a random key provided manually
///
/// Use this when the secret was encrypted with a random
/// key and the link does not include the '#<key>' fragment.
///
/// You will be prompted to enter the key manually.
#[arg(short = 'r', long)]
random_key: bool,
},

/// Show or configure the current configuration
Expand Down Expand Up @@ -221,11 +199,7 @@ fn main() -> Result<()> {
password,
attachments,
} => send(text, view_count, expire, password, attachments)?,
Command::Recv {
source,
password,
random_key,
} => receive(source, password, random_key)?,
Command::Recv { source } => receive(source)?,
Command::Config(args) => {
config(args)?;
}
Expand Down Expand Up @@ -341,7 +315,7 @@ fn send(
Ok(())
}

fn receive(source: String, password: bool, random_key: bool) -> Result<()> {
fn receive(source: String) -> Result<()> {
let Some(secret_id) = source.split('/').next_back() else {
return Err(anyhow!("Could not find the secret id in the secret link."));
};
Expand All @@ -362,8 +336,8 @@ fn receive(source: String, password: bool, random_key: bool) -> Result<()> {
let payload: EncryptedPayload = reqwest_json(client.get(format!("{server_url}/{id}")))
.context("Failed to fetch the secret")?;

decrypt_random_key(key, &payload.payload)
.context("Failed to decrypt using random key schema")?
decrypt_payload(&payload.payload, key)
.context("Failed to decrypt the payload with the given key")?
} else {
let payload: EncryptedPayload =
reqwest_json(client.get(format!("{server_url}/{secret_id}")))
Expand All @@ -375,16 +349,8 @@ fn receive(source: String, password: bool, random_key: bool) -> Result<()> {
// If password flag is set, use password
// If random key flag is set, use random key
// Otherwise, use password
if password {
decrypt_password(&key, &payload.payload)
.context("Failed to decrypt using password schema")?
} else if random_key {
decrypt_random_key(&key, &payload.payload)
.context("Failed to decrypt using random key schema")?
} else {
decrypt_password(&key, &payload.payload)
.context("Failed to decrypt using password schema")?
}
decrypt_payload(&payload.payload, &key)
.context("Failed to decrypt the payload with the given key")?
}
.into_shared();

Expand Down Expand Up @@ -469,36 +435,13 @@ fn reqwest_json<T: serde::de::DeserializeOwned>(
req.send()?.error_for_status()?.json()
}

fn decrypt_random_key(key: &str, payload: &[u8]) -> Result<FullSecretV1> {
let decoded_key = URL_SAFE
.decode(key)
.context("Failed to decode key. Is the key valid?")?;

let arr_ref: &[u8; 32] = decoded_key
.as_slice()
.try_into()
.context("Failed to decode key. Is the key valid")?;

let decrypted =
decrypt_with_random_key(payload, arr_ref).context("Failed to decrypt secret")?;
fn decrypt_payload(payload: &[u8], key: &str) -> Result<FullSecretV1> {
let bytes = decrypt(payload, key).context("Failed to decrypt secret")?;

let full_secret = Payload::from_bytes(decrypted)
Payload::from_bytes(bytes)
.context("Failed to deserialize secret")?
.to_full_secret()
.context("Failed to deserialize secret")?;

Ok(full_secret)
}

fn decrypt_password(key: &str, payload: &[u8]) -> Result<FullSecretV1> {
let decrypted = decrypt_with_password(payload, key).context("Failed to decrypt secret")?;

let full_secret = Payload::from_bytes(decrypted)
.context("Failed to serialize secret")?
.to_full_secret()
.context("Failed to serialize secret")?;

Ok(full_secret)
.context("Failed to deserialize secret")
}

fn save_file(file: &SecretFile, download_path: &Option<PathBuf>) -> Result<()> {
Expand Down
2 changes: 2 additions & 0 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ edition = "2024"
description = """
Crypto primitives for encrypting / decrypting for Vial
"""
readme = "README.md"
homepage = "https://github.com/TheRustyPickle/Vial"
repository = "https://github.com/TheRustyPickle/Vial"
license = "MIT"
Expand All @@ -15,6 +16,7 @@ categories = ["command-line-utilities"]
aead = "0.5.2"
anyhow.workspace = true
argon2 = { version = "0.5.3", default-features = false, features = ["std"] }
base64.workspace = true
chacha20poly1305 = "0.10.1"

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
Expand Down
27 changes: 27 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Blob format (v1)

### Password scheme

| Offset | Size | Field |
|--------|------|-------|
| 0 | 1 | Version (`0x01`) |
| 1 | 1 | Scheme (`0x01`) |
| 2 | 4 | Argon2 `m_cost` (KiB), LE |
| 6 | 4 | Argon2 `t_cost` (iterations), LE |
| 10 | 4 | Argon2 `p_cost` (parallelism), LE |
| 14 | 22 | Argon2 salt (base64 salt string bytes) |
| 36 | 24 | XChaCha20-Poly1305 nonce |
| 60 | _ | AEAD ciphertext (includes 16-byte tag) |

Decryption re-derives the 32-byte key using Argon2id with the stored params, then decrypts with XChaCha20-Poly1305.

### Random key scheme

| Offset | Size | Field |
|--------|------|-------|
| 0 | 1 | Version (`0x01`) |
| 1 | 1 | Scheme (`0x02`) |
| 2 | 24 | XChaCha20-Poly1305 nonce |
| 26 | _ | AEAD ciphertext (includes 16-byte tag) |

The 32-byte key is transmitted out-of-band (URL fragment).
129 changes: 115 additions & 14 deletions core/src/crypto.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,51 @@
use anyhow::{Context, Result, anyhow, bail};
use argon2::Argon2;
use argon2::Params as Argon2Params;
use argon2::password_hash::SaltString;
use argon2::{Algorithm, Argon2, Version};
use base64::{Engine as _, engine::general_purpose::URL_SAFE};
use chacha20poly1305::XNonce;
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{Key, XChaCha20Poly1305};

const BLOB_VERSION: u8 = 1;
const HEADER_LEN: usize = 2;
const ARGON2_PARAMS_LEN: usize = 12;
const SALT_LEN: usize = 22;
const NONCE_LEN: usize = 24;

enum Scheme {
Password = 1,
Random = 2,
}

impl Scheme {
fn from_u8(byte: u8) -> Option<Self> {
match byte {
1 => Some(Self::Password),
2 => Some(Self::Random),
_ => None,
}
}
}

fn write_argon2_params(buf: &mut Vec<u8>, m_cost: u32, t_cost: u32, p_cost: u32) {
buf.extend_from_slice(&m_cost.to_le_bytes());
buf.extend_from_slice(&t_cost.to_le_bytes());
buf.extend_from_slice(&p_cost.to_le_bytes());
}

fn read_argon2_params(blob: &[u8]) -> (u32, u32, u32) {
let m = u32::from_le_bytes(blob[HEADER_LEN..HEADER_LEN + 4].try_into().unwrap());
let t = u32::from_le_bytes(blob[HEADER_LEN + 4..HEADER_LEN + 8].try_into().unwrap());
let p = u32::from_le_bytes(blob[HEADER_LEN + 8..HEADER_LEN + 12].try_into().unwrap());
(m, t, p)
}

pub fn encrypt_with_password(plaintext: &[u8], password: &str) -> Result<Vec<u8>> {
let salt = SaltString::generate(&mut OsRng);
let params = Argon2Params::default();
let argon2 = Argon2::new(Algorithm::default(), Version::default(), params.clone());

let argon2 = Argon2::default();
let salt = SaltString::generate(&mut OsRng);

let mut key_buffer = [0u8; 32];
argon2
Expand All @@ -32,7 +66,16 @@ pub fn encrypt_with_password(plaintext: &[u8], password: &str) -> Result<Vec<u8>

let salt_bytes = salt.as_str().as_bytes();

let mut storage = Vec::with_capacity(salt_bytes.len() + NONCE_LEN + ciphertext.len());
let cap = HEADER_LEN + ARGON2_PARAMS_LEN + salt_bytes.len() + NONCE_LEN + ciphertext.len();
let mut storage = Vec::with_capacity(cap);
storage.push(BLOB_VERSION);
storage.push(Scheme::Password as u8);
write_argon2_params(
&mut storage,
params.m_cost(),
params.t_cost(),
params.p_cost(),
);
storage.extend_from_slice(salt_bytes);
storage.extend_from_slice(&nonce);
storage.extend_from_slice(&ciphertext);
Expand All @@ -41,17 +84,36 @@ pub fn encrypt_with_password(plaintext: &[u8], password: &str) -> Result<Vec<u8>
}

pub fn decrypt_with_password(encrypted_blob: &[u8], password: &str) -> Result<Vec<u8>> {
if encrypted_blob.len() < (SALT_LEN + NONCE_LEN) {
const MIN_LEN: usize = HEADER_LEN + ARGON2_PARAMS_LEN + SALT_LEN + NONCE_LEN + 1;

if encrypted_blob.len() < MIN_LEN {
bail!("Data is too short to be valid");
}

let salt_str = std::str::from_utf8(&encrypted_blob[0..SALT_LEN])
let version = encrypted_blob[0];

if version != BLOB_VERSION {
bail!("Unsupported blob version: {version}");
}

if !matches!(Scheme::from_u8(encrypted_blob[1]), Some(Scheme::Password)) {
bail!("Blob is not a password-encrypted secret");
}

let (m_cost, t_cost, p_cost) = read_argon2_params(encrypted_blob);
let params = Argon2Params::new(m_cost, t_cost, p_cost, None)
.map_err(|e| anyhow::anyhow!("Blob contains invalid Argon2 params: {e}"))?;
let argon2 = Argon2::new(Algorithm::default(), Version::default(), params);

let params_end = HEADER_LEN + ARGON2_PARAMS_LEN;
let salt_str = std::str::from_utf8(&encrypted_blob[params_end..params_end + SALT_LEN])
.context("Failed to parse salt as UTF-8")?;

let nonce_bytes = &encrypted_blob[SALT_LEN..(SALT_LEN + NONCE_LEN)];
let ciphertext = &encrypted_blob[(SALT_LEN + NONCE_LEN)..];
let nonce_start = params_end + SALT_LEN;
let nonce_end = nonce_start + NONCE_LEN;
let nonce_bytes = &encrypted_blob[nonce_start..nonce_end];
let ciphertext = &encrypted_blob[nonce_end..];

let argon2 = Argon2::default();
let mut key_buffer = [0u8; 32];
argon2
.hash_password_into(password.as_bytes(), salt_str.as_bytes(), &mut key_buffer)
Expand Down Expand Up @@ -79,20 +141,32 @@ pub fn encrypt_with_random_key(plaintext: &[u8]) -> Result<(Vec<u8>, [u8; 32])>
.encrypt(&nonce, plaintext)
.map_err(|_| anyhow!("Encryption failed"))?;

let mut storage = Vec::with_capacity(NONCE_LEN + ciphertext.len());
let mut storage = Vec::with_capacity(HEADER_LEN + NONCE_LEN + ciphertext.len());
storage.push(BLOB_VERSION);
storage.push(Scheme::Random as u8);
storage.extend_from_slice(&nonce);
storage.extend_from_slice(&ciphertext);

Ok((storage, key.into()))
}

pub fn decrypt_with_random_key(encrypted_blob: &[u8], key_bytes: &[u8; 32]) -> Result<Vec<u8>> {
if encrypted_blob.len() < NONCE_LEN {
bail!("Data is too short to contain a nonce");
if encrypted_blob.len() < HEADER_LEN + NONCE_LEN + 1 {
bail!("Data is too short");
}

let version = encrypted_blob[0];

if version != BLOB_VERSION {
bail!("Unsupported blob version: {version}");
}

if !matches!(Scheme::from_u8(encrypted_blob[1]), Some(Scheme::Random)) {
bail!("Blob is not a random-key-encrypted secret");
}

let nonce_bytes = &encrypted_blob[0..NONCE_LEN];
let ciphertext = &encrypted_blob[NONCE_LEN..];
let nonce_bytes = &encrypted_blob[HEADER_LEN..HEADER_LEN + NONCE_LEN];
let ciphertext = &encrypted_blob[HEADER_LEN + NONCE_LEN..];

let key = Key::from_slice(key_bytes);
let cipher = XChaCha20Poly1305::new(key);
Expand All @@ -104,3 +178,30 @@ pub fn decrypt_with_random_key(encrypted_blob: &[u8], key_bytes: &[u8; 32]) -> R

Ok(plaintext)
}

pub fn decrypt(encrypted_blob: &[u8], key_or_password: &str) -> Result<Vec<u8>> {
if encrypted_blob.len() < HEADER_LEN {
bail!("Data is too short to be valid");
}

let version = encrypted_blob[0];

if version != BLOB_VERSION {
bail!("Unsupported blob version: {version}");
}

match Scheme::from_u8(encrypted_blob[1]) {
Some(Scheme::Password) => decrypt_with_password(encrypted_blob, key_or_password),
Some(Scheme::Random) => {
let key_bytes = URL_SAFE
.decode(key_or_password)
.context("Failed to decode key (expected base64-encoded random key)")?;
let key: &[u8; 32] = key_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("Key must be exactly 32 bytes. Got {}", key_bytes.len()))?;
decrypt_with_random_key(encrypted_blob, key)
}
None => bail!("Unknown scheme byte: {}", encrypted_blob[1]),
}
}
Loading
Loading