diff --git a/crates/proton-sdk/src/crypto.rs b/crates/proton-sdk/src/crypto.rs index f415cac..0154f3d 100644 --- a/crates/proton-sdk/src/crypto.rs +++ b/crates/proton-sdk/src/crypto.rs @@ -12,6 +12,7 @@ mod errors; mod keys; mod messages; mod srp; +mod verify; pub use content::ContentKey; pub use derive::derive_key_passphrase; @@ -19,6 +20,7 @@ pub use encrypt::{generate_node_hash_key, generate_node_key, GeneratedNodeKey}; pub use errors::CryptoError; pub use keys::{decrypt_armored_with_keys, PrivateKey}; pub use srp::{generate_proofs, SrpProofs, DEFAULT_BIT_LENGTH}; +pub use verify::{verify_detached, PublicKey, VerificationKeyRing, VerificationStatus}; /// Result alias for crypto operations. pub type CryptoResult = std::result::Result; diff --git a/crates/proton-sdk/src/crypto/keys.rs b/crates/proton-sdk/src/crypto/keys.rs index ae6b4c2..d9add88 100644 --- a/crates/proton-sdk/src/crypto/keys.rs +++ b/crates/proton-sdk/src/crypto/keys.rs @@ -51,6 +51,22 @@ impl PrivateKey { messages::decrypt_binary(ciphertext, &self.key, &self.password()) } + /// Decrypt an armored message addressed to this key and verify its inline + /// signature (if any) against `ring`. The plaintext is always returned; the + /// [`VerificationStatus`] is non-fatal metadata (C# `NodeCrypto.DecryptMessage`). + pub fn decrypt_armored_verify( + &self, + armored: &str, + ring: &super::VerificationKeyRing, + ) -> Result<(Vec, super::VerificationStatus), CryptoError> { + super::verify::decrypt_and_verify(armored, &self.key, &self.password(), ring) + } + + /// The public half of this key, for use as an anonymous-fallback verifier. + pub(crate) fn signed_public_key(&self) -> pgp::composed::SignedPublicKey { + self.key.signed_public_key() + } + /// Verify an armored detached signature over `data` against this key's /// public key. Returns `Ok(())` only on a good signature. pub fn verify_detached_signature(&self, armored_sig: &str, data: &[u8]) -> Result<(), CryptoError> { diff --git a/crates/proton-sdk/src/crypto/verify.rs b/crates/proton-sdk/src/crypto/verify.rs new file mode 100644 index 0000000..558a9ad --- /dev/null +++ b/crates/proton-sdk/src/crypto/verify.rs @@ -0,0 +1,268 @@ +//! Signature-verification primitives: public keys, a verification key ring and +//! a non-fatal verification status. +//! +//! Proton treats authorship verification as *metadata*, not a hard failure: a +//! decrypt always yields plaintext, and the caller is handed a +//! [`VerificationStatus`] describing whether the accompanying signature checked +//! out. This mirrors the C# SDK's `AuthorshipVerificationFailure` model, where +//! `NodeCrypto` records the `PgpVerificationStatus` of each decrypt rather than +//! throwing. + +use pgp::composed::{ + Deserializable, Message, SignedPublicKey, SignedSecretKey, StandaloneSignature, + VerificationResult, +}; +use pgp::types::{Password, PublicKeyTrait}; +use serde::{Deserialize, Serialize}; + +use super::errors::CryptoError; + +/// Outcome of an authorship/signature check. +/// +/// Non-fatal by design — mirrors C# `PgpVerificationStatus`, with an explicit +/// `NoVerifier` for the (common, for us) case where a signature is present but +/// no public verification key could be resolved. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum VerificationStatus { + /// A signature was present and verified against a trusted key. + Ok, + /// The message or data carried no signature. + NotSigned, + /// A signature was present but no verification key was available. + NoVerifier, + /// A signature was present but did not verify against any supplied key. + Failed, +} + +impl VerificationStatus { + /// Whether the signature verified successfully. + pub fn is_ok(self) -> bool { + matches!(self, VerificationStatus::Ok) + } +} + +/// A parsed PGP public key used to verify authorship signatures. +#[derive(Debug, Clone)] +pub struct PublicKey { + key: SignedPublicKey, +} + +impl PublicKey { + /// Parse an armored public key. + pub fn from_armored(armored: &str) -> Result { + let (key, _headers) = SignedPublicKey::from_string(armored) + .map_err(|e| CryptoError::Parse(format!("public key: {e}")))?; + Ok(Self { key }) + } + + fn signed(&self) -> &SignedPublicKey { + &self.key + } +} + +/// A set of public keys to verify a signature against. +/// +/// The C# `AuthorshipClaim.GetKeyRing` resolves to the claimed author's keys, +/// or — when the author is anonymous — falls back to the decryption key itself. +/// This type captures the resolved ring; an empty ring yields +/// [`VerificationStatus::NoVerifier`]. +#[derive(Debug, Clone, Default)] +pub struct VerificationKeyRing { + keys: Vec, +} + +impl VerificationKeyRing { + /// An empty ring: any present signature resolves to `NoVerifier`. + pub fn empty() -> Self { + Self::default() + } + + /// A ring of the supplied public keys. + pub fn from_public_keys(keys: &[PublicKey]) -> Self { + Self { + keys: keys.iter().map(|k| k.signed().clone()).collect(), + } + } + + /// A ring containing the public half of a private key — the anonymous + /// fallback (C# `AuthorshipClaim.GetKeyRing(anonymousFallbackKey)`). + pub fn from_private(key: &super::PrivateKey) -> Self { + Self { + keys: vec![key.signed_public_key()], + } + } + + /// A ring of a private key's public half *plus* the supplied public keys. + /// Used for content-key and hash-key signatures, where C# + /// `GetContentKeyAndHashKeyVerificationKeyRing` always includes the node + /// key alongside any claimed-author keys. + pub fn from_private_and_public_keys(key: &super::PrivateKey, public_keys: &[PublicKey]) -> Self { + let mut keys = Vec::with_capacity(public_keys.len() + 1); + keys.push(key.signed_public_key()); + keys.extend(public_keys.iter().map(|k| k.signed().clone())); + Self { keys } + } + + /// Whether the ring has no keys. + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } + + fn dyn_refs(&self) -> Vec<&dyn PublicKeyTrait> { + self.keys.iter().map(|k| k as &dyn PublicKeyTrait).collect() + } +} + +/// Verify an armored detached signature over `data` against `ring`. +pub fn verify_detached(armored_sig: &str, data: &[u8], ring: &VerificationKeyRing) -> VerificationStatus { + let signature = match StandaloneSignature::from_string(armored_sig) { + Ok((sig, _headers)) => sig, + Err(_) => return VerificationStatus::Failed, + }; + if ring.is_empty() { + return VerificationStatus::NoVerifier; + } + for key in &ring.keys { + if signature.verify(key, data).is_ok() { + return VerificationStatus::Ok; + } + } + VerificationStatus::Failed +} + +/// Decrypt an armored PGP message and verify its inline signature (if any) +/// against `ring`. Always returns the plaintext; the status is metadata. +pub(crate) fn decrypt_and_verify( + armored: &str, + key: &SignedSecretKey, + key_pw: &Password, + ring: &VerificationKeyRing, +) -> Result<(Vec, VerificationStatus), CryptoError> { + let (message, _headers) = + Message::from_string(armored).map_err(|e| CryptoError::Parse(format!("message: {e}")))?; + + let mut decrypted = message + .decrypt(key_pw, key) + .map_err(|e| CryptoError::Decrypt(e.to_string()))?; + + // `verify_nested` requires a decompressed message read to the end. + while decrypted.is_compressed() { + decrypted = decrypted + .decompress() + .map_err(|e| CryptoError::Decrypt(format!("decompress: {e}")))?; + } + + let data = decrypted + .as_data_vec() + .map_err(|e| CryptoError::Decrypt(format!("read literal: {e}")))?; + + let status = verify_message(&decrypted, ring); + Ok((data, status)) +} + +/// Classify an already-read, decompressed message against `ring`. +fn verify_message(message: &Message<'_>, ring: &VerificationKeyRing) -> VerificationStatus { + if !message.is_signed() && !message.is_one_pass_signed() { + return VerificationStatus::NotSigned; + } + if ring.is_empty() { + return VerificationStatus::NoVerifier; + } + match message.verify_nested(&ring.dyn_refs()) { + Ok(results) => { + if results + .iter() + .any(|r| matches!(r, VerificationResult::Valid(_))) + { + VerificationStatus::Ok + } else { + VerificationStatus::Failed + } + } + Err(_) => VerificationStatus::Failed, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::generate_node_key; + + #[test] + fn detached_signature_verifies_against_signer() { + let signer = generate_node_key().expect("generate signer key"); + let data = b"manifest bytes"; + let sig = signer.key.sign_detached(data).expect("sign"); + + let ring = VerificationKeyRing::from_private(&signer.key); + assert_eq!(verify_detached(&sig, data, &ring), VerificationStatus::Ok); + } + + #[test] + fn detached_signature_fails_against_wrong_key() { + let signer = generate_node_key().expect("generate signer key"); + let other = generate_node_key().expect("generate other key"); + let data = b"manifest bytes"; + let sig = signer.key.sign_detached(data).expect("sign"); + + let ring = VerificationKeyRing::from_private(&other.key); + assert_eq!(verify_detached(&sig, data, &ring), VerificationStatus::Failed); + } + + #[test] + fn detached_signature_fails_on_tampered_data() { + let signer = generate_node_key().expect("generate signer key"); + let sig = signer.key.sign_detached(b"manifest bytes").expect("sign"); + + let ring = VerificationKeyRing::from_private(&signer.key); + assert_eq!( + verify_detached(&sig, b"tampered bytes", &ring), + VerificationStatus::Failed + ); + } + + #[test] + fn detached_signature_without_verifier_is_no_verifier() { + let signer = generate_node_key().expect("generate signer key"); + let data = b"manifest bytes"; + let sig = signer.key.sign_detached(data).expect("sign"); + + assert_eq!( + verify_detached(&sig, data, &VerificationKeyRing::empty()), + VerificationStatus::NoVerifier + ); + } + + #[test] + fn inline_signed_message_round_trips_with_status() { + let signer = generate_node_key().expect("generate signer key"); + let armored = signer + .key + .encrypt_and_sign(&signer.key, b"hello world", false, true) + .expect("encrypt+sign"); + + let ring = VerificationKeyRing::from_private(&signer.key); + let (data, status) = signer + .key + .decrypt_armored_verify(&armored, &ring) + .expect("decrypt+verify"); + assert_eq!(data, b"hello world"); + assert_eq!(status, VerificationStatus::Ok); + } + + #[test] + fn inline_signed_message_without_verifier_is_no_verifier() { + let signer = generate_node_key().expect("generate signer key"); + let armored = signer + .key + .encrypt_and_sign(&signer.key, b"hello world", false, true) + .expect("encrypt+sign"); + + let (data, status) = signer + .key + .decrypt_armored_verify(&armored, &VerificationKeyRing::empty()) + .expect("decrypt+verify"); + assert_eq!(data, b"hello world"); + assert_eq!(status, VerificationStatus::NoVerifier); + } +} diff --git a/crates/proton-sdk/src/ids.rs b/crates/proton-sdk/src/ids.rs index eaec21f..d87f612 100644 --- a/crates/proton-sdk/src/ids.rs +++ b/crates/proton-sdk/src/ids.rs @@ -82,9 +82,13 @@ string_id!( /// Identifies a Drive link (node) within a volume. LinkId ); +string_id!( + /// Identifies a Drive volume event; doubles as the enumeration cursor. + DriveEventId +); /// Globally addresses a Drive node: a [`LinkId`] qualified by its [`VolumeId`]. -#[derive(Clone, PartialEq, Eq, Hash, Debug)] +#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] pub struct NodeUid { pub volume_id: VolumeId, pub link_id: LinkId,