mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
feat(sdk/crypto): add signature-verification primitives and keyring support
This commit is contained in:
1 parent
1d889daa8c
commit
edab8ff8e9
4 files changed
+291
-1
No files matched your search
@@ -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<T> = std::result::Result<T, CryptoError>;
|
||||
@@ -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<u8>, 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> {
|
||||
|
||||
@@ -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<Self, CryptoError> {
|
||||
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<SignedPublicKey>,
|
||||
}
|
||||
|
||||
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<u8>, 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user