Rust crypto core: FrodoKEM port, hybrid suite, Fury, STREAM, key files
FrodoKEM-640-SHAKE is ported from CIRCL v1.6.4 (the RustCrypto crate implements the incompatible ISO variant) and reproduces the reference NIST KAT digest. ML-KEM-768 comes from RustCrypto ml-kem. Derivation, encapsulation, implicit rejection, KEK, header MAC, header parsing, STREAM and Argon2id key files all match vectors from the Go oracle, Go's files decrypt in Rust, and Go decrypts Rust's. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Generated
+1263
File diff suppressed because it is too large
Load Diff
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.99.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 140
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user