Skip to main content

dmsg_protocol/
signing.rs

1//! COSE profiles: three portable document profiles and a distinct typed app-action profile.
2//! Document payloads exclude execution context; app actions explicitly bind their receiver and origin.
3use crate::app_action::{validate_app_action, APP_ACTION_PROFILE};
4use crate::*;
5use cose2::{iana, Header, Key, Label, Sign1Message, Value, Verifier};
6use dmsg_types::*;
7
8/// Experimental dMsg text document profile media type (v1), used in protected header 16.
9pub const TEXT_PROFILE: &str = "application/vnd.dmsg.text-statement+cose;v=1";
10/// Experimental dMsg SHA-256 document profile media type (v1), used in protected header 16.
11pub const DIGEST_PROFILE: &str = "application/vnd.dmsg.digest-statement+cose;v=1";
12/// Experimental dMsg statement about one file (v1), used in protected header 16.
13pub const FILE_STATEMENT_PROFILE: &str = "application/vnd.dmsg.file-statement+cose;v=1";
14/// Content type of the deterministic CBOR file-statement payload.
15pub const FILE_STATEMENT_CONTENT_TYPE: &str = "application/cbor";
16/// Maximum encoded file-statement payload size (16,384 bytes).
17pub const MAX_FILE_STATEMENT_BYTES: usize = 16_384;
18/// Required content type of the text profile; not used in the digest profile.
19pub const TEXT_CONTENT_TYPE: &str = "text/plain;charset=utf-8";
20/// COSE header 15: protected CWT claims (issuer, optional subject and issued-at).
21pub const CWT_CLAIMS: i64 = 15;
22/// COSE header 16: protected document profile media type.
23pub const TYPE_HEADER: i64 = 16;
24/// COSE header 258: digest profile hash algorithm, fixed to SHA-256 (-16).
25pub const HASH_ALGORITHM: i64 = 258;
26/// COSE header 259: optional original-content media type in the digest profile.
27pub const PREIMAGE_CONTENT_TYPE: i64 = 259;
28/// COSE header 260: optional original-content URI in the digest profile.
29pub const PAYLOAD_LOCATION: i64 = 260;
30/// COSE header 270: unprotected timestamp token; its presence establishes no TSA trust.
31pub const CTT_HEADER: i64 = 270;
32/// Maximum document key identifier size in bytes (256); signing requires a nonempty kid.
33pub const MAX_KID_BYTES: usize = 256;
34/// Maximum opaque timestamp token size in bytes (131,072).
35pub const MAX_TIMESTAMP_BYTES: usize = 131_072;
36/// Maximum encoded public COSE_Key size in bytes (2,048).
37pub const MAX_COSE_KEY_BYTES: usize = 2048;
38/// Maximum COSE_Sign1 size in bytes (196,608), including optional timestamp evidence.
39pub const MAX_ARTIFACT_BYTES: usize = MAX_PAYLOAD + MAX_TIMESTAMP_BYTES;
40const CRITICAL: [Label; 3] = [
41    Label::Int(CWT_CLAIMS),
42    Label::Int(TYPE_HEADER),
43    Label::Int(HASH_ALGORITHM),
44];
45
46/// Closed wire schema, separate from the public preparation enum. Canonical
47/// decoding also rejects unknown/duplicate keys, explicit nulls and CBOR aliases.
48#[derive(cbor2::Cbor)]
49struct FileStatementPayload {
50    #[cbor(key = 1)]
51    text: String,
52    #[cbor(key = 2)]
53    sha256: Hash,
54    #[cbor(key = 3)]
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    content_type: Option<String>,
57    #[cbor(key = 4)]
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    location: Option<String>,
60}
61
62pub(crate) fn malformed(_: cose2::Error) -> Error {
63    Error::IntegrityFailed
64}
65
66/// The one document signature algorithm: Ed25519 (COSE -19).
67pub const COSE_ALGORITHM: Label = Label::Int(iana::AlgorithmEd25519);
68
69/// Select Statement for text/files, FileAttestation for digests and AppAction for typed actions.
70///
71/// This selects a derivation domain without validating the statement.
72pub fn statement_purpose(statement: &Statement) -> KeyPurpose {
73    match statement.content {
74        StatementContent::AppAction(_) => KeyPurpose::AppAction,
75        StatementContent::Text(_) | StatementContent::FileStatement { .. } => KeyPurpose::Statement,
76        StatementContent::Digest { .. } => KeyPurpose::FileAttestation,
77    }
78}
79
80fn media_type(value: &str) -> Result<()> {
81    ensure_valid(
82        value.len() <= 256 && value.parse::<mime::Mime>().is_ok(),
83        "media type",
84    )
85}
86
87/// Check the supported document profile's claims and content limits.
88///
89/// Issuer must be a canonical URI. Optional subject is nonempty, at most 8192
90/// UTF-8 bytes, has no control characters, and must be a URI when it contains
91/// `:`, following the StringOrURI rule. Text is 1..4096 bytes. File media types
92/// are at most 256 bytes; optional locations are canonical URIs. Claimed issued_at
93/// is not checked against a clock, and original content is not fetched or hashed.
94///
95/// # Errors
96/// Text size violations return `Error::QuotaExceeded`; invalid claims, media types
97/// or locations return `Error::InvalidInput`.
98pub fn validate_statement(statement: &Statement) -> Result<()> {
99    validate_uri(&statement.issuer)?;
100    if let StatementContent::AppAction(action) = &statement.content {
101        validate_app_action(action)?;
102        // No independently supplied prose/subject/time may contradict the action.
103        ensure(
104            statement.subject.is_none() && statement.issued_at.is_none(),
105            Error::UnsupportedProtocol,
106        )?;
107    }
108    if let Some(subject) = &statement.subject {
109        ensure_valid(
110            !subject.is_empty()
111                && subject.len() <= MAX_URI_BYTES
112                && !subject.chars().any(char::is_control),
113            "statement subject",
114        )?;
115        if subject.contains(':') {
116            validate_uri(subject)?;
117        }
118    }
119    if let StatementContent::Text(text) | StatementContent::FileStatement { text, .. } =
120        &statement.content
121    {
122        ensure(!text.is_empty() && text.len() <= 4096, Error::QuotaExceeded)?;
123    }
124    if let StatementContent::Digest {
125        content_type,
126        location,
127        ..
128    }
129    | StatementContent::FileStatement {
130        content_type,
131        location,
132        ..
133    } = &statement.content
134    {
135        if let Some(value) = content_type {
136            media_type(value)?;
137        }
138        if let Some(value) = location {
139            validate_uri(value)?;
140        }
141    }
142    Ok(())
143}
144
145fn parse_file_statement(payload: &[u8]) -> Result<StatementContent> {
146    ensure(
147        payload.len() <= MAX_FILE_STATEMENT_BYTES,
148        Error::QuotaExceeded,
149    )?;
150    let FileStatementPayload {
151        text,
152        sha256,
153        content_type,
154        location,
155    } = decode_canonical(payload)?;
156    Ok(StatementContent::FileStatement {
157        text,
158        sha256,
159        content_type,
160        location,
161    })
162}
163
164fn claims(statement: &Statement) -> Value {
165    let mut values = vec![(Value::from(1), Value::Text(statement.issuer.clone()))];
166    if let Some(subject) = &statement.subject {
167        values.push((Value::from(2), Value::Text(subject.clone())));
168    }
169    if let Some(at) = statement.issued_at {
170        values.push((Value::from(6), Value::from(at)));
171    }
172    Value::Map(values)
173}
174
175/// Prepare a document's COSE_Sign1 message and exact bytes to sign.
176///
177/// Returns `(unsigned_message, Sig_structure)`. Claims and profile headers are
178/// protected, content is encoded by its profile, and external AAD is empty.
179/// Ed25519 signs the returned bytes directly. The kid must contain 1..256 bytes.
180/// No key access, signing, approval or network call occurs here.
181///
182/// # Errors
183/// Returns statement validation errors, `Error::InvalidInput` for kid
184/// bounds, `Error::QuotaExceeded` for oversized signing input, or
185/// `Error::IntegrityFailed` if COSE preparation fails.
186pub fn prepare_cose(statement: &Statement, kid: &[u8]) -> Result<(Sign1Message, Vec<u8>)> {
187    validate_statement(statement)?;
188    ensure_valid(!kid.is_empty() && kid.len() <= MAX_KID_BYTES, "kid")?;
189    let payload = match &statement.content {
190        StatementContent::AppAction(action) => canonical(action),
191        StatementContent::Text(text) => text.as_bytes().to_vec(),
192        StatementContent::Digest { sha256, .. } => sha256.to_vec(),
193        StatementContent::FileStatement {
194            text,
195            sha256,
196            content_type,
197            location,
198        } => canonical(&FileStatementPayload {
199            text: text.clone(),
200            sha256: *sha256,
201            content_type: content_type.clone(),
202            location: location.clone(),
203        }),
204    };
205    let mut message = Sign1Message::new(Some(payload));
206    message.protected.set_kid(kid.to_vec());
207    message.protected.insert(CWT_CLAIMS, claims(statement));
208    match &statement.content {
209        StatementContent::AppAction(_) => {
210            message.protected.set_crit([CWT_CLAIMS, TYPE_HEADER]);
211            message.protected.insert(TYPE_HEADER, APP_ACTION_PROFILE);
212            message.protected.set_content_type("application/cbor");
213        }
214        StatementContent::Text(_) => {
215            message.protected.set_crit([CWT_CLAIMS, TYPE_HEADER]);
216            message.protected.insert(TYPE_HEADER, TEXT_PROFILE);
217            message.protected.set_content_type(TEXT_CONTENT_TYPE);
218        }
219        StatementContent::FileStatement { .. } => {
220            message.protected.set_crit([CWT_CLAIMS, TYPE_HEADER]);
221            message
222                .protected
223                .insert(TYPE_HEADER, FILE_STATEMENT_PROFILE);
224            message
225                .protected
226                .set_content_type(FILE_STATEMENT_CONTENT_TYPE);
227        }
228        StatementContent::Digest {
229            content_type,
230            location,
231            ..
232        } => {
233            message
234                .protected
235                .set_crit([CWT_CLAIMS, TYPE_HEADER, HASH_ALGORITHM]);
236            message.protected.insert(TYPE_HEADER, DIGEST_PROFILE);
237            message
238                .protected
239                .insert(HASH_ALGORITHM, iana::AlgorithmSHA_256);
240            if let Some(value) = content_type {
241                message
242                    .protected
243                    .insert(PREIMAGE_CONTENT_TYPE, value.clone());
244            }
245            if let Some(value) = location {
246                message.protected.insert(PAYLOAD_LOCATION, value.clone());
247            }
248        }
249    }
250    let tbs = message
251        .prepare_signature(Some(COSE_ALGORITHM), None, None)
252        .map_err(malformed)?;
253    ensure(tbs.len() <= MAX_PAYLOAD, Error::QuotaExceeded)?;
254    Ok((message, tbs))
255}
256
257fn text(header: &Header, label: i64) -> Result<Option<&str>> {
258    match header.get(label) {
259        None => Ok(None),
260        Some(Value::Text(value)) => Ok(Some(value)),
261        _ => Err(Error::IntegrityFailed),
262    }
263}
264
265fn parse_claims(header: &Header) -> Result<(String, Option<String>, Option<i64>)> {
266    let Some(Value::Map(values)) = header.get(CWT_CLAIMS) else {
267        return Err(Error::IntegrityFailed);
268    };
269    let (mut issuer, mut subject, mut issued_at) = (None, None, None);
270    for (key, value) in values {
271        match (key, value) {
272            (Value::Integer(key), Value::Text(value)) if *key == 1.into() && issuer.is_none() => {
273                issuer = Some(value.clone())
274            }
275            (Value::Integer(key), Value::Text(value)) if *key == 2.into() && subject.is_none() => {
276                subject = Some(value.clone())
277            }
278            (Value::Integer(key), Value::Integer(value))
279                if *key == 6.into() && issued_at.is_none() =>
280            {
281                issued_at = Some(i64::try_from(*value).map_err(|_| Error::IntegrityFailed)?)
282            }
283            _ => return Err(Error::UnsupportedProtocol),
284        }
285    }
286    Ok((issuer.ok_or(Error::IntegrityFailed)?, subject, issued_at))
287}
288
289fn parse_message(message: &Sign1Message) -> Result<(Statement, &[u8])> {
290    let headers = &message.protected;
291    ensure(
292        headers.alg().map_err(malformed)?.as_ref() == Some(&COSE_ALGORITHM),
293        Error::UnsupportedProtocol,
294    )?;
295    let kid = headers
296        .kid()
297        .map_err(malformed)?
298        .ok_or(Error::IntegrityFailed)?;
299    ensure(
300        !kid.is_empty() && kid.len() <= MAX_KID_BYTES,
301        Error::IntegrityFailed,
302    )?;
303    ensure(
304        message.unprotected.iter().all(|(label, value)| {
305            *label == Label::Int(CTT_HEADER)
306                && matches!(value, Value::Bytes(b) if !b.is_empty() && b.len() <= MAX_TIMESTAMP_BYTES)
307        }),
308        Error::UnsupportedProtocol,
309    )?;
310    let payload = message.payload.as_deref().ok_or(Error::IntegrityFailed)?;
311    let profile = text(headers, TYPE_HEADER)?.ok_or(Error::IntegrityFailed)?;
312    let (allowed, critical, content): (&[i64], &[Label], StatementContent) = match profile {
313        APP_ACTION_PROFILE => {
314            ensure(
315                text(headers, iana::HeaderParameterContentType)? == Some("application/cbor"),
316                Error::UnsupportedProtocol,
317            )?;
318            (
319                &[1, 2, 3, 4, 15, 16],
320                &CRITICAL[..2],
321                StatementContent::AppAction(Box::new(decode_canonical(payload)?)),
322            )
323        }
324        TEXT_PROFILE => {
325            ensure(
326                text(headers, iana::HeaderParameterContentType)? == Some(TEXT_CONTENT_TYPE),
327                Error::UnsupportedProtocol,
328            )?;
329            (
330                &[1, 2, 3, 4, 15, 16],
331                &CRITICAL[..2],
332                StatementContent::Text(
333                    std::str::from_utf8(payload)
334                        .map_err(|_| Error::IntegrityFailed)?
335                        .into(),
336                ),
337            )
338        }
339        FILE_STATEMENT_PROFILE => {
340            ensure(
341                text(headers, iana::HeaderParameterContentType)?
342                    == Some(FILE_STATEMENT_CONTENT_TYPE),
343                Error::UnsupportedProtocol,
344            )?;
345            (
346                &[1, 2, 3, 4, 15, 16],
347                &CRITICAL[..2],
348                parse_file_statement(payload)?,
349            )
350        }
351        DIGEST_PROFILE => {
352            ensure(
353                headers.get_i64(HASH_ALGORITHM).map_err(malformed)? == Some(iana::AlgorithmSHA_256),
354                Error::UnsupportedProtocol,
355            )?;
356            (
357                &[1, 2, 4, 15, 16, 258, 259, 260],
358                &CRITICAL,
359                StatementContent::Digest {
360                    sha256: Hash::new(payload.try_into().map_err(|_| Error::IntegrityFailed)?),
361                    content_type: text(headers, PREIMAGE_CONTENT_TYPE)?.map(str::to_owned),
362                    location: text(headers, PAYLOAD_LOCATION)?.map(str::to_owned),
363                },
364            )
365        }
366        _ => return Err(Error::UnsupportedProtocol),
367    };
368    ensure(
369        headers
370            .iter()
371            .all(|(label, _)| matches!(label, Label::Int(id) if allowed.contains(id))),
372        Error::UnsupportedProtocol,
373    )?;
374    let declared = headers
375        .crit()
376        .map_err(malformed)?
377        .ok_or(Error::IntegrityFailed)?;
378    ensure(
379        declared.len() == critical.len() && critical.iter().all(|label| declared.contains(label)),
380        Error::UnsupportedProtocol,
381    )?;
382    headers
383        .ensure_crit_understood(&CRITICAL)
384        .map_err(malformed)?;
385    let (issuer, subject, issued_at) = parse_claims(headers)?;
386    let statement = Statement {
387        issuer,
388        subject,
389        issued_at,
390        content,
391    };
392    validate_statement(&statement)?;
393    Ok((statement, kid))
394}
395
396/// Encode a public-only Ed25519 COSE_Key.
397///
398/// `public` is a raw 32-byte Ed25519 key, not CBOR. The result declares the
399/// algorithm and verify operation. Empty kid is allowed to compute a thumbprint
400/// before choosing a signing kid; otherwise kid is at most 256 bytes. This does
401/// not establish key ownership.
402///
403/// # Errors
404/// Invalid/weak Ed25519 keys return `Error::IntegrityFailed`; oversized kid
405/// returns `Error::InvalidInput`.
406pub fn public_cose_key(kid: &[u8], public: &[u8]) -> Result<Vec<u8>> {
407    ensure_valid(kid.len() <= MAX_KID_BYTES, "kid")?;
408    validate_ed25519_key(public)?;
409    let mut key = Key::new();
410    key.set_alg(COSE_ALGORITHM).set_kid(kid.to_vec());
411    key.set_ops([iana::KeyOperationVerify]);
412    key.set_kty(iana::KeyTypeOKP);
413    key.insert(iana::OKPKeyParameterCrv, iana::EllipticCurveEd25519);
414    key.insert(iana::OKPKeyParameterX, public.to_vec());
415    key.to_vec().map_err(malformed)
416}
417
418/// Compute the RFC 9679 SHA-256 thumbprint of required public COSE key members.
419///
420/// Excludes kid, alg and key_ops. This is not raw-public-key SHA-256 or a
421/// complete key/profile verifier.
422///
423/// # Errors
424/// Rejects keys above 2048 bytes, private d parameters, missing required members,
425/// key types other than OKP and malformed CBOR.
426pub fn key_thumbprint(encoded: &[u8]) -> Result<Hash> {
427    ensure(encoded.len() <= MAX_COSE_KEY_BYTES, Error::QuotaExceeded)?;
428    let key = Key::from_slice(encoded).map_err(malformed)?;
429    thumbprint(&key)
430}
431
432pub(crate) fn thumbprint(key: &Key) -> Result<Hash> {
433    ensure(
434        !key.contains_key(iana::OKPKeyParameterD),
435        Error::IntegrityFailed,
436    )?;
437    ensure(
438        key.kty().map_err(malformed)? == Some(Label::Int(iana::KeyTypeOKP)),
439        Error::UnsupportedProtocol,
440    )?;
441    let mut required = Key::new();
442    for label in [1, -1, -2] {
443        required.insert(
444            label,
445            key.get(label).ok_or(Error::IntegrityFailed)?.clone(),
446        );
447    }
448    Ok(sha256(&required.to_vec().map_err(malformed)?))
449}
450
451struct ProfileVerifier(cose2::ed25519::Ed25519Verifier);
452
453impl Verifier for ProfileVerifier {
454    fn alg(&self) -> Option<Label> {
455        Some(COSE_ALGORITHM)
456    }
457
458    fn understood_critical_headers(&self) -> &[Label] {
459        &CRITICAL
460    }
461
462    fn verify(&self, bytes: &[u8], signature: &[u8]) -> std::result::Result<(), cose2::Error> {
463        self.0.verify(bytes, signature)
464    }
465}
466
467fn verifier(key: &Key) -> Result<ProfileVerifier> {
468    ensure(
469        !key.contains_key(iana::OKPKeyParameterD)
470            && key
471                .allows_any_operation(&[iana::KeyOperationVerify])
472                .map_err(malformed)?,
473        Error::IntegrityFailed,
474    )?;
475    ensure(
476        key.alg().map_err(malformed)? == Some(COSE_ALGORITHM),
477        Error::IntegrityFailed,
478    )?;
479    Ok(ProfileVerifier(
480        cose2::ed25519::Ed25519Verifier::from_cose_key(key).map_err(malformed)?,
481    ))
482}
483
484/// Verify supported COSE document profiles and their mathematical signature.
485///
486/// Checks tagged COSE_Sign1, protected Ed25519 algorithm/kid/claims/profile,
487/// critical headers, public-key constraints and signature. Preserves original
488/// protected bytes. An attached public key is not an identity credential; this
489/// does not check original digest content, issuer ownership, authorization,
490/// current status, or TSA trust.
491///
492/// # Errors
493/// Size bounds yield `Error::QuotaExceeded`, unknown semantics yield
494/// `Error::UnsupportedProtocol`, and malformed data/key/signature mismatches
495/// usually yield `Error::IntegrityFailed`. Claim validation errors also propagate.
496pub fn verify_artifact(artifact: &SignedArtifact) -> Result<Statement> {
497    Ok(verify_and_parse_artifact(artifact)?.statement)
498}
499
500/// Internal, per-call reuse of verified data; never a cache or a wire type.
501pub(crate) struct VerifiedArtifact {
502    pub statement: Statement,
503    pub message: Sign1Message,
504    pub key: Key,
505}
506
507impl VerifiedArtifact {
508    pub fn signing_bytes(&self) -> Result<Vec<u8>> {
509        Sign1Message::to_be_signed(
510            self.message.protected_raw(),
511            &[],
512            self.message
513                .payload
514                .as_deref()
515                .ok_or(Error::IntegrityFailed)?,
516        )
517        .map_err(malformed)
518    }
519}
520
521pub(crate) fn verify_and_parse_artifact(artifact: &SignedArtifact) -> Result<VerifiedArtifact> {
522    ensure(
523        artifact.cose_sign1.len() <= MAX_ARTIFACT_BYTES
524            && artifact.cose_key.len() <= MAX_COSE_KEY_BYTES,
525        Error::QuotaExceeded,
526    )?;
527    ensure(
528        artifact.cose_sign1.first() == Some(&0xd2),
529        Error::IntegrityFailed,
530    )?;
531    let message = Sign1Message::from_slice(&artifact.cose_sign1).map_err(malformed)?;
532    let (statement, kid) = parse_message(&message)?;
533    let key = Key::from_slice(&artifact.cose_key).map_err(malformed)?;
534    ensure(
535        key.kid().map_err(malformed)?.is_none_or(|id| id == kid),
536        Error::IntegrityFailed,
537    )?;
538    message.verify(&verifier(&key)?, None).map_err(malformed)?;
539    Ok(VerifiedArtifact {
540        statement,
541        message,
542        key,
543    })
544}
545
546/// Hash raw signature bytes extracted from a COSE_Sign1 message.
547///
548/// Used for execution-receipt matching; the hash excludes CBOR framing.
549/// This extraction helper does not verify the signature,
550/// dMsg profile, canonical outer encoding or key ownership.
551///
552/// # Errors
553/// Oversized artifacts return `Error::QuotaExceeded`; COSE decoding errors return
554/// `Error::IntegrityFailed`.
555pub fn signature_digest(cose_sign1: &[u8]) -> Result<Hash> {
556    ensure(cose_sign1.len() <= MAX_ARTIFACT_BYTES, Error::QuotaExceeded)?;
557    Ok(sha256(
558        Sign1Message::from_slice(cose_sign1)
559            .map_err(malformed)?
560            .signature(),
561    ))
562}