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}