1512 lines
53 KiB
Rust
1512 lines
53 KiB
Rust
//! Endpoint-to-endpoint authenticated encryption for MTP pipes.
|
|
//!
|
|
//! Pipe negotiation and QUIC/WebTransport remain transport primitives. This
|
|
//! module adds the application-facing record layer that callers can place on
|
|
//! top of an accepted [`PipeWriter`] or [`PipeReader`], plus an explicit
|
|
//! signed/KEM session-offer helper. The raw stream adapter does not infer
|
|
//! application identities or derive keys from clear pipe metadata.
|
|
|
|
use mtp_codec::{
|
|
DataValue, DecodeLimits, MtpProtectionPurpose, ProtectionError, ProtectionPolicy,
|
|
ProtectionPurpose,
|
|
};
|
|
use mtp_crypto::{
|
|
AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, KemPublicKey, Keyring, PublicKeyBundle,
|
|
SignatureScheme, XChaCha20Poly1305,
|
|
};
|
|
use rand::RngExt;
|
|
use std::fmt;
|
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
use zeroize::{Zeroize, Zeroizing};
|
|
|
|
const PIPE_E2EE_DOMAIN: &[u8] = b"MTP-PIPE-E2EE-1";
|
|
const PIPE_RECORD_KDF_DOMAIN: &[u8] = b"MTP-PIPE-E2EE-1/KEY";
|
|
const PIPE_TRANSCRIPT_DOMAIN: &[u8] = b"MTP-PIPE-TRANSCRIPT-1";
|
|
const PIPE_RECORD_MESSAGE_LABEL: &[u8] = b"/message";
|
|
const PIPE_RECORD_NEXT_LABEL: &[u8] = b"/next";
|
|
const SESSION_ID_MAX_LEN: usize = 1024;
|
|
const RECORD_LENGTH_BYTES: usize = 4;
|
|
const RECORD_TYPE_BYTES: usize = 1;
|
|
const RECORD_TYPE_DATA: u8 = 0;
|
|
const RECORD_TYPE_FINAL: u8 = 1;
|
|
const XCHACHA_OVERHEAD: usize =
|
|
mtp_crypto::aead::XCHACHA20POLY1305_NONCE_LEN + mtp_crypto::aead::AUTH_TAG_LEN;
|
|
|
|
/// Maximum encoded ciphertext size of one encrypted pipe record.
|
|
pub const MAX_ENCRYPTED_PIPE_RECORD: usize = 16 * 1024 * 1024;
|
|
|
|
/// Purpose authenticated by the signed session-key offer.
|
|
pub const PIPE_SESSION_SIGNATURE_PURPOSE: u8 = MtpProtectionPurpose::PipeSessionSignature.value();
|
|
/// Generic purpose authenticated by the encrypted session-key offer.
|
|
pub const PIPE_SESSION_ENCRYPTION_PURPOSE: u8 = MtpProtectionPurpose::PipeSessionEncryption.value();
|
|
/// Maximum serialized size of a session-key offer.
|
|
pub const MAX_PIPE_SESSION_OFFER: usize = 64 * 1024;
|
|
const PIPE_SESSION_OFFER_DOMAIN: &str = "MTP-PIPE-SESSION-1";
|
|
const FS_INIT_DOMAIN: &str = "MTP-PIPE-FS-INIT-1";
|
|
const FS_RESPONSE_DOMAIN: &str = "MTP-PIPE-FS-RESPONSE-1";
|
|
const FS_FINISH_DOMAIN: &str = "MTP-PIPE-FS-FINISH-1";
|
|
const FS_ROOT_INFO: &[u8] = b"MTP-PIPE-FS-ROOT-1";
|
|
|
|
/// The context authenticated by every encrypted pipe record.
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
pub struct PipeProtectionContext {
|
|
session_id: Vec<u8>,
|
|
purpose: u8,
|
|
direction: u8,
|
|
transcript_hash: [u8; 32],
|
|
}
|
|
|
|
impl fmt::Debug for PipeProtectionContext {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("PipeProtectionContext")
|
|
.field("session_id_len", &self.session_id.len())
|
|
.field("purpose", &self.purpose)
|
|
.field("direction", &self.direction)
|
|
.field("transcript_hash", &"[REDACTED]")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl PipeProtectionContext {
|
|
/// Create a context shared by both endpoints of one logical pipe stream.
|
|
///
|
|
/// `session_id` must identify the authenticated pipe/session and should
|
|
/// include both endpoint identities and the pipe identity. `direction`
|
|
/// is a protocol-defined value that must be identical at both endpoints;
|
|
/// use different values for the two directions of a bidirectional design.
|
|
pub fn new(
|
|
session_id: impl AsRef<[u8]>,
|
|
purpose: u8,
|
|
direction: u8,
|
|
) -> Result<Self, EncryptedPipeError> {
|
|
let session_id = session_id.as_ref();
|
|
if session_id.is_empty() || session_id.len() > SESSION_ID_MAX_LEN {
|
|
return Err(EncryptedPipeError::InvalidContext);
|
|
}
|
|
if MtpProtectionPurpose::is_reserved(purpose) {
|
|
return Err(EncryptedPipeError::InvalidContext);
|
|
}
|
|
Ok(Self {
|
|
session_id: session_id.to_vec(),
|
|
purpose,
|
|
direction,
|
|
transcript_hash: base_transcript_hash(session_id, purpose, direction),
|
|
})
|
|
}
|
|
|
|
fn from_parameters(parameters: &PipeSessionParameters) -> Self {
|
|
Self {
|
|
session_id: parameters.session_id.clone(),
|
|
purpose: parameters.purpose,
|
|
direction: parameters.direction,
|
|
transcript_hash: parameters.transcript_hash(),
|
|
}
|
|
}
|
|
|
|
pub fn session_id(&self) -> &[u8] {
|
|
&self.session_id
|
|
}
|
|
|
|
pub fn purpose(&self) -> u8 {
|
|
self.purpose
|
|
}
|
|
|
|
pub fn direction(&self) -> u8 {
|
|
self.direction
|
|
}
|
|
|
|
pub fn transcript_hash(&self) -> &[u8; 32] {
|
|
&self.transcript_hash
|
|
}
|
|
}
|
|
|
|
/// Endpoint and stream metadata that a pipe-session key must bind.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct PipeSessionParameters {
|
|
session_id: Vec<u8>,
|
|
pipe_id: u32,
|
|
sender_id: u64,
|
|
recipient_id: u64,
|
|
purpose: u8,
|
|
direction: u8,
|
|
}
|
|
|
|
impl PipeSessionParameters {
|
|
pub fn new(
|
|
session_id: impl AsRef<[u8]>,
|
|
pipe_id: u32,
|
|
sender_id: u64,
|
|
recipient_id: u64,
|
|
purpose: u8,
|
|
direction: u8,
|
|
) -> Result<Self, PipeSessionError> {
|
|
if pipe_id == 0 {
|
|
return Err(PipeSessionError::InvalidParameters(
|
|
"pipe id must be non-zero",
|
|
));
|
|
}
|
|
let session_id = session_id.as_ref().to_vec();
|
|
PipeProtectionContext::new(&session_id, purpose, direction)
|
|
.map_err(|_| PipeSessionError::InvalidParameters("invalid session id"))?;
|
|
Ok(Self {
|
|
session_id,
|
|
pipe_id,
|
|
sender_id,
|
|
recipient_id,
|
|
purpose,
|
|
direction,
|
|
})
|
|
}
|
|
|
|
pub fn session_id(&self) -> &[u8] {
|
|
&self.session_id
|
|
}
|
|
|
|
pub fn pipe_id(&self) -> u32 {
|
|
self.pipe_id
|
|
}
|
|
|
|
pub fn sender_id(&self) -> u64 {
|
|
self.sender_id
|
|
}
|
|
|
|
pub fn recipient_id(&self) -> u64 {
|
|
self.recipient_id
|
|
}
|
|
|
|
pub fn purpose(&self) -> u8 {
|
|
self.purpose
|
|
}
|
|
|
|
pub fn direction(&self) -> u8 {
|
|
self.direction
|
|
}
|
|
|
|
fn context(&self) -> PipeProtectionContext {
|
|
PipeProtectionContext::from_parameters(self)
|
|
}
|
|
|
|
fn transcript_hash(&self) -> [u8; 32] {
|
|
let mut transcript = Vec::with_capacity(64 + self.session_id.len());
|
|
transcript.extend_from_slice(PIPE_TRANSCRIPT_DOMAIN);
|
|
append_transcript_field(&mut transcript, &self.session_id);
|
|
transcript.extend_from_slice(&self.pipe_id.to_be_bytes());
|
|
transcript.extend_from_slice(&self.sender_id.to_be_bytes());
|
|
transcript.extend_from_slice(&self.recipient_id.to_be_bytes());
|
|
transcript.push(self.purpose);
|
|
transcript.push(self.direction);
|
|
mtp_crypto::sha256(&transcript)
|
|
}
|
|
}
|
|
|
|
fn append_transcript_field(out: &mut Vec<u8>, value: &[u8]) {
|
|
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
|
out.extend_from_slice(value);
|
|
}
|
|
|
|
fn base_transcript_hash(session_id: &[u8], purpose: u8, direction: u8) -> [u8; 32] {
|
|
let mut transcript = Vec::with_capacity(32 + session_id.len());
|
|
transcript.extend_from_slice(PIPE_TRANSCRIPT_DOMAIN);
|
|
append_transcript_field(&mut transcript, session_id);
|
|
transcript.push(purpose);
|
|
transcript.push(direction);
|
|
mtp_crypto::sha256(&transcript)
|
|
}
|
|
|
|
/// Errors returned while establishing an encrypted pipe session.
|
|
#[derive(Debug)]
|
|
pub enum PipeSessionError {
|
|
InvalidParameters(&'static str),
|
|
InvalidOffer,
|
|
OfferTooLarge(usize),
|
|
UnexpectedEof,
|
|
Io(std::io::Error),
|
|
Codec(mtp_common::CodecError),
|
|
Protection(ProtectionError),
|
|
Crypto(mtp_crypto::CryptoError),
|
|
}
|
|
|
|
impl fmt::Display for PipeSessionError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::InvalidParameters(message) => {
|
|
write!(f, "invalid pipe session parameters: {message}")
|
|
}
|
|
Self::InvalidOffer => f.write_str("invalid pipe session offer"),
|
|
Self::OfferTooLarge(length) => {
|
|
write!(f, "pipe session offer is too large: {length} bytes")
|
|
}
|
|
Self::UnexpectedEof => f.write_str("truncated pipe session offer"),
|
|
Self::Io(error) => write!(f, "pipe session I/O error: {error}"),
|
|
Self::Codec(error) => write!(f, "pipe session codec error: {error}"),
|
|
Self::Protection(error) => write!(f, "pipe session protection error: {error}"),
|
|
Self::Crypto(error) => write!(f, "pipe session crypto error: {error}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for PipeSessionError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::Io(error) => Some(error),
|
|
Self::Codec(error) => Some(error),
|
|
Self::Protection(error) => Some(error),
|
|
Self::Crypto(error) => Some(error),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<std::io::Error> for PipeSessionError {
|
|
fn from(error: std::io::Error) -> Self {
|
|
Self::Io(error)
|
|
}
|
|
}
|
|
|
|
impl From<mtp_common::CodecError> for PipeSessionError {
|
|
fn from(error: mtp_common::CodecError) -> Self {
|
|
Self::Codec(error)
|
|
}
|
|
}
|
|
|
|
impl From<ProtectionError> for PipeSessionError {
|
|
fn from(error: ProtectionError) -> Self {
|
|
Self::Protection(error)
|
|
}
|
|
}
|
|
|
|
impl From<mtp_crypto::CryptoError> for PipeSessionError {
|
|
fn from(error: mtp_crypto::CryptoError) -> Self {
|
|
Self::Crypto(error)
|
|
}
|
|
}
|
|
|
|
fn session_offer_value(params: &PipeSessionParameters, key: [u8; 32]) -> DataValue {
|
|
DataValue::Array(vec![
|
|
DataValue::Str(PIPE_SESSION_OFFER_DOMAIN.to_owned()),
|
|
DataValue::Bytes(params.session_id.clone()),
|
|
DataValue::UnsignedNumber(params.pipe_id as u128),
|
|
DataValue::UnsignedNumber(params.sender_id as u128),
|
|
DataValue::UnsignedNumber(params.recipient_id as u128),
|
|
DataValue::UnsignedNumber(params.purpose as u128),
|
|
DataValue::UnsignedNumber(params.direction as u128),
|
|
DataValue::Bytes(key.to_vec()),
|
|
])
|
|
}
|
|
|
|
fn fs_common_fields(params: &PipeSessionParameters) -> Vec<DataValue> {
|
|
vec![
|
|
DataValue::Bytes(params.session_id.clone()),
|
|
DataValue::UnsignedNumber(params.pipe_id as u128),
|
|
DataValue::UnsignedNumber(params.sender_id as u128),
|
|
DataValue::UnsignedNumber(params.recipient_id as u128),
|
|
DataValue::UnsignedNumber(params.purpose as u128),
|
|
DataValue::UnsignedNumber(params.direction as u128),
|
|
]
|
|
}
|
|
|
|
fn fs_init_value(params: &PipeSessionParameters, nonce: [u8; 32]) -> DataValue {
|
|
let mut fields = vec![DataValue::Str(FS_INIT_DOMAIN.to_owned())];
|
|
fields.extend(fs_common_fields(params));
|
|
fields.push(DataValue::Bytes(nonce.to_vec()));
|
|
DataValue::Array(fields)
|
|
}
|
|
|
|
fn fs_response_value(
|
|
params: &PipeSessionParameters,
|
|
init_hash: [u8; 32],
|
|
ephemeral_public_key: &[u8],
|
|
) -> DataValue {
|
|
let mut fields = vec![DataValue::Str(FS_RESPONSE_DOMAIN.to_owned())];
|
|
fields.extend(fs_common_fields(params));
|
|
fields.push(DataValue::Bytes(init_hash.to_vec()));
|
|
fields.push(DataValue::Bytes(ephemeral_public_key.to_vec()));
|
|
DataValue::Array(fields)
|
|
}
|
|
|
|
fn fs_finish_value(
|
|
params: &PipeSessionParameters,
|
|
response_hash: [u8; 32],
|
|
ciphertext: &[u8],
|
|
) -> DataValue {
|
|
let mut fields = vec![DataValue::Str(FS_FINISH_DOMAIN.to_owned())];
|
|
fields.extend(fs_common_fields(params));
|
|
fields.push(DataValue::Bytes(response_hash.to_vec()));
|
|
fields.push(DataValue::Bytes(ciphertext.to_vec()));
|
|
DataValue::Array(fields)
|
|
}
|
|
|
|
fn validate_fs_common(
|
|
fields: &[DataValue],
|
|
expected: &PipeSessionParameters,
|
|
expected_domain: &str,
|
|
expected_len: usize,
|
|
) -> Result<(), PipeSessionError> {
|
|
if fields.len() != expected_len
|
|
|| fields.first().and_then(DataValue::as_str) != Some(expected_domain)
|
|
|| offer_field(fields, 1)?.as_bytes_slice() != Some(expected.session_id())
|
|
|| u32::try_from(unsigned_field(fields, 2)?).ok() != Some(expected.pipe_id)
|
|
|| u64::try_from(unsigned_field(fields, 3)?).ok() != Some(expected.sender_id)
|
|
|| u64::try_from(unsigned_field(fields, 4)?).ok() != Some(expected.recipient_id)
|
|
|| u8::try_from(unsigned_field(fields, 5)?).ok() != Some(expected.purpose)
|
|
|| u8::try_from(unsigned_field(fields, 6)?).ok() != Some(expected.direction)
|
|
{
|
|
return Err(PipeSessionError::InvalidOffer);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn derive_forward_secure_chain_key(
|
|
shared_secret: &[u8],
|
|
handshake_transcript: &[u8; 32],
|
|
) -> Result<[u8; 32], PipeSessionError> {
|
|
let key = mtp_crypto::hkdf_expand(shared_secret, handshake_transcript, FS_ROOT_INFO, 32)?;
|
|
key.try_into().map_err(|_| PipeSessionError::InvalidOffer)
|
|
}
|
|
|
|
fn forward_secure_context(
|
|
params: &PipeSessionParameters,
|
|
handshake_transcript: &[u8; 32],
|
|
) -> PipeProtectionContext {
|
|
let mut transcript = Vec::with_capacity(PIPE_TRANSCRIPT_DOMAIN.len() + 64);
|
|
transcript.extend_from_slice(PIPE_TRANSCRIPT_DOMAIN);
|
|
transcript.extend_from_slice(¶ms.transcript_hash());
|
|
transcript.extend_from_slice(handshake_transcript);
|
|
let hash: [u8; 32] = mtp_crypto::sha256(&transcript);
|
|
PipeProtectionContext {
|
|
session_id: params.session_id.clone(),
|
|
purpose: params.purpose,
|
|
direction: params.direction,
|
|
transcript_hash: hash,
|
|
}
|
|
}
|
|
|
|
enum PipeSigner {
|
|
Ed25519(Ed25519Signer),
|
|
Dual(DualSigner),
|
|
}
|
|
|
|
impl SignatureScheme for PipeSigner {
|
|
fn algorithm(&self) -> u8 {
|
|
match self {
|
|
Self::Ed25519(signer) => signer.algorithm(),
|
|
Self::Dual(signer) => signer.algorithm(),
|
|
}
|
|
}
|
|
|
|
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, mtp_crypto::CryptoError> {
|
|
match self {
|
|
Self::Ed25519(signer) => signer.sign(message),
|
|
Self::Dual(signer) => signer.sign(message),
|
|
}
|
|
}
|
|
|
|
fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), mtp_crypto::CryptoError> {
|
|
match self {
|
|
Self::Ed25519(signer) => signer.verify(message, signature),
|
|
Self::Dual(signer) => signer.verify(message, signature),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pipe_signer_for_keyring(sender_keyring: &Keyring) -> Result<PipeSigner, PipeSessionError> {
|
|
match (
|
|
sender_keyring.sig_pq_secret_key.as_bytes().is_empty(),
|
|
sender_keyring.sig_pq_public_key.as_bytes().is_empty(),
|
|
) {
|
|
(true, true) => {
|
|
sender_keyring.validate_ed25519_signing()?;
|
|
Ok(PipeSigner::Ed25519(Ed25519Signer::new(
|
|
&sender_keyring.sig_cl_secret_key,
|
|
)?))
|
|
}
|
|
(false, false) => {
|
|
sender_keyring.validate_dual_signing()?;
|
|
Ok(PipeSigner::Dual(DualSigner::new(
|
|
&sender_keyring.sig_cl_secret_key,
|
|
&sender_keyring.sig_pq_secret_key,
|
|
&sender_keyring.sig_pq_public_key,
|
|
)?))
|
|
}
|
|
_ => Err(PipeSessionError::InvalidParameters(
|
|
"incomplete ML-DSA key pair",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn pipe_signature_policy(keyring: &Keyring) -> Result<ProtectionPolicy, PipeSessionError> {
|
|
match (
|
|
keyring.sig_pq_secret_key.as_bytes().is_empty(),
|
|
keyring.sig_pq_public_key.as_bytes().is_empty(),
|
|
) {
|
|
(true, true) => Ok(ProtectionPolicy::from(mtp_codec::SignaturePolicy::Ed25519)),
|
|
(false, false) => Ok(ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual)),
|
|
_ => Err(PipeSessionError::InvalidParameters(
|
|
"incomplete ML-DSA key pair",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn build_session_offer(
|
|
params: &PipeSessionParameters,
|
|
sender_keyring: &Keyring,
|
|
recipient_public_keys: &[PublicKeyBundle],
|
|
key: [u8; 32],
|
|
) -> Result<Vec<u8>, PipeSessionError> {
|
|
if recipient_public_keys.is_empty() {
|
|
return Err(PipeSessionError::InvalidParameters(
|
|
"at least one pipe-session recipient is required",
|
|
));
|
|
}
|
|
for recipient_public_key in recipient_public_keys {
|
|
recipient_public_key.validate()?;
|
|
}
|
|
let signer = pipe_signer_for_keyring(sender_keyring)?;
|
|
let signed = session_offer_value(params, key).sign(
|
|
params.sender_id,
|
|
ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE),
|
|
&signer,
|
|
)?;
|
|
let encrypted = signed.encrypt_for(
|
|
recipient_public_keys,
|
|
ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE),
|
|
)?;
|
|
let offer = encrypted.to_bytes()?;
|
|
if offer.len() > MAX_PIPE_SESSION_OFFER {
|
|
return Err(PipeSessionError::OfferTooLarge(offer.len()));
|
|
}
|
|
Ok(offer)
|
|
}
|
|
|
|
async fn write_session_offer<S: AsyncWrite + Unpin>(
|
|
stream: &mut S,
|
|
offer: &[u8],
|
|
) -> Result<(), PipeSessionError> {
|
|
let length =
|
|
u32::try_from(offer.len()).map_err(|_| PipeSessionError::OfferTooLarge(offer.len()))?;
|
|
stream.write_all(&length.to_be_bytes()).await?;
|
|
stream.write_all(offer).await?;
|
|
stream.flush().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_session_offer<R: AsyncRead + Unpin>(
|
|
stream: &mut R,
|
|
) -> Result<Vec<u8>, PipeSessionError> {
|
|
let mut length_bytes = [0u8; 4];
|
|
stream
|
|
.read_exact(&mut length_bytes)
|
|
.await
|
|
.map_err(|error| {
|
|
if error.kind() == std::io::ErrorKind::UnexpectedEof {
|
|
PipeSessionError::UnexpectedEof
|
|
} else {
|
|
PipeSessionError::Io(error)
|
|
}
|
|
})?;
|
|
let length = u32::from_be_bytes(length_bytes) as usize;
|
|
if length == 0 || length > MAX_PIPE_SESSION_OFFER {
|
|
return Err(PipeSessionError::OfferTooLarge(length));
|
|
}
|
|
let mut offer = vec![0u8; length];
|
|
stream.read_exact(&mut offer).await.map_err(|error| {
|
|
if error.kind() == std::io::ErrorKind::UnexpectedEof {
|
|
PipeSessionError::UnexpectedEof
|
|
} else {
|
|
PipeSessionError::Io(error)
|
|
}
|
|
})?;
|
|
Ok(offer)
|
|
}
|
|
|
|
fn offer_field(fields: &[DataValue], index: usize) -> Result<&DataValue, PipeSessionError> {
|
|
fields.get(index).ok_or(PipeSessionError::InvalidOffer)
|
|
}
|
|
|
|
fn unsigned_field(fields: &[DataValue], index: usize) -> Result<u128, PipeSessionError> {
|
|
offer_field(fields, index)?
|
|
.as_unsigned_number()
|
|
.ok_or(PipeSessionError::InvalidOffer)
|
|
}
|
|
|
|
fn validate_session_offer(
|
|
decrypted: DataValue,
|
|
expected: &PipeSessionParameters,
|
|
sender_public_key: &PublicKeyBundle,
|
|
policy: ProtectionPolicy,
|
|
) -> Result<[u8; 32], PipeSessionError> {
|
|
validate_session_offer_with_keys(
|
|
decrypted,
|
|
expected,
|
|
std::slice::from_ref(sender_public_key),
|
|
policy,
|
|
)
|
|
}
|
|
|
|
fn validate_session_offer_with_keys(
|
|
decrypted: DataValue,
|
|
expected: &PipeSessionParameters,
|
|
sender_public_keys: &[PublicKeyBundle],
|
|
policy: ProtectionPolicy,
|
|
) -> Result<[u8; 32], PipeSessionError> {
|
|
let signed = decrypted
|
|
.as_signed()
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
if signed.signer_id != expected.sender_id {
|
|
return Err(PipeSessionError::Protection(
|
|
ProtectionError::SignerIdMismatch {
|
|
expected: expected.sender_id,
|
|
actual: signed.signer_id,
|
|
},
|
|
));
|
|
}
|
|
for sender_public_key in sender_public_keys {
|
|
sender_public_key.validate()?;
|
|
}
|
|
signed.verify_with_key_history(
|
|
expected.sender_id,
|
|
sender_public_keys,
|
|
ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE),
|
|
policy,
|
|
)?;
|
|
|
|
let fields = signed
|
|
.value
|
|
.as_array_slice()
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
if fields.len() != 8
|
|
|| offer_field(fields, 0)?.as_str() != Some(PIPE_SESSION_OFFER_DOMAIN)
|
|
|| offer_field(fields, 1)?.as_bytes_slice() != Some(expected.session_id())
|
|
|| u32::try_from(unsigned_field(fields, 2)?).ok() != Some(expected.pipe_id)
|
|
|| u64::try_from(unsigned_field(fields, 3)?).ok() != Some(expected.sender_id)
|
|
|| u64::try_from(unsigned_field(fields, 4)?).ok() != Some(expected.recipient_id)
|
|
|| u8::try_from(unsigned_field(fields, 5)?).ok() != Some(expected.purpose)
|
|
|| u8::try_from(unsigned_field(fields, 6)?).ok() != Some(expected.direction)
|
|
{
|
|
return Err(PipeSessionError::InvalidOffer);
|
|
}
|
|
let key = offer_field(fields, 7)?
|
|
.as_bytes_slice()
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
key.try_into().map_err(|_| PipeSessionError::InvalidOffer)
|
|
}
|
|
|
|
/// Establish an encrypted writer by sending a signed, recipient-encrypted
|
|
/// session-key offer over the raw pipe, then return the authenticated record
|
|
/// layer for subsequent bytes.
|
|
pub async fn initiate_pipe_session<S: AsyncWrite + Unpin>(
|
|
stream: S,
|
|
params: PipeSessionParameters,
|
|
sender_keyring: &Keyring,
|
|
recipient_public_key: &PublicKeyBundle,
|
|
) -> Result<EncryptedPipeWriter<S>, PipeSessionError> {
|
|
let recipients = [recipient_public_key.clone()];
|
|
initiate_group_pipe_session(stream, params, sender_keyring, &recipients).await
|
|
}
|
|
|
|
/// Establish a pipe session for a group by encrypting one fresh session key
|
|
/// to every current member. Membership changes must create a fresh session
|
|
/// offer with the new recipient set; do not reuse the old record key for a
|
|
/// newly added member or continue sending it to a removed member.
|
|
pub async fn initiate_group_pipe_session<S: AsyncWrite + Unpin>(
|
|
mut stream: S,
|
|
params: PipeSessionParameters,
|
|
sender_keyring: &Keyring,
|
|
recipient_public_keys: &[PublicKeyBundle],
|
|
) -> Result<EncryptedPipeWriter<S>, PipeSessionError> {
|
|
let mut key = [0u8; 32];
|
|
rand::rng().fill(&mut key);
|
|
let offer = build_session_offer(¶ms, sender_keyring, recipient_public_keys, key)?;
|
|
write_session_offer(&mut stream, &offer).await?;
|
|
Ok(EncryptedPipeWriter::new(stream, key, params.context()))
|
|
}
|
|
|
|
/// Accept and authenticate a signed, recipient-encrypted session-key offer,
|
|
/// then return the record layer for subsequent bytes.
|
|
pub async fn accept_pipe_session<R: AsyncRead + Unpin>(
|
|
stream: R,
|
|
expected: &PipeSessionParameters,
|
|
recipient_keyring: &Keyring,
|
|
sender_public_key: &PublicKeyBundle,
|
|
) -> Result<EncryptedPipeReader<R>, PipeSessionError> {
|
|
let policy = pipe_signature_policy(recipient_keyring)?;
|
|
accept_pipe_session_with_policy(
|
|
stream,
|
|
expected,
|
|
recipient_keyring,
|
|
sender_public_key,
|
|
policy,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Policy-aware counterpart to [`accept_pipe_session`].
|
|
pub async fn accept_pipe_session_with_policy<R: AsyncRead + Unpin>(
|
|
mut stream: R,
|
|
expected: &PipeSessionParameters,
|
|
recipient_keyring: &Keyring,
|
|
sender_public_key: &PublicKeyBundle,
|
|
policy: ProtectionPolicy,
|
|
) -> Result<EncryptedPipeReader<R>, PipeSessionError> {
|
|
let offer = read_session_offer(&mut stream).await?;
|
|
let encrypted = DataValue::from_bytes_with_limits(
|
|
&offer,
|
|
DecodeLimits {
|
|
max_blob_size: MAX_PIPE_SESSION_OFFER,
|
|
..DecodeLimits::default()
|
|
},
|
|
)
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
let signed = encrypted.decrypt(
|
|
recipient_keyring,
|
|
ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE),
|
|
)?;
|
|
let key = validate_session_offer(signed, expected, sender_public_key, policy)?;
|
|
Ok(EncryptedPipeReader::new(stream, key, expected.context()))
|
|
}
|
|
|
|
/// Accept a pipe session against a trusted signing-key history. Historical
|
|
/// keys are local resolver state and never become visible in the offer.
|
|
pub async fn accept_pipe_session_with_key_history<R: AsyncRead + Unpin>(
|
|
mut stream: R,
|
|
expected: &PipeSessionParameters,
|
|
recipient_keyring: &Keyring,
|
|
sender_public_keys: &[PublicKeyBundle],
|
|
policy: ProtectionPolicy,
|
|
) -> Result<EncryptedPipeReader<R>, PipeSessionError> {
|
|
if sender_public_keys.is_empty() {
|
|
return Err(PipeSessionError::InvalidParameters(
|
|
"at least one sender verification key is required",
|
|
));
|
|
}
|
|
let offer = read_session_offer(&mut stream).await?;
|
|
let encrypted = DataValue::from_bytes_with_limits(
|
|
&offer,
|
|
DecodeLimits {
|
|
max_blob_size: MAX_PIPE_SESSION_OFFER,
|
|
..DecodeLimits::default()
|
|
},
|
|
)
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
let signed = encrypted.decrypt(
|
|
recipient_keyring,
|
|
ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE),
|
|
)?;
|
|
let key = validate_session_offer_with_keys(signed, expected, sender_public_keys, policy)?;
|
|
Ok(EncryptedPipeReader::new(stream, key, expected.context()))
|
|
}
|
|
|
|
fn sign_forward_secure_value(
|
|
value: DataValue,
|
|
signer_id: u64,
|
|
keyring: &Keyring,
|
|
) -> Result<Vec<u8>, PipeSessionError> {
|
|
let signer = pipe_signer_for_keyring(keyring)?;
|
|
value
|
|
.sign(
|
|
signer_id,
|
|
ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE),
|
|
&signer,
|
|
)?
|
|
.to_bytes()
|
|
.map_err(PipeSessionError::Codec)
|
|
}
|
|
|
|
fn verify_forward_secure_value(
|
|
bytes: &[u8],
|
|
expected_signer_id: u64,
|
|
signer_public_key: &PublicKeyBundle,
|
|
policy: ProtectionPolicy,
|
|
) -> Result<DataValue, PipeSessionError> {
|
|
verify_forward_secure_value_with_keys(
|
|
bytes,
|
|
expected_signer_id,
|
|
std::slice::from_ref(signer_public_key),
|
|
policy,
|
|
)
|
|
}
|
|
|
|
fn verify_forward_secure_value_with_keys(
|
|
bytes: &[u8],
|
|
expected_signer_id: u64,
|
|
signer_public_keys: &[PublicKeyBundle],
|
|
policy: ProtectionPolicy,
|
|
) -> Result<DataValue, PipeSessionError> {
|
|
if signer_public_keys.is_empty() {
|
|
return Err(PipeSessionError::InvalidParameters(
|
|
"at least one sender verification key is required",
|
|
));
|
|
}
|
|
let value = DataValue::from_bytes_with_limits(
|
|
bytes,
|
|
DecodeLimits {
|
|
max_blob_size: MAX_PIPE_SESSION_OFFER,
|
|
..DecodeLimits::default()
|
|
},
|
|
)
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
for signer_public_key in signer_public_keys {
|
|
signer_public_key.validate()?;
|
|
}
|
|
let signed = value.as_signed().ok_or(PipeSessionError::InvalidOffer)?;
|
|
signed.verify_with_key_history(
|
|
expected_signer_id,
|
|
signer_public_keys,
|
|
ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE),
|
|
policy,
|
|
)?;
|
|
Ok((*signed.value).clone())
|
|
}
|
|
|
|
fn handshake_hash(parts: &[&[u8]]) -> [u8; 32] {
|
|
let total = parts.iter().map(|part| part.len()).sum();
|
|
let mut transcript = Vec::with_capacity(total);
|
|
for part in parts {
|
|
append_transcript_field(&mut transcript, part);
|
|
}
|
|
mtp_crypto::sha256(&transcript)
|
|
}
|
|
|
|
/// Forward-secret duplex handshake.
|
|
///
|
|
/// Unlike the one-way session offer, this API requires a bidirectional stream:
|
|
/// the responder contributes an ephemeral KEM key, the initiator encapsulates
|
|
/// to it, and both sides derive record keys from the authenticated transcript.
|
|
/// Long-term KEM keys are not used, so later compromise of those keys cannot
|
|
/// recover recorded sessions. Long-term signing keys still authenticate the
|
|
/// exchange.
|
|
pub async fn initiate_forward_secure_pipe_session<S: AsyncRead + AsyncWrite + Unpin>(
|
|
mut stream: S,
|
|
params: PipeSessionParameters,
|
|
sender_keyring: &Keyring,
|
|
recipient_public_key: &PublicKeyBundle,
|
|
policy: ProtectionPolicy,
|
|
) -> Result<EncryptedPipeWriter<S>, PipeSessionError> {
|
|
recipient_public_key.validate()?;
|
|
let mut nonce = [0u8; 32];
|
|
rand::rng().fill(&mut nonce);
|
|
let init_bytes = sign_forward_secure_value(
|
|
fs_init_value(¶ms, nonce),
|
|
params.sender_id,
|
|
sender_keyring,
|
|
)?;
|
|
write_session_offer(&mut stream, &init_bytes).await?;
|
|
|
|
let response_bytes = read_session_offer(&mut stream).await?;
|
|
let response = verify_forward_secure_value(
|
|
&response_bytes,
|
|
params.recipient_id,
|
|
recipient_public_key,
|
|
policy,
|
|
)?;
|
|
let response_fields = response
|
|
.as_array_slice()
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
validate_fs_common(response_fields, ¶ms, FS_RESPONSE_DOMAIN, 9)?;
|
|
let init_hash = handshake_hash(&[&init_bytes]);
|
|
if response_fields[7].as_bytes_slice() != Some(init_hash.as_slice()) {
|
|
return Err(PipeSessionError::InvalidOffer);
|
|
}
|
|
let ephemeral_public = response_fields[8]
|
|
.as_bytes_slice()
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
let encapsulated =
|
|
mtp_crypto::HybridKem::encapsulate(&KemPublicKey::new(ephemeral_public.to_vec()))?;
|
|
let finish_bytes = sign_forward_secure_value(
|
|
fs_finish_value(
|
|
¶ms,
|
|
handshake_hash(&[&response_bytes]),
|
|
&encapsulated.ciphertext,
|
|
),
|
|
params.sender_id,
|
|
sender_keyring,
|
|
)?;
|
|
write_session_offer(&mut stream, &finish_bytes).await?;
|
|
|
|
let transcript = handshake_hash(&[&init_bytes, &response_bytes, &finish_bytes]);
|
|
let chain_key = derive_forward_secure_chain_key(&encapsulated.shared_secret, &transcript)?;
|
|
Ok(EncryptedPipeWriter::new(
|
|
stream,
|
|
chain_key,
|
|
forward_secure_context(¶ms, &transcript),
|
|
))
|
|
}
|
|
|
|
/// Responder side of [`initiate_forward_secure_pipe_session`].
|
|
pub async fn accept_forward_secure_pipe_session<S: AsyncRead + AsyncWrite + Unpin>(
|
|
stream: S,
|
|
expected: &PipeSessionParameters,
|
|
recipient_keyring: &Keyring,
|
|
sender_public_key: &PublicKeyBundle,
|
|
policy: ProtectionPolicy,
|
|
) -> Result<EncryptedPipeReader<S>, PipeSessionError> {
|
|
accept_forward_secure_pipe_session_with_key_history(
|
|
stream,
|
|
expected,
|
|
recipient_keyring,
|
|
std::slice::from_ref(sender_public_key),
|
|
policy,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Responder side of the forward-secure handshake with a local signing-key
|
|
/// history. Historical public keys remain local resolver state and are never
|
|
/// included in the handshake.
|
|
pub async fn accept_forward_secure_pipe_session_with_key_history<
|
|
S: AsyncRead + AsyncWrite + Unpin,
|
|
>(
|
|
mut stream: S,
|
|
expected: &PipeSessionParameters,
|
|
recipient_keyring: &Keyring,
|
|
sender_public_keys: &[PublicKeyBundle],
|
|
policy: ProtectionPolicy,
|
|
) -> Result<EncryptedPipeReader<S>, PipeSessionError> {
|
|
let init_bytes = read_session_offer(&mut stream).await?;
|
|
let init = verify_forward_secure_value_with_keys(
|
|
&init_bytes,
|
|
expected.sender_id,
|
|
sender_public_keys,
|
|
policy,
|
|
)?;
|
|
let init_fields = init
|
|
.as_array_slice()
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
validate_fs_common(init_fields, expected, FS_INIT_DOMAIN, 8)?;
|
|
let nonce = init_fields[7]
|
|
.as_bytes_slice()
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
if nonce.len() != 32 {
|
|
return Err(PipeSessionError::InvalidOffer);
|
|
}
|
|
|
|
let (ephemeral_secret, ephemeral_public) = mtp_crypto::HybridKem::generate_keypair();
|
|
let response_bytes = sign_forward_secure_value(
|
|
fs_response_value(
|
|
expected,
|
|
handshake_hash(&[&init_bytes]),
|
|
ephemeral_public.as_bytes(),
|
|
),
|
|
expected.recipient_id,
|
|
recipient_keyring,
|
|
)?;
|
|
write_session_offer(&mut stream, &response_bytes).await?;
|
|
|
|
let finish_bytes = read_session_offer(&mut stream).await?;
|
|
let finish = verify_forward_secure_value_with_keys(
|
|
&finish_bytes,
|
|
expected.sender_id,
|
|
sender_public_keys,
|
|
policy,
|
|
)?;
|
|
let finish_fields = finish
|
|
.as_array_slice()
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
validate_fs_common(finish_fields, expected, FS_FINISH_DOMAIN, 9)?;
|
|
let response_hash = handshake_hash(&[&response_bytes]);
|
|
if finish_fields[7].as_bytes_slice() != Some(response_hash.as_slice()) {
|
|
return Err(PipeSessionError::InvalidOffer);
|
|
}
|
|
let ciphertext = finish_fields[8]
|
|
.as_bytes_slice()
|
|
.ok_or(PipeSessionError::InvalidOffer)?;
|
|
let shared_secret = mtp_crypto::HybridKem::decapsulate(&ephemeral_secret, ciphertext)?;
|
|
let transcript = handshake_hash(&[&init_bytes, &response_bytes, &finish_bytes]);
|
|
let chain_key = derive_forward_secure_chain_key(&shared_secret, &transcript)?;
|
|
Ok(EncryptedPipeReader::new(
|
|
stream,
|
|
chain_key,
|
|
forward_secure_context(expected, &transcript),
|
|
))
|
|
}
|
|
|
|
/// Errors produced by the encrypted pipe record layer.
|
|
#[derive(Debug)]
|
|
pub enum EncryptedPipeError {
|
|
InvalidContext,
|
|
InvalidRecordLength(usize),
|
|
InvalidRecordType(u8),
|
|
InvalidState,
|
|
SequenceExhausted,
|
|
FinalRecordRequired,
|
|
UnexpectedEof,
|
|
Io(std::io::Error),
|
|
Crypto(mtp_crypto::CryptoError),
|
|
}
|
|
|
|
impl fmt::Display for EncryptedPipeError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::InvalidContext => write!(f, "invalid encrypted pipe context"),
|
|
Self::InvalidRecordLength(length) => {
|
|
write!(f, "invalid encrypted pipe record length: {length}")
|
|
}
|
|
Self::InvalidRecordType(record_type) => {
|
|
write!(f, "invalid encrypted pipe record type: {record_type}")
|
|
}
|
|
Self::InvalidState => f.write_str("encrypted pipe is no longer usable"),
|
|
Self::SequenceExhausted => write!(f, "encrypted pipe sequence exhausted"),
|
|
Self::FinalRecordRequired => f.write_str("encrypted pipe ended without a final record"),
|
|
Self::UnexpectedEof => write!(f, "truncated encrypted pipe record"),
|
|
Self::Io(error) => write!(f, "encrypted pipe I/O error: {error}"),
|
|
Self::Crypto(error) => write!(f, "encrypted pipe authentication failed: {error}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for EncryptedPipeError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::Io(error) => Some(error),
|
|
Self::Crypto(error) => Some(error),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<std::io::Error> for EncryptedPipeError {
|
|
fn from(error: std::io::Error) -> Self {
|
|
Self::Io(error)
|
|
}
|
|
}
|
|
|
|
impl From<mtp_crypto::CryptoError> for EncryptedPipeError {
|
|
fn from(error: mtp_crypto::CryptoError) -> Self {
|
|
Self::Crypto(error)
|
|
}
|
|
}
|
|
|
|
fn checked_record_length(plaintext_len: usize) -> Result<usize, EncryptedPipeError> {
|
|
let length = plaintext_len
|
|
.checked_add(XCHACHA_OVERHEAD)
|
|
.ok_or(EncryptedPipeError::InvalidRecordLength(usize::MAX))?;
|
|
if length > MAX_ENCRYPTED_PIPE_RECORD || length > u32::MAX as usize {
|
|
return Err(EncryptedPipeError::InvalidRecordLength(length));
|
|
}
|
|
Ok(length)
|
|
}
|
|
|
|
fn record_aad(
|
|
context: &PipeProtectionContext,
|
|
sequence: u64,
|
|
record_len: usize,
|
|
record_type: u8,
|
|
) -> Vec<u8> {
|
|
let mut aad = Vec::with_capacity(PIPE_E2EE_DOMAIN.len() + 2 + 32 + 8 + 4);
|
|
aad.extend_from_slice(PIPE_E2EE_DOMAIN);
|
|
aad.push(context.purpose);
|
|
aad.push(context.direction);
|
|
aad.extend_from_slice(context.transcript_hash());
|
|
aad.extend_from_slice(&sequence.to_be_bytes());
|
|
aad.extend_from_slice(&(record_len as u32).to_be_bytes());
|
|
aad.push(record_type);
|
|
aad
|
|
}
|
|
|
|
fn record_key_info(context: &PipeProtectionContext, sequence: u64, label: &[u8]) -> Vec<u8> {
|
|
let mut info = Vec::with_capacity(PIPE_RECORD_KDF_DOMAIN.len() + 2 + 32 + 8 + label.len());
|
|
info.extend_from_slice(PIPE_RECORD_KDF_DOMAIN);
|
|
info.push(context.purpose);
|
|
info.push(context.direction);
|
|
info.extend_from_slice(context.transcript_hash());
|
|
info.extend_from_slice(&sequence.to_be_bytes());
|
|
info.extend_from_slice(label);
|
|
info
|
|
}
|
|
|
|
fn derive_record_keys(
|
|
chain_key: &[u8; 32],
|
|
context: &PipeProtectionContext,
|
|
sequence: u64,
|
|
) -> Result<([u8; 32], [u8; 32]), EncryptedPipeError> {
|
|
let message_key = mtp_crypto::hkdf_expand(
|
|
chain_key,
|
|
context.transcript_hash(),
|
|
&record_key_info(context, sequence, PIPE_RECORD_MESSAGE_LABEL),
|
|
32,
|
|
)?;
|
|
let next_chain_key = mtp_crypto::hkdf_expand(
|
|
chain_key,
|
|
context.transcript_hash(),
|
|
&record_key_info(context, sequence, PIPE_RECORD_NEXT_LABEL),
|
|
32,
|
|
)?;
|
|
Ok((
|
|
message_key
|
|
.try_into()
|
|
.map_err(|_| EncryptedPipeError::InvalidContext)?,
|
|
next_chain_key
|
|
.try_into()
|
|
.map_err(|_| EncryptedPipeError::InvalidContext)?,
|
|
))
|
|
}
|
|
|
|
/// Writer for ordered, authenticated encrypted pipe records.
|
|
pub struct EncryptedPipeWriter<S> {
|
|
stream: S,
|
|
chain_key: Zeroizing<[u8; 32]>,
|
|
context: PipeProtectionContext,
|
|
sequence: u64,
|
|
state: PipeStreamState,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum PipeStreamState {
|
|
Open,
|
|
Finalized,
|
|
Failed,
|
|
}
|
|
|
|
impl<S> EncryptedPipeWriter<S> {
|
|
pub fn new(stream: S, key: [u8; 32], context: PipeProtectionContext) -> Self {
|
|
Self {
|
|
stream,
|
|
chain_key: Zeroizing::new(key),
|
|
context,
|
|
sequence: 0,
|
|
state: PipeStreamState::Open,
|
|
}
|
|
}
|
|
|
|
pub fn sequence(&self) -> u64 {
|
|
self.sequence
|
|
}
|
|
|
|
pub fn into_inner(self) -> S {
|
|
self.stream
|
|
}
|
|
}
|
|
|
|
impl<S: AsyncWrite + Unpin> EncryptedPipeWriter<S> {
|
|
/// Encrypt and append one record. Record boundaries are preserved by the
|
|
/// four-byte length prefix and are authenticated as associated data.
|
|
pub async fn write_record(&mut self, plaintext: &[u8]) -> Result<(), EncryptedPipeError> {
|
|
if self.state != PipeStreamState::Open {
|
|
return Err(EncryptedPipeError::InvalidState);
|
|
}
|
|
let result = self.write_record_inner(plaintext, RECORD_TYPE_DATA).await;
|
|
if result.is_err() {
|
|
self.poison();
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn write_record_inner(
|
|
&mut self,
|
|
plaintext: &[u8],
|
|
record_type: u8,
|
|
) -> Result<(), EncryptedPipeError> {
|
|
let sequence = self.sequence;
|
|
if sequence == u64::MAX {
|
|
return Err(EncryptedPipeError::SequenceExhausted);
|
|
}
|
|
let record_len = checked_record_length(plaintext.len())?;
|
|
let aad = record_aad(&self.context, sequence, record_len, record_type);
|
|
let (message_key, next_chain_key) =
|
|
derive_record_keys(&self.chain_key, &self.context, sequence)?;
|
|
let next_chain_key = Zeroizing::new(next_chain_key);
|
|
let cipher = XChaCha20Poly1305::new(message_key);
|
|
let ciphertext = cipher.encrypt(plaintext, &aad)?;
|
|
if ciphertext.len() != record_len {
|
|
return Err(EncryptedPipeError::InvalidRecordLength(ciphertext.len()));
|
|
}
|
|
|
|
self.stream
|
|
.write_all(&(record_len as u32).to_be_bytes())
|
|
.await?;
|
|
self.stream.write_all(&[record_type]).await?;
|
|
self.stream.write_all(&ciphertext).await?;
|
|
self.stream.flush().await?;
|
|
self.chain_key = next_chain_key;
|
|
self.sequence = sequence
|
|
.checked_add(1)
|
|
.ok_or(EncryptedPipeError::SequenceExhausted)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn poison(&mut self) {
|
|
self.chain_key.zeroize();
|
|
self.state = PipeStreamState::Failed;
|
|
}
|
|
|
|
/// Authenticate stream completion with a final empty record before
|
|
/// closing the underlying transport.
|
|
pub async fn finish(mut self) -> Result<(), EncryptedPipeError> {
|
|
if self.state != PipeStreamState::Open {
|
|
return Err(EncryptedPipeError::InvalidState);
|
|
}
|
|
if let Err(error) = self.write_record_inner(&[], RECORD_TYPE_FINAL).await {
|
|
self.poison();
|
|
return Err(error);
|
|
}
|
|
self.state = PipeStreamState::Finalized;
|
|
if let Err(error) = self.stream.shutdown().await {
|
|
self.poison();
|
|
return Err(error.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Reader for ordered, authenticated encrypted pipe records.
|
|
pub struct EncryptedPipeReader<R> {
|
|
stream: R,
|
|
chain_key: Zeroizing<[u8; 32]>,
|
|
context: PipeProtectionContext,
|
|
sequence: u64,
|
|
state: PipeStreamState,
|
|
}
|
|
|
|
impl<R> EncryptedPipeReader<R> {
|
|
pub fn new(stream: R, key: [u8; 32], context: PipeProtectionContext) -> Self {
|
|
Self {
|
|
stream,
|
|
chain_key: Zeroizing::new(key),
|
|
context,
|
|
sequence: 0,
|
|
state: PipeStreamState::Open,
|
|
}
|
|
}
|
|
|
|
pub fn sequence(&self) -> u64 {
|
|
self.sequence
|
|
}
|
|
|
|
pub fn into_inner(self) -> R {
|
|
self.stream
|
|
}
|
|
}
|
|
|
|
impl<R: AsyncRead + Unpin> EncryptedPipeReader<R> {
|
|
/// Read and authenticate the next record. `None` is returned only after a
|
|
/// valid authenticated final record; transport EOF alone is truncation.
|
|
pub async fn read_record(&mut self) -> Result<Option<Vec<u8>>, EncryptedPipeError> {
|
|
if self.state == PipeStreamState::Finalized {
|
|
return Ok(None);
|
|
}
|
|
if self.state == PipeStreamState::Failed {
|
|
return Err(EncryptedPipeError::InvalidState);
|
|
}
|
|
let result = self.read_record_inner().await;
|
|
if result.is_err() {
|
|
self.poison();
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn read_record_inner(&mut self) -> Result<Option<Vec<u8>>, EncryptedPipeError> {
|
|
if self.sequence == u64::MAX {
|
|
return Err(EncryptedPipeError::SequenceExhausted);
|
|
}
|
|
let mut prefix = [0u8; RECORD_LENGTH_BYTES];
|
|
self.stream.read_exact(&mut prefix).await.map_err(|error| {
|
|
if error.kind() == std::io::ErrorKind::UnexpectedEof {
|
|
EncryptedPipeError::FinalRecordRequired
|
|
} else {
|
|
EncryptedPipeError::Io(error)
|
|
}
|
|
})?;
|
|
|
|
let record_len = u32::from_be_bytes(prefix) as usize;
|
|
if !(XCHACHA_OVERHEAD..=MAX_ENCRYPTED_PIPE_RECORD).contains(&record_len) {
|
|
return Err(EncryptedPipeError::InvalidRecordLength(record_len));
|
|
}
|
|
let mut record_type = [0u8; RECORD_TYPE_BYTES];
|
|
self.stream
|
|
.read_exact(&mut record_type)
|
|
.await
|
|
.map_err(|error| {
|
|
if error.kind() == std::io::ErrorKind::UnexpectedEof {
|
|
EncryptedPipeError::UnexpectedEof
|
|
} else {
|
|
EncryptedPipeError::Io(error)
|
|
}
|
|
})?;
|
|
if !matches!(record_type[0], RECORD_TYPE_DATA | RECORD_TYPE_FINAL) {
|
|
return Err(EncryptedPipeError::InvalidRecordType(record_type[0]));
|
|
}
|
|
|
|
let mut ciphertext = vec![0u8; record_len];
|
|
self.stream
|
|
.read_exact(&mut ciphertext)
|
|
.await
|
|
.map_err(|error| {
|
|
if error.kind() == std::io::ErrorKind::UnexpectedEof {
|
|
EncryptedPipeError::UnexpectedEof
|
|
} else {
|
|
EncryptedPipeError::Io(error)
|
|
}
|
|
})?;
|
|
let sequence = self.sequence;
|
|
let aad = record_aad(&self.context, sequence, record_len, record_type[0]);
|
|
let (message_key, next_chain_key) =
|
|
derive_record_keys(&self.chain_key, &self.context, sequence)?;
|
|
let next_chain_key = Zeroizing::new(next_chain_key);
|
|
let cipher = XChaCha20Poly1305::new(message_key);
|
|
let plaintext = cipher.decrypt(&ciphertext, &aad)?;
|
|
self.chain_key = next_chain_key;
|
|
self.sequence = sequence
|
|
.checked_add(1)
|
|
.ok_or(EncryptedPipeError::SequenceExhausted)?;
|
|
if record_type[0] == RECORD_TYPE_FINAL {
|
|
if !plaintext.is_empty() {
|
|
return Err(EncryptedPipeError::InvalidRecordLength(plaintext.len()));
|
|
}
|
|
self.state = PipeStreamState::Finalized;
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(plaintext))
|
|
}
|
|
|
|
fn poison(&mut self) {
|
|
self.chain_key.zeroize();
|
|
self.state = PipeStreamState::Failed;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tokio::io::duplex;
|
|
|
|
#[test]
|
|
fn application_pipe_context_rejects_mtp_purposes() {
|
|
assert!(matches!(
|
|
PipeProtectionContext::new(b"application", PIPE_SESSION_SIGNATURE_PURPOSE, 0),
|
|
Err(EncryptedPipeError::InvalidContext)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn records_roundtrip_and_bind_context() {
|
|
let (left, right) = duplex(4096);
|
|
let context =
|
|
PipeProtectionContext::new(b"pipe-session/client/peer", 0x41, 0).expect("context");
|
|
let writer_context = context.clone();
|
|
let reader_context = context.clone();
|
|
let writer = tokio::spawn(async move {
|
|
let mut writer = EncryptedPipeWriter::new(left, [7u8; 32], writer_context);
|
|
writer.write_record(b"first").await.expect("first record");
|
|
writer.write_record(b"second").await.expect("second record");
|
|
writer.finish().await.expect("finish");
|
|
});
|
|
|
|
let mut reader = EncryptedPipeReader::new(right, [7u8; 32], reader_context);
|
|
assert_eq!(
|
|
reader.read_record().await.expect("read").as_deref(),
|
|
Some(b"first".as_slice())
|
|
);
|
|
assert_eq!(
|
|
reader.read_record().await.expect("read").as_deref(),
|
|
Some(b"second".as_slice())
|
|
);
|
|
assert!(reader.read_record().await.expect("eof").is_none());
|
|
writer.await.expect("writer task");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wrong_context_fails_authentication() {
|
|
let (left, right) = duplex(4096);
|
|
let writer_context = PipeProtectionContext::new(b"session-a", 1, 0).expect("context");
|
|
let reader_context = PipeProtectionContext::new(b"session-b", 1, 0).expect("context");
|
|
let writer = tokio::spawn(async move {
|
|
let mut writer = EncryptedPipeWriter::new(left, [9u8; 32], writer_context);
|
|
writer.write_record(b"secret").await.expect("write");
|
|
});
|
|
let mut reader = EncryptedPipeReader::new(right, [9u8; 32], reader_context);
|
|
assert!(matches!(
|
|
reader.read_record().await,
|
|
Err(EncryptedPipeError::Crypto(_))
|
|
));
|
|
assert!(matches!(
|
|
reader.read_record().await,
|
|
Err(EncryptedPipeError::InvalidState)
|
|
));
|
|
writer.await.expect("writer task");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn transport_eof_without_final_record_is_truncation() {
|
|
let (left, right) = duplex(4096);
|
|
let context = PipeProtectionContext::new(b"session", 0x40, 0).expect("context");
|
|
let mut writer = EncryptedPipeWriter::new(left, [3u8; 32], context.clone());
|
|
writer.write_record(b"not finished").await.expect("record");
|
|
let stream = writer.into_inner();
|
|
drop(stream);
|
|
|
|
let mut reader = EncryptedPipeReader::new(right, [3u8; 32], context);
|
|
assert_eq!(
|
|
reader.read_record().await.expect("record").as_deref(),
|
|
Some(b"not finished".as_slice())
|
|
);
|
|
assert!(matches!(
|
|
reader.read_record().await,
|
|
Err(EncryptedPipeError::FinalRecordRequired)
|
|
));
|
|
assert!(matches!(
|
|
reader.read_record().await,
|
|
Err(EncryptedPipeError::InvalidState)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn record_key_schedule_is_context_and_chain_bound() {
|
|
let context = PipeProtectionContext::new(b"session-a", 1, 0).expect("context");
|
|
let first = derive_record_keys(&[7u8; 32], &context, 0).expect("first keys");
|
|
let second = derive_record_keys(&first.1, &context, 1).expect("second keys");
|
|
let repeated = derive_record_keys(&[7u8; 32], &context, 1).expect("repeated keys");
|
|
let other_context = PipeProtectionContext::new(b"session-b", 1, 0).expect("context");
|
|
let other = derive_record_keys(&first.1, &other_context, 1).expect("other keys");
|
|
|
|
assert_ne!(first.0, second.0);
|
|
assert_ne!(second.0, repeated.0);
|
|
assert_ne!(second.0, other.0);
|
|
assert_ne!(second.1, other.1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn signed_session_offer_establishes_the_record_layer() {
|
|
let sender = Keyring::generate();
|
|
let recipient = Keyring::generate();
|
|
let sender_public = sender.public_key_bundle();
|
|
let recipient_public = recipient.public_key_bundle();
|
|
let params = PipeSessionParameters::new(b"session/client/peer/pipe-7", 7, 41, 99, 0x40, 0)
|
|
.expect("parameters");
|
|
let writer_params = params.clone();
|
|
let (left, right) = duplex(128 * 1024);
|
|
let writer_task = tokio::spawn(async move {
|
|
let mut writer = initiate_pipe_session(left, writer_params, &sender, &recipient_public)
|
|
.await
|
|
.expect("session offer");
|
|
writer
|
|
.write_record(b"authenticated pipe data")
|
|
.await
|
|
.expect("record");
|
|
writer.finish().await.expect("finish");
|
|
});
|
|
|
|
let mut reader = accept_pipe_session(right, ¶ms, &recipient, &sender_public)
|
|
.await
|
|
.expect("session accept");
|
|
assert_eq!(
|
|
reader.read_record().await.expect("record").as_deref(),
|
|
Some(b"authenticated pipe data".as_slice())
|
|
);
|
|
assert!(reader.read_record().await.expect("eof").is_none());
|
|
writer_task.await.expect("writer task");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_offer_accepts_a_trusted_historical_signing_key() {
|
|
let historical_sender = Keyring::generate();
|
|
let current_sender = Keyring::generate();
|
|
let recipient = Keyring::generate();
|
|
let recipient_public = recipient.public_key_bundle();
|
|
let historical_public = historical_sender.public_key_bundle();
|
|
let current_public = current_sender.public_key_bundle();
|
|
let params = PipeSessionParameters::new(b"historical-session", 8, 41, 99, 0x40, 0)
|
|
.expect("parameters");
|
|
let writer_params = params.clone();
|
|
let (left, right) = duplex(128 * 1024);
|
|
let writer_task = tokio::spawn(async move {
|
|
initiate_pipe_session(left, writer_params, &historical_sender, &recipient_public).await
|
|
});
|
|
let reader = accept_pipe_session_with_key_history(
|
|
right,
|
|
¶ms,
|
|
&recipient,
|
|
&[current_public, historical_public],
|
|
ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual),
|
|
)
|
|
.await
|
|
.expect("historical session offer");
|
|
let writer = writer_task.await.expect("writer task").expect("writer");
|
|
drop(writer);
|
|
// The successful setup is the assertion; no application record is
|
|
// needed to prove that the historical signature key was selected.
|
|
assert_eq!(reader.sequence(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn forward_secure_duplex_handshake_accepts_signing_key_history() {
|
|
let historical_sender = Keyring::generate();
|
|
let current_sender = Keyring::generate();
|
|
let recipient = Keyring::generate();
|
|
let historical_public = historical_sender.public_key_bundle();
|
|
let current_public = current_sender.public_key_bundle();
|
|
let recipient_public = recipient.public_key_bundle();
|
|
let params = PipeSessionParameters::new(b"forward-secure-session", 17, 41, 99, 0x40, 0)
|
|
.expect("parameters");
|
|
let responder_params = params.clone();
|
|
let (left, right) = duplex(256 * 1024);
|
|
let responder = tokio::spawn(async move {
|
|
accept_forward_secure_pipe_session_with_key_history(
|
|
right,
|
|
&responder_params,
|
|
&recipient,
|
|
&[current_public, historical_public],
|
|
ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual),
|
|
)
|
|
.await
|
|
});
|
|
let mut writer = initiate_forward_secure_pipe_session(
|
|
left,
|
|
params,
|
|
&historical_sender,
|
|
&recipient_public,
|
|
ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual),
|
|
)
|
|
.await
|
|
.expect("forward-secure initiator");
|
|
writer
|
|
.write_record(b"forward secret")
|
|
.await
|
|
.expect("record");
|
|
writer.finish().await.expect("finish");
|
|
let mut reader = responder.await.expect("responder task").expect("reader");
|
|
assert_eq!(
|
|
reader.read_record().await.expect("record").as_deref(),
|
|
Some(b"forward secret".as_slice())
|
|
);
|
|
assert!(reader.read_record().await.expect("final").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn group_session_offer_is_decryptable_by_each_current_member_only() {
|
|
let sender = Keyring::generate();
|
|
let first = Keyring::generate();
|
|
let second = Keyring::generate();
|
|
let outsider = Keyring::generate();
|
|
let params =
|
|
PipeSessionParameters::new(b"group-session", 11, 41, 99, 0x40, 0).expect("parameters");
|
|
let key = [8u8; 32];
|
|
let offer = build_session_offer(
|
|
¶ms,
|
|
&sender,
|
|
&[first.public_key_bundle(), second.public_key_bundle()],
|
|
key,
|
|
)
|
|
.expect("offer");
|
|
let encrypted = DataValue::from_bytes(&offer).expect("encrypted offer");
|
|
for member in [&first, &second] {
|
|
let opened = encrypted
|
|
.decrypt(
|
|
member,
|
|
ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE),
|
|
)
|
|
.expect("member decrypt");
|
|
let fields = opened
|
|
.as_signed()
|
|
.and_then(|value| value.value.as_array_slice())
|
|
.expect("signed fields");
|
|
assert_eq!(fields[7].as_bytes_slice(), Some(key.as_slice()));
|
|
}
|
|
assert!(matches!(
|
|
encrypted.decrypt(
|
|
&outsider,
|
|
ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE),
|
|
),
|
|
Err(ProtectionError::NoMatchingRecipient)
|
|
));
|
|
}
|
|
}
|