pub const MAGIC: [u8; 8] = *b"\0GNCRYPT";
pub const VERSION: u8 = V2;
pub const V1: u8 = 1;
pub const V2: u8 = 2;
pub const V1_HEADER_LEN: usize = 13;
pub const V2_HEADER_LEN: usize = 49;
const V2_FIXED_LEN: usize = 17;
pub const NONCE_LEN: usize = 32;
pub const TAG_LEN: usize = 16;
pub const MIN_LEN: usize = V1_HEADER_LEN + TAG_LEN;
pub const CHUNK: u32 = 1 << 20;
pub const MAX_CHUNK: u32 = 16 << 20;
pub const NONCE_INFO: &[u8] = b"gunnar-crypt envelope v2 file nonce";
#[cfg(feature = "cipher")]
const AD_LEN: usize = V2_HEADER_LEN + 8 + 1;
pub fn has_magic(bytes: &[u8]) -> bool {
bytes.len() >= MAGIC.len() && bytes[..MAGIC.len()] == MAGIC
}
pub fn plaintext_len(head: &[u8], total: u64) -> Option<u64> {
if !has_magic(head) || head.len() <= MAGIC.len() {
return None;
}
match head[MAGIC.len()] {
V1 => total.checked_sub(MIN_LEN as u64),
V2 => {
if head.len() < V2_FIXED_LEN {
return None;
}
let chunk = u64::from(chunk_of(head));
if chunk == 0 || chunk > u64::from(MAX_CHUNK) {
return None;
}
let body = total.checked_sub(V2_HEADER_LEN as u64)?;
let block = chunk + TAG_LEN as u64;
let (full, rest) = (body / block, body % block);
match rest {
0 if full > 0 => Some(full * chunk),
r if r < TAG_LEN as u64 => None,
r if r == TAG_LEN as u64 && full > 0 => None,
r => Some(full * chunk + r - TAG_LEN as u64),
}
}
_ => None,
}
}
pub fn is_envelope(bytes: &[u8]) -> bool {
plaintext_len(bytes, bytes.len() as u64).is_some()
}
pub fn epoch_of(bytes: &[u8]) -> Option<u32> {
if !is_envelope(bytes) {
return None;
}
let e = &bytes[MAGIC.len() + 1..V1_HEADER_LEN];
Some(u32::from_be_bytes([e[0], e[1], e[2], e[3]]))
}
pub fn version_of(head: &[u8]) -> Option<u8> {
(has_magic(head) && head.len() > MAGIC.len()).then(|| head[MAGIC.len()])
}
fn chunk_of(head: &[u8]) -> u32 {
u32::from_be_bytes([head[13], head[14], head[15], head[16]])
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenError {
NotEnvelope,
UnsupportedVersion(u8),
UnknownEpoch(u32),
Corrupt,
}
impl std::fmt::Display for OpenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OpenError::NotEnvelope => write!(f, "not a gunnar-crypt envelope"),
OpenError::UnsupportedVersion(v) => {
write!(
f,
"gunnar-crypt envelope version {v} is newer than this gunnar"
)
}
OpenError::UnknownEpoch(e) => write!(
f,
"sealed under key epoch {e}, which this key ring does not hold \
(run `gunnar crypt unlock` after pulling the newest .gunnar-crypt/keys)"
),
OpenError::Corrupt => write!(
f,
"the envelope did not verify: wrong key, or the content was altered"
),
}
}
}
impl std::error::Error for OpenError {}
#[derive(Debug)]
pub enum StreamError {
Open(OpenError),
Io(std::io::Error),
}
impl std::fmt::Display for StreamError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StreamError::Open(e) => e.fmt(f),
StreamError::Io(e) => e.fmt(f),
}
}
}
impl std::error::Error for StreamError {}
impl From<std::io::Error> for StreamError {
fn from(e: std::io::Error) -> Self {
StreamError::Io(e)
}
}
impl From<OpenError> for StreamError {
fn from(e: OpenError) -> Self {
StreamError::Open(e)
}
}
#[cfg(feature = "cipher")]
fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
let mut n = 0;
while n < buf.len() {
match r.read(&mut buf[n..]) {
Ok(0) => break,
Ok(k) => n += k,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(n)
}
#[cfg(feature = "cipher")]
mod cipher {
use std::io::{Read, Write};
use super::*;
use crate::keyring::{DataKey, Keyring};
use aes_siv::aead::KeyInit;
use aes_siv::siv::Aes256Siv;
use aes_siv::Tag;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use zeroize::Zeroizing;
fn siv(key: &DataKey) -> Aes256Siv {
Aes256Siv::new(key.bytes().into())
}
#[derive(Clone)]
pub struct FileNonce(Hmac<Sha256>);
impl FileNonce {
pub fn new(key: &DataKey) -> FileNonce {
let mut nonce_key = Zeroizing::new([0u8; 32]);
hkdf::Hkdf::<Sha256>::new(None, key.bytes())
.expand(NONCE_INFO, &mut nonce_key[..])
.expect("32 bytes is a valid HKDF-SHA256 length");
FileNonce(
<Hmac<Sha256> as Mac>::new_from_slice(&nonce_key[..])
.expect("HMAC takes a key of any length"),
)
}
pub fn update(&mut self, bytes: &[u8]) {
self.0.update(bytes);
}
pub fn finish(self) -> [u8; NONCE_LEN] {
self.0.finalize().into_bytes().into()
}
}
pub fn file_nonce(key: &DataKey, plaintext: &[u8]) -> [u8; NONCE_LEN] {
let mut n = FileNonce::new(key);
n.update(plaintext);
n.finish()
}
fn header_v2(epoch: u32, chunk: u32, nonce: &[u8; NONCE_LEN]) -> [u8; V2_HEADER_LEN] {
let mut h = [0u8; V2_HEADER_LEN];
h[..8].copy_from_slice(&MAGIC);
h[8] = V2;
h[9..13].copy_from_slice(&epoch.to_be_bytes());
h[13..17].copy_from_slice(&chunk.to_be_bytes());
h[17..].copy_from_slice(nonce);
h
}
fn block_ad(header: &[u8; V2_HEADER_LEN], index: u64, last: bool) -> [u8; AD_LEN] {
let mut ad = [0u8; AD_LEN];
ad[..V2_HEADER_LEN].copy_from_slice(header);
ad[V2_HEADER_LEN..V2_HEADER_LEN + 8].copy_from_slice(&index.to_be_bytes());
ad[AD_LEN - 1] = u8::from(last);
ad
}
const FIRST_FILL: usize = 64 << 10;
fn block_buf(size: usize) -> Zeroizing<Vec<u8>> {
Zeroizing::new(vec![0u8; size])
}
fn fill(
r: &mut impl Read,
buf: &mut Zeroizing<Vec<u8>>,
mut at: usize,
want: usize,
) -> std::io::Result<usize> {
loop {
let end = buf.len().min(want);
at += read_full(r, &mut buf[at..end])?;
if at < end || end == want {
return Ok(at);
}
let mut bigger = block_buf((buf.len() * 2).min(want));
bigger[..at].copy_from_slice(&buf[..at]);
*buf = bigger;
}
}
pub fn seal_stream(
key: &DataKey,
nonce: &[u8; NONCE_LEN],
plaintext: &mut impl Read,
out: &mut impl Write,
) -> std::io::Result<u64> {
seal_stream_chunked(key, CHUNK, nonce, plaintext, out)
}
fn seal_stream_chunked(
key: &DataKey,
chunk: u32,
nonce: &[u8; NONCE_LEN],
plaintext: &mut impl Read,
out: &mut impl Write,
) -> std::io::Result<u64> {
assert!(
chunk > 0 && chunk <= MAX_CHUNK,
"a chunk size readers accept"
);
let header = header_v2(key.epoch(), chunk, nonce);
out.write_all(&header)?;
let mut written = V2_HEADER_LEN as u64;
let mut siv = siv(key);
let chunk = chunk as usize;
let want = TAG_LEN + chunk;
let mut buf = block_buf(want.min(TAG_LEN + FIRST_FILL));
let mut n = fill(plaintext, &mut buf, TAG_LEN, want)? - TAG_LEN;
let mut index: u64 = 0;
loop {
let mut next = [0u8; 1];
let last = n < chunk || read_full(plaintext, &mut next)? == 0;
let ad = block_ad(&header, index, last);
let tag = siv
.encrypt_inout_detached([&ad[..]], (&mut buf[TAG_LEN..TAG_LEN + n]).into())
.expect("AES-SIV takes any length and one header");
buf[..TAG_LEN].copy_from_slice(tag.as_slice());
out.write_all(&buf[..TAG_LEN + n])?;
written += (TAG_LEN + n) as u64;
if last {
return Ok(written);
}
index += 1;
buf[TAG_LEN] = next[0];
n = fill(plaintext, &mut buf, TAG_LEN + 1, want)? - TAG_LEN;
}
}
pub fn seal(key: &DataKey, plaintext: &[u8]) -> Vec<u8> {
seal_with_chunk(key, CHUNK, plaintext)
}
#[doc(hidden)]
pub fn seal_with_chunk(key: &DataKey, chunk: u32, plaintext: &[u8]) -> Vec<u8> {
let nonce = file_nonce(key, plaintext);
let mut out = Vec::new();
seal_into_chunked(key, chunk, &nonce, plaintext, &mut out);
out
}
pub fn seal_into(key: &DataKey, nonce: &[u8; NONCE_LEN], plaintext: &[u8], out: &mut Vec<u8>) {
seal_into_chunked(key, CHUNK, nonce, plaintext, out)
}
fn seal_into_chunked(
key: &DataKey,
chunk: u32,
nonce: &[u8; NONCE_LEN],
plaintext: &[u8],
out: &mut Vec<u8>,
) {
assert!(
chunk > 0 && chunk <= MAX_CHUNK,
"a chunk size readers accept"
);
let header = header_v2(key.epoch(), chunk, nonce);
let chunk = chunk as usize;
let blocks = plaintext.len().div_ceil(chunk).max(1);
out.reserve(V2_HEADER_LEN + blocks * TAG_LEN + plaintext.len());
out.extend_from_slice(&header);
let mut siv = siv(key);
for i in 0..blocks {
let piece = &plaintext
[(i * chunk).min(plaintext.len())..((i + 1) * chunk).min(plaintext.len())];
let at = out.len();
out.extend_from_slice(&[0u8; TAG_LEN]);
out.extend_from_slice(piece);
let ad = block_ad(&header, i as u64, i + 1 == blocks);
let tag = siv
.encrypt_inout_detached([&ad[..]], (&mut out[at + TAG_LEN..]).into())
.expect("AES-SIV takes any length and one header");
out[at..at + TAG_LEN].copy_from_slice(tag.as_slice());
}
}
#[doc(hidden)]
pub fn seal_v1(key: &DataKey, plaintext: &[u8]) -> Vec<u8> {
let mut h = [0u8; V1_HEADER_LEN];
h[..8].copy_from_slice(&MAGIC);
h[8] = V1;
h[9..].copy_from_slice(&key.epoch().to_be_bytes());
let ct = siv(key)
.encrypt([&h[..]], plaintext)
.expect("AES-SIV takes any length and one header");
let mut out = Vec::with_capacity(V1_HEADER_LEN + ct.len());
out.extend_from_slice(&h);
out.extend_from_slice(&ct);
out
}
pub fn open_stream(
ring: &Keyring,
envelope: &mut impl Read,
out: &mut impl Write,
) -> Result<u64, StreamError> {
let mut head = [0u8; V2_HEADER_LEN];
let got = read_full(envelope, &mut head[..MAGIC.len() + 1])?;
if got < MAGIC.len() + 1 || !has_magic(&head) {
return Err(OpenError::NotEnvelope.into());
}
match head[MAGIC.len()] {
V1 => {
let mut all = head[..MAGIC.len() + 1].to_vec();
envelope.read_to_end(&mut all)?;
let plain = Zeroizing::new(open_v1(ring, &all)?);
out.write_all(&plain)?;
Ok(plain.len() as u64)
}
V2 => {
let got = read_full(envelope, &mut head[MAGIC.len() + 1..])?;
if got < V2_HEADER_LEN - MAGIC.len() - 1 {
return Err(OpenError::NotEnvelope.into());
}
open_v2_blocks(ring, &head, envelope, out)
}
v => Err(OpenError::UnsupportedVersion(v).into()),
}
}
fn key_for<'r>(ring: &'r Keyring, head: &[u8]) -> Result<&'r DataKey, OpenError> {
let epoch = u32::from_be_bytes([head[9], head[10], head[11], head[12]]);
ring.get(epoch).ok_or(OpenError::UnknownEpoch(epoch))
}
fn open_v2_blocks(
ring: &Keyring,
header: &[u8; V2_HEADER_LEN],
envelope: &mut impl Read,
out: &mut impl Write,
) -> Result<u64, StreamError> {
let chunk = chunk_of(header);
if chunk == 0 || chunk > MAX_CHUNK {
return Err(OpenError::Corrupt.into());
}
let key = key_for(ring, header)?;
let mut siv = siv(key);
let block = TAG_LEN + chunk as usize;
let mut buf = block_buf(block.min(FIRST_FILL));
let mut n = fill(envelope, &mut buf, 0, block)?;
let mut index: u64 = 0;
let mut written: u64 = 0;
loop {
if n < TAG_LEN {
return Err(OpenError::Corrupt.into());
}
let mut next = [0u8; 1];
let last = n < block || read_full(envelope, &mut next)? == 0;
if last && n == TAG_LEN && index > 0 {
return Err(OpenError::Corrupt.into());
}
let tag = Tag::try_from(&buf[..TAG_LEN]).expect("16 bytes");
let ad = block_ad(header, index, last);
siv.decrypt_inout_detached([&ad[..]], (&mut buf[TAG_LEN..n]).into(), &tag)
.map_err(|_| OpenError::Corrupt)?;
out.write_all(&buf[TAG_LEN..n])?;
written += (n - TAG_LEN) as u64;
if last {
return Ok(written);
}
index = index.checked_add(1).ok_or(OpenError::Corrupt)?;
buf[0] = next[0];
n = fill(envelope, &mut buf, 1, block)?;
}
}
fn open_v1(ring: &Keyring, envelope: &[u8]) -> Result<Vec<u8>, OpenError> {
if envelope.len() < MIN_LEN {
return Err(OpenError::NotEnvelope);
}
let key = key_for(ring, envelope)?;
siv(key)
.decrypt([&envelope[..V1_HEADER_LEN]], &envelope[V1_HEADER_LEN..])
.map_err(|_| OpenError::Corrupt)
}
pub fn open(ring: &Keyring, envelope: &[u8]) -> Result<Vec<u8>, OpenError> {
let mut out = Vec::new();
open_into(ring, envelope, &mut out)?;
Ok(out)
}
pub fn open_into(ring: &Keyring, envelope: &[u8], out: &mut Vec<u8>) -> Result<(), OpenError> {
if !has_magic(envelope) || envelope.len() <= MAGIC.len() {
return Err(OpenError::NotEnvelope);
}
match envelope[MAGIC.len()] {
V1 => {
let plain = Zeroizing::new(open_v1(ring, envelope)?);
out.extend_from_slice(&plain);
Ok(())
}
V2 => {
if envelope.len() < V2_HEADER_LEN {
return Err(OpenError::NotEnvelope);
}
let header: &[u8; V2_HEADER_LEN] =
envelope[..V2_HEADER_LEN].try_into().expect("checked above");
let chunk = chunk_of(header);
if chunk == 0 || chunk > MAX_CHUNK {
return Err(OpenError::Corrupt);
}
let key = key_for(ring, header)?;
let len =
plaintext_len(envelope, envelope.len() as u64).ok_or(OpenError::Corrupt)?;
let body = &envelope[V2_HEADER_LEN..];
let block = TAG_LEN + chunk as usize;
let blocks = body.len().div_ceil(block);
let start = out.len();
out.reserve(len as usize);
let mut siv = siv(key);
for i in 0..blocks {
let b = &body[i * block..((i + 1) * block).min(body.len())];
let tag = Tag::try_from(&b[..TAG_LEN]).expect("16 bytes");
let at = out.len();
out.extend_from_slice(&b[TAG_LEN..]);
let ad = block_ad(header, i as u64, i + 1 == blocks);
if siv
.decrypt_inout_detached([&ad[..]], (&mut out[at..]).into(), &tag)
.is_err()
{
zeroize::Zeroize::zeroize(&mut out[start..]);
out.truncate(start);
return Err(OpenError::Corrupt);
}
}
Ok(())
}
v => Err(OpenError::UnsupportedVersion(v)),
}
}
pub fn is_current(ring: &Keyring, head: &[u8]) -> bool {
head.len() >= V2_FIXED_LEN
&& has_magic(head)
&& head[MAGIC.len()] == V2
&& chunk_of(head) == CHUNK
&& u32::from_be_bytes([head[9], head[10], head[11], head[12]]) == ring.newest().epoch()
}
}
#[cfg(feature = "cipher")]
pub use cipher::{
file_nonce, is_current, open, open_into, open_stream, seal, seal_into, seal_stream, seal_v1,
seal_with_chunk, FileNonce,
};