vpr_core/
author.rs

1//! Author-related types and functions.
2//!
3//! This module contains types and utilities for handling author information,
4//! signatures, and commit validation in the VPR system.
5
6use crate::error::{PatientError, PatientResult};
7use crate::{EmailAddress, NonEmptyText};
8use base64::{engine::general_purpose, Engine as _};
9use serde::Deserialize;
10use x509_parser::prelude::*;
11
12/// Represents an author of a commit or record operation.
13#[derive(Clone, Debug)]
14pub struct Author {
15    /// The full name of the author.
16    pub name: NonEmptyText,
17
18    /// The professional role of the author (e.g., "Clinician", "Nurse").
19    pub role: NonEmptyText,
20
21    /// The email address of the author.
22    pub email: EmailAddress,
23
24    /// Professional registrations for the author (e.g., GMC number, NMC PIN).
25    pub registrations: Vec<AuthorRegistration>,
26
27    /// Optional digital signature for the commit.
28    pub signature: Option<Vec<u8>>,
29
30    /// Optional X.509 certificate for the author.
31    pub certificate: Option<Vec<u8>>,
32}
33
34/// Material embedded in the Git commit object to enable offline verification.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct EmbeddedCommitSignature {
37    /// Raw 64-byte ECDSA P-256 signature (`r || s`).
38    pub signature: Vec<u8>,
39    /// SEC1-encoded public key bytes.
40    pub public_key: Vec<u8>,
41    /// Optional X.509 certificate bytes (PEM or DER).
42    pub certificate: Option<Vec<u8>>,
43}
44
45#[derive(Deserialize)]
46struct VprCommitSignaturePayloadV1 {
47    signature: String,
48    public_key: String,
49    #[serde(default)]
50    certificate: Option<String>,
51}
52
53fn extract_cert_public_key_sec1(cert_bytes: &[u8]) -> PatientResult<Vec<u8>> {
54    let cert_der: Vec<u8> = if cert_bytes
55        .windows("-----BEGIN CERTIFICATE-----".len())
56        .any(|w| w == b"-----BEGIN CERTIFICATE-----")
57    {
58        let (_, pem) = x509_parser::pem::parse_x509_pem(cert_bytes)
59            .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))?;
60        pem.contents.to_vec()
61    } else {
62        cert_bytes.to_vec()
63    };
64
65    let (_, cert) = X509Certificate::from_der(cert_der.as_slice())
66        .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))?;
67    let spk = cert.public_key();
68    Ok(spk.subject_public_key.data.to_vec())
69}
70
71/// Extract the embedded signature material from a commit.
72///
73/// VPR stores a base64-encoded JSON container in the commit's `gpgsig` header that includes:
74/// - `signature` (base64 raw 64-byte `r||s`)
75/// - `public_key` (base64 SEC1 public key bytes)
76/// - optional `certificate` (base64 of PEM or DER bytes)
77///
78/// If a certificate is present, this validates that it corresponds to the embedded public key.
79pub fn extract_embedded_commit_signature(
80    commit: &git2::Commit<'_>,
81) -> PatientResult<EmbeddedCommitSignature> {
82    let sig_field = commit
83        .header_field_bytes("gpgsig")
84        .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
85    if sig_field.is_empty() {
86        return Err(PatientError::InvalidCommitSignaturePayload);
87    }
88
89    let sig_field_str = std::str::from_utf8(sig_field.as_ref())
90        .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
91    let sig_b64: String = sig_field_str.lines().map(|l| l.trim()).collect();
92
93    let payload_bytes = general_purpose::STANDARD
94        .decode(sig_b64)
95        .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
96    let payload: VprCommitSignaturePayloadV1 = serde_json::from_slice(&payload_bytes)
97        .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
98
99    let signature = general_purpose::STANDARD
100        .decode(payload.signature)
101        .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
102    let public_key = general_purpose::STANDARD
103        .decode(payload.public_key)
104        .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
105    let certificate = match payload.certificate {
106        Some(cert_b64) => Some(
107            general_purpose::STANDARD
108                .decode(cert_b64)
109                .map_err(|_| PatientError::InvalidCommitSignaturePayload)?,
110        ),
111        None => None,
112    };
113
114    if let Some(cert_bytes) = certificate.as_deref() {
115        let cert_public_key = extract_cert_public_key_sec1(cert_bytes)?;
116        if cert_public_key != public_key {
117            return Err(PatientError::AuthorCertificatePublicKeyMismatch);
118        }
119    }
120
121    Ok(EmbeddedCommitSignature {
122        signature,
123        public_key,
124        certificate,
125    })
126}
127
128/// A declared professional registration for an author.
129///
130/// This is rendered in commit trailers as:
131///
132/// `Author-Registration: <authority> <number>`
133#[derive(Clone, Debug, Eq, PartialEq, Hash)]
134pub struct AuthorRegistration {
135    pub authority: NonEmptyText,
136    pub number: NonEmptyText,
137}
138
139impl AuthorRegistration {
140    pub fn new(authority: impl Into<String>, number: impl Into<String>) -> PatientResult<Self> {
141        let authority_str = authority.into().trim().to_string();
142        let number_str = number.into().trim().to_string();
143
144        if authority_str.is_empty()
145            || number_str.is_empty()
146            || authority_str.contains(['\n', '\r'])
147            || number_str.contains(['\n', '\r'])
148            || authority_str.chars().any(char::is_whitespace)
149            || number_str.chars().any(char::is_whitespace)
150        {
151            return Err(PatientError::InvalidAuthorRegistration);
152        }
153
154        let authority = NonEmptyText::new(authority_str)
155            .map_err(|_| PatientError::InvalidAuthorRegistration)?;
156        let number =
157            NonEmptyText::new(number_str).map_err(|_| PatientError::InvalidAuthorRegistration)?;
158
159        Ok(Self { authority, number })
160    }
161}
162
163impl Author {
164    /// Validate that this author contains the mandatory commit author metadata.
165    ///
166    /// This validation is intended to run before commit creation/signing.
167    pub fn validate_commit_author(&self) -> PatientResult<()> {
168        // Role is guaranteed non-empty by NonEmptyText type
169        // Authority and number are guaranteed non-empty by NonEmptyText type
170
171        for reg in &self.registrations {
172            AuthorRegistration::new(reg.authority.as_str(), reg.number.as_str())?;
173        }
174
175        Ok(())
176    }
177}
178
179#[cfg(test)]
180mod author_tests {
181    use super::*;
182
183    fn base_author() -> Author {
184        Author {
185            name: NonEmptyText::new("Test Author").unwrap(),
186            role: NonEmptyText::new("Clinician").unwrap(),
187            email: EmailAddress::parse("test@example.com").unwrap(),
188            registrations: vec![],
189            signature: None,
190            certificate: None,
191        }
192    }
193
194    #[test]
195    fn validate_commit_author_rejects_invalid_registration() {
196        let _author = base_author();
197        // Try to create registration with invalid authority (contains space)
198        let err =
199            AuthorRegistration::new("G MC", "12345").expect_err("expected validation failure");
200        assert!(matches!(err, PatientError::InvalidAuthorRegistration));
201    }
202
203    #[test]
204    fn validate_commit_author_accepts_valid_author() {
205        let mut author = base_author();
206        author.registrations =
207            vec![AuthorRegistration::new("GMC", "12345").expect("valid registration")];
208
209        author
210            .validate_commit_author()
211            .expect("expected validation to succeed");
212    }
213}