Skip to content

Commit 0b23431

Browse files
committed
email sig ntests
1 parent 6347c9c commit 0b23431

File tree

6 files changed

+210
-44
lines changed

6 files changed

+210
-44
lines changed

LINES_OF_CODE.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22
Language Files Lines Code Comments Blanks
33
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4-
Go 13 4810 3571 587 652
5-
Python 155 34534 27061 1621 5852
6-
TypeScript 9 3134 1660 1236 238
4+
Go 11 3858 2888 455 515
5+
Python 179 42456 33223 1867 7366
6+
TypeScript 29 7857 5604 1577 676
77
─────────────────────────────────────────────────────────────────────────────────
8-
Rust 354 101187 83405 5735 12047
9-
|- Markdown 260 25449 667 18762 6020
10-
(Total) 126636 84072 24497 18067
8+
Rust 393 123260 102168 6468 14624
9+
|- Markdown 298 26491 648 19682 6161
10+
(Total) 149751 102816 26150 20785
1111
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
12-
Total 531 169114 116364 27941 24809
12+
Total 612 203922 144531 30049 29342
1313
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

jacs/src/email/canonicalize.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ pub fn extract_email_parts(raw_email: &[u8]) -> Result<ParsedEmailParts, EmailEr
3434
.push(value);
3535
}
3636

