Skip to main content

peerbadge_protocol/
types.rs

1use blind_rsa_signatures::{
2    BlindMessage as PbrsaBlindMessage, BlindSignature as PbrsaBlindSignature,
3    Signature as PbrsaSignature,
4};
5use nostr::secp256k1::{schnorr::Signature, Message};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use serde_json::Value;
8use serde_with::serde_as;
9use sha2::{digest::Output, Digest, Sha256};
10use std::str::FromStr;
11
12use crate::serde::{
13    Base64UrlUnpadded, PbrsaPublicKeyBase64UrlUnpadded, SchnorrSignatureBase64UrlUnpadded,
14    Sha256DigestBase64UrlUnpadded,
15};
16use crate::{
17    canonicalize_credential, canonicalize_issuer_authority, canonicalize_revocation,
18    CredentialsError, PbrsaPublicKey,
19};
20
21/// Protocol version marker used by the MVP credential format.
22///
23/// Version 1 implies the v1 canonicalization and blind-signature suite choices;
24/// those are not repeated as per-credential `suite`/`alg` fields.
25///
26/// Serializes as the JSON number `1`. Deserialization rejects all other version
27/// numbers.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct ProtocolV1;
30
31impl Serialize for ProtocolV1 {
32    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
33    where
34        S: Serializer,
35    {
36        serializer.serialize_u8(1)
37    }
38}
39
40impl<'de> Deserialize<'de> for ProtocolV1 {
41    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42    where
43        D: Deserializer<'de>,
44    {
45        let version = u16::deserialize(deserializer)?;
46        if version == 1 {
47            Ok(Self)
48        } else {
49            Err(serde::de::Error::custom(format_args!(
50                "unsupported protocol version: {version}"
51            )))
52        }
53    }
54}
55
56/// Issuer identifier.
57///
58/// Issuer identities are hard-bound to Nostr public keys.
59#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
60#[serde(transparent)]
61pub struct IssuerId(pub nostr::PublicKey);
62
63impl FromStr for IssuerId {
64    type Err = nostr::key::Error;
65
66    fn from_str(value: &str) -> Result<Self, Self::Err> {
67        nostr::PublicKey::parse(value).map(Self)
68    }
69}
70
71/// Holder identifier.
72///
73/// Holder identities are Nostr public keys encoded with the SDK's existing
74/// Nostr key serialization.
75#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
76#[serde(transparent)]
77pub struct HolderId(pub nostr::PublicKey);
78
79impl FromStr for HolderId {
80    type Err = nostr::key::Error;
81
82    fn from_str(value: &str) -> Result<Self, Self::Err> {
83        nostr::PublicKey::parse(value).map(Self)
84    }
85}
86
87/// Unix timestamp in seconds.
88///
89/// Serializes as a JSON number while keeping protocol timestamp fields strongly
90/// typed in Rust.
91#[derive(
92    Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
93)]
94#[serde(transparent)]
95pub struct Timestamp(pub u64);
96
97impl Timestamp {
98    /// Return the timestamp as seconds since the Unix epoch.
99    pub const fn as_secs(self) -> u64 {
100        self.0
101    }
102}
103
104impl From<u64> for Timestamp {
105    fn from(value: u64) -> Self {
106        Self(value)
107    }
108}
109
110impl From<Timestamp> for u64 {
111    fn from(value: Timestamp) -> Self {
112        value.0
113    }
114}
115
116/// SHA-256 digest of a finalized credential payload.
117#[serde_as]
118#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
119#[serde(transparent)]
120pub struct CredentialDigest(#[serde_as(as = "Sha256DigestBase64UrlUnpadded")] pub Output<Sha256>);
121
122impl From<Output<Sha256>> for CredentialDigest {
123    fn from(value: Output<Sha256>) -> Self {
124        Self(value)
125    }
126}
127
128/// JSON-friendly issuer secret export.
129#[serde_as]
130#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct IssuerSecretKeys {
132    pub issuer_id_secret_key: String,
133    #[serde_as(as = "Base64UrlUnpadded")]
134    pub issuance_secret_key: Vec<u8>,
135}
136
137/// Signed issuer metadata used by verifiers before accepting credentials.
138#[serde_as]
139#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
140pub struct IssuerAuthority {
141    pub version: ProtocolV1,
142    pub issuer: Issuer,
143    pub proof: SchnorrSignatureProof,
144}
145
146/// Schnorr signature proof encoded for JSON.
147#[serde_as]
148#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
149pub struct SchnorrSignatureProof {
150    #[serde_as(as = "SchnorrSignatureBase64UrlUnpadded")]
151    pub signature: Signature,
152}
153
154impl IssuerAuthority {
155    /// Compute the signature digest for this issuer authority payload.
156    pub fn digest(&self) -> Result<Output<Sha256>, CredentialsError> {
157        self.issuer.digest()
158    }
159
160    /// Verify this issuer authority's identity signature and return the issuer metadata.
161    pub fn verify(&self) -> Result<Issuer, CredentialsError> {
162        validate_revocation_locations(&self.issuer.revocation)?;
163
164        verify_identity_signature(
165            &self.issuer.issuer_id_pubkey,
166            &self.proof.signature,
167            Message::from_digest(self.digest()?.into()),
168        )?;
169
170        Ok(self.issuer.clone())
171    }
172}
173
174/// Issuer metadata signed by the issuer identity key.
175#[serde_as]
176#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
177pub struct Issuer {
178    pub issuer_id_pubkey: IssuerId,
179    /// PBRSA public key used to verify credentials.
180    ///
181    /// Serializes as DER encoded with unpadded URL-safe base64.
182    #[serde_as(as = "PbrsaPublicKeyBase64UrlUnpadded")]
183    pub issuance_key: PbrsaPublicKey,
184    pub revocation: Vec<RevocationLocation>,
185}
186
187impl Issuer {
188    /// Compute the signature digest for this issuer metadata.
189    pub fn digest(&self) -> Result<Output<Sha256>, CredentialsError> {
190        let canonical = canonicalize_issuer_authority(self)?;
191        Ok(Sha256::new()
192            .chain_update(ISSUER_AUTHORITY_SIGNATURE_DOMAIN_SEPARATOR)
193            .chain_update(canonical)
194            .finalize())
195    }
196}
197
198/// Location where issuer revocations may be published.
199#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
200pub struct RevocationLocation {
201    pub protocol: String,
202    pub location: String,
203}
204
205/// Signed revocation wire object.
206#[serde_as]
207#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
208pub struct SignedRevocation {
209    pub version: ProtocolV1,
210    pub revocation: Revocation,
211    pub proof: RevocationProof,
212}
213
214/// Issuer proof for a signed revocation.
215#[serde_as]
216#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
217pub struct RevocationProof {
218    pub issuer_id_pubkey: IssuerId,
219    #[serde_as(as = "SchnorrSignatureBase64UrlUnpadded")]
220    pub signature: Signature,
221}
222
223impl SignedRevocation {
224    /// Compute the signature digest for this revocation payload.
225    pub fn digest(&self) -> Result<Output<Sha256>, CredentialsError> {
226        self.revocation.digest()
227    }
228
229    /// Verify this revocation's issuer signature and return the revocation payload.
230    pub fn verify(&self) -> Result<Revocation, CredentialsError> {
231        verify_identity_signature(
232            &self.proof.issuer_id_pubkey,
233            &self.proof.signature,
234            Message::from_digest(self.digest()?.into()),
235        )?;
236
237        Ok(self.revocation.clone())
238    }
239}
240
241/// Revocation payload signed by the issuer identity key.
242#[serde_as]
243#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
244pub struct Revocation {
245    /// SHA-256 digest of the finalized credential.
246    pub credential_digest: CredentialDigest,
247}
248
249impl Revocation {
250    /// Compute the signature digest for this revocation payload.
251    pub fn digest(&self) -> Result<Output<Sha256>, CredentialsError> {
252        let canonical = canonicalize_revocation(self)?;
253        Ok(Sha256::new()
254            .chain_update(REVOCATION_SIGNATURE_DOMAIN_SEPARATOR)
255            .chain_update(canonical)
256            .finalize())
257    }
258}
259
260/// Domain separator for issuer authority identity signatures.
261pub const ISSUER_AUTHORITY_SIGNATURE_DOMAIN_SEPARATOR: &[u8] =
262    b"fedi-credential/issuer-authority-signature/v1\0";
263
264/// Domain separator for revocation identity signatures.
265pub const REVOCATION_SIGNATURE_DOMAIN_SEPARATOR: &[u8] =
266    b"fedi-credential/revocation-signature/v1\0";
267
268fn validate_revocation_locations(locations: &[RevocationLocation]) -> Result<(), CredentialsError> {
269    if locations
270        .iter()
271        .any(|location| location.protocol.is_empty() || location.location.is_empty())
272    {
273        return Err(CredentialsError::VerificationFailed);
274    }
275
276    Ok(())
277}
278
279fn verify_identity_signature(
280    issuer_id: &IssuerId,
281    signature: &Signature,
282    message: Message,
283) -> Result<(), CredentialsError> {
284    verify_identity_signature_with_key(&issuer_id.0, signature, message)
285}
286
287pub(crate) fn verify_identity_signature_with_key(
288    identity_public_key: &nostr::PublicKey,
289    signature: &Signature,
290    message: Message,
291) -> Result<(), CredentialsError> {
292    let public_key = identity_public_key
293        .xonly()
294        .map_err(|_| CredentialsError::VerificationFailed)?;
295
296    nostr::SECP256K1
297        .verify_schnorr(signature, &message, &public_key)
298        .map_err(|_| CredentialsError::VerificationFailed)
299}
300
301/// Domain separator prepended to canonical credential JSON before hashing.
302pub const CREDENTIAL_DIGEST_DOMAIN_SEPARATOR: &[u8] = b"fedi-credential/credential-digest/v1\0";
303
304/// Final holder credential.
305///
306/// `info` is the JSON value visible to the issuer during issuance.
307/// `blind_msg` is the JSON value hidden from the issuer while signing and
308/// disclosed in the final credential. For the current Fedi/Nostr use case this
309/// will likely contain a Nostr holder public key, but that is application data,
310/// not a protocol-level field.
311///
312/// The credential revocation digest is computed over the canonical credential
313/// payload, excluding the proof.
314#[serde_as]
315#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
316pub struct SignedCredential {
317    pub version: ProtocolV1,
318    pub credential: Credential,
319    pub proof: CredentialProof,
320}
321
322/// Final credential payload signed by the issuance key.
323#[serde_as]
324#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
325pub struct Credential {
326    pub issuer_id_pubkey: IssuerId,
327    pub info: Value,
328    pub blind_msg: Value,
329}
330
331/// Issuance proof for a finalized credential payload.
332#[serde_as]
333#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
334pub struct CredentialProof {
335    /// PBRSA credential signature bytes.
336    ///
337    /// Serializes as unpadded URL-safe base64.
338    #[serde_as(as = "Base64UrlUnpadded")]
339    pub signature: PbrsaSignature,
340}
341
342impl Credential {
343    /// Compute the revocation digest for this credential payload.
344    pub fn digest(&self) -> Result<Output<Sha256>, CredentialsError> {
345        let canonical = canonicalize_credential(self)?;
346
347        let mut hasher = Sha256::new();
348        hasher.update(CREDENTIAL_DIGEST_DOMAIN_SEPARATOR);
349        hasher.update(canonical);
350        Ok(hasher.finalize())
351    }
352}
353
354/// Request produced by a holder during issuance.
355///
356/// The holder keeps the original unblinded `blind_msg` locally and sends only
357/// the blinded message to the issuer.
358#[serde_as]
359#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
360pub struct IssuanceRequest {
361    pub version: ProtocolV1,
362    /// PBRSA blinded message bytes sent by the holder during issuance.
363    ///
364    /// Serializes as unpadded URL-safe base64.
365    #[serde_as(as = "Base64UrlUnpadded")]
366    pub blinded_message: PbrsaBlindMessage,
367}
368
369/// Response produced by an issuer during issuance.
370///
371/// The response includes the issuer-selected `info` JSON and the blind signature.
372/// The holder combines this with their original unblinded `blind_msg` and
373/// deterministic message preparation to assemble a final [`SignedCredential`].
374#[serde_as]
375#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
376pub struct IssuanceResponse {
377    pub version: ProtocolV1,
378    pub issuer_id: IssuerId,
379    pub info: Value,
380    /// PBRSA blind signature bytes returned by the issuer during issuance.
381    ///
382    /// Serializes as unpadded URL-safe base64.
383    #[serde_as(as = "Base64UrlUnpadded")]
384    pub blind_signature: PbrsaBlindSignature,
385}
386
387#[cfg(test)]
388mod tests {
389    use blind_rsa_signatures::Signature as PbrsaSignature;
390    use serde_json::json;
391    use sha2::Digest;
392
393    use super::*;
394
395    fn credential() -> SignedCredential {
396        SignedCredential {
397            version: ProtocolV1,
398            credential: Credential {
399                issuer_id_pubkey: IssuerId(nostr::PublicKey::from_byte_array([1u8; 32])),
400                info: json!({
401                    "z": 1,
402                    "a": {
403                        "b": true,
404                        "a": false,
405                    },
406                }),
407                blind_msg: json!({
408                    "holder": "alice",
409                    "nonce": 7,
410                }),
411            },
412            proof: CredentialProof {
413                signature: PbrsaSignature(vec![1, 2, 3, 4]),
414            },
415        }
416    }
417
418    #[test]
419    fn digest_hashes_domain_separator_and_canonical_credential_json() {
420        let credential = credential();
421
422        let canonical = canonicalize_credential(&credential.credential).unwrap();
423        let expected = Sha256::new()
424            .chain_update(CREDENTIAL_DIGEST_DOMAIN_SEPARATOR)
425            .chain_update(canonical)
426            .finalize();
427
428        assert_eq!(credential.credential.digest().unwrap(), expected);
429    }
430
431    #[test]
432    fn digest_excludes_signature() {
433        let mut first = credential();
434        let mut second = credential();
435        first.proof.signature = PbrsaSignature(vec![1, 2, 3, 4]);
436        second.proof.signature = PbrsaSignature(vec![1, 2, 3, 5]);
437
438        assert_eq!(
439            first.credential.digest().unwrap(),
440            second.credential.digest().unwrap()
441        );
442    }
443}