feat(sdk/crypto): add signature-verification primitives and keyring support

This commit is contained in:
nvrl committed 2026-06-25 15:54:03 +02:00
1 parent 1d889daa8c
commit edab8ff8e9
4 files changed
+291 -1

No files matched your search

+2
View File
@@ -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>;
+16
View File
@@ -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> {
+268
View File
@@ -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);
}
}
+5 -1
View File
@@ -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,