37+
// Validate that the parsed message has a From header (RFC 5322 required).
38+
// mail_parser accepts garbage input and returns a Message with empty headers;
39+
// this gate ensures we fail early on non-email input.
40+
if !headers.contains_key("from") {
41+
return Err(EmailError::InvalidEmailFormat(
42+
"missing required From header".into(),
43+
));
44+
}
45+
3746
// Extract body parts
3847
let body_plain = extract_body_part(&message, "text/plain");
3948
let body_html = extract_body_part(&message, "text/html");
@@ -530,15 +539,6 @@ pub fn compute_attachment_hash(filename: &str, content_type: &str, raw_bytes: &[
530539
format!("sha256:{}", hex::encode(hash))
531540
}
532541

533-
/// Strip trailing whitespace bytes (CR, LF, SP, TAB) from a byte slice.
534-
pub(crate) fn strip_trailing_whitespace(bytes: &[u8]) -> &[u8] {
535-
let mut end = bytes.len();
536-
while end > 0 && matches!(bytes[end - 1], b'\r' | b'\n' | b' ' | b'\t') {
537-
end -= 1;
538-
}
539-
&bytes[..end]
540-
}
541-
542542
/// Strip trailing CRLF/LF bytes from MIME-decoded content.
543543
///
544544
/// Only strips line terminators (\r, \n), NOT spaces or tabs.
@@ -695,7 +695,7 @@ mod tests {
695695
#[test]
696696
fn extract_email_parts_returns_error_on_garbage() {
697697
let result = extract_email_parts(b"not an email at all");
698-
assert!(result.is_err() || result.unwrap().headers.is_empty());
698+
assert!(result.is_err(), "garbage input must return Err");
699699
}
700700

701701
#[test]

jacs/src/email/error.rs

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,32 @@ pub fn check_email_size(raw_email: &[u8]) -> Result<(), EmailError> {
8888

8989
impl From<EmailError> for crate::error::JacsError {
9090
fn from(e: EmailError) -> Self {
91-
crate::error::JacsError::CryptoError(e.to_string())
91+
use crate::error::JacsError;
92+
match &e {
93+
// Crypto-related errors
94+
EmailError::SignatureVerificationFailed(_)
95+
| EmailError::AlgorithmMismatch(_) => JacsError::CryptoError(e.to_string()),
96+
97+
// Validation / malformed-input errors
98+
EmailError::InvalidEmailFormat(_)
99+
| EmailError::CanonicalizationFailed(_)
100+
| EmailError::MissingJacsSignature
101+
| EmailError::InvalidJacsDocument(_)
102+
| EmailError::ContentTampered(_)
103+
| EmailError::EmailTooLarge { .. }
104+
| EmailError::UnsupportedFeature(_) => JacsError::ValidationError(e.to_string()),
105+
106+
// Document / structural errors
107+
EmailError::ChainVerificationFailed(_) => JacsError::DocumentError(e.to_string()),
108+
109+
// Network / registry errors
110+
EmailError::SignerKeyNotFound(_)
111+
| EmailError::DNSVerificationFailed(_) => JacsError::NetworkError(e.to_string()),
112+
113+
// Identity / trust errors
114+
EmailError::IdentityMismatch(_)
115+
| EmailError::ReplayDetected(_) => JacsError::TrustError(e.to_string()),
116+
}
92117
}
93118
}
94119

@@ -219,9 +244,33 @@ mod tests {
219244

220245
#[test]
221246
fn email_error_converts_to_jacs_error() {
222-
let email_err = EmailError::MissingJacsSignature;
223-
let jacs_err: crate::error::JacsError = email_err.into();
224-
let msg = format!("{}", jacs_err);
225-
assert!(msg.contains("Missing jacs-signature.json"));
247+
// Validation errors
248+
let jacs_err: crate::error::JacsError = EmailError::MissingJacsSignature.into();
249+
assert!(
250+
matches!(jacs_err, crate::error::JacsError::ValidationError(_)),
251+
"MissingJacsSignature should map to ValidationError, got: {:?}",
252+
jacs_err
253+
);
254+
assert!(format!("{}", jacs_err).contains("Missing jacs-signature.json"));
255+
256+
// Crypto errors
257+
let jacs_err: crate::error::JacsError =
258+
EmailError::SignatureVerificationFailed("bad sig".into()).into();
259+
assert!(matches!(jacs_err, crate::error::JacsError::CryptoError(_)));
260+
261+
// Network errors
262+
let jacs_err: crate::error::JacsError =
263+
EmailError::DNSVerificationFailed("timeout".into()).into();
264+
assert!(matches!(jacs_err, crate::error::JacsError::NetworkError(_)));
265+
266+
// Trust errors
267+
let jacs_err: crate::error::JacsError =
268+
EmailError::IdentityMismatch("wrong issuer".into()).into();
269+
assert!(matches!(jacs_err, crate::error::JacsError::TrustError(_)));
270+
271+
// Document errors
272+
let jacs_err: crate::error::JacsError =
273+
EmailError::ChainVerificationFailed("broken link".into()).into();
274+
assert!(matches!(jacs_err, crate::error::JacsError::DocumentError(_)));
226275
}
227276
}

jacs/src/email/sign.rs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,10 @@ fn prepare_for_forwarding(
168168
Err(e) => return Err(e),
169169
};
170170

171-
// Compute parent_signature_hash = sha256(normalized bytes of existing jacs-signature.json)
172-
// Strip trailing whitespace for consistency with verification-side hash computation
173-
let trimmed_jacs_bytes = strip_trailing_ws(&jacs_bytes);
171+
// Compute parent_signature_hash = sha256(exact bytes of existing jacs-signature.json)
174172
let parent_hash = {
175173
let mut hasher = Sha256::new();
176-
hasher.update(trimmed_jacs_bytes);
174+
hasher.update(&jacs_bytes);
177175
format!("sha256:{}", hex::encode(hasher.finalize()))
178176
};
179177

@@ -434,17 +432,14 @@ pub fn build_jacs_email_document(
434432
})
435433
}
436434

437-
// Use shared strip_trailing_whitespace from canonicalize module (DRY).
438-
use super::canonicalize::strip_trailing_whitespace as strip_trailing_ws;
439-
440435
/// Canonical JSON per RFC 8785 (JSON Canonicalization Scheme / JCS).
441436
///
442437
/// Uses the `serde_json_canonicalizer` crate for full compliance including:
443438
/// - Sorted keys
444439
/// - IEEE 754 number serialization
445440
/// - Minimal Unicode escape handling
446441
/// - No unnecessary whitespace
447-
pub(crate) fn canonicalize_json_rfc8785(value: &serde_json::Value) -> String {
442+
pub fn canonicalize_json_rfc8785(value: &serde_json::Value) -> String {
448443
serde_json_canonicalizer::to_string(value).unwrap_or_else(|_| "null".to_string())
449444
}
450445

jacs/src/email/types.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,53 @@ pub struct ContentVerificationResult {
210210
pub chain: Vec<ChainEntry>,
211211
}
212212

213+
/// HAI-specific email verification result that wraps JACS content verification
214+
/// and adds registry + DNS fields.
215+
///
216+
/// This is the canonical definition used by both haisdk and the HAI API.
217+
#[derive(Debug, Clone, Serialize, Deserialize)]
218+
pub struct EmailVerificationResultV2 {
219+
/// Overall validity: false if any verification step fails.
220+
pub valid: bool,
221+
/// JACS agent ID of the signer (from registry).
222+
#[serde(default)]
223+
pub jacs_id: String,
224+
/// Signing algorithm.
225+
#[serde(default)]
226+
pub algorithm: String,
227+
/// HAI reputation tier (e.g., "free_chaotic", "dns_certified", "fully_certified").
228+
#[serde(default)]
229+
pub reputation_tier: String,
230+
/// DNS verification result. None if DNS check was skipped (free tier).
231+
#[serde(skip_serializing_if = "Option::is_none")]
232+
pub dns_verified: Option<bool>,
233+
/// Per-field verification results (from JACS content verification).
234+
#[serde(default)]
235+
pub field_results: Vec<FieldResult>,
236+
/// Forwarding chain entries.
237+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
238+
pub chain: Vec<ChainEntry>,
239+
/// Error message if verification failed.
240+
#[serde(skip_serializing_if = "Option::is_none")]
241+
pub error: Option<String>,
242+
}
243+
244+
impl EmailVerificationResultV2 {
245+
/// Create an error result with the given fields.
246+
pub fn err(jacs_id: &str, reputation_tier: &str, error: &str) -> Self {
247+
Self {
248+
valid: false,
249+
jacs_id: jacs_id.to_string(),
250+
algorithm: String::new(),
251+
reputation_tier: reputation_tier.to_string(),
252+
dns_verified: None,
253+
field_results: Vec::new(),
254+
chain: Vec::new(),
255+
error: Some(error.to_string()),
256+
}
257+
}
258+
}
259+
213260
#[cfg(test)]
214261
mod tests {
215262
use super::*;

0 commit comments

Comments
 (0)