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
354 changes: 224 additions & 130 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ workspace = true
[dependencies]
dioxus = { version = "0.7", features = ["web"] }
serde_json = "1"
gloo-net = "0.6"
gloo-net = "0.7"
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Location"] }
6 changes: 6 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ allow = [
"CDLA-Permissive-2.0",
]
confidence-threshold = 0.9
# The SQLCipher amalgamation bundled for the encrypted library. SQLite itself is
# public domain ("blessing") and libtomcrypt is WTFPL/public domain; both are more
# permissive than MIT. Scoped to this crate so neither licence is allowed elsewhere.
exceptions = [
{ crate = "sqlcipher-src", allow = ["blessing", "WTFPL"] },
]

[bans]
multiple-versions = "allow"
Expand Down
2 changes: 1 addition & 1 deletion docs/licenses.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Echo itself is released under the [MIT licence](../LICENSE).

## Dependencies

Rust dependencies are checked by `cargo deny check` (see `deny.toml`) on every CI run. Only permissive licences are allowed: MIT, Apache-2.0 (with or without the LLVM exception), BSD-2-Clause, BSD-3-Clause, ISC, Zlib, Unicode-3.0, CC0-1.0, BSL-1.0, MPL-2.0 and CDLA-Permissive-2.0. A crate offered under several licences passes if one of them is on that list.
Rust dependencies are checked by `cargo deny check` (see `deny.toml`) on every CI run. Only permissive licences are allowed: MIT, Apache-2.0 (with or without the LLVM exception), BSD-2-Clause, BSD-3-Clause, ISC, Zlib, Unicode-3.0, CC0-1.0, BSL-1.0, MPL-2.0 and CDLA-Permissive-2.0. A crate offered under several licences passes if one of them is on that list. One exception: `sqlcipher-src`, the SQLCipher source behind the encrypted library, also carries SQLite's public-domain "blessing" and libtomcrypt's WTFPL, both more permissive than MIT, so `deny.toml` allows those two for that crate only.

The runtime JavaScript dependencies:

