Skip to main content

actpub_activitystreams/
proof.rs

1//! [FEP-8b32 Object Integrity Proof][fep8b32] / W3C
2//! [Data Integrity 1.0][di] proof block.
3//!
4//! Object Integrity Proofs let any AS 2.0 object carry one or more
5//! cryptographic signatures inline in the document, independent of the
6//! HTTP transport. This is what FEP-8b32 calls a `proof` and what the
7//! W3C VC ecosystem calls a Data Integrity proof — they are the same
8//! shape.
9//!
10//! The single field that varies between cryptosuites is
11//! [`proof_value`](Proof::proof_value): an opaque multibase-encoded
12//! signature whose semantics depend on
13//! [`cryptosuite`](Proof::cryptosuite). For `eddsa-jcs-2022` the value
14//! is a base58btc-encoded raw Ed25519 signature over the JCS
15//! canonicalisation of the document with `proofValue` removed.
16//!
17//! This crate models the wire form only; the actual cryptosuite
18//! implementation lives in the upcoming `actpub-core` crate so that
19//! the data layer remains free of crypto dependencies.
20//!
21//! [fep8b32]: https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md
22//! [di]: https://www.w3.org/TR/vc-data-integrity/
23
24use std::collections::BTreeMap;
25
26use chrono::{DateTime, FixedOffset};
27use serde::{Deserialize, Serialize};
28use url::Url;
29
30/// FEP-8b32 / W3C Data Integrity Proof block.
31///
32/// The mandatory fields per FEP-8b32 §3.2 are
33/// [`type_`](Self::type_), [`cryptosuite`](Self::cryptosuite),
34/// [`created`](Self::created),
35/// [`verification_method`](Self::verification_method),
36/// [`proof_purpose`](Self::proof_purpose) and
37/// [`proof_value`](Self::proof_value). The remaining fields appear in
38/// specialised use cases (challenge–response auth, proof chaining).
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41#[allow(
42    clippy::struct_field_names,
43    reason = "the `proof_purpose`, `proof_value` and `previous_proof` field names are mandated verbatim by the W3C Data Integrity / FEP-8b32 vocabulary and cannot be renamed without breaking interoperability"
44)]
45pub struct Proof {
46    /// Discriminator for the proof scheme. MUST be
47    /// [`Proof::DATA_INTEGRITY_PROOF`] when the proof is a Data
48    /// Integrity / FEP-8b32 proof.
49    #[serde(rename = "type")]
50    pub type_: String,
51
52    /// Cryptosuite identifier (e.g. `"eddsa-jcs-2022"`,
53    /// `"eddsa-rdfc-2022"`, `"ecdsa-jcs-2019"`). FEP-8b32 currently
54    /// mandates `eddsa-jcs-2022` for Fediverse interoperability.
55    pub cryptosuite: String,
56
57    /// Wall-clock time at which the signer produced the proof. RFC 3339
58    /// / `xsd:dateTime` form on the wire.
59    pub created: DateTime<FixedOffset>,
60
61    /// URL identifying the verification method (key) used to produce
62    /// the signature. MUST resolve via the actor's `assertionMethod`
63    /// or `authentication` list.
64    pub verification_method: Url,
65
66    /// Reason the signature was generated. Per FEP-8b32 §3.2 always
67    /// `"assertionMethod"` for content-signing in the Fediverse;
68    /// `"authentication"` is reserved for challenge–response flows.
69    pub proof_purpose: String,
70
71    /// Opaque cryptosuite-specific signature value, multibase-encoded.
72    pub proof_value: String,
73
74    /// Optional domain to which this proof is bound (e.g. for
75    /// preventing cross-domain replay).
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub domain: Option<String>,
78
79    /// Optional challenge nonce for interactive authentication
80    /// proofs.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub challenge: Option<String>,
83
84    /// Optional URL of a previous proof in a proof chain (e.g. used by
85    /// FEP-1b12 group `Announce` re-signing).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub previous_proof: Option<Url>,
88
89    /// Forward-compatible bag for proof parameters that future
90    /// cryptosuites may add.
91    #[serde(flatten)]
92    pub extra: BTreeMap<String, serde_json::Value>,
93}
94
95impl Proof {
96    /// The mandatory `type` discriminator value for Data Integrity /
97    /// FEP-8b32 proofs.
98    pub const DATA_INTEGRITY_PROOF: &'static str = "DataIntegrityProof";
99
100    /// The `eddsa-jcs-2022` cryptosuite identifier — the Fediverse
101    /// baseline per FEP-8b32 §3.1.
102    pub const CRYPTOSUITE_EDDSA_JCS_2022: &'static str = "eddsa-jcs-2022";
103
104    /// The `proofPurpose` value used by FEP-8b32 to attest that the
105    /// signed document is asserted by the signer.
106    pub const PURPOSE_ASSERTION_METHOD: &'static str = "assertionMethod";
107
108    /// The `proofPurpose` value used by FEP-8b32 challenge-response
109    /// authentication.
110    pub const PURPOSE_AUTHENTICATION: &'static str = "authentication";
111}
112
113#[cfg(test)]
114mod tests {
115    use pretty_assertions::assert_eq;
116    use serde_json::json;
117
118    use super::*;
119
120    #[test]
121    fn fep_8b32_minimal_proof_roundtrips() {
122        // chrono serialises a UTC `FixedOffset` as `Z` (RFC 3339 short form);
123        // the test fixture uses the same form for byte-stable roundtrip.
124        let raw = json!({
125            "type": "DataIntegrityProof",
126            "cryptosuite": "eddsa-jcs-2022",
127            "created": "2026-04-20T10:00:00Z",
128            "verificationMethod": "https://example.com/users/alice#ed25519-key",
129            "proofPurpose": "assertionMethod",
130            "proofValue": "z3F4nT9mC8rE7QXJyV9hP2wKzN8sA5bL"
131        });
132        let proof: Proof = serde_json::from_value(raw.clone()).unwrap();
133        assert_eq!(proof.type_, Proof::DATA_INTEGRITY_PROOF);
134        assert_eq!(proof.cryptosuite, Proof::CRYPTOSUITE_EDDSA_JCS_2022);
135        assert_eq!(proof.proof_purpose, Proof::PURPOSE_ASSERTION_METHOD);
136        let back = serde_json::to_value(&proof).unwrap();
137        assert_eq!(back, raw);
138    }
139
140    #[test]
141    fn proof_with_challenge_and_previous_roundtrips() {
142        let raw = json!({
143            "type": "DataIntegrityProof",
144            "cryptosuite": "eddsa-jcs-2022",
145            "created": "2026-04-20T10:00:00Z",
146            "verificationMethod": "https://example.com/users/alice#ed25519-key",
147            "proofPurpose": "authentication",
148            "proofValue": "z3F4nT9mC8rE7QXJyV9hP2wKzN8sA5bL",
149            "domain": "example.com",
150            "challenge": "8b9c0d1e",
151            "previousProof": "https://example.com/proofs/123"
152        });
153        let proof: Proof = serde_json::from_value(raw.clone()).unwrap();
154        assert_eq!(proof.domain.as_deref(), Some("example.com"));
155        assert_eq!(proof.challenge.as_deref(), Some("8b9c0d1e"));
156        assert!(proof.previous_proof.is_some());
157        let back = serde_json::to_value(&proof).unwrap();
158        assert_eq!(back, raw);
159    }
160
161    #[test]
162    fn extra_proof_parameters_roundtrip() {
163        // Forward-compatible bag for cryptosuite-specific extensions.
164        let raw = json!({
165            "type": "DataIntegrityProof",
166            "cryptosuite": "future-suite-2030",
167            "created": "2026-04-20T10:00:00Z",
168            "verificationMethod": "https://example.com/key",
169            "proofPurpose": "assertionMethod",
170            "proofValue": "zABC",
171            "futureParam": "futureValue"
172        });
173        let proof: Proof = serde_json::from_value(raw.clone()).unwrap();
174        assert_eq!(proof.extra.len(), 1);
175        let back = serde_json::to_value(&proof).unwrap();
176        assert_eq!(back, raw);
177    }
178}