Rewrite vault in Rust (format-compatible with 0.5) #1

Merged
andydixon merged 19 commits from rust-port into master 2026-10-08 18:38:32 +02:00
24 changed files with 5944 additions and 0 deletions
Showing only changes of commit c324f6a306 - Show all commits
Generated
+1263
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "vault"
version = "0.6.0"
edition = "2024"
rust-version = "1.99"
license = "GPL-3.0-or-later"
description = "Post-quantum encrypted file vault with a decoy passphrase"
homepage = "https://dixon.cx/vault"
repository = "https://github.com/andydixon/vault"
publish = false
[dependencies]
argon2 = { version = "0.6", default-features = false, features = ["alloc", "zeroize"] }
chacha20poly1305 = { version = "0.11", default-features = false, features = ["alloc"] }
hkdf = "0.13"
hmac = "0.13"
sha2 = { version = "0.11", default-features = false }
sha3 = { version = "0.11", default-features = false }
ml-kem = { version = "0.3.2", features = ["zeroize", "hazmat"] }
zeroize = { version = "1.9", features = ["derive"] }
getrandom = "0.4"
base64 = "0.23"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jiff = "0.2"
tar = { version = "0.4.46", default-features = false }
flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier", "gzip"] }
[dev-dependencies]
aes = "0.9"
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1.5", features = ["fs", "process", "mm", "termios", "event", "net", "stdio", "param"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Memory", "Win32_Security"] }
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Test-only: decrypts files another implementation wrote, for the Rust
// port's interoperability tests. Skips unless VAULT_ORACLE_DECRYPT is set.
package pqcrypt
import (
"bytes"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// TestOracleDecrypt opens every DIR/pattern-SIZE.fury with the identity of
// master seed pattern(32, 1) and checks it holds pattern(SIZE, 0x5a), and
// that every DIR/bad-*.fury is rejected.
func TestOracleDecrypt(t *testing.T) {
dir := os.Getenv("VAULT_ORACLE_DECRYPT")
if dir == "" {
t.Skip("set VAULT_ORACLE_DECRYPT")
}
id, err := NewPQIdentityFromSeed(0x0001, pattern(32, 1))
if err != nil {
t.Fatal(err)
}
files, _ := filepath.Glob(filepath.Join(dir, "*.fury"))
if len(files) == 0 {
t.Fatal("no files")
}
for _, f := range files {
data, _ := os.ReadFile(f)
r, err := Decrypt(bytes.NewReader(data), id)
var got []byte
if err == nil {
got, err = io.ReadAll(r)
}
base := filepath.Base(f)
if strings.HasPrefix(base, "bad-") {
if err == nil {
t.Errorf("%s: accepted", base)
}
continue
}
size, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(base, "pattern-"), ".fury"))
if err != nil || !bytes.Equal(got, pattern(size, 0x5a)) {
t.Errorf("%s: %v", base, err)
}
t.Logf("%s ok", base)
}
}
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.99.0"
components = ["rustfmt", "clippy"]
profile = "minimal"
+1
View File
@@ -0,0 +1 @@
max_width = 140
+497
View File
@@ -0,0 +1,497 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! FrodoKEM-640-SHAKE exactly as CIRCL v1.6.4 implements it
//! (`kem/frodo/frodo640shake`): the round-3 FrodoKEM with a 48-byte key
//! seed, 16-byte encapsulation seed, 16-byte shared secret and 9720-byte
//! ciphertext. The RustCrypto `frodo-kem` crate implements the later ISO
//! variant (salted, 9752-byte ciphertexts, a different seed layout), which
//! cannot open existing vaults, so this module is a line-by-line port of
//! CIRCL's code, checked against vectors from the Go oracle and against the
//! NIST KAT digest CIRCL itself tests.
//!
//! All arithmetic is mod 2^16 with masking to 15 bits where CIRCL masks;
//! loops never branch on secret data, and the decapsulation re-encryption
//! check selects the rejection secret with a constant-time mask.
use sha3::Shake128;
use sha3::digest::{ExtendableOutput, Update, XofReader};
use zeroize::Zeroize;
const N: usize = 640;
const NBAR: usize = 8;
const LOG_Q: u32 = 15;
const Q_MASK: u16 = (1 << LOG_Q) - 1;
const SEED_A: usize = 16;
const PK_HASH: usize = 16;
const EXTRACTED_BITS: u32 = 2;
const MESSAGE: usize = 16;
const BP_PACKED: usize = (LOG_Q as usize * N * NBAR) / 8;
pub const SHARED_KEY_SIZE: usize = 16;
pub const KEY_SEED_SIZE: usize = SHARED_KEY_SIZE + SHARED_KEY_SIZE + 16;
pub const ENCAPSULATION_SEED_SIZE: usize = 16;
pub const CIPHERTEXT_SIZE: usize = 9720;
pub const PUBLIC_KEY_SIZE: usize = 9616;
pub const PRIVATE_KEY_SIZE: usize = 19888;
const CDF: [u16; 13] = [
4643, 13363, 20579, 25843, 29227, 31145, 32103, 32525, 32689, 32745, 32762, 32766, 32767,
];
/// A packed public key (`seedA || pack(B)`), as CIRCL's `MarshalBinary`.
pub struct PublicKey {
seed_a: [u8; SEED_A],
b: Vec<u16>, // N x NBAR
}
/// A private key. Its secret parts are cleared on drop.
pub struct PrivateKey {
s_reject: [u8; SHARED_KEY_SIZE],
pk: PublicKey,
s: Vec<u16>, // transpose(S), NBAR x N
hpk: [u8; PK_HASH],
}
impl Drop for PrivateKey {
fn drop(&mut self) {
self.s_reject.zeroize();
self.s.zeroize();
}
}
fn shake(parts: &[&[u8]], out: &mut [u8]) {
let mut h = Shake128::default();
for p in parts {
h.update(p);
}
h.finalize_xof().read(out);
}
fn le_u16s(bytes: &[u8], out: &mut [u16]) {
for (o, c) in out.iter_mut().zip(bytes.as_chunks::<2>().0) {
*o = u16::from_le_bytes([c[0], c[1]]);
}
}
/// Inverse-transform sampling from the FrodoKEM error distribution.
fn sample(v: &mut [u16]) {
for x in v.iter_mut() {
let sign = *x & 1;
let unif = *x >> 1;
let mut g: u16 = 0;
for c in &CDF[..CDF.len() - 1] {
g = g.wrapping_add(c.wrapping_sub(unif) >> 15);
}
*x = (sign.wrapping_neg() ^ g).wrapping_add(sign);
}
}
/// Row i of A is SHAKE128(le16(i) || seedA), as little-endian u16s.
fn expand_a(seed_a: &[u8; SEED_A]) -> Vec<u16> {
let mut a = vec![0u16; N * N];
let mut input = [0u8; 2 + SEED_A];
input[2..].copy_from_slice(seed_a);
let mut row = [0u8; N * 2];
for i in 0..N {
input[0] = i as u8;
input[1] = (i >> 8) as u8;
shake(&[&input], &mut row);
le_u16s(&row, &mut a[i * N..(i + 1) * N]);
}
a
}
/// out (N x NBAR) += A*S + E, with S stored transposed (NBAR x N).
fn mul_add_as_plus_e(out: &mut [u16], a: &[u16], s: &[u16], e: &[u16]) {
for i in 0..N {
let row = &a[i * N..(i + 1) * N];
for k in 0..NBAR {
let sk = &s[k * N..(k + 1) * N];
let mut sum = e[i * NBAR + k];
for j in 0..N {
sum = sum.wrapping_add(row[j].wrapping_mul(sk[j]));
}
out[i * NBAR + k] = out[i * NBAR + k].wrapping_add(sum);
}
}
}
/// out (NBAR x N) += S*A + E. CIRCL walks A by column; summing row by row
/// gives the same result mod 2^16 with far better locality.
fn mul_add_sa_plus_e(out: &mut [u16], s: &[u16], a: &[u16], e: &[u16]) {
let mut acc = e[..NBAR * N].to_vec();
for j in 0..N {
let row = &a[j * N..(j + 1) * N];
for k in 0..NBAR {
let skj = s[k * N + j];
let dst = &mut acc[k * N..(k + 1) * N];
for i in 0..N {
dst[i] = dst[i].wrapping_add(row[i].wrapping_mul(skj));
}
}
}
for (o, v) in out.iter_mut().zip(&acc) {
*o = o.wrapping_add(*v);
}
acc.zeroize();
}
/// out (NBAR x NBAR) = S*B + E, masked.
fn mul_add_sb_plus_e(out: &mut [u16], s: &[u16], b: &[u16], e: &[u16]) {
for k in 0..NBAR {
for i in 0..NBAR {
let mut v = e[k * NBAR + i];
for j in 0..N {
v = v.wrapping_add(s[k * N + j].wrapping_mul(b[j * NBAR + i]));
}
out[k * NBAR + i] = v & Q_MASK;
}
}
}
/// out (NBAR x NBAR) = B'*S, with S transposed, masked.
fn mul_bs(out: &mut [u16], b: &[u16], s: &[u16]) {
for i in 0..NBAR {
for j in 0..NBAR {
let mut v: u16 = 0;
for k in 0..N {
v = v.wrapping_add(b[i * N + k].wrapping_mul(s[j * N + k]));
}
out[i * NBAR + j] = v & Q_MASK;
}
}
}
fn pack(out: &mut [u8], v: &[u16]) {
for (o, x) in out.as_chunks_mut::<15>().0.iter_mut().zip(v.as_chunks::<8>().0) {
let m: [u16; 8] = core::array::from_fn(|i| x[i] & Q_MASK);
o[0] = (m[0] >> 7) as u8;
o[1] = (((m[0] & 0x7f) as u8) << 1) | (m[1] >> 14) as u8;
o[2] = (m[1] >> 6) as u8;
o[3] = (((m[1] & 0x3f) as u8) << 2) | (m[2] >> 13) as u8;
o[4] = (m[2] >> 5) as u8;
o[5] = (((m[2] & 0x1f) as u8) << 3) | (m[3] >> 12) as u8;
o[6] = (m[3] >> 4) as u8;
o[7] = (((m[3] & 0x0f) as u8) << 4) | (m[4] >> 11) as u8;
o[8] = (m[4] >> 3) as u8;
o[9] = (((m[4] & 0x07) as u8) << 5) | (m[5] >> 10) as u8;
o[10] = (m[5] >> 2) as u8;
o[11] = (((m[5] & 0x03) as u8) << 6) | (m[6] >> 9) as u8;
o[12] = (m[6] >> 1) as u8;
o[13] = (((m[6] & 0x01) as u8) << 7) | (m[7] >> 8) as u8;
o[14] = m[7] as u8;
}
}
fn unpack(out: &mut [u16], b: &[u8]) {
for (o, i) in out.as_chunks_mut::<8>().0.iter_mut().zip(b.as_chunks::<15>().0) {
let w = |x: u8| x as u16;
o[0] = (w(i[0]) << 7) | (w(i[1] & 0xfe) >> 1);
o[1] = (w(i[1] & 0x01) << 14) | (w(i[2]) << 6) | (w(i[3] & 0xfc) >> 2);
o[2] = (w(i[3] & 0x03) << 13) | (w(i[4]) << 5) | (w(i[5] & 0xf8) >> 3);
o[3] = (w(i[5] & 0x07) << 12) | (w(i[6]) << 4) | (w(i[7] & 0xf0) >> 4);
o[4] = (w(i[7] & 0x0f) << 11) | (w(i[8]) << 3) | (w(i[9] & 0xe0) >> 5);
o[5] = (w(i[9] & 0x1f) << 10) | (w(i[10]) << 2) | (w(i[11] & 0xc0) >> 6);
o[6] = (w(i[11] & 0x3f) << 9) | (w(i[12]) << 1) | (w(i[13] & 0x80) >> 7);
o[7] = (w(i[13] & 0x7f) << 8) | w(i[14]);
}
}
fn encode_message(out: &mut [u16], msg: &[u8; MESSAGE]) {
let mask = (1u16 << EXTRACTED_BITS) - 1;
let mut pos = 0;
for c in msg.as_chunks::<2>().0 {
let mut v = u16::from_le_bytes([c[0], c[1]]);
for _ in 0..(16 / EXTRACTED_BITS) {
out[pos] = (v & mask) << (LOG_Q - EXTRACTED_BITS);
pos += 1;
v >>= EXTRACTED_BITS;
}
}
}
fn decode_message(out: &mut [u8; MESSAGE], m: &[u16]) {
let mask = (1u16 << EXTRACTED_BITS) - 1;
let mut pos = 0;
for o in out.iter_mut() {
for j in 0..(8 / EXTRACTED_BITS) {
let mut t = (m[pos] & Q_MASK) + (1 << (LOG_Q - EXTRACTED_BITS - 1));
t >>= LOG_Q - EXTRACTED_BITS;
t &= mask;
*o |= (t as u8) << (j * EXTRACTED_BITS);
pos += 1;
}
}
}
/// 0 if equal, 1 otherwise, without branching on the contents.
fn ct_differs(a: &[u16], b: &[u16]) -> u8 {
let mut v: u16 = 0;
for (x, y) in a.iter().zip(b) {
v |= x ^ y;
}
((v | v.wrapping_neg()) >> 15) as u8
}
impl PublicKey {
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = vec![0u8; PUBLIC_KEY_SIZE];
out[..SEED_A].copy_from_slice(&self.seed_a);
pack(&mut out[SEED_A..], &self.b);
out
}
/// Any 9616 bytes form a key, as in CIRCL; only the length is checked.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
if buf.len() != PUBLIC_KEY_SIZE {
return None;
}
let mut seed_a = [0u8; SEED_A];
seed_a.copy_from_slice(&buf[..SEED_A]);
let mut b = vec![0u16; N * NBAR];
unpack(&mut b, &buf[SEED_A..]);
Some(PublicKey { seed_a, b })
}
fn hash(&self) -> [u8; PK_HASH] {
let mut h = [0u8; PK_HASH];
shake(&[&self.to_bytes()], &mut h);
h
}
/// Encapsulates with the 16-byte message `mu` (CIRCL's
/// `EncapsulateTo` seed), returning the ciphertext and shared secret.
/// Callers must pass fresh randomness; reusing `mu` repeats the secret.
pub fn encapsulate(&self, mu: &[u8; ENCAPSULATION_SEED_SIZE]) -> (Vec<u8>, [u8; SHARED_KEY_SIZE]) {
let hpk = self.hash();
let mut g2 = [0u8; 2 * SHARED_KEY_SIZE];
shake(&[&hpk, mu], &mut g2);
let (sp, ep, epp, mut noise) = noise(&g2[..SHARED_KEY_SIZE]);
let a = expand_a(&self.seed_a);
let mut bp = vec![0u16; NBAR * N];
mul_add_sa_plus_e(&mut bp, &noise[sp.clone()], &a, &noise[ep]);
let mut v = [0u16; NBAR * NBAR];
mul_add_sb_plus_e(&mut v, &noise[sp], &self.b, &noise[epp]);
let mut c = [0u16; NBAR * NBAR];
encode_message(&mut c, mu);
for (c, v) in c.iter_mut().zip(&v) {
*c = c.wrapping_add(*v) & Q_MASK;
}
let mut ct = vec![0u8; CIPHERTEXT_SIZE];
pack(&mut ct[..BP_PACKED], &bp);
pack(&mut ct[BP_PACKED..], &c);
let mut ss = [0u8; SHARED_KEY_SIZE];
shake(&[&ct, &g2[SHARED_KEY_SIZE..]], &mut ss);
noise.zeroize();
g2.zeroize();
v.zeroize();
(ct, ss)
}
}
type Span = core::ops::Range<usize>;
/// SHAKE128(0x96 || seedSE) expanded and sampled into S', E', E''.
fn noise(seed_se: &[u8]) -> (Span, Span, Span, Vec<u16>) {
let total = 2 * N * NBAR + NBAR * NBAR;
let mut bytes = vec![0u8; 2 * total];
shake(&[&[0x96], seed_se], &mut bytes);
let mut v = vec![0u16; total];
le_u16s(&bytes, &mut v);
bytes.zeroize();
sample(&mut v);
(0..N * NBAR, N * NBAR..2 * N * NBAR, 2 * N * NBAR..total, v)
}
impl PrivateKey {
/// CIRCL's `newKeyFromSeed`: seed is s || seedSE || z.
pub fn derive(seed: &[u8; KEY_SEED_SIZE]) -> Self {
let mut seed_a = [0u8; SEED_A];
shake(&[&seed[2 * SHARED_KEY_SIZE..]], &mut seed_a);
let mut bytes = vec![0u8; 2 * 2 * N * NBAR];
shake(&[&[0x5f], &seed[SHARED_KEY_SIZE..2 * SHARED_KEY_SIZE]], &mut bytes);
let mut s = vec![0u16; N * NBAR];
let mut e = vec![0u16; N * NBAR];
le_u16s(&bytes[..2 * N * NBAR], &mut s);
le_u16s(&bytes[2 * N * NBAR..], &mut e);
bytes.zeroize();
sample(&mut s);
sample(&mut e);
let a = expand_a(&seed_a);
let mut b = vec![0u16; N * NBAR];
mul_add_as_plus_e(&mut b, &a, &s, &e);
e.zeroize();
let pk = PublicKey { seed_a, b };
let hpk = pk.hash();
let mut s_reject = [0u8; SHARED_KEY_SIZE];
s_reject.copy_from_slice(&seed[..SHARED_KEY_SIZE]);
PrivateKey { s_reject, pk, s, hpk }
}
pub fn public(&self) -> &PublicKey {
&self.pk
}
/// CIRCL's packed form: s || pk || S (little-endian) || H(pk).
pub fn to_bytes(&self) -> zeroize::Zeroizing<Vec<u8>> {
let mut out = zeroize::Zeroizing::new(Vec::with_capacity(PRIVATE_KEY_SIZE));
out.extend_from_slice(&self.s_reject);
out.extend_from_slice(&self.pk.to_bytes());
for x in &self.s {
out.extend_from_slice(&x.to_le_bytes());
}
out.extend_from_slice(&self.hpk);
out
}
/// Decapsulates with implicit rejection: a ciphertext that does not
/// re-encrypt identically yields SHAKE(ct || s) instead of an error.
pub fn decapsulate(&self, ct: &[u8]) -> Option<[u8; SHARED_KEY_SIZE]> {
if ct.len() != CIPHERTEXT_SIZE {
return None;
}
let mut bp = vec![0u16; NBAR * N];
let mut c = [0u16; NBAR * NBAR];
unpack(&mut bp, &ct[..BP_PACKED]);
unpack(&mut c, &ct[BP_PACKED..]);
let mut w = [0u16; NBAR * NBAR];
mul_bs(&mut w, &bp, &self.s);
for (w, c) in w.iter_mut().zip(&c) {
*w = c.wrapping_sub(*w) & Q_MASK;
}
let mut mu = [0u8; MESSAGE];
decode_message(&mut mu, &w);
let mut g2 = [0u8; 2 * SHARED_KEY_SIZE];
shake(&[&self.hpk, &mu], &mut g2);
let (sp, ep, epp, mut noise) = noise(&g2[..SHARED_KEY_SIZE]);
let a = expand_a(&self.pk.seed_a);
let mut bbp = vec![0u16; NBAR * N];
mul_add_sa_plus_e(&mut bbp, &noise[sp.clone()], &a, &noise[ep]);
for x in bbp.iter_mut() {
*x &= Q_MASK;
}
mul_add_sb_plus_e(&mut w, &noise[sp], &self.pk.b, &noise[epp]);
let mut cc = [0u16; NBAR * NBAR];
encode_message(&mut cc, &mu);
for (cc, w) in cc.iter_mut().zip(&w) {
*cc = cc.wrapping_add(*w) & Q_MASK;
}
let differs = ct_differs(&bp, &bbp) | ct_differs(&c, &cc);
let mask = differs.wrapping_neg(); // 0xff to reject, 0 to accept
let mut k = [0u8; SHARED_KEY_SIZE];
for (i, k) in k.iter_mut().enumerate() {
*k = (g2[SHARED_KEY_SIZE + i] & !mask) | (self.s_reject[i] & mask);
}
let mut ss = [0u8; SHARED_KEY_SIZE];
shake(&[ct, &k], &mut ss);
for z in [&mut mu[..], &mut g2[..], &mut k[..]] {
z.zeroize();
}
noise.zeroize();
w.zeroize();
cc.zeroize();
bbp.zeroize();
Some(ss)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use sha2::Digest;
/// NIST's AES-256 CTR_DRBG from the KAT generator's randombytes.c.
struct Drbg {
key: [u8; 32],
v: [u8; 16],
}
impl Drbg {
fn new(entropy: &[u8; 48]) -> Self {
let mut d = Drbg { key: [0; 32], v: [0; 16] };
d.update(Some(entropy));
d
}
fn block(&mut self) -> [u8; 16] {
for b in self.v.iter_mut().rev() {
*b = b.wrapping_add(1);
if *b != 0 {
break;
}
}
let c = aes::Aes256::new(&self.key.into());
let mut blk = self.v.into();
c.encrypt_block(&mut blk);
blk.into()
}
fn update(&mut self, data: Option<&[u8; 48]>) {
let mut t = [0u8; 48];
for i in 0..3 {
t[16 * i..16 * i + 16].copy_from_slice(&self.block());
}
if let Some(d) = data {
for (t, d) in t.iter_mut().zip(d) {
*t ^= d;
}
}
self.key.copy_from_slice(&t[..32]);
self.v.copy_from_slice(&t[32..]);
}
fn fill(&mut self, out: &mut [u8]) {
for c in out.chunks_mut(16) {
let b = self.block();
c.copy_from_slice(&b[..c.len()]);
}
self.update(None);
}
}
fn upper(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02X}")).collect()
}
/// CIRCL's TestPQCgenKATKem: the SHA-256 of the KAT response file it
/// regenerates, which matches the FrodoKEM reference implementation's
/// PQCkemKAT_19888_shake.rsp.
#[test]
fn nist_kat_digest() {
let mut seed = [0u8; 48];
for (i, s) in seed.iter_mut().enumerate() {
*s = i as u8;
}
let mut g = Drbg::new(&seed);
let mut f = sha2::Sha256::new();
Digest::update(&mut f, b"# FrodoKEM-640-SHAKE\n\n");
for i in 0..100 {
g.fill(&mut seed);
Digest::update(&mut f, format!("count = {i}\nseed = {}\n", upper(&seed)).as_bytes());
let mut g2 = Drbg::new(&seed);
let mut kseed = [0u8; KEY_SEED_SIZE];
g2.fill(&mut kseed);
let sk = PrivateKey::derive(&kseed);
let pk = sk.public().to_bytes();
let mut eseed = [0u8; ENCAPSULATION_SEED_SIZE];
g2.fill(&mut eseed);
let (ct, ss) = PublicKey::from_bytes(&pk).unwrap().encapsulate(&eseed);
assert_eq!(sk.decapsulate(&ct).unwrap(), ss);
Digest::update(
&mut f,
format!(
"pk = {}\nsk = {}\nct = {}\nss = {}\n\n",
upper(&pk),
upper(&sk.to_bytes()),
upper(&ct),
upper(&ss)
)
.as_bytes(),
);
}
let digest: String = f.finalize().iter().map(|x| format!("{x:02x}")).collect();
assert_eq!(digest, "604a10cfc871dfaed9cb5b057c644ab03b16852cea7f39bc7f9831513b5b1cfa");
}
}
+263
View File
@@ -0,0 +1,263 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Whole-file Fury encryption: a header wrapping a fresh 32-byte file key,
//! a 16-byte nonce, then the STREAM payload.
//!
//! - header MAC: HMAC-SHA-256 keyed by HKDF-SHA-256(file key, salt none,
//! info "header"), over the header up to and including "---";
//! - payload key: HKDF-SHA-256(file key, salt = nonce, info "payload").
use super::header::{self, Header, ParseError};
use super::kem::{FILE_KEY_SIZE, Identity, Recipient, UnwrapError};
use super::stream::{self, StreamReader, StreamWriter};
use crate::rng::Rng;
use crate::secret::{SecretArray, SecretVec};
use hkdf::Hkdf;
use hmac::{KeyInit, Mac};
use sha2::Sha256;
use std::io::{self, Read, Write};
pub const NONCE_SIZE: usize = 16;
#[derive(Debug)]
pub enum Error {
/// No stanza opens with this identity: the file is someone else's.
NoIdentityMatch,
Header(ParseError),
/// A stanza of our suite was malformed.
Stanza(String),
BadMac,
Io(io::Error),
TooLarge,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::NoIdentityMatch => f.write_str("no identity matched any of the recipients"),
Error::Header(e) => write!(f, "failed to read header: {e}"),
Error::Stanza(m) => f.write_str(m),
Error::BadMac => f.write_str("bad header MAC"),
Error::Io(e) => write!(f, "{e}"),
Error::TooLarge => f.write_str("encrypted payload is larger than expected"),
}
}
}
impl std::error::Error for Error {}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self {
Error::Io(e)
}
}
fn header_mac(file_key: &[u8; FILE_KEY_SIZE], h: &Header) -> hmac::Hmac<Sha256> {
let mut k = SecretArray::<32>::zero();
Hkdf::<Sha256>::new(None, file_key)
.expand(b"header", k.expose_mut())
.expect("32 bytes");
let mut m = <hmac::Hmac<Sha256> as KeyInit>::new_from_slice(k.expose()).expect("any key length");
m.update(&h.marshal_without_mac());
m
}
pub fn payload_key(file_key: &[u8; FILE_KEY_SIZE], nonce: &[u8; NONCE_SIZE]) -> SecretArray<32> {
let mut k = SecretArray::<32>::zero();
Hkdf::<Sha256>::new(Some(nonce), file_key)
.expand(b"payload", k.expose_mut())
.expect("32 bytes");
k
}
/// Builds a header for `recipient` and returns it with the payload key and
/// nonce to use. All randomness is fresh from `rng`.
fn begin(recipient: &Recipient, rng: &mut Rng) -> (Header, [u8; NONCE_SIZE], SecretArray<32>) {
let mut file_key = SecretArray::<FILE_KEY_SIZE>::zero();
rng.fill(file_key.expose_mut());
let mut h = Header {
recipients: vec![recipient.wrap(file_key.expose(), rng)],
mac: [0; 32],
};
h.mac = header_mac(file_key.expose(), &h).finalize().into_bytes().into();
let nonce: [u8; NONCE_SIZE] = rng.array();
let key = payload_key(file_key.expose(), &nonce);
(h, nonce, key)
}
/// Encrypts a plaintext held in memory, appending the file to `out`.
pub fn encrypt_to(out: &mut Vec<u8>, recipient: &Recipient, plaintext: &[u8], rng: &mut Rng) {
let (h, nonce, key) = begin(recipient, rng);
out.extend_from_slice(&h.marshal());
out.extend_from_slice(&nonce);
stream::seal_all(key.expose(), plaintext, out);
}
/// Streaming encryption to `dst`; call `finish` on the result.
pub fn encrypt_writer<W: Write>(mut dst: W, recipient: &Recipient, rng: &mut Rng) -> io::Result<StreamWriter<W>> {
let (h, nonce, key) = begin(recipient, rng);
h.write_to(&mut dst)?;
dst.write_all(&nonce)?;
Ok(StreamWriter::new(key.expose(), dst))
}
/// Go's `decryptHdr`: unwrap with `id`, then check the header MAC in
/// constant time.
fn file_key(h: &Header, id: &Identity) -> Result<SecretArray<FILE_KEY_SIZE>, Error> {
let fk = match id.unwrap(&h.recipients) {
Ok(k) => k,
Err(UnwrapError::NotMine) => return Err(Error::NoIdentityMatch),
Err(UnwrapError::Malformed(m)) => return Err(Error::Stanza(m)),
};
header_mac(fk.expose(), h).verify_slice(&h.mac).map_err(|_| Error::BadMac)?;
Ok(fk)
}
/// Streaming decryption. Nothing read from the result is authentic until
/// it has returned end of input without error.
pub fn decrypt_reader<R: Read>(src: R, id: &Identity) -> Result<StreamReader<impl Read>, Error> {
let (h, mut payload) = header::parse(src).map_err(Error::Header)?;
let fk = file_key(&h, id)?;
let mut nonce = [0u8; NONCE_SIZE];
payload
.read_exact(&mut nonce)
.map_err(|e| Error::Io(io::Error::new(e.kind(), format!("failed to read nonce: {e}"))))?;
let key = payload_key(fk.expose(), &nonce);
Ok(StreamReader::new(key.expose(), payload))
}
/// Decrypts a whole file whose plaintext is at most `max_plaintext` bytes,
/// reading no more than that much ciphertext. The result is fully
/// authenticated, final chunk and end of input included.
pub fn decrypt_bounded<R: Read>(src: R, id: &Identity, max_plaintext: usize) -> Result<SecretVec, Error> {
let (h, mut payload) = header::parse(src).map_err(Error::Header)?;
let fk = file_key(&h, id)?;
let mut nonce = [0u8; NONCE_SIZE];
payload
.read_exact(&mut nonce)
.map_err(|e| Error::Io(io::Error::new(e.kind(), format!("failed to read nonce: {e}"))))?;
let key = payload_key(fk.expose(), &nonce);
let chunks = max_plaintext.div_ceil(stream::CHUNK_SIZE).max(1);
let max_ct = max_plaintext + chunks * stream::TAG_SIZE;
let mut buf = SecretVec::zeroed(max_ct + 1);
let n = stream::read_full(&mut payload, buf.expose_mut())?;
if n > max_ct {
return Err(Error::TooLarge);
}
let plain = stream::open_all(key.expose(), &mut buf.expose_mut()[..n]).map_err(|e| Error::Io(e.into()))?;
buf.resize_within_capacity(plain);
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::kem::{
SUITE_ID,
tests::{unhex, vectors},
};
use crate::crypto::stream::tests::hex;
fn pattern(n: usize, salt: u8) -> Vec<u8> {
(0..n).map(|i| (i.wrapping_mul(7) as u8) ^ salt).collect()
}
#[test]
fn header_vectors_match_go() {
for v in vectors().wrap {
let raw = unhex(&v.header_hex);
let (h, _) = header::parse(&raw[..]).unwrap();
assert_eq!(h.marshal(), raw, "canonical serialisation");
let fk: [u8; 32] = unhex(&v.file_key).try_into().unwrap();
assert_eq!(hex(&header_mac(&fk, &h).finalize().into_bytes()), v.mac);
let id = Identity::from_suite_seed(SUITE_ID, &unhex(&v.master_seed)).unwrap();
assert_eq!(file_key(&h, &id).unwrap().expose(), &fk);
let nonce: [u8; 16] = unhex(&v.nonce).try_into().unwrap();
assert_eq!(hex(payload_key(&fk, &nonce).expose()), v.payload_key);
// A one-bit change anywhere the MAC covers is caught.
let mut tampered = h.clone();
tampered.recipients[0].body[0] ^= 1;
assert!(file_key(&tampered, &id).is_err());
let mut wrong_mac = h.clone();
wrong_mac.mac[31] ^= 1;
assert!(matches!(file_key(&wrong_mac, &id), Err(Error::BadMac)));
}
}
#[test]
fn header_parser_agrees_with_go() {
for (i, c) in vectors().header_cases.into_iter().enumerate() {
let input = unhex(&c.input_hex);
let got = header::parse(&input[..]);
assert_eq!(
got.is_ok(),
c.accept,
"case {i}: {:?}",
String::from_utf8_lossy(&input[..input.len().min(80)])
);
if let Ok((_, mut rest)) = got {
let mut r = Vec::new();
rest.read_to_end(&mut r).unwrap();
assert_eq!(hex(&r), c.rest_hex.unwrap_or_default(), "case {i} payload");
}
}
}
#[test]
fn decrypts_go_files() {
let id = Identity::from_suite_seed(SUITE_ID, &pattern(32, 1)).unwrap();
for size in [0usize, 1, 65535, 65536, 65537, 131072, 200000] {
let path = format!("{}/tests/vectors/fury/pattern-{size}.fury", env!("CARGO_MANIFEST_DIR"));
let data = std::fs::read(&path).unwrap();
let mut out = Vec::new();
decrypt_reader(&data[..], &id).unwrap().read_to_end(&mut out).unwrap();
assert_eq!(out, pattern(size, 0x5a), "{path}");
let b = decrypt_bounded(&data[..], &id, size).unwrap();
assert_eq!(b.expose(), &pattern(size, 0x5a)[..]);
if size > 0 {
assert!(matches!(
decrypt_bounded(&data[..], &id, size - 1),
Err(Error::TooLarge) | Err(Error::Io(_))
));
}
let other = Identity::generate(&mut crate::rng::os());
assert!(matches!(decrypt_reader(&data[..], &other), Err(Error::NoIdentityMatch)));
}
}
#[test]
fn round_trip_and_tamper() {
let id = Identity::generate(&mut crate::rng::os());
for size in [0usize, 1, 65536, 70000] {
let p = pattern(size, 3);
let mut f = Vec::new();
encrypt_to(&mut f, &id.recipient(), &p, &mut crate::rng::os());
let mut w = encrypt_writer(Vec::new(), &id.recipient(), &mut crate::rng::os()).unwrap();
w.write_all(&p).unwrap();
let f2 = w.finish().unwrap();
for f in [&f, &f2] {
assert_eq!(decrypt_bounded(&f[..], &id, size).unwrap().expose(), &p[..]);
let mut g = f.clone();
let last = g.len() - 1;
g[last] ^= 1;
assert!(decrypt_bounded(&g[..], &id, size).is_err());
let mut out = Vec::new();
assert!(decrypt_reader(&g[..], &id).unwrap().read_to_end(&mut out).is_err());
}
}
}
/// With identical synthetic randomness the in-memory and streaming
/// encryptors produce identical files.
#[test]
fn deterministic_under_test_entropy() {
let id = Identity::generate(&mut crate::rng::os());
let e = crate::rng::Entropy::Seeded([9; 32]);
let p = pattern(200_000, 1);
let mut a = Vec::new();
encrypt_to(&mut a, &id.recipient(), &p, &mut e.rng(b"x"));
let mut w = encrypt_writer(Vec::new(), &id.recipient(), &mut e.rng(b"x")).unwrap();
w.write_all(&p).unwrap();
assert_eq!(a, w.finish().unwrap());
}
}
+396
View File
@@ -0,0 +1,396 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The Fury header: serialisation and a strict parser matching the Go
//! `internal/format` package byte for byte.
//!
//! ```text
//! fury-encryption.org/v1\n
//! -> TYPE ARG...\n one stanza per recipient
//! BASE64 body, 64 columns per line, ending with a line shorter than 64
//! --- BASE64(MAC)\n
//! ```
//!
//! Base64 is the standard alphabet without padding, with non-zero trailing
//! bits rejected. A stanza's lines total at most 1 MiB and the header at
//! most 16 MiB.
use base64::Engine;
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use std::io::{self, Read, Write};
pub const MAX_HEADER_SIZE: u64 = 16 << 20;
pub const MAX_STANZA_SIZE: u64 = 1 << 20;
pub const INTRO: &[u8] = b"fury-encryption.org/v1\n";
const STANZA_PREFIX: &[u8] = b"->";
const FOOTER_PREFIX: &[u8] = b"---";
const COLUMNS: usize = 64;
const BYTES_PER_LINE: usize = COLUMNS / 4 * 3;
pub const B64: GeneralPurpose = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_encode_padding(false)
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
.with_decode_allow_trailing_bits(false),
);
pub fn encode(b: &[u8]) -> String {
B64.encode(b)
}
/// Strict unpadded base64; CR and LF are rejected outright, as in Go.
pub fn decode(s: &[u8]) -> Result<Vec<u8>, String> {
if s.iter().any(|&c| c == b'\n' || c == b'\r') {
return Err("unexpected newline character".into());
}
B64.decode(s).map_err(|e| e.to_string().to_lowercase())
}
#[derive(Clone, PartialEq, Eq)]
pub struct Stanza {
pub typ: String,
pub args: Vec<String>,
pub body: Vec<u8>,
}
#[derive(Clone, PartialEq, Eq)]
pub struct Header {
pub recipients: Vec<Stanza>,
pub mac: [u8; 32],
}
impl Stanza {
pub fn marshal(&self, w: &mut Vec<u8>) {
w.extend_from_slice(STANZA_PREFIX);
for a in std::iter::once(&self.typ).chain(&self.args) {
w.push(b' ');
w.extend_from_slice(a.as_bytes());
}
w.push(b'\n');
let enc = encode(&self.body);
let mut chunks = enc.as_bytes().chunks(COLUMNS).peekable();
let mut last_full = true;
while let Some(c) = chunks.next() {
w.extend_from_slice(c);
if c.len() == COLUMNS {
w.push(b'\n');
} else {
last_full = false;
}
if chunks.peek().is_none() && c.len() < COLUMNS {
w.push(b'\n');
}
}
// A body whose encoding fills its last line (or is empty) ends with
// an empty line, so every stanza ends with a short line.
if last_full {
w.push(b'\n');
}
}
}
impl Header {
/// Everything the MAC covers: intro, stanzas and the footer prefix.
pub fn marshal_without_mac(&self) -> Vec<u8> {
let mut w = INTRO.to_vec();
for s in &self.recipients {
s.marshal(&mut w);
}
w.extend_from_slice(FOOTER_PREFIX);
w
}
pub fn marshal(&self) -> Vec<u8> {
let mut w = self.marshal_without_mac();
w.push(b' ');
w.extend_from_slice(encode(&self.mac).as_bytes());
w.push(b'\n');
w
}
pub fn write_to(&self, w: &mut impl Write) -> io::Result<()> {
w.write_all(&self.marshal())
}
}
/// A reader with as much look-ahead as the parser needs. After parsing,
/// [`Peek::into_payload`] hands back the unread bytes followed by the rest
/// of the source.
/// The payload after a parsed header: read-ahead bytes, then the source.
pub type Payload<R> = io::Chain<io::Cursor<Vec<u8>>, R>;
pub struct Peek<R> {
inner: R,
buf: Vec<u8>,
pos: usize,
eof: bool,
}
impl<R: Read> Peek<R> {
pub fn new(inner: R) -> Self {
Peek {
inner,
buf: Vec::with_capacity(8192),
pos: 0,
eof: false,
}
}
fn fill(&mut self) -> io::Result<bool> {
if self.eof {
return Ok(false);
}
if self.pos > 0 && self.pos == self.buf.len() {
self.buf.clear();
self.pos = 0;
}
let start = self.buf.len();
self.buf.resize(start + 8192, 0);
let n = loop {
match self.inner.read(&mut self.buf[start..]) {
Ok(n) => break n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => {
self.buf.truncate(start);
return Err(e);
}
}
};
self.buf.truncate(start + n);
if n == 0 {
self.eof = true;
}
Ok(n > 0)
}
/// Up to `n` bytes ahead, fewer only at end of input.
fn peek(&mut self, n: usize) -> io::Result<&[u8]> {
while self.buf.len() - self.pos < n && self.fill()? {}
let end = (self.pos + n).min(self.buf.len());
Ok(&self.buf[self.pos..end])
}
/// Reads one line including its newline. Fails if the line, added to
/// `*count`, would exceed `limit`, or if input ends before a newline.
fn line(&mut self, count: &mut u64, limit: u64) -> Result<Vec<u8>, LineError> {
let mut line = Vec::new();
loop {
if self.pos == self.buf.len() && !self.fill().map_err(LineError::Io)? {
return Err(LineError::Eof);
}
let avail = &self.buf[self.pos..];
let (take, done) = match avail.iter().position(|&c| c == b'\n') {
Some(i) => (i + 1, true),
None => (avail.len(), false),
};
if *count + take as u64 > limit {
return Err(LineError::TooLong);
}
*count += take as u64;
line.extend_from_slice(&avail[..take]);
self.pos += take;
if done {
return Ok(line);
}
}
}
pub fn into_payload(self) -> Payload<R> {
let mut rest = self.buf;
rest.drain(..self.pos);
io::Cursor::new(rest).chain(self.inner)
}
}
enum LineError {
Io(io::Error),
Eof,
TooLong,
}
#[derive(Debug)]
pub enum ParseError {
Io(io::Error),
Malformed(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::Io(e) => write!(f, "failed to read header: {e}"),
ParseError::Malformed(m) => write!(f, "parsing fury header: {m}"),
}
}
}
fn bad<T>(m: impl Into<String>) -> Result<T, ParseError> {
Err(ParseError::Malformed(m.into()))
}
fn split_args(line: &[u8]) -> (&[u8], Vec<&[u8]>) {
let l = line.strip_suffix(b"\n").unwrap_or(line);
let mut parts = l.split(|&c| c == b' ');
let first = parts.next().unwrap_or(b"");
(first, parts.collect())
}
fn valid_string(s: &[u8]) -> bool {
!s.is_empty() && s.iter().all(|&c| (33..=126).contains(&c))
}
fn read_stanza<R: Read>(p: &mut Peek<R>) -> Result<(Stanza, u64), ParseError> {
let mut n = 0u64;
let line = match p.line(&mut n, MAX_STANZA_SIZE) {
Ok(l) => l,
Err(LineError::TooLong) => return bad(format!("stanza exceeds {MAX_STANZA_SIZE} byte limit")),
Err(LineError::Eof) => return bad("failed to read line: unexpected eof"),
Err(LineError::Io(e)) => return Err(ParseError::Io(e)),
};
if !line.starts_with(STANZA_PREFIX) {
return bad("malformed stanza opening line");
}
let (prefix, args) = split_args(&line);
if prefix != STANZA_PREFIX || args.is_empty() || !args.iter().all(|a| valid_string(a)) {
return bad("malformed stanza");
}
// valid_string admits only printable ASCII, so these are UTF-8.
let text = |a: &[u8]| String::from_utf8(a.to_vec()).expect("ascii");
let mut s = Stanza {
typ: text(args[0]),
args: args[1..].iter().map(|a| text(a)).collect(),
body: Vec::new(),
};
loop {
let line = match p.line(&mut n, MAX_STANZA_SIZE) {
Ok(l) => l,
Err(LineError::TooLong) => return bad(format!("stanza exceeds {MAX_STANZA_SIZE} byte limit")),
Err(LineError::Eof) => return bad("failed to read line: unexpected eof"),
Err(LineError::Io(e)) => return Err(ParseError::Io(e)),
};
let b = match decode(line.strip_suffix(b"\n").unwrap_or(&line)) {
Ok(b) => b,
Err(e) => {
if line.starts_with(FOOTER_PREFIX) || line.starts_with(STANZA_PREFIX) {
return bad("malformed body line: stanza ended without a short line");
}
return bad(format!("malformed body line: {e}"));
}
};
if b.len() > BYTES_PER_LINE {
return bad("malformed body line: too long");
}
let short = b.len() < BYTES_PER_LINE;
s.body.extend_from_slice(&b);
if short {
return Ok((s, n));
}
}
}
/// Parses a header, returning it and the reader positioned at the payload.
pub fn parse<R: Read>(input: R) -> Result<(Header, Payload<R>), ParseError> {
let mut p = Peek::new(input);
let mut total = 0u64;
match p.line(&mut total, MAX_HEADER_SIZE) {
Ok(l) if l == INTRO => {}
Ok(_) => return bad("unexpected intro"),
Err(LineError::TooLong) => return bad(format!("header exceeds {MAX_HEADER_SIZE} byte limit")),
Err(LineError::Eof) if total == 0 => return bad("file is empty"),
Err(LineError::Eof) => return bad("failed to read intro: unexpected eof"),
Err(LineError::Io(e)) => return Err(ParseError::Io(e)),
}
let mut recipients = Vec::new();
loop {
let peek = p.peek(FOOTER_PREFIX.len()).map_err(ParseError::Io)?;
if peek.len() < FOOTER_PREFIX.len() {
return bad("failed to read header: unexpected eof");
}
if peek == FOOTER_PREFIX {
let line = match p.line(&mut total, MAX_HEADER_SIZE) {
Ok(l) => l,
Err(LineError::TooLong) => return bad(format!("header exceeds {MAX_HEADER_SIZE} byte limit")),
Err(LineError::Eof) => return bad("failed to read header: unexpected eof"),
Err(LineError::Io(e)) => return Err(ParseError::Io(e)),
};
let (prefix, args) = split_args(&line);
if prefix != FOOTER_PREFIX || args.len() != 1 {
return bad("malformed closing line");
}
let mac = match decode(args[0]) {
Ok(m) if m.len() == 32 => m,
_ => return bad("malformed closing line"),
};
let mut m = [0u8; 32];
m.copy_from_slice(&mac);
return Ok((Header { recipients, mac: m }, p.into_payload()));
}
let (s, n) = read_stanza(&mut p)?;
total += n;
if total > MAX_HEADER_SIZE {
return bad(format!("header exceeds {MAX_HEADER_SIZE} byte limit"));
}
recipients.push(s);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stanza(body: usize) -> Stanza {
Stanza {
typ: "t".into(),
args: vec!["a".into()],
body: vec![7u8; body],
}
}
#[test]
fn body_line_wrapping() {
let mut w = Vec::new();
stanza(0).marshal(&mut w);
assert_eq!(w, b"-> t a\n\n");
w.clear();
stanza(48).marshal(&mut w);
let line = encode(&[7u8; 48]);
assert_eq!(line.len(), 64);
assert_eq!(w, format!("-> t a\n{line}\n\n").as_bytes());
w.clear();
stanza(50).marshal(&mut w);
let enc = encode(&[7u8; 50]);
assert_eq!(w, format!("-> t a\n{}\n{}\n", &enc[..64], &enc[64..]).as_bytes());
}
#[test]
fn round_trip_and_payload() {
let h = Header {
recipients: vec![stanza(48), stanza(3)],
mac: [9; 32],
};
let mut b = h.marshal();
b.extend_from_slice(b"payload");
let (got, mut rest) = parse(&b[..]).unwrap();
assert!(got == h);
let mut p = Vec::new();
rest.read_to_end(&mut p).unwrap();
assert_eq!(p, b"payload");
}
#[test]
fn oversized_stanza_and_header() {
let big = "A".repeat(MAX_STANZA_SIZE as usize);
let h = format!("fury-encryption.org/v1\n-> x {big}\n\n--- {}\n", encode(&[0; 32]));
assert!(parse(h.as_bytes()).is_err());
// Just under the stanza limit is fine...
let ok = "A".repeat(MAX_STANZA_SIZE as usize - 10);
let h = format!("fury-encryption.org/v1\n-> x {ok}\n\n--- {}\n", encode(&[0; 32]));
assert!(parse(h.as_bytes()).is_ok());
// ...but seventeen such stanzas pass 16 MiB.
let mut h = String::from("fury-encryption.org/v1\n");
for _ in 0..17 {
h.push_str(&format!("-> x {ok}\n\n"));
}
h.push_str(&format!("--- {}\n", encode(&[0; 32])));
assert!(matches!(parse(h.as_bytes()), Err(ParseError::Malformed(m)) if m.contains("header exceeds")));
}
}
+425
View File
@@ -0,0 +1,425 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The hybrid FrodoKEM-640-SHAKE + ML-KEM-768 suite (ID 0x0001, stanza tag
//! `frodo640shake+mlkem768`), as the Go `pqcrypt` package defines it.
//!
//! - Keys: a 32-byte master seed is expanded with
//! HKDF-SHA-256(seed, salt = none, info = "fury/v1 seed-expand " + tag)
//! into a 48-byte FrodoKEM seed followed by a 64-byte ML-KEM seed (d||z).
//! - Wrap: both KEMs encapsulate with fresh randomness; the KEK is
//! HKDF-SHA-256(ss1 || ss2, salt = none, info = "fury/v1 kek " + tag ||
//! ct1 || ct2), and the 32-byte file key is sealed under it with
//! ChaCha20-Poly1305 and an all-zero nonce. The zero nonce is safe only
//! because every KEK is fresh and used once; nothing else may use
//! [`seal_once`].
use super::frodo;
use super::header::{self, Stanza};
use crate::rng::Rng;
use crate::secret::SecretArray;
use chacha20poly1305::aead::{AeadInOut, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce, Tag};
use hkdf::Hkdf;
use ml_kem::kem::{Decapsulate, KeyExport};
use ml_kem::{DecapsulationKey, EncapsulationKey, MlKem768};
use sha2::Sha256;
use zeroize::{Zeroize, Zeroizing};
pub const SUITE_ID: u16 = 0x0001;
pub const TAG: &str = "frodo640shake+mlkem768";
pub const SEED_SIZE: usize = 32;
pub const FILE_KEY_SIZE: usize = 32;
const MLKEM_PK: usize = 1184;
const MLKEM_CT: usize = 1088;
const MLKEM_SEED: usize = 64;
pub const PUBLIC_KEY_SIZE: usize = frodo::PUBLIC_KEY_SIZE + MLKEM_PK;
/// Why a stanza could not be unwrapped.
#[derive(Debug, PartialEq, Eq)]
pub enum UnwrapError {
/// The stanza is not for this identity (wrong suite, or the file key
/// does not open): try the next stanza or identity.
NotMine,
/// The stanza claims this suite but is malformed; Go treats this as
/// fatal for the whole file.
Malformed(String),
}
fn hkdf_expand(ikm: &[u8], salt: Option<&[u8]>, info: &[&[u8]], out: &mut [u8]) {
let h = Hkdf::<Sha256>::new(salt, ikm);
h.expand_multi_info(info, out).expect("hkdf output length within limits");
}
/// The recipient: FrodoKEM public key || ML-KEM encapsulation key.
pub struct Recipient {
bytes: Vec<u8>,
frodo: frodo::PublicKey,
mlkem: EncapsulationKey<MlKem768>,
}
impl Recipient {
pub fn from_bytes(pub_key: &[u8]) -> Result<Self, String> {
if pub_key.len() != PUBLIC_KEY_SIZE {
return Err(format!("invalid {TAG} public key size"));
}
let frodo = frodo::PublicKey::from_bytes(&pub_key[..frodo::PUBLIC_KEY_SIZE]).ok_or("invalid FrodoKEM public key")?;
let ek = ml_kem::kem::Key::<EncapsulationKey<MlKem768>>::try_from(&pub_key[frodo::PUBLIC_KEY_SIZE..])
.map_err(|_| "invalid ML-KEM public key")?;
let mlkem = EncapsulationKey::<MlKem768>::new(&ek).map_err(|_| "invalid ML-KEM public key")?;
Ok(Recipient {
bytes: pub_key.to_vec(),
frodo,
mlkem,
})
}
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
/// Wraps `file_key` with fresh encapsulations drawn from `rng`.
pub fn wrap(&self, file_key: &[u8; FILE_KEY_SIZE], rng: &mut Rng) -> Stanza {
let mut mu = Zeroizing::new([0u8; frodo::ENCAPSULATION_SEED_SIZE]);
rng.fill(&mut mu[..]);
let mut m = Zeroizing::new([0u8; 32]);
rng.fill(&mut m[..]);
self.wrap_with(file_key, &mu, &m)
}
fn wrap_with(&self, file_key: &[u8; FILE_KEY_SIZE], mu: &[u8; 16], m: &[u8; 32]) -> Stanza {
let (ct1, mut ss1) = self.frodo.encapsulate(mu);
let (ct2, ss2) = self.mlkem.encapsulate_deterministic(&(*m).into());
let mut ss2 = Zeroizing::new(ss2);
let kek = derive_kek(&ss1, &ss2[..], &ct1, &ct2[..]);
ss1.zeroize();
ss2.zeroize();
let body = seal_once(&kek, file_key);
Stanza {
typ: TAG.into(),
args: vec![header::encode(&ct1), header::encode(&ct2[..])],
body,
}
}
}
/// HKDF over both shared secrets, binding both ciphertexts.
fn derive_kek(ss1: &[u8], ss2: &[u8], ct1: &[u8], ct2: &[u8]) -> SecretArray<32> {
let mut secret = Zeroizing::new([0u8; frodo::SHARED_KEY_SIZE + 32]);
secret[..frodo::SHARED_KEY_SIZE].copy_from_slice(ss1);
secret[frodo::SHARED_KEY_SIZE..].copy_from_slice(ss2);
let label = format!("fury/v1 kek {TAG}");
let mut kek = SecretArray::<32>::zero();
hkdf_expand(&secret[..], None, &[label.as_bytes(), ct1, ct2], kek.expose_mut());
kek
}
/// ChaCha20-Poly1305 under an all-zero nonce, for a key used exactly once.
fn seal_once(kek: &SecretArray<32>, file_key: &[u8; FILE_KEY_SIZE]) -> Vec<u8> {
let mut k = Key::from(*kek.expose());
let aead = ChaCha20Poly1305::new(&k);
k.zeroize();
let mut body = file_key.to_vec();
let tag = aead
.encrypt_inout_detached(&Nonce::default(), &[], body.as_mut_slice().into())
.expect("32 bytes fit");
body.extend_from_slice(&tag);
body
}
fn open_once(kek: &SecretArray<32>, body: &[u8]) -> Option<SecretArray<FILE_KEY_SIZE>> {
if body.len() != FILE_KEY_SIZE + 16 {
return None;
}
let mut k = Key::from(*kek.expose());
let aead = ChaCha20Poly1305::new(&k);
k.zeroize();
let mut out = SecretArray::<FILE_KEY_SIZE>::zero();
out.expose_mut().copy_from_slice(&body[..FILE_KEY_SIZE]);
let tag = Tag::try_from(&body[FILE_KEY_SIZE..]).ok()?;
aead.decrypt_inout_detached(&Nonce::default(), &[], (&mut out.expose_mut()[..]).into(), &tag)
.ok()?;
Some(out)
}
/// A private identity derived from a master seed. It owns the seed and
/// both expanded private keys; dropping it clears what this crate owns
/// (see `crate::secret` for the limits of that). There is no way to use an
/// identity after [`Identity::close`] or drop:
///
/// ```compile_fail
/// let id = vault::crypto::kem::Identity::generate(&mut vault::rng::os());
/// id.close();
/// let _ = id.seed(); // use of a moved value
/// ```
pub struct Identity {
seed: SecretArray<SEED_SIZE>,
frodo: frodo::PrivateKey,
mlkem: Box<DecapsulationKey<MlKem768>>,
recipient_bytes: Vec<u8>,
}
impl Identity {
pub fn generate(rng: &mut Rng) -> Self {
let mut seed = SecretArray::<SEED_SIZE>::zero();
rng.fill(seed.expose_mut());
Self::from_seed(seed)
}
/// Derives the identity of `suite` from its master seed.
pub fn from_suite_seed(suite: u16, seed: &[u8]) -> Result<Self, String> {
if suite != SUITE_ID {
return Err(format!("unknown suite id {suite:#06x}"));
}
let s = SecretArray::<SEED_SIZE>::from_slice(seed).ok_or_else(|| format!("invalid seed size {} for suite {TAG}", seed.len()))?;
Ok(Self::from_seed(s))
}
pub fn from_seed(seed: SecretArray<SEED_SIZE>) -> Self {
let mut expanded = Zeroizing::new([0u8; frodo::KEY_SEED_SIZE + MLKEM_SEED]);
let info = format!("fury/v1 seed-expand {TAG}");
hkdf_expand(seed.expose(), None, &[info.as_bytes()], &mut expanded[..]);
let mut fseed = Zeroizing::new([0u8; frodo::KEY_SEED_SIZE]);
fseed.copy_from_slice(&expanded[..frodo::KEY_SEED_SIZE]);
let frodo = frodo::PrivateKey::derive(&fseed);
// ml-kem takes the seed by value (a stack copy we cannot clear) and
// keeps it, zeroizing it on drop.
let mut mseed = ml_kem::Seed::default();
mseed.copy_from_slice(&expanded[frodo::KEY_SEED_SIZE..]);
let mlkem = Box::new(DecapsulationKey::<MlKem768>::from_seed(mseed));
mseed.zeroize();
let mut recipient_bytes = frodo.public().to_bytes();
recipient_bytes.extend_from_slice(&mlkem.encapsulation_key().to_bytes());
Identity {
seed,
frodo,
mlkem,
recipient_bytes,
}
}
pub fn suite_id(&self) -> u16 {
SUITE_ID
}
/// The master seed: secret-equivalent to the whole identity.
pub fn seed(&self) -> &[u8; SEED_SIZE] {
self.seed.expose()
}
pub fn recipient(&self) -> Recipient {
Recipient::from_bytes(&self.recipient_bytes).expect("derived key is valid")
}
pub fn recipient_bytes(&self) -> &[u8] {
&self.recipient_bytes
}
/// The private key bytes as Go serialises them (FrodoKEM || ML-KEM
/// expanded), for compatibility checks only.
#[allow(deprecated)]
pub fn private_key_bytes(&self) -> Zeroizing<Vec<u8>> {
use ml_kem::ExpandedKeyEncoding;
let mut v = self.frodo.to_bytes();
let mut m = self.mlkem.to_expanded_bytes();
v.extend_from_slice(&m);
m.zeroize();
v
}
pub fn close(self) {}
/// Go's `unwrap` for one stanza.
pub fn unwrap_stanza(&self, s: &Stanza) -> Result<SecretArray<FILE_KEY_SIZE>, UnwrapError> {
if s.typ != TAG {
return Err(UnwrapError::NotMine);
}
if s.args.len() != 2 {
return Err(UnwrapError::Malformed(format!("invalid {TAG} stanza: expected 2 arguments")));
}
let ct1 = header::decode(s.args[0].as_bytes()).map_err(|e| UnwrapError::Malformed(format!("invalid FrodoKEM ciphertext: {e}")))?;
let ct2 = header::decode(s.args[1].as_bytes()).map_err(|e| UnwrapError::Malformed(format!("invalid ML-KEM ciphertext: {e}")))?;
if ct1.len() != frodo::CIPHERTEXT_SIZE || ct2.len() != MLKEM_CT {
return Err(UnwrapError::Malformed("encrypted value has unexpected length".into()));
}
let mut ss1 = self.frodo.decapsulate(&ct1).expect("length checked");
let ct2a = ml_kem::Ciphertext::<MlKem768>::try_from(&ct2[..]).expect("length checked");
let mut ss2 = self.mlkem.decapsulate(&ct2a);
// Both KEMs reject implicitly: a key mismatch shows up as a wrong
// KEK, which the AEAD below detects.
let kek = derive_kek(&ss1, &ss2[..], &ct1, &ct2);
ss1.zeroize();
ss2.zeroize();
open_once(&kek, &s.body).ok_or(UnwrapError::NotMine)
}
/// Go's `multiUnwrap`: the first stanza that opens wins; a malformed
/// stanza of this suite stops the search.
pub fn unwrap(&self, stanzas: &[Stanza]) -> Result<SecretArray<FILE_KEY_SIZE>, UnwrapError> {
for s in stanzas {
match self.unwrap_stanza(s) {
Err(UnwrapError::NotMine) => continue,
r => return r,
}
}
Err(UnwrapError::NotMine)
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::crypto::stream::tests::hex;
use serde::Deserialize;
use sha2::Digest;
pub fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
fn sha(b: &[u8]) -> String {
hex(&sha2::Sha256::digest(b))
}
#[derive(Deserialize)]
pub struct Crypto {
pub frodo640shake: Vec<KemV>,
pub mlkem768: Vec<KemV>,
pub hybrid: Vec<HybridV>,
pub wrap: Vec<WrapV>,
pub header_cases: Vec<HeaderCase>,
}
#[derive(Deserialize)]
pub struct KemV {
seed: String,
pub_sha256: String,
priv_sha256: String,
encap_seed: String,
ct_sha256: String,
ss: String,
reject_flip_index: usize,
reject_ss: String,
}
#[derive(Deserialize)]
pub struct HybridV {
master_seed: String,
pub_sha256: String,
priv_sha256: String,
pub_prefix: String,
}
#[derive(Deserialize)]
pub struct WrapV {
pub master_seed: String,
frodo_encap_seed: String,
mlkem_encap_seed: String,
pub file_key: String,
kek: String,
pub header_hex: String,
pub mac: String,
pub nonce: String,
pub payload_key: String,
stanza_arg0_sha256: String,
stanza_body: String,
}
#[derive(Deserialize)]
pub struct HeaderCase {
pub input_hex: String,
pub accept: bool,
pub rest_hex: Option<String>,
}
pub fn vectors() -> Crypto {
serde_json::from_str(include_str!("../../tests/vectors/crypto.json")).unwrap()
}
#[test]
fn frodo_matches_circl() {
for v in vectors().frodo640shake {
let seed: [u8; frodo::KEY_SEED_SIZE] = unhex(&v.seed).try_into().unwrap();
let sk = frodo::PrivateKey::derive(&seed);
let pk = sk.public().to_bytes();
assert_eq!(sha(&pk), v.pub_sha256);
assert_eq!(sha(&sk.to_bytes()), v.priv_sha256);
let pk2 = frodo::PublicKey::from_bytes(&pk).unwrap();
let mu: [u8; 16] = unhex(&v.encap_seed).try_into().unwrap();
let (ct, ss) = pk2.encapsulate(&mu);
assert_eq!(sha(&ct), v.ct_sha256);
assert_eq!(hex(&ss), v.ss);
assert_eq!(hex(&sk.decapsulate(&ct).unwrap()), v.ss);
let mut bad = ct.clone();
bad[v.reject_flip_index] ^= 1;
assert_eq!(hex(&sk.decapsulate(&bad).unwrap()), v.reject_ss, "implicit rejection");
}
}
#[test]
#[allow(deprecated)]
fn mlkem_matches_circl() {
use ml_kem::ExpandedKeyEncoding;
for v in vectors().mlkem768 {
let mut seed = ml_kem::Seed::default();
seed.copy_from_slice(&unhex(&v.seed));
let dk = DecapsulationKey::<MlKem768>::from_seed(seed);
let ek = dk.encapsulation_key();
assert_eq!(sha(&ek.to_bytes()), v.pub_sha256);
assert_eq!(sha(&dk.to_expanded_bytes()), v.priv_sha256);
let m: [u8; 32] = unhex(&v.encap_seed).try_into().unwrap();
let (ct, ss) = ek.encapsulate_deterministic(&m.into());
assert_eq!(sha(&ct), v.ct_sha256);
assert_eq!(hex(&ss), v.ss);
let mut bad = ct;
bad[v.reject_flip_index] ^= 1;
assert_eq!(hex(&dk.decapsulate(&bad)), v.reject_ss, "implicit rejection");
}
}
#[test]
fn hybrid_derivation_matches_go() {
for v in vectors().hybrid {
let id = Identity::from_suite_seed(SUITE_ID, &unhex(&v.master_seed)).unwrap();
assert_eq!(sha(id.recipient_bytes()), v.pub_sha256);
assert_eq!(hex(&id.recipient_bytes()[..64]), v.pub_prefix);
assert_eq!(sha(&id.private_key_bytes()), v.priv_sha256);
}
assert!(Identity::from_suite_seed(2, &[0; 32]).is_err());
assert!(Identity::from_suite_seed(SUITE_ID, &[0; 31]).is_err());
}
#[test]
fn wrap_matches_go_and_unwraps() {
for v in vectors().wrap {
let id = Identity::from_suite_seed(SUITE_ID, &unhex(&v.master_seed)).unwrap();
let fk: [u8; 32] = unhex(&v.file_key).try_into().unwrap();
let mu: [u8; 16] = unhex(&v.frodo_encap_seed).try_into().unwrap();
let m: [u8; 32] = unhex(&v.mlkem_encap_seed).try_into().unwrap();
let st = id.recipient().wrap_with(&fk, &mu, &m);
assert_eq!(sha(st.args[0].as_bytes()), v.stanza_arg0_sha256);
assert_eq!(hex(&st.body), v.stanza_body);
let ct1 = header::decode(st.args[0].as_bytes()).unwrap();
let ct2 = header::decode(st.args[1].as_bytes()).unwrap();
let ss1 = id.frodo.decapsulate(&ct1).unwrap();
let ss2 = id.mlkem.decapsulate(&ml_kem::Ciphertext::<MlKem768>::try_from(&ct2[..]).unwrap());
assert_eq!(hex(derive_kek(&ss1, &ss2[..], &ct1, &ct2).expose()), v.kek);
assert_eq!(id.unwrap(&[st]).unwrap().expose(), &fk);
}
}
#[test]
fn fresh_wraps_differ_and_foreign_keys_do_not_match() {
let a = Identity::generate(&mut crate::rng::os());
let b = Identity::generate(&mut crate::rng::os());
let fk = [7u8; 32];
let s1 = a.recipient().wrap(&fk, &mut crate::rng::os());
let s2 = a.recipient().wrap(&fk, &mut crate::rng::os());
assert!(s1.args != s2.args && s1.body != s2.body, "encapsulations must be fresh");
assert_eq!(b.unwrap(std::slice::from_ref(&s1)).err(), Some(UnwrapError::NotMine));
let mut bad = s1.clone();
bad.args.pop();
assert!(matches!(a.unwrap(&[bad]), Err(UnwrapError::Malformed(_))));
let mut other = s1.clone();
other.typ = "x25519".into();
assert_eq!(a.unwrap(&[other, s1]).unwrap().expose(), &fk);
}
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Fury file encryption, compatible with the Go `internal/pqcrypt`,
//! `internal/format` and `internal/stream` packages.
pub mod frodo;
pub mod fury;
pub mod header;
pub mod kem;
pub mod stream;
+759
View File
@@ -0,0 +1,759 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The STREAM payload encryption of the Go `internal/stream` package.
//!
//! The plaintext is cut into 64 KiB chunks, each sealed with
//! ChaCha20-Poly1305 under a 12-byte nonce whose bytes 3..11 hold the
//! big-endian chunk index and whose last byte is 1 for the final chunk
//! only. Only the final chunk may be short; it may be empty only when it is
//! the sole chunk (an empty plaintext). A reader rejects truncation (no
//! final-flagged chunk), an empty non-first final chunk, and any byte after
//! the final chunk.
//!
//! Chunk nonces depend only on the index, so chunks may be sealed or opened
//! in any order and produce identical bytes; the readers here classify each
//! chunk as final or not from the shape of the stream (one chunk of
//! read-ahead) and open it exactly once, which is the same verdict Go's
//! sequential try-then-retry reader reaches.
use chacha20poly1305::aead::{AeadInOut, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce, Tag};
use std::io::{self, Read, Write};
use zeroize::Zeroize;
pub const CHUNK_SIZE: usize = 64 * 1024;
pub const TAG_SIZE: usize = 16;
pub const ENC_CHUNK_SIZE: usize = CHUNK_SIZE + TAG_SIZE;
const LAST_CHUNK_FLAG: u8 = 0x01;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamError {
/// The stream ended before a final chunk.
Truncated,
/// A chunk failed to authenticate.
Auth,
EmptyLastChunk,
TrailingData,
CounterOverflow,
/// The ciphertext length cannot be a STREAM encoding.
InvalidSize(u64),
}
impl std::fmt::Display for StreamError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
StreamError::Truncated => "unexpected eof: encrypted payload is truncated",
StreamError::Auth => "failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with",
StreamError::EmptyLastChunk => "last chunk is empty; encrypted payload is malformed",
StreamError::TrailingData => "trailing data after end of encrypted file",
StreamError::CounterOverflow => "stream: chunk counter would wrap around",
StreamError::InvalidSize(n) => return write!(f, "invalid encrypted payload size: {n}"),
})
}
}
impl std::error::Error for StreamError {}
impl From<StreamError> for io::Error {
fn from(e: StreamError) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, e)
}
}
pub fn nonce(index: u64, last: bool) -> Nonce {
let mut n = [0u8; 12];
n[3..11].copy_from_slice(&index.to_be_bytes());
if last {
n[11] = LAST_CHUNK_FLAG;
}
Nonce::from(n)
}
/// A STREAM key. The cipher state holds a copy of the key that
/// `chacha20poly1305` does not expose for clearing; see SECURITY.md.
pub struct Cipher(ChaCha20Poly1305);
impl Cipher {
pub fn new(key: &[u8; 32]) -> Self {
let mut k = Key::from(*key);
let c = ChaCha20Poly1305::new(&k);
k.zeroize();
Cipher(c)
}
/// Seals `buf` (at most CHUNK_SIZE bytes of plaintext followed by
/// TAG_SIZE bytes of room) in place.
pub fn seal(&self, index: u64, last: bool, buf: &mut [u8]) {
let n = buf.len() - TAG_SIZE;
debug_assert!(n <= CHUNK_SIZE);
let (msg, tag) = buf.split_at_mut(n);
let t = self
.0
.encrypt_inout_detached(&nonce(index, last), &[], msg.into())
.expect("chunk within aead limits");
tag.copy_from_slice(&t);
}
/// Opens a sealed chunk in place; on success the first `len - 16` bytes
/// are plaintext. On failure the buffer holds no plaintext.
pub fn open(&self, index: u64, last: bool, buf: &mut [u8]) -> Result<usize, StreamError> {
if buf.len() < TAG_SIZE {
return Err(StreamError::Auth);
}
let n = buf.len() - TAG_SIZE;
let (msg, tag) = buf.split_at_mut(n);
let tag = Tag::try_from(&*tag).expect("tag size");
self.0
.decrypt_inout_detached(&nonce(index, last), &[], msg.into(), &tag)
.map_err(|_| StreamError::Auth)?;
Ok(n)
}
}
fn next_index(i: u64) -> Result<u64, StreamError> {
i.checked_add(1).ok_or(StreamError::CounterOverflow)
}
/// Encrypting writer. Call [`StreamWriter::finish`] to seal the final
/// chunk; dropping without finishing leaves a truncated stream that every
/// reader rejects.
pub struct StreamWriter<W: Write> {
cipher: Cipher,
dst: Option<W>,
buf: Vec<u8>,
index: u64,
}
impl<W: Write> StreamWriter<W> {
pub fn new(key: &[u8; 32], dst: W) -> Self {
StreamWriter {
cipher: Cipher::new(key),
dst: Some(dst),
buf: Vec::with_capacity(ENC_CHUNK_SIZE),
index: 0,
}
}
#[cfg(test)]
fn starting_at(key: &[u8; 32], dst: W, index: u64) -> Self {
let mut w = Self::new(key, dst);
w.index = index;
w
}
fn flush_chunk(&mut self, last: bool) -> io::Result<()> {
let n = self.buf.len();
self.buf.resize(n + TAG_SIZE, 0);
self.cipher.seal(self.index, last, &mut self.buf);
self.dst.as_mut().expect("writer in use").write_all(&self.buf)?;
self.buf.clear();
self.index = next_index(self.index)?;
Ok(())
}
pub fn finish(mut self) -> io::Result<W> {
self.flush_chunk(true)?;
Ok(self.dst.take().expect("writer in use"))
}
}
impl<W: Write> Write for StreamWriter<W> {
fn write(&mut self, mut p: &[u8]) -> io::Result<usize> {
let total = p.len();
while !p.is_empty() {
let take = p.len().min(CHUNK_SIZE - self.buf.len());
self.buf.extend_from_slice(&p[..take]);
p = &p[take..];
// Flush a full chunk only once more data proves it is not the
// last one.
if self.buf.len() == CHUNK_SIZE && !p.is_empty() {
self.flush_chunk(false)?;
}
}
Ok(total)
}
fn flush(&mut self) -> io::Result<()> {
self.dst.as_mut().expect("writer in use").flush()
}
}
impl<W: Write> Drop for StreamWriter<W> {
fn drop(&mut self) {
self.buf.zeroize();
}
}
/// Reads until `buf` is full or the source ends; returns the count.
pub fn read_full(src: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> {
let mut n = 0;
while n < buf.len() {
match src.read(&mut buf[n..]) {
Ok(0) => break,
Ok(k) => n += k,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(n)
}
/// After a final chunk: the source must be at end of input.
fn expect_eof(src: &mut impl Read) -> io::Result<()> {
let mut b = [0u8; 1];
loop {
match src.read(&mut b) {
Ok(0) => return Ok(()),
Ok(_) => return Err(StreamError::TrailingData.into()),
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
}
/// Decrypting reader with one chunk of read-ahead: a full-length chunk is
/// held until the next read shows whether anything follows it.
pub struct StreamReader<R: Read> {
cipher: Cipher,
src: R,
index: u64,
started: bool,
done: bool,
failed: bool,
pending: Option<Vec<u8>>,
/// Plaintext of a short final chunk, opened together with the chunk
/// before it and delivered after it.
queued: Option<Vec<u8>>,
out: Vec<u8>,
pos: usize,
spare: Vec<Vec<u8>>,
}
impl<R: Read> StreamReader<R> {
pub fn new(key: &[u8; 32], src: R) -> Self {
StreamReader {
cipher: Cipher::new(key),
src,
index: 0,
started: false,
done: false,
failed: false,
pending: None,
queued: None,
out: Vec::new(),
pos: 0,
spare: Vec::new(),
}
}
/// Reads one raw chunk into a recycled buffer, truncated to its length.
fn read_raw(&mut self) -> io::Result<Vec<u8>> {
let mut b = self.spare.pop().unwrap_or_default();
b.resize(ENC_CHUNK_SIZE, 0);
let n = read_full(&mut self.src, &mut b)?;
b.truncate(n);
Ok(b)
}
/// Opens `raw` in place as chunk `self.index`, leaving its plaintext.
fn open(&mut self, mut raw: Vec<u8>, last: bool) -> io::Result<Vec<u8>> {
match self.cipher.open(self.index, last, &mut raw) {
Ok(n) => {
raw.truncate(n);
self.index = next_index(self.index)?;
Ok(raw)
}
Err(e) => {
raw.zeroize();
Err(e.into())
}
}
}
fn deliver(&mut self, plain: Vec<u8>) {
let mut old = std::mem::replace(&mut self.out, plain);
old.zeroize();
old.clear();
self.spare.push(old);
self.pos = 0;
}
fn advance(&mut self) -> io::Result<()> {
if !self.started {
self.started = true;
let raw = self.read_raw()?;
if raw.is_empty() {
return Err(StreamError::Truncated.into());
}
if raw.len() < ENC_CHUNK_SIZE {
// A short first chunk is final; empty is the empty file.
let p = self.open(raw, true)?;
expect_eof(&mut self.src)?;
self.deliver(p);
self.done = true;
return Ok(());
}
self.pending = Some(raw);
}
let pending = self.pending.take().expect("pending chunk");
let next = self.read_raw()?;
if next.is_empty() {
// Nothing follows: a full-length final chunk, and end of input
// has just been seen.
let p = self.open(pending, true)?;
self.deliver(p);
self.done = true;
} else if next.len() < ENC_CHUNK_SIZE {
if next.len() == TAG_SIZE {
return Err(StreamError::EmptyLastChunk.into());
}
let p = self.open(pending, false)?;
let q = self.open(next, true)?;
expect_eof(&mut self.src)?;
self.deliver(p);
self.queued = Some(q);
self.done = true;
} else {
let p = self.open(pending, false)?;
self.deliver(p);
self.pending = Some(next);
}
Ok(())
}
}
impl<R: Read> Read for StreamReader<R> {
fn read(&mut self, p: &mut [u8]) -> io::Result<usize> {
if p.is_empty() {
return Ok(0);
}
loop {
if self.pos < self.out.len() {
let n = p.len().min(self.out.len() - self.pos);
p[..n].copy_from_slice(&self.out[self.pos..self.pos + n]);
self.pos += n;
return Ok(n);
}
if let Some(q) = self.queued.take() {
self.deliver(q);
continue;
}
if self.failed {
return Err(io::Error::new(io::ErrorKind::InvalidData, "encrypted stream already failed"));
}
if self.done {
return Ok(0);
}
if let Err(e) = self.advance() {
self.failed = true;
self.out.zeroize();
self.out.clear();
return Err(e);
}
}
}
}
impl<R: Read> Drop for StreamReader<R> {
fn drop(&mut self) {
self.out.zeroize();
for b in self.pending.iter_mut().chain(self.queued.iter_mut()).chain(self.spare.iter_mut()) {
b.zeroize();
}
}
}
/// Seals a whole plaintext held in memory, chunk by chunk, appending to
/// `out`. Byte-identical to [`StreamWriter`].
pub fn seal_all(key: &[u8; 32], plaintext: &[u8], out: &mut Vec<u8>) {
let c = Cipher::new(key);
let chunks = plaintext.len().div_ceil(CHUNK_SIZE).max(1);
out.reserve(plaintext.len() + chunks * TAG_SIZE);
for i in 0..chunks {
let start = i * CHUNK_SIZE;
let end = (start + CHUNK_SIZE).min(plaintext.len());
let at = out.len();
out.extend_from_slice(&plaintext[start..end]);
out.resize(out.len() + TAG_SIZE, 0);
c.seal(i as u64, i + 1 == chunks, &mut out[at..]);
}
}
/// Opens a whole sealed payload in place. On success the plaintext is
/// `buf[..returned]`; on any failure the buffer is cleared.
pub fn open_all(key: &[u8; 32], buf: &mut [u8]) -> Result<usize, StreamError> {
let r = open_all_inner(key, buf);
if r.is_err() {
buf.zeroize();
}
r
}
fn open_all_inner(key: &[u8; 32], buf: &mut [u8]) -> Result<usize, StreamError> {
let size = buf.len() as u64;
let chunks = encrypted_chunk_count(size)?;
let c = Cipher::new(key);
let mut plain = 0usize;
for i in 0..chunks {
let start = i as usize * ENC_CHUNK_SIZE;
let end = (start + ENC_CHUNK_SIZE).min(buf.len());
let n = c.open(i, i + 1 == chunks, &mut buf[start..end])?;
// Compact: plaintext of chunk i lands at i*CHUNK_SIZE <= start.
buf.copy_within(start..start + n, plain);
plain += n;
}
Ok(plain)
}
/// The number of chunks in a payload of `size` bytes, or an error if no
/// STREAM encoding has that size (Go's `EncryptedChunkCount`).
pub fn encrypted_chunk_count(size: u64) -> Result<u64, StreamError> {
let enc = ENC_CHUNK_SIZE as u64;
let chunks = size.div_ceil(enc);
let plain = size.checked_sub(chunks * TAG_SIZE as u64).ok_or(StreamError::InvalidSize(size))?;
let expected = if plain == 0 { 1 } else { plain.div_ceil(CHUNK_SIZE as u64) };
if expected != chunks {
return Err(StreamError::InvalidSize(size));
}
Ok(chunks)
}
pub fn plaintext_size(encrypted: u64) -> Result<u64, StreamError> {
Ok(encrypted - encrypted_chunk_count(encrypted)? * TAG_SIZE as u64)
}
/// Positional reads, as `std::os::unix::fs::FileExt::read_at`.
pub trait ReadAt {
fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result<usize>;
}
impl ReadAt for [u8] {
fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result<usize> {
let off = usize::try_from(offset).unwrap_or(usize::MAX).min(self.len());
let n = buf.len().min(self.len() - off);
buf[..n].copy_from_slice(&self[off..off + n]);
Ok(n)
}
}
impl ReadAt for std::fs::File {
fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result<usize> {
#[cfg(unix)]
{
std::os::unix::fs::FileExt::read_at(self, buf, offset)
}
#[cfg(windows)]
{
std::os::windows::fs::FileExt::seek_read(self, buf, offset)
}
#[cfg(not(any(unix, windows)))]
{
let _ = (buf, offset);
Err(io::Error::new(io::ErrorKind::Unsupported, "positional reads unsupported"))
}
}
}
fn read_exact_at<S: ReadAt + ?Sized>(src: &S, buf: &mut [u8], mut off: u64) -> io::Result<()> {
let mut done = 0;
while done < buf.len() {
match src.read_at(&mut buf[done..], off) {
Ok(0) => return Err(io::ErrorKind::UnexpectedEof.into()),
Ok(n) => {
done += n;
off += n as u64;
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(())
}
/// Random-access decryption (Go's `DecryptReaderAt`). Construction checks
/// the size is a valid encoding and authenticates the final chunk, so a
/// truncated or extended payload is rejected before any read.
pub struct StreamReaderAt<'a, S: ReadAt + ?Sized> {
cipher: Cipher,
src: &'a S,
size: u64,
chunks: u64,
cache: std::sync::Mutex<Option<(u64, Vec<u8>)>>,
}
impl<'a, S: ReadAt + ?Sized> StreamReaderAt<'a, S> {
pub fn new(key: &[u8; 32], src: &'a S, encrypted_size: u64) -> io::Result<Self> {
let cipher = Cipher::new(key);
let chunks = encrypted_chunk_count(encrypted_size)?;
let last = chunks - 1;
let off = last * ENC_CHUNK_SIZE as u64;
let mut buf = vec![0u8; (encrypted_size - off) as usize];
read_exact_at(src, &mut buf, off)?;
let n = cipher.open(last, true, &mut buf).inspect_err(|_| buf.zeroize())?;
buf.truncate(n);
Ok(StreamReaderAt {
cipher,
src,
size: encrypted_size - chunks * TAG_SIZE as u64,
chunks,
cache: std::sync::Mutex::new(Some((last, buf))),
})
}
pub fn size(&self) -> u64 {
self.size
}
/// Fills as much of `p` as lies before the end of the plaintext and
/// returns the count; reading at or past the end returns 0.
pub fn read_at(&self, p: &mut [u8], mut off: u64) -> io::Result<usize> {
if off > self.size {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("offset out of range [0:{}]: {off}", self.size),
));
}
let mut n = 0;
let mut chunk = vec![0u8; ENC_CHUNK_SIZE];
let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
while n < p.len() && off < self.size {
let idx = off / CHUNK_SIZE as u64;
let plain: &[u8] = match &*cache {
Some((i, data)) if *i == idx => data,
_ => {
let coff = idx * ENC_CHUNK_SIZE as u64;
let enc = (self.size + self.chunks * TAG_SIZE as u64 - coff).min(ENC_CHUNK_SIZE as u64) as usize;
read_exact_at(self.src, &mut chunk[..enc], coff)?;
let k = self.cipher.open(idx, idx == self.chunks - 1, &mut chunk[..enc])?;
if let Some((_, old)) = cache.as_mut() {
old.zeroize();
}
*cache = Some((idx, chunk[..k].to_vec()));
&cache.as_ref().expect("just set").1
}
};
let within = (off - idx * CHUNK_SIZE as u64) as usize;
let take = (plain.len() - within).min(p.len() - n);
p[n..n + take].copy_from_slice(&plain[within..within + take]);
n += take;
off += take as u64;
}
chunk.zeroize();
Ok(n)
}
}
impl<S: ReadAt + ?Sized> Drop for StreamReaderAt<'_, S> {
fn drop(&mut self) {
if let Some((_, d)) = self.cache.get_mut().unwrap_or_else(|e| e.into_inner()).as_mut() {
d.zeroize();
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
fn pt(n: usize) -> Vec<u8> {
(0..n).map(|i| (i * 13 + 7) as u8).collect()
}
fn seal(key: &[u8; 32], p: &[u8]) -> Vec<u8> {
let mut w = StreamWriter::new(key, Vec::new());
w.write_all(p).unwrap();
w.finish().unwrap()
}
fn open_stream(key: &[u8; 32], c: &[u8]) -> io::Result<Vec<u8>> {
let mut out = Vec::new();
StreamReader::new(key, c).read_to_end(&mut out)?;
Ok(out)
}
#[derive(serde::Deserialize)]
struct Vectors {
key: String,
vectors: Vec<V>,
}
#[derive(serde::Deserialize)]
struct V {
size: usize,
ct_len: usize,
ct_sha256: String,
ct_hex: Option<String>,
}
fn sha256(b: &[u8]) -> String {
use sha2::Digest;
hex(&sha2::Sha256::digest(b))
}
pub(crate) fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn go_oracle_vectors() {
let v: Vectors = serde_json::from_str(include_str!("../../tests/vectors/stream.json")).unwrap();
let key: [u8; 32] = (1..=32).collect::<Vec<u8>>().try_into().unwrap();
assert_eq!(hex(&key), v.key);
for t in v.vectors {
let p = pt(t.size);
let c = seal(&key, &p);
assert_eq!(c.len(), t.ct_len, "size {}", t.size);
assert_eq!(sha256(&c), t.ct_sha256, "size {}", t.size);
if let Some(h) = t.ct_hex {
assert_eq!(hex(&c), h);
}
let mut all = Vec::new();
seal_all(&key, &p, &mut all);
assert_eq!(all, c, "seal_all size {}", t.size);
assert_eq!(open_stream(&key, &c).unwrap(), p);
let n = open_all(&key, &mut all).unwrap();
assert_eq!(&all[..n], &p[..]);
assert_eq!(plaintext_size(c.len() as u64).unwrap(), t.size as u64);
}
}
#[test]
fn writes_in_any_steps_match() {
let key = [3u8; 32];
let p = pt(3 * CHUNK_SIZE + 5);
let whole = seal(&key, &p);
for step in [1, 7, 4096, CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1] {
let mut w = StreamWriter::new(&key, Vec::new());
for c in p.chunks(step) {
w.write_all(c).unwrap();
}
assert_eq!(w.finish().unwrap(), whole, "step {step}");
}
}
#[test]
fn reads_in_any_steps_match() {
let key = [4u8; 32];
for size in [0, 1, CHUNK_SIZE, CHUNK_SIZE + 1, 2 * CHUNK_SIZE, 2 * CHUNK_SIZE + 17] {
let p = pt(size);
let c = seal(&key, &p);
for step in [1, 1000, CHUNK_SIZE, CHUNK_SIZE + 3] {
let mut r = StreamReader::new(&key, &c[..]);
let mut got = Vec::new();
let mut b = vec![0u8; step];
loop {
let n = r.read(&mut b).unwrap();
if n == 0 {
break;
}
got.extend_from_slice(&b[..n]);
}
assert_eq!(got, p, "size {size} step {step}");
}
}
}
/// Every malformation is rejected by the streaming reader, the in-memory
/// opener and (where the size is valid) the random-access reader alike.
#[test]
fn malformed_streams_rejected_consistently() {
let key = [5u8; 32];
let mut cases: Vec<(String, Vec<u8>)> = Vec::new();
for size in [
0,
1,
CHUNK_SIZE - 1,
CHUNK_SIZE,
CHUNK_SIZE + 1,
2 * CHUNK_SIZE,
2 * CHUNK_SIZE + 100,
] {
let c = seal(&key, &pt(size));
cases.push((format!("{size} truncated by one"), c[..c.len() - 1].to_vec()));
cases.push((format!("{size} trailing byte"), [&c[..], &[0]].concat()));
cases.push((format!("{size} trailing chunk"), [&c[..], &c[..]].concat()));
if c.len() > ENC_CHUNK_SIZE {
cases.push((format!("{size} final chunk dropped"), c[..ENC_CHUNK_SIZE].to_vec()));
let mut swapped = c.clone();
swapped[..ENC_CHUNK_SIZE].copy_from_slice(&c[c.len() - ENC_CHUNK_SIZE..]);
if swapped != c {
cases.push((format!("{size} chunk swapped"), swapped));
}
}
for at in [0, c.len() / 2, c.len() - 1] {
let mut f = c.clone();
f[at] ^= 0x80;
cases.push((format!("{size} flipped at {at}"), f));
}
}
cases.push(("empty".into(), Vec::new()));
cases.push(("tag only, second chunk".into(), {
// A full non-final chunk followed by an empty final chunk is
// never produced and must be refused.
let p = pt(CHUNK_SIZE);
let c = Cipher::new(&key);
let mut a = p.clone();
a.resize(ENC_CHUNK_SIZE, 0);
c.seal(0, false, &mut a);
let mut b = vec![0u8; TAG_SIZE];
c.seal(1, true, &mut b);
[a, b].concat()
}));
for (name, c) in cases {
assert!(open_stream(&key, &c).is_err(), "stream accepted: {name}");
let mut b = c.clone();
assert!(open_all(&key, &mut b).is_err(), "open_all accepted: {name}");
assert!(b.iter().all(|&x| x == 0), "open_all left data behind: {name}");
if let Ok(r) = StreamReaderAt::new(&key, &c[..], c.len() as u64) {
let mut out = vec![0u8; r.size() as usize];
assert!(r.read_at(&mut out, 0).is_err(), "reader-at accepted: {name}");
}
}
let wrong = seal(&[6u8; 32], b"x");
assert!(open_stream(&key, &wrong).is_err());
}
#[test]
fn random_access() {
let key = [8u8; 32];
for size in [0, 1, CHUNK_SIZE, CHUNK_SIZE + 1, 3 * CHUNK_SIZE - 5] {
let p = pt(size);
let c = seal(&key, &p);
let r = StreamReaderAt::new(&key, &c[..], c.len() as u64).unwrap();
assert_eq!(r.size(), size as u64);
for (off, len) in [(0, size), (size / 2, size / 3 + 1), (size.saturating_sub(1), 10), (size, 4)] {
let mut b = vec![0u8; len];
let n = r.read_at(&mut b, off as u64).unwrap();
let want = &p[off.min(size)..(off + len).min(size)];
assert_eq!(&b[..n], want, "size {size} off {off} len {len}");
}
assert!(r.read_at(&mut [0u8; 1], size as u64 + 1).is_err());
}
// Concurrent readers share the cache safely.
let p = pt(4 * CHUNK_SIZE);
let c = seal(&key, &p);
let r = StreamReaderAt::new(&key, &c[..], c.len() as u64).unwrap();
std::thread::scope(|s| {
for t in 0..8u64 {
let (r, p) = (&r, &p);
s.spawn(move || {
for i in 0..200u64 {
let off = ((i * 7919 + t * 104729) % p.len() as u64) as usize;
let mut b = [0u8; 100];
let n = r.read_at(&mut b, off as u64).unwrap();
assert_eq!(&b[..n], &p[off..(off + 100).min(p.len())]);
}
});
}
});
assert!(StreamReaderAt::new(&key, &c[..], c.len() as u64 - 1).is_err());
assert!(encrypted_chunk_count(15).is_err());
assert!(encrypted_chunk_count(ENC_CHUNK_SIZE as u64 + 16).is_err());
}
#[test]
fn counter_overflow_is_refused() {
let mut w = StreamWriter::starting_at(&[1u8; 32], Vec::new(), u64::MAX);
w.write_all(&pt(CHUNK_SIZE + 1)).unwrap_err();
assert_eq!(next_index(u64::MAX), Err(StreamError::CounterOverflow));
}
}
+341
View File
@@ -0,0 +1,341 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The passphrase-protected key file, byte-compatible with the Go version.
//!
//! A PEM block of type `VAULT KEY` holds 198 bytes: an 18-byte header
//! (`VAULTKEY1`, Argon2id time 3 and memory 65536 KiB as big-endian u32s,
//! and 4 lanes as one byte) and two 90-byte slots. A slot is a 16-byte
//! salt, a 24-byte XChaCha20-Poly1305 nonce and the sealed big-endian suite
//! ID and 32-byte master seed, with the header as associated data and the
//! key Argon2id(passphrase, salt). The second slot is a decoy identity or
//! random bytes, and which slot is real is random, so the file says nothing
//! about whether a decoy exists. Unlocking always runs Argon2id for both
//! slots, so timing does not reveal which one matched.
use crate::crypto::kem::{self, Identity};
use crate::rng::Rng;
use crate::secret::{SecretArray, SecretVec};
use argon2::{Algorithm, Argon2, Block, Params, Version};
use base64::Engine;
use chacha20poly1305::aead::{AeadInOut, KeyInit};
use chacha20poly1305::{Tag, XChaCha20Poly1305, XNonce};
use std::io::Write;
use std::path::Path;
use zeroize::Zeroize;
pub const PEM_TYPE: &str = "VAULT KEY";
const MAGIC: &[u8] = b"VAULTKEY1";
const ARGON_TIME: u32 = 3;
const ARGON_MEMORY: u32 = 64 * 1024; // KiB
const ARGON_THREADS: u8 = 4;
const SALT: usize = 16;
const XNONCE: usize = 24;
const SLOT_PLAIN: usize = 2 + kem::SEED_SIZE;
pub const SLOT_SIZE: usize = SALT + XNONCE + SLOT_PLAIN + 16;
pub const HEADER_SIZE: usize = 18;
pub const BODY_SIZE: usize = HEADER_SIZE + 2 * SLOT_SIZE;
/// Key files are a few hundred bytes; anything this large is not one.
const MAX_FILE: u64 = 64 << 10;
pub fn header() -> [u8; HEADER_SIZE] {
let mut h = [0u8; HEADER_SIZE];
h[..9].copy_from_slice(MAGIC);
h[9..13].copy_from_slice(&ARGON_TIME.to_be_bytes());
h[13..17].copy_from_slice(&ARGON_MEMORY.to_be_bytes());
h[17] = ARGON_THREADS;
h
}
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
NotAKeyFile,
IncorrectPassphrase,
Io(String),
Invalid(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::NotAKeyFile => f.write_str("not a vault key file"),
Error::IncorrectPassphrase => f.write_str("incorrect passphrase"),
Error::Io(m) | Error::Invalid(m) => f.write_str(m),
}
}
}
/// Argon2id(passphrase, salt) with the file's fixed parameters. The
/// passphrase bytes are used exactly as given: no normalisation.
pub fn slot_key(passphrase: &[u8], salt: &[u8]) -> SecretArray<32> {
let params = Params::new(ARGON_MEMORY, ARGON_TIME, ARGON_THREADS as u32, Some(32)).expect("valid parameters");
let a = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut blocks = vec![Block::default(); a.params().block_count()];
let mut key = SecretArray::<32>::zero();
let r = a.hash_password_into_with_memory(passphrase, salt, key.expose_mut(), &mut blocks[..]);
// The working memory is derived from the passphrase; clear all 64 MiB.
for b in blocks.iter_mut() {
b.zeroize();
}
r.expect("argon2 inputs within limits");
key
}
fn seal_slot(id: &Identity, passphrase: &[u8], rng: &mut Rng) -> Vec<u8> {
let mut slot = vec![0u8; SALT + XNONCE];
rng.fill(&mut slot);
let key = slot_key(passphrase, &slot[..SALT]);
let aead = XChaCha20Poly1305::new(key.expose().into());
let mut plain = SecretVec::with_capacity(SLOT_PLAIN);
plain.extend_within_capacity(&id.suite_id().to_be_bytes());
plain.extend_within_capacity(id.seed());
let nonce = XNonce::try_from(&slot[SALT..]).expect("nonce size");
let tag = aead
.encrypt_inout_detached(&nonce, &header(), plain.expose_mut().into())
.expect("small message");
slot.extend_from_slice(plain.expose());
slot.extend_from_slice(&tag);
slot
}
/// Opens one slot, or None if the passphrase does not open it.
fn open_slot(slot: &[u8], passphrase: &[u8]) -> Option<SecretVec> {
let key = slot_key(passphrase, &slot[..SALT]);
let aead = XChaCha20Poly1305::new(key.expose().into());
let nonce = XNonce::try_from(&slot[SALT..SALT + XNONCE]).expect("nonce size");
let ct = &slot[SALT + XNONCE..];
let mut plain = SecretVec::zeroed(SLOT_PLAIN);
plain.expose_mut().copy_from_slice(&ct[..SLOT_PLAIN]);
let tag = Tag::try_from(&ct[SLOT_PLAIN..]).expect("tag size");
aead.decrypt_inout_detached(&nonce, &header(), plain.expose_mut().into(), &tag)
.ok()?;
Some(plain)
}
/// The PEM text for a key file body, exactly as Go's `pem.Encode` writes it.
pub fn encode_pem(body: &[u8]) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(body);
let mut s = format!("-----BEGIN {PEM_TYPE}-----\n");
for line in b64.as_bytes().chunks(64) {
s.push_str(std::str::from_utf8(line).expect("ascii"));
s.push('\n');
}
s.push_str(&format!("-----END {PEM_TYPE}-----\n"));
s
}
/// The block body if `data` is one `VAULT KEY` PEM block, optionally with
/// text before it (as Go's `pem.Decode` allows) and only whitespace after.
pub fn decode_pem(data: &[u8]) -> Option<Vec<u8>> {
let begin = format!("-----BEGIN {PEM_TYPE}-----");
let end = format!("-----END {PEM_TYPE}-----");
let start = if data.starts_with(b"-----BEGIN ") {
0
} else {
find(data, b"\n-----BEGIN ")? + 1
};
let mut lines = data[start..].split_inclusive(|&c| c == b'\n');
let first = lines.next()?;
if trim_right(first) != begin.as_bytes() {
return None;
}
let mut b64 = Vec::new();
let mut after = None;
let mut consumed = start + first.len();
for line in lines.by_ref() {
consumed += line.len();
let t = trim_right(line);
if t == end.as_bytes() {
after = Some(consumed);
break;
}
if t.contains(&b':') {
// A PEM header line; Go accepts and ignores them.
continue;
}
b64.extend(t.iter().filter(|c| !matches!(c, b' ' | b'\t')));
}
let after = after?;
if !data[after..].iter().all(|c| c.is_ascii_whitespace()) {
return None;
}
base64::engine::general_purpose::STANDARD.decode(&b64).ok()
}
fn trim_right(l: &[u8]) -> &[u8] {
let mut e = l.len();
while e > 0 && matches!(l[e - 1], b' ' | b'\t' | b'\r' | b'\n') {
e -= 1;
}
&l[..e]
}
fn find(h: &[u8], n: &[u8]) -> Option<usize> {
h.windows(n.len()).position(|w| w == n)
}
/// Builds a key file body: one slot for `passphrase`, the other for
/// `decoy` or random bytes if it is empty, in random order.
pub fn new_body(passphrase: &[u8], decoy: &[u8], rng: &mut Rng) -> Vec<u8> {
let real_slot = (rng.array::<1>()[0] & 1) as usize;
let mut slots: [Vec<u8>; 2] = [Vec::new(), Vec::new()];
for (i, pass) in [passphrase, decoy].into_iter().enumerate() {
let idx = real_slot ^ i;
if pass.is_empty() {
let mut r = vec![0u8; SLOT_SIZE];
rng.fill(&mut r);
slots[idx] = r;
continue;
}
let id = Identity::generate(rng);
slots[idx] = seal_slot(&id, pass, rng);
}
let mut body = header().to_vec();
body.extend_from_slice(&slots[0]);
body.extend_from_slice(&slots[1]);
body
}
/// Creates a new key file at `path`, refusing to replace an existing one.
pub fn write(path: &Path, passphrase: &[u8], decoy: &[u8]) -> Result<(), Error> {
let body = new_body(passphrase, decoy, &mut crate::rng::os());
let pem = encode_pem(&body);
let ioerr = |op: &str, e: std::io::Error| Error::Io(crate::util::io_msg(op, path, &e));
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
crate::platform::fs::create_private_dir_all(parent).map_err(|e| Error::Io(crate::util::io_msg("mkdir", parent, &e)))?;
}
let mut f = crate::platform::fs::create_new_private(path).map_err(|e| ioerr("open", e))?;
if let Err(e) = f.write_all(pem.as_bytes()).and_then(|_| f.sync_all()) {
drop(f);
let _ = std::fs::remove_file(path);
return Err(ioerr("write", e));
}
drop(f);
if let Some(parent) = path.parent() {
let _ = crate::platform::fs::sync_dir(if parent.as_os_str().is_empty() { Path::new(".") } else { parent });
}
Ok(())
}
/// Checks a key file body's framing, returning its two slots.
pub fn parse_body(body: &[u8]) -> Result<[&[u8]; 2], Error> {
if body.len() != BODY_SIZE || body[..HEADER_SIZE] != header() {
return Err(Error::NotAKeyFile);
}
let s = &body[HEADER_SIZE..];
Ok([&s[..SLOT_SIZE], &s[SLOT_SIZE..]])
}
/// The identity whose slot `passphrase` opens. Both slots are always tried.
pub fn unlock_body(body: &[u8], passphrase: &[u8]) -> Result<Identity, Error> {
let slots = parse_body(body)?;
// Both Argon2id runs happen whatever the outcome, concurrently, so the
// time taken does not depend on which slot (if any) matched.
let opened: Vec<Option<SecretVec>> = std::thread::scope(|s| {
let h: Vec<_> = slots.iter().map(|slot| s.spawn(move || open_slot(slot, passphrase))).collect();
h.into_iter().map(|h| h.join().expect("argon2 thread")).collect()
});
if let Some(plain) = opened.into_iter().flatten().next() {
let p = plain.expose();
let suite = u16::from_be_bytes([p[0], p[1]]);
return Identity::from_suite_seed(suite, &p[2..]).map_err(Error::Invalid);
}
Err(Error::IncorrectPassphrase)
}
pub fn read_body(path: &Path) -> Result<Vec<u8>, Error> {
use std::io::Read;
let f = std::fs::File::open(path).map_err(|e| Error::Io(crate::util::io_msg("open", path, &e)))?;
let mut data = Vec::new();
f.take(MAX_FILE + 1)
.read_to_end(&mut data)
.map_err(|e| Error::Io(crate::util::io_msg("read", path, &e)))?;
if data.len() as u64 > MAX_FILE {
return Err(Error::NotAKeyFile);
}
let body = decode_pem(&data).ok_or(Error::NotAKeyFile)?;
parse_body(&body)?;
Ok(body)
}
pub fn unlock(path: &Path, passphrase: &[u8]) -> Result<Identity, Error> {
unlock_body(&read_body(path)?, passphrase)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::kem::tests::unhex;
use crate::crypto::stream::tests::hex;
use serde_json::Value;
fn store_vectors() -> Value {
serde_json::from_str(include_str!("../tests/vectors/store.json")).unwrap()
}
#[test]
fn argon2id_matches_go() {
let v = store_vectors();
for c in v["argon2id"].as_array().unwrap() {
let k = slot_key(&unhex(c["passphrase_hex"].as_str().unwrap()), &unhex(c["salt"].as_str().unwrap()));
assert_eq!(hex(k.expose()), c["key"].as_str().unwrap());
}
assert_eq!(hex(&header()), v["key_header_hex"].as_str().unwrap());
}
#[test]
fn opens_go_key_files() {
let v = store_vectors();
for (name, slots) in v["keys"].as_object().unwrap() {
let path = format!("{}/tests/vectors/{name}", env!("CARGO_MANIFEST_DIR"));
let body = read_body(Path::new(&path)).unwrap();
assert_eq!(body.len(), 198);
for (_, s) in slots.as_object().unwrap() {
let id = unlock_body(&body, s["passphrase"].as_str().unwrap().as_bytes()).unwrap();
assert_eq!(hex(id.seed()), s["seed"].as_str().unwrap());
assert_eq!(id.suite_id() as u64, s["suite"].as_u64().unwrap());
}
assert_eq!(unlock_body(&body, b"wrong").err(), Some(Error::IncorrectPassphrase));
// Re-encoding reproduces Go's PEM text exactly.
assert_eq!(encode_pem(&body).as_bytes(), &std::fs::read(&path).unwrap()[..]);
}
}
#[test]
fn new_key_files_round_trip() {
let body = new_body(b"real", b"decoy", &mut crate::rng::os());
let a = unlock_body(&body, b"real").unwrap();
let b = unlock_body(&body, b"decoy").unwrap();
assert_ne!(a.seed(), b.seed());
assert!(unlock_body(&body, b"other").is_err());
let solo = new_body(b"real", b"", &mut crate::rng::os());
assert_eq!(solo.len(), body.len());
assert!(unlock_body(&solo, b"").is_err());
// Passphrases are bytes: no normalisation of composed/decomposed é.
let nfc = "caf\u{e9}".as_bytes();
let nfd = "cafe\u{301}".as_bytes();
let k = new_body(nfc, b"", &mut crate::rng::os());
assert!(unlock_body(&k, nfc).is_ok());
assert!(unlock_body(&k, nfd).is_err());
}
#[test]
fn malformed_key_files() {
let body = new_body(b"p", b"", &mut crate::rng::os());
let pem = encode_pem(&body);
assert_eq!(decode_pem(pem.as_bytes()).unwrap(), body);
assert_eq!(decode_pem(format!("junk\n{pem}\n \n").as_bytes()).unwrap(), body);
assert!(decode_pem(format!("{pem}x").as_bytes()).is_none());
assert!(decode_pem(pem.replace("VAULT KEY", "VAULT KEX").as_bytes()).is_none());
assert!(decode_pem(pem.replace("-----END", "-----FIN").as_bytes()).is_none());
let mut short = body.clone();
short.pop();
assert_eq!(parse_body(&short).err(), Some(Error::NotAKeyFile));
let mut wrong = body.clone();
wrong[17] = 1; // a different Argon2 lane count
assert_eq!(parse_body(&wrong).err(), Some(Error::NotAKeyFile));
let mut tampered = body.clone();
tampered[HEADER_SIZE + SALT + XNONCE] ^= 1;
tampered[HEADER_SIZE + SLOT_SIZE + SALT + XNONCE] ^= 1;
assert_eq!(unlock_body(&tampered, b"p").err(), Some(Error::IncorrectPassphrase));
}
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! vault: a post-quantum encrypted file vault. The binary is a thin wrapper
//! around [`cli::main`]; the library exists so integration tests and
//! benchmarks can reach the same code.
#![deny(unsafe_op_in_unsafe_fn)]
pub mod crypto;
pub mod keyfile;
pub mod platform;
pub mod rng;
pub mod secret;
pub mod util;
+1
View File
@@ -0,0 +1 @@
fn main() {}
+488
View File
@@ -0,0 +1,488 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Filesystem primitives with the guarantees the store and extraction need.
//!
//! - [`publish`] moves a finished temporary file to its final name without
//! ever replacing anything there, symlinks (dangling or not) included:
//! `renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on
//! macOS, `link`+`unlink` on other Unix systems and when a Linux
//! filesystem lacks RENAME_NOREPLACE, and `MoveFileExW` without
//! `MOVEFILE_REPLACE_EXISTING` on Windows. There is no check-then-rename
//! fallback: if no atomic primitive works, publishing fails.
//! - [`Dir`] creates and publishes files relative to an open directory. On
//! Unix every path component below the chosen destination is opened with
//! `O_NOFOLLOW|O_DIRECTORY`, so a symlink planted inside the destination
//! cannot redirect extraction elsewhere. Windows has no `openat`; there
//! each component is checked not to be a symlink or junction before use,
//! which narrows but does not close the race (see SECURITY.md).
//! - [`sync_dir`] makes a directory's entries durable where the OS allows
//! it (Unix). Windows offers no directory fsync through these APIs; NTFS
//! journals metadata, and that is the durability on offer there.
use std::ffi::OsStr;
use std::fs::{File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
pub fn create_private_dir_all(p: &Path) -> io::Result<()> {
let mut b = std::fs::DirBuilder::new();
b.recursive(true);
#[cfg(unix)]
b.mode(0o700);
b.create(p)
}
pub fn create_private_dir(p: &Path) -> io::Result<()> {
let mut b = std::fs::DirBuilder::new();
#[cfg(unix)]
b.mode(0o700);
b.create(p)
}
/// Creates a new file readable only by its owner; fails if `p` exists.
pub fn create_new_private(p: &Path) -> io::Result<File> {
let mut o = OpenOptions::new();
o.write(true).create_new(true);
#[cfg(unix)]
o.mode(0o600);
o.open(p)
}
pub fn sync_dir(dir: &Path) -> io::Result<()> {
#[cfg(unix)]
{
File::open(dir)?.sync_all()
}
#[cfg(not(unix))]
{
let _ = dir;
Ok(())
}
}
/// Sets a path's access and modification times to the Unix epoch,
/// following symlinks, as Go's `os.Chtimes(p, epoch, epoch)`.
pub fn set_epoch(p: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use rustix::fs::{AtFlags, CWD, Timespec, Timestamps, utimensat};
let z = Timespec { tv_sec: 0, tv_nsec: 0 };
utimensat(
CWD,
p,
&Timestamps {
last_access: z,
last_modification: z,
},
AtFlags::empty(),
)?;
Ok(())
}
#[cfg(not(unix))]
{
let f = open_for_times(p)?;
f.set_times(
std::fs::FileTimes::new()
.set_accessed(std::time::UNIX_EPOCH)
.set_modified(std::time::UNIX_EPOCH),
)
}
}
#[cfg(windows)]
fn open_for_times(p: &Path) -> io::Result<File> {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_WRITE_ATTRIBUTES};
OpenOptions::new()
.access_mode(FILE_WRITE_ATTRIBUTES)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
.open(p)
}
#[cfg(not(any(unix, windows)))]
fn open_for_times(p: &Path) -> io::Result<File> {
OpenOptions::new().write(true).open(p)
}
fn random_suffix() -> String {
let b: [u8; 8] = crate::rng::os().array();
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// Creates `dir/<prefix><random>` exclusively, mode 0600.
pub fn create_temp(dir: &Path, prefix: &str) -> io::Result<(File, PathBuf)> {
loop {
let p = dir.join(format!("{prefix}{}", random_suffix()));
match create_new_private(&p) {
Ok(f) => return Ok((f, p)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
}
fn exists_error(dst: &Path) -> io::Error {
io::Error::new(io::ErrorKind::AlreadyExists, format!("{} already exists", dst.display()))
}
/// Renames `tmp` to `dst` only if `dst` does not exist (in any form). On
/// failure `tmp` is left for the caller to remove.
pub fn publish(tmp: &Path, dst: &Path) -> io::Result<()> {
let r = publish_inner(tmp, dst);
match r {
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(exists_error(dst)),
r => r,
}
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
fn publish_inner(tmp: &Path, dst: &Path) -> io::Result<()> {
use rustix::fs::{CWD, RenameFlags, renameat_with};
match renameat_with(CWD, tmp, CWD, dst, RenameFlags::NOREPLACE) {
Ok(()) => Ok(()),
Err(e) if e == rustix::io::Errno::INVAL || e == rustix::io::Errno::NOSYS || e == rustix::io::Errno::NOTSUP => {
link_publish(tmp, dst)
}
Err(e) => Err(e.into()),
}
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))))]
fn publish_inner(tmp: &Path, dst: &Path) -> io::Result<()> {
link_publish(tmp, dst)
}
#[cfg(not(any(unix, windows)))]
fn publish_inner(tmp: &Path, dst: &Path) -> io::Result<()> {
link_publish(tmp, dst)
}
/// `link` never replaces an existing name, so link-then-unlink is an
/// atomic no-clobber publish wherever hard links work.
#[cfg(not(windows))]
fn link_publish(tmp: &Path, dst: &Path) -> io::Result<()> {
std::fs::hard_link(tmp, dst)?;
// The data is safely at dst; a failure to drop the temporary name is
// reported but dst stays.
std::fs::remove_file(tmp)
}
#[cfg(windows)]
fn publish_inner(tmp: &Path, dst: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW};
let w = |p: &Path| -> Vec<u16> { p.as_os_str().encode_wide().chain(Some(0)).collect() };
let (a, b) = (w(tmp), w(dst));
// SAFETY: both arguments are NUL-terminated UTF-16 strings that outlive
// the call; MoveFileExW does not retain them.
let ok = unsafe { MoveFileExW(a.as_ptr(), b.as_ptr(), MOVEFILE_WRITE_THROUGH) };
if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) }
}
/// Sets permission bits as Go's `File.Chmod` does: all of `mode & 0o777`
/// on Unix, only the read-only attribute on Windows.
pub fn set_mode(f: &File, mode: u32) -> io::Result<()> {
#[cfg(unix)]
{
f.set_permissions(std::fs::Permissions::from_mode(mode & 0o777))
}
#[cfg(not(unix))]
{
let mut p = f.metadata()?.permissions();
p.set_readonly(mode & 0o200 == 0);
f.set_permissions(p)
}
}
/// A destination directory that files are created and published inside
/// without following symlinks below it.
pub struct Dir {
path: PathBuf,
#[cfg(unix)]
fd: rustix::fd::OwnedFd,
}
impl Dir {
/// Opens the chosen destination itself; a symlink here is followed,
/// because the user named it.
pub fn open(path: &Path) -> io::Result<Dir> {
#[cfg(unix)]
{
use rustix::fs::{CWD, Mode, OFlags, openat};
let fd = openat(CWD, path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty())?;
Ok(Dir {
path: path.to_path_buf(),
fd,
})
}
#[cfg(not(unix))]
{
if !std::fs::metadata(path)?.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
format!("{} is not a directory", path.display()),
));
}
Ok(Dir { path: path.to_path_buf() })
}
}
pub fn path(&self) -> &Path {
&self.path
}
/// Opens (creating with mode 0700 if missing) the subdirectory `name`,
/// refusing a symlink or anything that is not a directory.
pub fn subdir(&self, name: &OsStr) -> io::Result<Dir> {
check_component(name)?;
let path = self.path.join(name);
#[cfg(unix)]
{
use rustix::fs::{Mode, OFlags, mkdirat, openat};
let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let open = || openat(&self.fd, name, flags, Mode::empty());
let fd = match open() {
Ok(fd) => fd,
Err(rustix::io::Errno::NOENT) => {
match mkdirat(&self.fd, name, Mode::from_raw_mode(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
Err(e) => return Err(e.into()),
}
open().map_err(|e| refuse(e, &path))?
}
Err(e) => return Err(refuse(e, &path)),
};
Ok(Dir { path, fd })
}
#[cfg(not(unix))]
{
match std::fs::symlink_metadata(&path) {
Ok(m) if m.is_dir() && !m.file_type().is_symlink() && !is_reparse(&m) => {}
Ok(_) => {
return Err(io::Error::other(format!("{} is not a directory, or is a link", path.display())));
}
Err(e) if e.kind() == io::ErrorKind::NotFound => match create_private_dir(&path) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return self.subdir(name),
Err(e) => return Err(e),
},
Err(e) => return Err(e),
}
Ok(Dir { path })
}
}
/// Creates a private temporary file in this directory.
pub fn create_temp(&self, prefix: &str) -> io::Result<(File, std::ffi::OsString)> {
loop {
let name: std::ffi::OsString = format!("{prefix}{}", random_suffix()).into();
#[cfg(unix)]
let r = {
use rustix::fs::{Mode, OFlags, openat};
openat(
&self.fd,
&name,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_raw_mode(0o600),
)
.map(File::from)
.map_err(io::Error::from)
};
#[cfg(not(unix))]
let r = create_new_private(&self.path.join(&name));
match r {
Ok(f) => return Ok((f, name)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
}
/// Publishes `tmp` as `name` in this directory, never replacing
/// anything already there.
pub fn publish(&self, tmp: &OsStr, name: &OsStr) -> io::Result<()> {
check_component(name)?;
let dst = self.path.join(name);
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
{
use rustix::fs::{RenameFlags, renameat_with};
match renameat_with(&self.fd, tmp, &self.fd, name, RenameFlags::NOREPLACE) {
Ok(()) => return Ok(()),
Err(rustix::io::Errno::EXIST) => return Err(exists_error(&dst)),
Err(e) if e == rustix::io::Errno::INVAL || e == rustix::io::Errno::NOSYS || e == rustix::io::Errno::NOTSUP => {}
Err(e) => return Err(e.into()),
}
}
#[cfg(unix)]
{
use rustix::fs::{AtFlags, linkat, unlinkat};
match linkat(&self.fd, tmp, &self.fd, name, AtFlags::empty()) {
Ok(()) => {}
Err(rustix::io::Errno::EXIST) => return Err(exists_error(&dst)),
Err(e) => return Err(e.into()),
}
unlinkat(&self.fd, tmp, AtFlags::empty())?;
Ok(())
}
#[cfg(not(unix))]
{
publish(&self.path.join(tmp), &dst)
}
}
pub fn remove(&self, name: &OsStr) -> io::Result<()> {
#[cfg(unix)]
{
rustix::fs::unlinkat(&self.fd, name, rustix::fs::AtFlags::empty())?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::remove_file(self.path.join(name))
}
}
pub fn sync(&self) -> io::Result<()> {
#[cfg(unix)]
{
rustix::fs::fsync(&self.fd)?;
}
Ok(())
}
}
#[cfg(unix)]
fn refuse(e: rustix::io::Errno, path: &Path) -> io::Error {
if e == rustix::io::Errno::LOOP || e == rustix::io::Errno::NOTDIR {
io::Error::other(format!(
"{} is not a directory, or is a symlink; refusing to extract through it",
path.display()
))
} else {
e.into()
}
}
#[cfg(windows)]
fn is_reparse(m: &std::fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
m.file_attributes() & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(any(unix, windows)))]
fn is_reparse(_: &std::fs::Metadata) -> bool {
false
}
/// A single, plain path component: no separators, not "." or "..".
fn check_component(name: &OsStr) -> io::Result<()> {
let ok = !name.is_empty()
&& name != "."
&& name != ".."
&& Path::new(name).components().count() == 1
&& matches!(Path::new(name).components().next(), Some(std::path::Component::Normal(_)));
if ok {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid path component {:?}", name),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vault-fs-{tag}-{}-{}", std::process::id(), random_suffix()));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn publish_never_replaces() {
let d = tmpdir("pub");
let (_, t1) = create_temp(&d, ".t-").unwrap();
std::fs::write(&t1, b"one").unwrap();
publish(&t1, &d.join("out")).unwrap();
let (_, t2) = create_temp(&d, ".t-").unwrap();
std::fs::write(&t2, b"two").unwrap();
let e = publish(&t2, &d.join("out")).unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(d.join("out")).unwrap(), b"one");
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("nowhere"), d.join("dangling")).unwrap();
assert_eq!(publish(&t2, &d.join("dangling")).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert!(!d.join("nowhere").exists(), "followed a dangling symlink");
}
std::fs::remove_dir_all(d).unwrap();
}
/// Many threads race to publish the same name: exactly one wins and
/// every loser's file is left for it to clean up.
#[test]
fn concurrent_publishers() {
let d = tmpdir("race");
let wins = std::sync::atomic::AtomicUsize::new(0);
std::thread::scope(|s| {
for i in 0..16 {
let (d, wins) = (&d, &wins);
s.spawn(move || {
let dir = Dir::open(d).unwrap();
let (mut f, t) = dir.create_temp(".t-").unwrap();
std::io::Write::write_all(&mut f, format!("{i}").as_bytes()).unwrap();
match dir.publish(&t, OsStr::new("same")) {
Ok(()) => {
wins.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
Err(e) => {
assert_eq!(e.kind(), io::ErrorKind::AlreadyExists);
dir.remove(&t).unwrap();
}
}
});
}
});
assert_eq!(wins.into_inner(), 1);
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1);
std::fs::remove_dir_all(d).unwrap();
}
#[cfg(unix)]
#[test]
fn subdir_refuses_symlinks() {
let d = tmpdir("conf");
let outside = tmpdir("outside");
std::os::unix::fs::symlink(&outside, d.join("evil")).unwrap();
let dir = Dir::open(&d).unwrap();
assert!(dir.subdir(OsStr::new("evil")).is_err());
assert!(dir.subdir(OsStr::new("..")).is_err());
assert!(dir.subdir(OsStr::new("a/b")).is_err());
let sub = dir.subdir(OsStr::new("ok")).unwrap();
assert!(sub.path().ends_with("ok"));
std::fs::write(d.join("file"), b"").unwrap();
assert!(dir.subdir(OsStr::new("file")).is_err());
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
std::fs::remove_dir_all(d).unwrap();
std::fs::remove_dir_all(outside).unwrap();
}
#[test]
fn epoch_times() {
let d = tmpdir("epoch");
let f = d.join("f");
std::fs::write(&f, b"x").unwrap();
set_epoch(&f).unwrap();
set_epoch(&d).unwrap();
for p in [&f, &d] {
assert_eq!(std::fs::metadata(p).unwrap().modified().unwrap(), std::time::UNIX_EPOCH);
}
std::fs::remove_dir_all(d).unwrap();
}
}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The vault lock on `DIR/.lock`, interoperable with the Go version on the
//! same store:
//!
//! - Unix: classic POSIX record locks (`fcntl(F_SETLK/F_SETLKW)` over the
//! whole file), exactly what Go uses. Not `flock(2)`, which does not
//! exclude `fcntl` locks on Linux, and not OFD locks. The kernel drops
//! them when the process dies. POSIX record locks belong to the process,
//! so this process must hold the lock file open only once.
//! - Windows: `LockFileEx` on byte 0, as Go does; released on death.
//! - Elsewhere: an exclusive `O_EXCL` lock file. A crash leaves it behind;
//! after a minute of waiting the error names it for manual removal.
//!
//! Readers take the lock shared, writers exclusive. If it is held in a
//! conflicting mode, `waiting` is called once and the call blocks.
use std::fs::File;
use std::io;
use std::path::Path;
pub const LOCK_NAME: &str = ".lock";
/// A held lock; dropping it releases the lock.
pub struct Lock {
_file: Option<File>,
#[cfg(not(any(unix, windows)))]
remove: Option<std::path::PathBuf>,
}
impl Drop for Lock {
fn drop(&mut self) {
#[cfg(not(any(unix, windows)))]
if let Some(p) = self.remove.take() {
let _ = std::fs::remove_file(p);
}
}
}
#[cfg(unix)]
pub fn acquire(path: &Path, exclusive: bool, waiting: &mut dyn FnMut()) -> io::Result<Lock> {
use rustix::fs::{FlockOperation, fcntl_lock};
use std::os::unix::fs::OpenOptionsExt;
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(path)?;
let (try_op, wait_op) = if exclusive {
(FlockOperation::NonBlockingLockExclusive, FlockOperation::LockExclusive)
} else {
(FlockOperation::NonBlockingLockShared, FlockOperation::LockShared)
};
match fcntl_lock(&f, try_op) {
Ok(()) => {}
Err(e) if e == rustix::io::Errno::AGAIN || e == rustix::io::Errno::ACCESS => {
waiting();
loop {
match fcntl_lock(&f, wait_op) {
Ok(()) => break,
Err(rustix::io::Errno::INTR) => continue,
Err(e) => return Err(e.into()),
}
}
}
Err(e) => return Err(e.into()),
}
Ok(Lock { _file: Some(f) })
}
#[cfg(windows)]
pub fn acquire(path: &Path, exclusive: bool, waiting: &mut dyn FnMut()) -> io::Result<Lock> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx};
use windows_sys::Win32::System::IO::OVERLAPPED;
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
let flags = if exclusive { LOCKFILE_EXCLUSIVE_LOCK } else { 0 };
let lock = |flags: u32| -> io::Result<()> {
// SAFETY: the handle is valid for the life of `f`; the OVERLAPPED is
// zeroed (offset 0) and outlives this synchronous call.
let mut ov: OVERLAPPED = unsafe { std::mem::zeroed() };
let ok = unsafe { LockFileEx(f.as_raw_handle() as _, flags, 0, 1, 0, &mut ov) };
if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) }
};
match lock(flags | LOCKFILE_FAIL_IMMEDIATELY) {
Ok(()) => {}
Err(e) if e.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) => {
waiting();
lock(flags)?;
}
Err(e) => return Err(e),
}
Ok(Lock { _file: Some(f) })
}
#[cfg(not(any(unix, windows)))]
pub fn acquire(path: &Path, _exclusive: bool, waiting: &mut dyn FnMut()) -> io::Result<Lock> {
for i in 0.. {
match super::fs::create_new_private(path) {
Ok(_) => {
return Ok(Lock {
_file: None,
remove: Some(path.to_path_buf()),
});
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
if i == 0 {
waiting();
}
if i == 600 {
return Err(io::Error::other(format!(
"vault still locked after a minute; if no other vault process is running, remove {}",
path.display()
)));
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
unreachable!()
}
/// Locks the vault at `root`. A shared (reader) lock on a read-only
/// filesystem, where nothing can be writing, proceeds without a lock; every
/// other failure is reported.
pub fn lock_vault(root: &Path, exclusive: bool) -> Result<Lock, String> {
let path = root.join(LOCK_NAME);
let mut waiting = || eprintln!("waiting for another vault process to finish");
match acquire(&path, exclusive, &mut waiting) {
Ok(l) => Ok(l),
Err(e) if !exclusive && e.kind() == io::ErrorKind::ReadOnlyFilesystem => Ok(Lock {
_file: None,
#[cfg(not(any(unix, windows)))]
remove: None,
}),
Err(e) => Err(format!("cannot lock the vault: {}", crate::util::io_msg("lock", &path, &e))),
}
}
+134
View File
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Locked, non-dumpable memory for the agent's secret.
//!
//! [`LockedBuf`] is an anonymous private mapping of whole pages: it never
//! moves or grows, it is `mlock`ed (kept out of swap) before any secret is
//! written into it and, on Linux, excluded from core dumps with
//! `MADV_DONTDUMP`. On drop it is cleared with volatile writes, unlocked and
//! unmapped, in that order. Every system call result is checked; a failure
//! is an error, never a silent downgrade.
//!
//! [`harden_process`] sets the core-file size limit to zero and, on Linux,
//! `PR_SET_DUMPABLE` to 0, which also stops same-user `ptrace` and
//! `/proc/PID/mem` access. Other Unix systems get the rlimit only. Windows
//! is not supported by the agent (see SECURITY.md).
use std::io;
#[cfg(unix)]
pub struct LockedBuf {
ptr: std::ptr::NonNull<u8>,
len: usize,
map_len: usize,
}
// SAFETY: the buffer is uniquely owned; no aliasing pointers escape except
// through borrows of self.
#[cfg(unix)]
unsafe impl Send for LockedBuf {}
#[cfg(unix)]
impl LockedBuf {
pub fn new(len: usize) -> io::Result<LockedBuf> {
use rustix::mm::{MapFlags, ProtFlags, mlock, mmap_anonymous, munmap};
let page = rustix::param::page_size();
let map_len = len.div_ceil(page).max(1) * page;
// SAFETY: a fresh anonymous private mapping at an address of the
// kernel's choosing aliases nothing.
let p = unsafe { mmap_anonymous(std::ptr::null_mut(), map_len, ProtFlags::READ | ProtFlags::WRITE, MapFlags::PRIVATE)? };
let unmap = |p| {
// SAFETY: p/map_len is exactly the mapping created above.
let _ = unsafe { munmap(p, map_len) };
};
// SAFETY: the range is the mapping just created.
if let Err(e) = unsafe { mlock(p, map_len) } {
unmap(p);
return Err(io::Error::new(e.kind(), format!("cannot lock agent memory (mlock): {e}")));
}
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use rustix::mm::{Advice, madvise};
// SAFETY: the range is the mapping just created.
if let Err(e) = unsafe { madvise(p, map_len, Advice::LinuxDontDump) } {
// SAFETY: as above.
let _ = unsafe { rustix::mm::munlock(p, map_len) };
unmap(p);
return Err(io::Error::new(
e.kind(),
format!("cannot exclude agent memory from core dumps: {e}"),
));
}
}
let ptr = std::ptr::NonNull::new(p.cast::<u8>()).expect("mmap returned null");
Ok(LockedBuf { ptr, len, map_len })
}
pub fn as_slice(&self) -> &[u8] {
// SAFETY: ptr is valid for map_len >= len bytes for the life of self.
unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
}
pub fn as_mut_slice(&mut self) -> &mut [u8] {
// SAFETY: as above, and &mut self guarantees exclusivity.
unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len) }
}
pub fn clear(&mut self) {
// SAFETY: as above, over the whole mapping.
let all = unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.map_len) };
zeroize::Zeroize::zeroize(all);
}
}
#[cfg(unix)]
impl Drop for LockedBuf {
fn drop(&mut self) {
self.clear();
let p = self.ptr.as_ptr().cast();
// SAFETY: p/map_len is the mapping owned by self, not used again.
unsafe {
let _ = rustix::mm::munlock(p, self.map_len);
let _ = rustix::mm::munmap(p, self.map_len);
}
}
}
/// No core dumps; on Linux, not dumpable (and so not ptrace-able).
#[cfg(unix)]
pub fn harden_process() -> io::Result<()> {
use rustix::process::{Resource, Rlimit, setrlimit};
setrlimit(
Resource::Core,
Rlimit {
current: Some(0),
maximum: Some(0),
},
)
.map_err(|e| io::Error::new(e.kind(), format!("cannot disable core dumps: {e}")))?;
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use rustix::process::{DumpableBehavior, dumpable_behavior, set_dumpable_behavior};
set_dumpable_behavior(DumpableBehavior::NotDumpable)
.map_err(|e| io::Error::new(e.kind(), format!("cannot make the agent undumpable: {e}")))?;
if dumpable_behavior()? != DumpableBehavior::NotDumpable {
return Err(io::Error::other("the agent is still dumpable"));
}
}
Ok(())
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
#[test]
fn locked_buf() {
let mut b = LockedBuf::new(34).unwrap();
assert_eq!(b.as_slice().len(), 34);
b.as_mut_slice()[0] = 7;
b.clear();
assert!(b.as_slice().iter().all(|&x| x == 0));
}
}
+9
View File
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Operating-system specifics. All `unsafe` code outside third-party crates
//! lives in this module, in small documented blocks.
pub mod fs;
pub mod lock;
pub mod memory;
pub mod term;
+203
View File
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Terminal prompts, as the Go `internal/term` package and
//! `golang.org/x/term.ReadPassword` behave: the prompt and input use the
//! controlling terminal (`/dev/tty`, else a terminal on standard input;
//! `CONIN$`/`CONOUT$` on Windows), echo is off while a secret is typed,
//! the line is cleared afterwards, and the bytes typed are kept exactly
//! (a backspace byte deletes the previous byte; `\n` ends the line on Unix,
//! `\r` on Windows).
use crate::secret::SecretVec;
use std::fs::File;
use std::io::{self, Read, Write};
/// Longest secret accepted from the terminal; the buffer never grows.
const MAX_SECRET: usize = 64 << 10;
fn open_terminal() -> io::Result<(File, File)> {
#[cfg(windows)]
{
let i = std::fs::OpenOptions::new().read(true).write(true).open("CONIN$")?;
let o = std::fs::OpenOptions::new().write(true).open("CONOUT$")?;
Ok((i, o))
}
#[cfg(not(windows))]
{
match std::fs::OpenOptions::new().read(true).write(true).open("/dev/tty") {
Ok(t) => {
let o = t.try_clone()?;
Ok((t, o))
}
Err(e) => {
#[cfg(unix)]
if rustix::termios::isatty(rustix::stdio::stdin()) {
let i = File::from(rustix::io::dup(rustix::stdio::stdin())?);
let o = i.try_clone()?;
return Ok((i, o));
}
Err(io::Error::new(
e.kind(),
format!(
"standard input is not a terminal, and /dev/tty is not available: {}",
crate::util::io_reason(&e)
),
))
}
}
}
}
fn clear_line(out: &mut File) {
let _ = out.write_all(b"\r\n");
#[cfg(windows)]
let ok = enable_vt(out);
#[cfg(not(windows))]
let ok = true;
if ok {
let _ = out.write_all(b"\x1b[F\x1b[K");
}
}
#[cfg(windows)]
fn enable_vt(out: &File) -> bool {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::Console::{
ENABLE_PROCESSED_OUTPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, GetConsoleMode, SetConsoleMode,
};
let h = out.as_raw_handle() as _;
let mut mode = 0;
// SAFETY: h is a live console handle owned by `out`.
unsafe {
GetConsoleMode(h, &mut mode) != 0 && SetConsoleMode(h, mode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0
}
}
/// x/term's readPasswordLine.
fn read_line_bytes(r: &mut impl Read) -> io::Result<SecretVec> {
let mut ret = SecretVec::with_capacity(MAX_SECRET);
let mut b = [0u8; 1];
loop {
match r.read(&mut b) {
Ok(0) => {
if !ret.is_empty() {
return Ok(ret);
}
return Err(io::ErrorKind::UnexpectedEof.into());
}
Ok(_) => match b[0] {
0x08 => {
let n = ret.len().saturating_sub(1);
ret.resize_within_capacity(n);
}
b'\n' if cfg!(not(windows)) => return Ok(ret),
b'\r' if cfg!(windows) => return Ok(ret),
b'\n' | b'\r' => {}
c => {
if ret.len() == MAX_SECRET {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "input too long"));
}
ret.extend_within_capacity(&[c]);
}
},
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
}
#[cfg(unix)]
fn without_echo<T>(input: &mut File, f: impl FnOnce(&mut File) -> io::Result<T>) -> io::Result<T> {
use rustix::termios::{InputModes, LocalModes, OptionalActions, tcgetattr, tcsetattr};
let old = tcgetattr(&*input)?;
let mut new = old.clone();
new.local_modes.remove(LocalModes::ECHO);
new.local_modes.insert(LocalModes::ICANON | LocalModes::ISIG);
new.input_modes.insert(InputModes::ICRNL);
tcsetattr(&*input, OptionalActions::Now, &new)?;
let r = f(input);
tcsetattr(&*input, OptionalActions::Now, &old)?;
r
}
#[cfg(windows)]
fn without_echo<T>(input: &mut File, f: impl FnOnce(&mut File) -> io::Result<T>) -> io::Result<T> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::Console::{
ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, ENABLE_PROCESSED_OUTPUT, GetConsoleMode, SetConsoleMode,
};
let h = input.as_raw_handle() as _;
let mut old = 0;
// SAFETY: h is a live console handle owned by `input`.
if unsafe { GetConsoleMode(h, &mut old) } == 0 {
return Err(io::Error::last_os_error());
}
let new = (old & !ENABLE_ECHO_INPUT) | ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_OUTPUT;
// SAFETY: as above.
if unsafe { SetConsoleMode(h, new) } == 0 {
return Err(io::Error::last_os_error());
}
let r = f(input);
// SAFETY: as above.
unsafe { SetConsoleMode(h, old) };
r
}
#[cfg(not(any(unix, windows)))]
fn without_echo<T>(_: &mut File, _: impl FnOnce(&mut File) -> io::Result<T>) -> io::Result<T> {
Err(io::Error::new(io::ErrorKind::Unsupported, "no terminal support on this platform"))
}
/// Reads a secret from the terminal with echo off. The prompt is erased.
pub fn read_secret(prompt: &str) -> io::Result<SecretVec> {
let (mut input, mut out) = open_terminal()?;
write!(out, "{prompt} ")?;
let r = without_echo(&mut input, read_line_bytes);
clear_line(&mut out);
r
}
/// Asks a yes/no question on the terminal; only "y" or "yes" is yes.
pub fn confirm(prompt: &str) -> io::Result<bool> {
let (mut input, mut out) = open_terminal()?;
write!(out, "{prompt} ")?;
let mut line = Vec::new();
let mut b = [0u8; 1];
loop {
match input.read(&mut b) {
Ok(0) => {
if line.is_empty() {
return Err(io::ErrorKind::UnexpectedEof.into());
}
break;
}
Ok(_) => {
line.push(b[0]);
if b[0] == b'\n' || line.len() > 4096 {
break;
}
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
let answer = String::from_utf8_lossy(&line).trim().to_lowercase();
Ok(answer == "y" || answer == "yes")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_line_editing() {
let got = read_line_bytes(&mut &b"ab\x08c\rd\nrest"[..]).unwrap();
if cfg!(windows) {
assert_eq!(got.expose(), b"ac");
} else {
assert_eq!(got.expose(), b"acd");
}
assert_eq!(read_line_bytes(&mut &b"tail"[..]).unwrap().expose(), b"tail");
assert!(read_line_bytes(&mut &b""[..]).is_err());
}
}
+86
View File
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Randomness.
//!
//! Production code only ever uses the operating system's CSPRNG. Unit tests
//! can substitute a seeded generator so that sequential and parallel paths
//! can be compared byte for byte; that variant exists only under
//! `cfg(test)` and cannot be selected by a user or a build of the binary.
/// Where a computation draws its randomness from. Each independent unit of
/// work asks for its own [`Rng`] by label, so seeded test runs stay
/// deterministic however work is scheduled across threads.
#[derive(Clone)]
pub enum Entropy {
Os,
#[cfg(test)]
Seeded([u8; 32]),
}
pub enum Rng {
Os,
#[cfg(test)]
Seeded(Box<dyn sha3::digest::XofReader + Send>),
}
impl Entropy {
pub fn rng(&self, _label: &[u8]) -> Rng {
match self {
Entropy::Os => Rng::Os,
#[cfg(test)]
Entropy::Seeded(seed) => {
use sha3::digest::{ExtendableOutput, Update};
let mut h = sha3::Shake256::default();
h.update(b"vault test rng");
h.update(seed);
h.update(&(_label.len() as u64).to_be_bytes());
h.update(_label);
Rng::Seeded(Box::new(h.finalize_xof()))
}
}
}
}
impl Rng {
pub fn fill(&mut self, out: &mut [u8]) {
match self {
// A failing system CSPRNG is not something to continue past,
// exactly as Go's crypto/rand treats it.
Rng::Os => getrandom::fill(out).expect("vault: the operating system's random number generator failed"),
#[cfg(test)]
Rng::Seeded(r) => r.read(out),
}
}
pub fn array<const N: usize>(&mut self) -> [u8; N] {
let mut b = [0u8; N];
self.fill(&mut b);
b
}
pub fn u64(&mut self) -> u64 {
u64::from_le_bytes(self.array())
}
/// A uniform float in [0, 1).
pub fn f64(&mut self) -> f64 {
(self.u64() >> 11) as f64 / (1u64 << 53) as f64
}
/// A uniform integer in [0, n), n > 0, without modulo bias.
pub fn below(&mut self, n: u64) -> u64 {
assert!(n > 0);
let zone = u64::MAX - (u64::MAX % n);
loop {
let v = self.u64();
if v < zone {
return v % n;
}
}
}
}
/// Shorthand for operating-system randomness.
pub fn os() -> Rng {
Rng::Os
}
+154
View File
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Owned secret buffers.
//!
//! These types are deliberately neither `Clone`, `Copy`, `Debug` nor
//! serialisable, so a secret cannot be duplicated or printed by accident.
//! Each is heap-allocated once at its final size and cleared with `zeroize`
//! when dropped, including during unwinding. Clearing shortens the lifetime
//! of the bytes these types own; it cannot reach copies the compiler made in
//! registers or on the stack, copies held inside third-party crates' key
//! objects, or memory already swapped out, and an abort or kill skips it.
use zeroize::Zeroize;
/// A fixed-size secret, boxed so moving it never copies the bytes.
pub struct SecretArray<const N: usize>(Box<[u8; N]>);
impl<const N: usize> SecretArray<N> {
pub fn zero() -> Self {
SecretArray(Box::new([0u8; N]))
}
pub fn from_slice(b: &[u8]) -> Option<Self> {
let mut s = Self::zero();
if b.len() != N {
return None;
}
s.0.copy_from_slice(b);
Some(s)
}
pub fn expose(&self) -> &[u8; N] {
&self.0
}
pub fn expose_mut(&mut self) -> &mut [u8; N] {
&mut self.0
}
}
impl<const N: usize> Drop for SecretArray<N> {
fn drop(&mut self) {
self.0.zeroize();
}
}
/// A variable-length secret such as a passphrase or decrypted plaintext.
/// The capacity is fixed at creation; operations that could reallocate (and
/// leave an uncleared copy behind) are not offered.
pub struct SecretVec(Vec<u8>);
impl SecretVec {
pub fn with_capacity(n: usize) -> Self {
SecretVec(Vec::with_capacity(n))
}
/// Takes ownership of `v`, which from now on is cleared on drop.
pub fn from_vec(v: Vec<u8>) -> Self {
SecretVec(v)
}
pub fn zeroed(n: usize) -> Self {
SecretVec(vec![0u8; n])
}
pub fn expose(&self) -> &[u8] {
&self.0
}
pub fn expose_mut(&mut self) -> &mut [u8] {
&mut self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn capacity(&self) -> usize {
self.0.capacity()
}
/// Appends without reallocating; panics if `b` does not fit.
pub fn extend_within_capacity(&mut self, b: &[u8]) {
assert!(self.0.len() + b.len() <= self.0.capacity(), "secret buffer would reallocate");
self.0.extend_from_slice(b);
}
/// Resizes within the existing capacity, zero-filling any growth;
/// panics rather than reallocate.
pub fn resize_within_capacity(&mut self, n: usize) {
assert!(n <= self.0.capacity(), "secret buffer would reallocate");
if n < self.0.len() {
self.0[n..].zeroize();
}
self.0.resize(n, 0);
}
/// Clears the contents for reuse, keeping the allocation.
pub fn clear(&mut self) {
self.0.zeroize();
}
}
impl Drop for SecretVec {
fn drop(&mut self) {
// Zeroize the whole capacity, not just the live length.
self.0.zeroize();
}
}
impl PartialEq<[u8]> for SecretVec {
fn eq(&self, other: &[u8]) -> bool {
use subtle_eq::ct_eq;
ct_eq(&self.0, other)
}
}
pub(crate) mod subtle_eq {
/// Constant-time equality for equal-length inputs; lengths are public.
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut d = 0u8;
for (x, y) in a.iter().zip(b) {
d |= x ^ y;
}
core::hint::black_box(d) == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_vec_never_reallocates() {
let mut s = SecretVec::with_capacity(4);
s.extend_within_capacity(b"ab");
s.resize_within_capacity(4);
assert_eq!(s.expose(), b"ab\0\0");
assert!(std::panic::catch_unwind(move || s.extend_within_capacity(b"x")).is_err());
}
#[test]
fn secret_array_length_checked() {
assert!(SecretArray::<4>::from_slice(b"abc").is_none());
assert_eq!(SecretArray::<3>::from_slice(b"abc").unwrap().expose(), b"abc");
}
}
+539
View File
@@ -0,0 +1,539 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Small helpers that reproduce Go standard-library behaviour the CLI
//! exposes: size and duration parsing and printing, `path.Clean`, RFC 3339
//! timestamps, and Go-style I/O error messages.
use std::path::Path;
use std::time::Duration;
/// `op path: reason`, with the OS reason lower-cased and without Rust's
/// "(os error N)" suffix, as Go prints a `*PathError`.
pub fn io_msg(op: &str, path: &Path, e: &std::io::Error) -> String {
format!("{op} {}: {}", path.display(), io_reason(e))
}
pub fn io_reason(e: &std::io::Error) -> String {
let s = e.to_string();
let s = match s.find(" (os error ") {
Some(i) => s[..i].to_string(),
None => s,
};
let mut c = s.chars();
match c.next() {
Some(f) => f.to_lowercase().chain(c).collect(),
None => s,
}
}
/// `humanSize`: bytes below 1 KiB, otherwise one decimal and a binary unit.
pub fn human_size(n: u64) -> String {
const UNIT: u64 = 1024;
if n < UNIT {
return format!("{n} B");
}
let (mut div, mut exp) = (UNIT, 0usize);
let mut m = n / UNIT;
while m >= UNIT {
div *= UNIT;
exp += 1;
m /= UNIT;
}
format!("{:.1} {}iB", n as f64 / div as f64, &"KMGTPE"[exp..exp + 1])
}
/// `parseSize`: a byte count with optional K, M, G or T (powers of 1024)
/// and an optional trailing B, at most 1 TiB.
pub fn parse_size(s: &str, limit: u64) -> Result<u64, String> {
let err = || format!("invalid size {s:?}");
let upper = s.to_uppercase();
let mut num = upper.strip_suffix('B').unwrap_or(&upper).to_string();
let mut shift = 0u32;
for (i, unit) in "KMGT".chars().enumerate() {
if let Some(rest) = num.strip_suffix(unit) {
num = rest.to_string();
shift = 10 * (i as u32 + 1);
}
}
let n = go_parse_int(&num).ok_or_else(err)?;
if n < 0 || n as u64 > limit >> shift {
return Err(err());
}
Ok((n as u64) << shift)
}
/// Go's `strconv.ParseInt(s, 10, 64)`: optional sign, decimal digits.
fn go_parse_int(s: &str) -> Option<i64> {
let (neg, digits) = match s.as_bytes().first() {
Some(b'+') => (false, &s[1..]),
Some(b'-') => (true, &s[1..]),
_ => (false, s),
};
if digits.is_empty() || !digits.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
let v: i128 = digits.parse().ok()?;
let v = if neg { -v } else { v };
i64::try_from(v).ok()
}
/// Go's `time.ParseDuration`, in nanoseconds.
pub fn parse_duration(s: &str) -> Result<i64, String> {
let err = || format!("time: invalid duration {:?}", s);
let orig = s;
let mut s = s;
let mut neg = false;
if let Some(c @ ('-' | '+')) = s.chars().next() {
neg = c == '-';
s = &s[1..];
}
if s == "0" {
return Ok(0);
}
if s.is_empty() {
return Err(err());
}
let mut total: u128 = 0;
while !s.is_empty() {
let int_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
let int_part = &s[..int_end];
s = &s[int_end..];
let mut frac_part = "";
if let Some(rest) = s.strip_prefix('.') {
let fe = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
frac_part = &rest[..fe];
s = &rest[fe..];
if int_part.is_empty() && frac_part.is_empty() {
return Err(err());
}
} else if int_part.is_empty() {
return Err(err());
}
let ue = s.find(|c: char| c == '.' || c.is_ascii_digit()).unwrap_or(s.len());
let unit = &s[..ue];
s = &s[ue..];
let scale: u128 = match unit {
"ns" => 1,
"us" | "µs" | "μs" => 1_000,
"ms" => 1_000_000,
"s" => 1_000_000_000,
"m" => 60_000_000_000,
"h" => 3_600_000_000_000,
"" => return Err(format!("time: missing unit in duration {orig:?}")),
_ => return Err(format!("time: unknown unit {unit:?} in duration {orig:?}")),
};
let v: u128 = if int_part.is_empty() {
0
} else {
int_part.parse().map_err(|_| err())?
};
let mut ns = v.checked_mul(scale).ok_or_else(err)?;
if !frac_part.is_empty() {
// Go truncates the fraction to the unit's precision.
let mut f: u128 = 0;
let mut div: u128 = 1;
for c in frac_part.bytes() {
if div > 1_000_000_000_000_000_000 {
break;
}
f = f * 10 + (c - b'0') as u128;
div *= 10;
}
ns += f * scale / div;
}
total = total.checked_add(ns).ok_or_else(err)?;
if total > i64::MAX as u128 + neg as u128 {
return Err(err());
}
}
Ok(if neg { (total as i128).wrapping_neg() as i64 } else { total as i64 })
}
/// Go's `time.Duration.String` for non-negative durations.
pub fn format_duration(d: Duration) -> String {
let ns = d.as_nanos();
if ns == 0 {
return "0s".into();
}
if ns < 1_000_000_000 {
let (unit, div, prec) = if ns < 1_000 {
("ns", 1u128, 0)
} else if ns < 1_000_000 {
("µs", 1_000, 3)
} else {
("ms", 1_000_000, 6)
};
return format!("{}{unit}", frac(ns, div, prec));
}
let mut out = String::new();
let h = ns / 3_600_000_000_000;
let m = (ns / 60_000_000_000) % 60;
let s_ns = ns % 60_000_000_000;
if h > 0 {
out.push_str(&format!("{h}h"));
}
if h > 0 || m > 0 {
out.push_str(&format!("{m}m"));
}
out.push_str(&format!("{}s", frac(s_ns, 1_000_000_000, 9)));
out
}
fn frac(v: u128, div: u128, prec: usize) -> String {
let whole = v / div;
let f = v % div;
if f == 0 || prec == 0 {
return whole.to_string();
}
let mut digits = format!("{f:0prec$}");
while digits.ends_with('0') {
digits.pop();
}
format!("{whole}.{digits}")
}
/// Go's `path.Clean` for slash-separated paths.
pub fn clean(p: &str) -> String {
if p.is_empty() {
return ".".into();
}
let rooted = p.starts_with('/');
let mut parts: Vec<&str> = Vec::new();
for seg in p.split('/') {
match seg {
"" | "." => {}
".." => {
if parts.last().is_some_and(|l| *l != "..") {
parts.pop();
} else if !rooted {
parts.push("..");
}
}
s => parts.push(s),
}
}
let body = parts.join("/");
match (rooted, body.is_empty()) {
(true, _) => format!("/{body}"),
(false, true) => ".".into(),
(false, false) => body,
}
}
/// Go's `path.Base`.
pub fn base(p: &str) -> String {
if p.is_empty() {
return ".".into();
}
let t = p.trim_end_matches('/');
if t.is_empty() {
return "/".into();
}
t.rsplit('/').next().unwrap_or(t).to_string()
}
/// Go's `path.Join`: join non-empty elements with '/', then clean.
pub fn join(parts: &[&str]) -> String {
let nonempty: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
if nonempty.is_empty() {
return String::new();
}
clean(&nonempty.join("/"))
}
/// A UTC instant with nanosecond precision, as stored in records.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct Instant {
pub secs: i64,
pub nanos: u32,
}
impl Instant {
pub fn now() -> Self {
Self::from_system(std::time::SystemTime::now())
}
pub fn from_system(t: std::time::SystemTime) -> Self {
match t.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => Instant {
secs: d.as_secs() as i64,
nanos: d.subsec_nanos(),
},
Err(e) => {
let d = e.duration();
let (s, n) = (d.as_secs() as i64, d.subsec_nanos());
if n == 0 {
Instant { secs: -s, nanos: 0 }
} else {
Instant {
secs: -s - 1,
nanos: 1_000_000_000 - n,
}
}
}
}
}
pub fn to_system(self) -> std::time::SystemTime {
if self.secs >= 0 {
std::time::UNIX_EPOCH + Duration::new(self.secs as u64, self.nanos)
} else {
std::time::UNIX_EPOCH - Duration::new(self.secs.unsigned_abs(), 0) + Duration::new(0, self.nanos)
}
}
fn timestamp(self) -> Option<jiff::Timestamp> {
// jiff wants both parts to share a sign.
if self.secs < 0 && self.nanos > 0 {
jiff::Timestamp::new(self.secs + 1, self.nanos as i32 - 1_000_000_000).ok()
} else {
jiff::Timestamp::new(self.secs, self.nanos as i32).ok()
}
}
/// RFC 3339 with up to nine fractional digits, trailing zeros removed,
/// and "Z": Go's `time.RFC3339Nano` for a UTC time. None outside years
/// 0 to 9999, which Go's JSON encoding refuses too.
pub fn to_rfc3339(self) -> Option<String> {
let days = self.secs.div_euclid(86_400);
let sod = self.secs.rem_euclid(86_400);
let (y, mo, d) = civil_from_days(days);
if !(0..=9999).contains(&y) {
return None;
}
let mut s = format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}", sod / 3600, sod / 60 % 60, sod % 60);
if self.nanos != 0 {
let mut f = format!("{:09}", self.nanos);
while f.ends_with('0') {
f.pop();
}
s.push('.');
s.push_str(&f);
}
s.push('Z');
Some(s)
}
/// Strict RFC 3339 (`YYYY-MM-DDTHH:MM:SS[.frac](Z|+HH:MM|-HH:MM)`), as
/// Go's `time.Time.UnmarshalJSON` accepts it.
pub fn parse_rfc3339(s: &str) -> Option<Self> {
let b = s.as_bytes();
let num = |r: std::ops::Range<usize>| -> Option<i64> {
let t = b.get(r)?;
if t.iter().all(u8::is_ascii_digit) {
std::str::from_utf8(t).ok()?.parse().ok()
} else {
None
}
};
if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
return None;
}
let (y, mo, d, h, mi, se) = (num(0..4)?, num(5..7)?, num(8..10)?, num(11..13)?, num(14..16)?, num(17..19)?);
if !(1..=12).contains(&mo) || d < 1 || d > days_in_month(y, mo) || h > 23 || mi > 59 || se > 59 {
return None;
}
let mut i = 19;
let mut nanos = 0u32;
if b[i] == b'.' {
let start = i + 1;
i = start;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let digits = &s[start..i];
if digits.is_empty() || digits.len() > 9 {
return None;
}
nanos = format!("{digits:0<9}").parse().ok()?;
}
let offset = match &b[i..] {
b"Z" => 0,
[sign @ (b'+' | b'-'), _, _, b':', _, _] => {
let oh = num(i + 1..i + 3)?;
let om = num(i + 4..i + 6)?;
if oh > 23 || om > 59 {
return None;
}
let o = oh * 3600 + om * 60;
if *sign == b'-' { -o } else { o }
}
_ => return None,
};
let secs = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset;
Some(Instant { secs, nanos })
}
/// "2006-01-02 15:04" in the local time zone, as `ls` and `list` show.
pub fn local_minute(self) -> String {
match self.timestamp() {
Some(ts) => {
let z = ts.to_zoned(jiff::tz::TimeZone::system());
format!("{:04}-{:02}-{:02} {:02}:{:02}", z.year(), z.month(), z.day(), z.hour(), z.minute())
}
None => "????-??-?? ??:??".into(),
}
}
/// "15:04:05" local, for unlock's message.
pub fn local_clock(self) -> String {
match self.timestamp() {
Some(ts) => {
let z = ts.to_zoned(jiff::tz::TimeZone::system());
format!("{:02}:{:02}:{:02}", z.hour(), z.minute(), z.second())
}
None => "??:??:??".into(),
}
}
}
fn days_in_month(y: i64, m: i64) -> i64 {
match m {
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
/// Days since 1970-01-01 of a proleptic Gregorian date (Hinnant).
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(yoe + era * 400 + (m <= 2) as i64, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn v() -> Value {
serde_json::from_str(include_str!("../tests/vectors/store.json")).unwrap()
}
#[test]
fn sizes_match_go() {
let v = v();
for (input, want) in v["parse_size"].as_object().unwrap() {
let got = parse_size(input, 1 << 40).ok();
assert_eq!(got, want.as_u64(), "parseSize({input:?})");
}
for (n, want) in v["human_size"].as_object().unwrap() {
assert_eq!(human_size(n.parse().unwrap()), want.as_str().unwrap(), "humanSize({n})");
}
}
#[test]
fn durations_match_go() {
for (input, want) in v()["durations"].as_object().unwrap() {
let got = parse_duration(input).ok();
match want {
Value::Null => assert_eq!(got, None, "ParseDuration({input:?})"),
w => {
let ns = w["ns"].as_i64().unwrap();
assert_eq!(got, Some(ns), "ParseDuration({input:?})");
if ns >= 0 {
assert_eq!(format_duration(Duration::from_nanos(ns as u64)), w["string"].as_str().unwrap());
}
}
}
}
}
#[test]
fn clean_matches_go() {
for (input, want) in v()["path_clean"].as_object().unwrap() {
assert_eq!(clean(input), want.as_str().unwrap(), "Clean({input:?})");
}
assert_eq!(base("a/b/"), "b");
assert_eq!(base("/"), "/");
assert_eq!(join(&["", "a", "b/../c"]), "a/c");
}
#[test]
fn rfc3339() {
for (s, n) in [
(0, 0),
(1700000000, 123456789),
(-1, 500000000),
(253402300799, 999999999),
(1234567890, 120000000),
] {
let t = Instant { secs: s, nanos: n };
let text = t.to_rfc3339().unwrap();
assert_eq!(Instant::parse_rfc3339(&text), Some(t), "{text}");
}
assert_eq!(
Instant {
secs: 1234567890,
nanos: 120000000
}
.to_rfc3339()
.unwrap(),
"2009-02-13T23:31:30.12Z"
);
assert_eq!(Instant::parse_rfc3339("2009-02-13T23:31:30+01:00").unwrap().secs, 1234567890 - 3600);
for bad in [
"2009-02-13 23:31:30Z",
"2009-02-13T23:31:30",
"2009-02-13T23:31:30.Z",
"2009-13-13T23:31:30Z",
"2009-02-13T23:31:30.1234567890Z",
] {
assert_eq!(Instant::parse_rfc3339(bad), None, "{bad}");
}
assert_eq!(
Instant {
secs: 253402300800,
nanos: 0
}
.to_rfc3339(),
None
);
assert_eq!(
Instant {
secs: -62167219200,
nanos: 0
}
.to_rfc3339()
.unwrap(),
"0000-01-01T00:00:00Z"
);
assert_eq!(
Instant {
secs: -62167219201,
nanos: 0
}
.to_rfc3339(),
None
);
assert_eq!(Instant::parse_rfc3339("2023-02-29T00:00:00Z"), None);
assert!(Instant::parse_rfc3339("2024-02-29T00:00:00Z").is_some());
let neg = Instant::from_system(std::time::UNIX_EPOCH - Duration::new(0, 1));
assert_eq!(
neg,
Instant {
secs: -1,
nanos: 999_999_999
}
);
assert_eq!(Instant::from_system(neg.to_system()), neg);
}
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
pub fn root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
/// Whether the Go toolchain is available for oracle-backed tests. When it
/// is not, those tests print SKIPPED and return; CI installs Go so they
/// always run there (VAULT_REQUIRE_ORACLE=1 turns a skip into a failure).
pub fn have_go() -> bool {
let ok = Command::new("go").arg("version").output().is_ok_and(|o| o.status.success());
if !ok && std::env::var_os("VAULT_REQUIRE_ORACLE").is_some() {
panic!("VAULT_REQUIRE_ORACLE is set but go is not available");
}
ok
}
pub fn skip(why: &str) {
eprintln!("SKIPPED: {why}");
}
/// Builds the Go oracle CLI once per test process ($VAULT_GO_ORACLE wins).
pub fn go_oracle() -> Option<PathBuf> {
static BIN: OnceLock<Option<PathBuf>> = OnceLock::new();
BIN.get_or_init(|| {
if let Some(p) = std::env::var_os("VAULT_GO_ORACLE") {
return Some(PathBuf::from(p));
}
if !have_go() {
return None;
}
let out = root().join("target").join("vault-go-oracle");
let st = Command::new("go")
.args(["build", "-o"])
.arg(&out)
.arg(".")
.current_dir(root().join("oracle/go"))
.env("CGO_ENABLED", "0")
.status()
.expect("run go build");
assert!(st.success(), "building the Go oracle failed");
Some(out)
})
.clone()
}
pub fn go_test(pkg: &str, test: &str, env: &[(&str, &Path)]) -> std::process::Output {
let mut c = Command::new("go");
c.args(["test", "-count=1", "-v", "-run", &format!("^{test}$"), pkg])
.current_dir(root().join("oracle/go"));
for (k, v) in env {
c.env(k, v);
}
c.output().expect("run go test")
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Rust-to-Go: files the Rust implementation encrypts must open in the
//! frozen Go oracle, and files it corrupts must not.
mod common;
use vault::crypto::{fury, kem};
fn pattern(n: usize, salt: u8) -> Vec<u8> {
(0..n).map(|i| (i.wrapping_mul(7) as u8) ^ salt).collect()
}
#[test]
fn go_decrypts_rust_files() {
if !common::have_go() {
return common::skip("go not installed");
}
let dir = tempfile_dir();
let id = kem::Identity::from_suite_seed(kem::SUITE_ID, &pattern(32, 1)).unwrap();
for size in [0usize, 1, 16, 65535, 65536, 65537, 131072, 300_001] {
let mut out = Vec::new();
fury::encrypt_to(&mut out, &id.recipient(), &pattern(size, 0x5a), &mut vault::rng::os());
std::fs::write(dir.join(format!("pattern-{size}.fury")), &out).unwrap();
if size == 65537 {
let mut bad = out.clone();
bad.truncate(bad.len() - 1);
std::fs::write(dir.join("bad-truncated.fury"), &bad).unwrap();
let mut bad = out.clone();
bad.push(0);
std::fs::write(dir.join("bad-trailing.fury"), &bad).unwrap();
let mut bad = out;
let mid = bad.len() / 2;
bad[mid] ^= 4;
std::fs::write(dir.join("bad-flipped.fury"), &bad).unwrap();
}
}
let o = common::go_test("./internal/pqcrypt", "TestOracleDecrypt", &[("VAULT_ORACLE_DECRYPT", &dir)]);
let log = String::from_utf8_lossy(&o.stdout);
assert!(
o.status.success(),
"go oracle rejected Rust output:\n{log}{}",
String::from_utf8_lossy(&o.stderr)
);
assert_eq!(log.matches(".fury ok").count(), 8, "{log}");
std::fs::remove_dir_all(dir).unwrap();
}
fn tempfile_dir() -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!(
"vault-interop-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&d).unwrap();
d
}