Expand Down
16 changes: 8 additions & 8 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ workspace = true
[dependencies]
axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
rusqlite = { version = "0.32", features = ["bundled-sqlcipher-vendored-openssl"] }
rusqlite = { version = "0.41", features = ["bundled-sqlcipher-vendored-openssl", "fallible_uint"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
chrono = "0.4"
base64 = "0.22"
sha2 = "0.10"
tower-http = { version = "0.6", features = ["fs", "set-header"] }
base64 = "0.23"
sha2 = "0.11"
tower-http = { version = "0.7", features = ["fs", "set-header"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
url = "2"
async-stream = "0.3"
Expand All @@ -29,11 +29,11 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
fs2 = "0.4"
libc = "0.2"
aes-gcm = "0.10"
hkdf = "0.12"
rand = "0.8"
aes-gcm = "0.11"
hkdf = "0.13"
rand = "0.10"
zeroize = "1"
bip39 = "2.2"
bip39 = "3.0"

[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
Expand Down
45 changes: 45 additions & 0 deletions server/src/digest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//! Lowercase hexadecimal SHA-256 digests: the form the library stores and verifies.
//!
//! `sha2` 0.11 digests are `hybrid_array` arrays, which do not implement `LowerHex`,
//! so `format!("{:x}", ...)` no longer works on them.

use sha2::{Digest, Sha256};
use std::fmt::Write as _;

/// Encodes bytes as lowercase hex, two characters per byte.
pub(crate) fn lower_hex(bytes: &[u8]) -> String {
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
// Writing to a String cannot fail.
let _ = write!(out, "{byte:02x}");
out
})
}

/// The lowercase hex SHA-256 digest of `data`.
pub(crate) fn sha256_hex(data: impl AsRef<[u8]>) -> String {
lower_hex(&Sha256::digest(data.as_ref()))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn matches_the_fips_180_2_test_vectors() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}

#[test]
fn pads_every_byte_to_two_digits() {
assert_eq!(lower_hex(&[0x00, 0x0f, 0xa0, 0xff]), "000fa0ff");
}
}
2 changes: 1 addition & 1 deletion server/src/extensions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ fn seconds() -> i64 {
chrono::Utc::now().timestamp()
}
fn hash(data: impl AsRef<[u8]>) -> String {
format!("{:x}", Sha256::digest(data.as_ref()))
crate::digest::sha256_hex(data.as_ref())
}
fn uid(raw: &str) -> Result<()> {
if Uuid::parse_str(raw).is_err() || raw.len() != 36 {
Expand Down
4 changes: 2 additions & 2 deletions server/src/extensions/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use super::{
ApiError, Connection, HeaderMap, IntoResponse, Json, OptionalExtension, Path, Response, Result,
StatusCode, Uuid, Value, LIBRARY_ID,
};
use rand::{rngs::OsRng, RngCore};
use rand::{rand_core::UnwrapErr, rngs::SysRng, Rng};
use std::sync::{Mutex, OnceLock};

/// A request stays decidable for this long; decided rows are kept as long again for claim retries.
Expand Down Expand Up @@ -86,7 +86,7 @@ fn consume_intent(value: &str, now: i64) -> Result<bool> {
/// Issue a single-use, two-minute token that pre-approves one Echo-initiated pairing.
pub(super) async fn intent() -> Result<Json<Value>> {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
UnwrapErr(SysRng).fill_bytes(&mut bytes);
let value = bytes
.iter()
.flat_map(|byte| [byte >> 4, byte & 0x0f])
Expand Down
2 changes: 1 addition & 1 deletion server/src/extensions/recordings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ fn verify_complete_files(db: &Connection, r: &Value) -> Result<()> {
plaintext.fill(0);
offset += count as u64;
}
if format!("{:x}", digest.finalize()) != expected_digest {
if crate::digest::lower_hex(&digest.finalize()) != expected_digest {
return Err(damaged());
}
}
Expand Down
4 changes: 2 additions & 2 deletions server/src/google_oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use axum::{
response::{IntoResponse, Redirect, Response},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use rand::{rngs::OsRng, RngCore};
use rand::{rand_core::UnwrapErr, rngs::SysRng, Rng};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
Expand Down Expand Up @@ -74,7 +74,7 @@ pub(super) fn config() -> Result<Config, ApiError> {
}
fn random_secret() -> Zeroizing<String> {
let mut bytes = Zeroizing::new([0; 32]);
OsRng.fill_bytes(bytes.as_mut());
UnwrapErr(SysRng).fill_bytes(bytes.as_mut());
Zeroizing::new(URL_SAFE_NO_PAD.encode(bytes.as_ref()))
}
fn same_secret(a: &str, b: &str) -> bool {
Expand Down
4 changes: 1 addition & 3 deletions server/src/library_validation.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
//! Authenticate an offline local library, including unindexed recovery files.
use crate::{security::ApiError, store};
use sha2::{Digest, Sha256};
use std::path::Path;

fn verify_protected_tree(path: &Path, depth: usize) -> Result<(), ApiError> {
Expand Down Expand Up @@ -94,8 +93,7 @@ pub(crate) fn verify() -> Result<(), ApiError> {
}
let path = store::data_dir()?.join(area).join(id).join(file);
let data = zeroize::Zeroizing::new(store::read_protected(&path)?);
if data.len() as u64 != bytes || format!("{:x}", Sha256::digest(data.as_slice())) != digest
{
if data.len() as u64 != bytes || crate::digest::sha256_hex(data.as_slice()) != digest {
return Err(ApiError::bad("The selected library contains missing or damaged audio. Restore a complete backup; the original has not been changed."));
}
}
Expand Down
1 change: 1 addition & 0 deletions server/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#![warn(unreachable_pub)]
mod api;
mod config;
mod digest;
mod extensions;
mod integrations;
mod library_schema;
Expand Down
37 changes: 30 additions & 7 deletions server/src/protected_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use aes_gcm::{
};
use base64::{engine::general_purpose::STANDARD, Engine};
use hkdf::Hkdf;
use rand::{rngs::OsRng, RngCore};
use rand::{rand_core::UnwrapErr, rngs::SysRng, Rng};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::{
Expand Down Expand Up @@ -171,7 +171,7 @@ pub(crate) fn library_key(root: &Path) -> Result<LibraryKey> {
let entry = keyring::Entry::new("com.echo.voice.library", &key_id)
.map_err(|e| std::io::Error::other(format!("Keychain entry: {e}")))?;
let mut bytes = Zeroizing::new([0u8; 32]);
OsRng.fill_bytes(bytes.as_mut());
UnwrapErr(SysRng).fill_bytes(bytes.as_mut());
let encoded = Zeroizing::new(STANDARD.encode(bytes.as_ref()));
entry
.set_password(&encoded)
Expand Down Expand Up @@ -361,12 +361,12 @@ pub(crate) fn write_file(
}
let mut header = [0u8; 48];
header[..8].copy_from_slice(MAGIC);
OsRng.fill_bytes(&mut header[8..40]);
UnwrapErr(SysRng).fill_bytes(&mut header[8..40]);
header[40..].copy_from_slice(&(plaintext.len() as u64).to_le_bytes());
let cipher = key.cipher(&header[8..40], context)?;
let tag = cipher
.encrypt(
Nonce::from_slice(&nonce(0)),
&Nonce::from(nonce(0)),
Payload {
msg: &[],
aad: &header,
Expand All @@ -380,7 +380,7 @@ pub(crate) fn write_file(
let sequence = index as u64 + 1;
let sealed = cipher
.encrypt(
Nonce::from_slice(&nonce(sequence)),
&Nonce::from(nonce(sequence)),
Payload {
msg: block,
aad: &binding(&header, sequence),
Expand Down Expand Up @@ -419,7 +419,7 @@ impl Reader {
file.read_exact(&mut tag)?;
cipher
.decrypt(
Nonce::from_slice(&nonce(0)),
&Nonce::from(nonce(0)),
Payload {
msg: &tag,
aad: &header,
Expand Down Expand Up @@ -456,7 +456,7 @@ impl Reader {
let plaintext = Zeroizing::new(
self.cipher
.decrypt(
Nonce::from_slice(&nonce(index + 1)),
&Nonce::from(nonce(index + 1)),
Payload {
msg: &sealed,
aad: &binding(&self.header, index + 1),
Expand Down Expand Up @@ -557,6 +557,29 @@ mod tests {
fs::remove_file(active).unwrap();
}
}
/// Written by the crates this library shipped with (hkdf 0.12, aes-gcm 0.10).
/// Encrypted files already on users' disks must keep opening after upgrades.
const SHIPPED_FILE: &str = "RUNIT0VOQzHSchOcIAW11v+NueCHpGmnMm29t8k2z+FrAVflw+4AAigAAAAAAAAAKM54pF2rdmSj7g3URH63j+Fz2p2FVOqUCYlRhrwSEIV7XNhCJ/5hpztO57cgcImXmvaeYQyzDbsEQaW0Qx2Ac0Yv7+WT+qCW";

#[test]
fn files_written_by_shipped_crates_still_open() {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let dir = fixture();
let path = dir.path().join("shipped");
fs::write(&path, STANDARD.decode(SHIPPED_FILE).unwrap()).unwrap();
let key = LibraryKey::from_bytes([7; 32]);
let data: Vec<u8> = (0u8..40).collect();
let mut reader = Reader::open(&key, &path, "audio:fixture:part-1").unwrap();
assert_eq!(reader.read_all().unwrap(), data);
assert!(Reader::open(&key, &path, "audio:fixture:part-2").is_err());
assert!(Reader::open(
&LibraryKey::from_bytes([8; 32]),
&path,
"audio:fixture:part-1"
)
.is_err());
}

#[test]
fn ranges_and_empty_files_are_authenticated() {
let dir = fixture();
Expand Down
44 changes: 35 additions & 9 deletions server/src/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use axum::{
use base64::{engine::general_purpose::STANDARD, Engine};
use bip39::{Language, Mnemonic};
use hkdf::Hkdf;
use rand::{rngs::OsRng, RngCore};
use rand::{rand_core::UnwrapErr, rngs::SysRng, Rng};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -69,10 +69,10 @@ fn key_for(secret: &[u8], salt: &str, purpose: &str) -> Result<Aes256Gcm> {
}
fn seal(cipher: &Aes256Gcm, data: &[u8], context: &[u8]) -> Result<Sealed> {
let mut nonce = [0u8; 12];
OsRng.fill_bytes(&mut nonce);
UnwrapErr(SysRng).fill_bytes(&mut nonce);
let ciphertext = cipher
.encrypt(
Nonce::from_slice(&nonce),
&Nonce::from(nonce),
Payload {
msg: data,
aad: context,
Expand All @@ -93,18 +93,19 @@ fn open(
if sealed.nonce.len() != 16 || sealed.ciphertext.len() > ((max + 16).div_ceil(3) * 4) {
return Err(failure());
}
let nonce = STANDARD.decode(&sealed.nonce).map_err(|_| failure())?;
if nonce.len() != 12 {
return Err(failure());
}
let nonce: [u8; 12] = STANDARD
.decode(&sealed.nonce)
.map_err(|_| failure())?
.try_into()
.map_err(|_| failure())?;
let ciphertext = STANDARD.decode(&sealed.ciphertext).map_err(|_| failure())?;
if ciphertext.len() > max + 16 {
return Err(failure());
}
Ok(Zeroizing::new(
cipher
.decrypt(
Nonce::from_slice(&nonce),
&Nonce::from(nonce),
Payload {
msg: &ciphertext,
aad: context,
Expand Down Expand Up @@ -173,7 +174,7 @@ fn validate_recovery(recovery: &Recovery, master: &[u8; 32]) -> Result<()> {
}
fn make_recovery(master: &[u8; 32]) -> Result<(Recovery, Zeroizing<String>)> {
let mut entropy = Zeroizing::new([0u8; 32]);
OsRng.fill_bytes(entropy.as_mut());
UnwrapErr(SysRng).fill_bytes(entropy.as_mut());
let phrase = Zeroizing::new(
Mnemonic::from_entropy(entropy.as_ref())
.map_err(|_| failure())?
Expand Down Expand Up @@ -511,6 +512,31 @@ fn decode_backup(backup: &Backup, key: &[u8; 32]) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;

/// Sealed by the crates this library shipped with (bip39 2.2, hkdf 0.12,
/// aes-gcm 0.10, sha2 0.10). Libraries and recovery phrases already saved by
/// users must keep opening after dependency upgrades. Synthetic test vector:
/// the phrase protects the fixed test key `[42; 32]`, not a real library.
const SHIPPED_RECOVERY: &str = r#"{"version":1,"id":"dc25e00a-2ced-4996-92c1-9a5d57ceec24","fingerprint":"FZyjxA4b74aKKeOcrJveZwpsXQewXWQVGfLZumKJJQI=","sealed":{"nonce":"8HlI59EOoZ61ieat","ciphertext":"FGhI+BnDZhCHBGw3mobNhW++G2MAd29YmZB/ujr3mz9M5mJoJY4VfzC4TKkQnKFP"},"integrity":{"nonce":"GPXxI/SzTyj2pSLm","ciphertext":"up5VIm/QWcvD3eA4hc38Cg=="}}"#;
const SHIPPED_PHRASE: &str = "salute arrive door age nominee danger follow real same share blossom general trip wrap cradle bird baby demand coil zero settle erase avoid shallow";

#[test]
fn recovery_envelopes_sealed_by_shipped_crates_still_open() {
let recovery: Recovery = serde_json::from_str(SHIPPED_RECOVERY).unwrap();
assert_eq!(*recover_key(&recovery, SHIPPED_PHRASE).unwrap(), [42; 32]);
validate_recovery(&recovery, &[42; 32]).unwrap();
let mut wrong = SHIPPED_PHRASE.split(' ').collect::<Vec<_>>();
wrong.swap(0, 1);
assert!(recover_key(&recovery, &wrong.join(" ")).is_err());
}

#[test]
fn recovery_phrases_match_the_bip39_english_test_vector() {
let phrase = Mnemonic::from_entropy(&[0; 32]).unwrap().to_string();
assert_eq!(phrase, format!("{}art", "abandon ".repeat(23)));
let parsed = Mnemonic::parse_in(Language::English, &phrase).unwrap();
assert_eq!(parsed.to_entropy(), vec![0; 32]);
}
#[test]
fn library_recovery_ignores_legacy_chatgpt_files() {
let _guard = store::TEST_LIBRARY_LOCK.blocking_lock();
Expand Down
6 changes: 1 addition & 5 deletions server/src/speech.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ use axum::{
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::{
collections::{HashMap, VecDeque},
path::{Component, Path as FsPath, PathBuf},
Expand Down Expand Up @@ -354,10 +353,7 @@ impl Drop for UploadGuard {
/// Name transient audio by canonical library identity, never by a shared machine temp path.
fn spool_namespace(project: &FsPath, data: &FsPath) -> Result<PathBuf> {
let canonical = std::fs::canonicalize(data)?;
let identity = format!(
"{:x}",
Sha256::digest(canonical.as_os_str().as_encoded_bytes())
);
let identity = crate::digest::sha256_hex(canonical.as_os_str().as_encoded_bytes());
let temporary = project.join("tmp");
let namespace = temporary.join(format!("native-speech-{identity}"));
for folder in [&temporary, &namespace] {
Expand Down
Loading
Loading