Skip to main content

peerbadge_wasm/
lib.rs

1use peerbadge_protocol as protocol;
2use peerbadge_schemas as schemas;
3use serde::{de::DeserializeOwned, Serialize};
4use wasm_bindgen::prelude::*;
5
6#[wasm_bindgen(typescript_custom_section)]
7const TYPESCRIPT_SURFACE: &'static str = r#"
8/** Any JSON value accepted by credential info and blind-message fields. */
9export type JsonValue =
10  | null
11  | boolean
12  | number
13  | string
14  | JsonValue[]
15  | { readonly [key: string]: JsonValue };
16
17/** Holder-created request sent to an issuer during credential issuance. */
18export interface IssuanceRequest {
19  readonly version: 1;
20  /** Unpadded URL-safe base64 encoded PBRSA blinded message. */
21  readonly blinded_message: string;
22}
23
24/** Issuer-created response containing the blind signature for an issuance request. */
25export interface IssuanceResponse {
26  readonly version: 1;
27  /** Nostr public key identifying the issuer that signed the response. */
28  readonly issuer_id: string;
29  /** Issuer-visible credential information bound into the blind signature. */
30  readonly info: JsonValue;
31  /** Unpadded URL-safe base64 encoded PBRSA blind signature. */
32  readonly blind_signature: string;
33}
34
35/** Exported issuer secret material for application-managed backup or restore. */
36export interface IssuerSecretKeys {
37  /** Nostr issuer identity secret key encoded as hex. */
38  readonly issuer_id_secret_key: string;
39  /** Unpadded URL-safe base64 encoded PBRSA issuance secret key. */
40  readonly issuance_secret_key: string;
41}
42
43/** Final credential presented by a holder to a verifier. */
44export interface SignedCredential {
45  readonly version: 1;
46  readonly credential: Credential;
47  readonly proof: CredentialProof;
48}
49
50/** Holder-signed authorization allowing an auxiliary subject key to use credentials. */
51export interface HolderAuthorization {
52  readonly version: 1;
53  readonly authorization: HolderAuthorizationStatement;
54  readonly proof: SchnorrSignatureProof;
55}
56
57/** Input for `HolderContext.authorizeCredentialUse`. */
58export interface HolderAuthorizationRequest {
59  /** External application's Nostr subject public key. */
60  readonly subject_pubkey: string;
61}
62
63/** Holder statement signed by `HolderContext.authorizeCredentialUse` and returned in `HolderAuthorization`. */
64export interface HolderAuthorizationStatement {
65  /** Nostr public key identifying the holder. */
66  readonly holder_id_pubkey: string;
67  /** External application's Nostr subject public key. */
68  readonly subject_pubkey: string;
69  /** Credential digest this authorization allows the subject to present. */
70  readonly credential_digest: CredentialDigest;
71  /** Unix timestamp in seconds. */
72  readonly issued_at: Timestamp;
73}
74
75/** Unpadded URL-safe base64 encoded canonical credential digest. */
76export type CredentialDigest = string;
77
78/** Unix timestamp in seconds. */
79export type Timestamp = number;
80
81/** Credential payload signed by the issuer's issuance key. */
82export interface Credential {
83  /** Nostr public key identifying the issuer. */
84  readonly issuer_id_pubkey: string;
85  /** Credential information that was visible to the issuer during issuance. */
86  readonly info: JsonValue;
87  /** Holder-controlled value hidden during issuance and disclosed in the final credential. */
88  readonly blind_msg: JsonValue;
89}
90
91/** Proof over a finalized credential payload. */
92export interface CredentialProof {
93  /** Unpadded URL-safe base64 encoded PBRSA credential signature. */
94  readonly signature: string;
95}
96
97/** Signed issuer metadata that verifiers trust before accepting credentials. */
98export interface IssuerAuthority {
99  readonly version: 1;
100  readonly issuer: Issuer;
101  readonly proof: SchnorrSignatureProof;
102}
103
104/** Schnorr signature proof encoded for JSON transport. */
105export interface SchnorrSignatureProof {
106  readonly signature: string;
107}
108
109/** Public issuer metadata bound into an issuer authority. */
110export interface Issuer {
111  /** Nostr public key identifying the issuer. */
112  readonly issuer_id_pubkey: string;
113  /** Unpadded URL-safe base64 encoded PBRSA issuance public key. */
114  readonly issuance_key: string;
115  /** Locations where applications may fetch this issuer's revocations. */
116  readonly revocation: readonly RevocationLocation[];
117}
118
119/** Issuer-signed credential revocation object. */
120export interface SignedRevocation {
121  readonly version: 1;
122  readonly revocation: Revocation;
123  readonly proof: RevocationProof;
124}
125
126/** Issuer identity proof for a signed revocation. */
127export interface RevocationProof {
128  /** Nostr public key identifying the issuer that signed the revocation. */
129  readonly issuer_id_pubkey: string;
130  /** Schnorr signature over the revocation payload. */
131  readonly signature: string;
132}
133
134/** Revocation payload signed by an issuer identity key. */
135export interface Revocation {
136  /** Unpadded URL-safe base64 encoded SHA-256 digest. */
137  readonly credential_digest: CredentialDigest;
138}
139
140/** Application-owned location where issuer revocations may be published. */
141export interface RevocationLocation {
142  /** Transport or publication protocol name, such as "nostr". */
143  readonly protocol: string;
144  /** Protocol-specific location, such as a relay URL. */
145  readonly location: string;
146}
147
148/** Result of creating a holder issuance request. */
149export interface PendingIssuanceResult {
150  /** Request to send to the issuer. */
151  readonly request: IssuanceRequest;
152  /** Local holder state required to finalize the issuer response. */
153  readonly pending: PendingIssuance;
154}
155"#;
156
157fn from_js<T: DeserializeOwned>(value: JsValue) -> Result<T, JsError> {
158    serde_wasm_bindgen::from_value(value).map_err(|error| JsError::new(&error.to_string()))
159}
160
161fn to_js<T: Serialize>(value: &T) -> Result<JsValue, JsError> {
162    value
163        .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
164        .map_err(|error| JsError::new(&error.to_string()))
165}
166
167fn current_unix_timestamp() -> Result<u64, JsError> {
168    let seconds = (js_sys::Date::now() / 1000.0).floor();
169    if !seconds.is_finite() || seconds < 0.0 {
170        return Err(JsError::new("current time must be non-negative"));
171    }
172
173    Ok(seconds as u64)
174}
175
176fn reflect_error(error: JsValue) -> JsError {
177    JsError::new(
178        &error
179            .as_string()
180            .unwrap_or_else(|| "failed to set JS object property".to_owned()),
181    )
182}
183
184/// Install a tracing subscriber that forwards Rust tracing events to the JavaScript console.
185///
186/// Returns `true` when this call installs the subscriber. Returns `false` when another global
187/// tracing subscriber is already installed.
188#[wasm_bindgen(js_name = initTracing)]
189pub fn init_tracing() -> bool {
190    tracing_wasm::try_set_as_global_default().is_ok()
191}
192
193#[wasm_bindgen]
194#[derive(Clone)]
195/// Issuer-side context for creating issuer authorities, issuing credentials, and revoking credentials.
196pub struct IssuerContext {
197    inner: protocol::IssuerContext,
198}
199
200#[wasm_bindgen]
201impl IssuerContext {
202    /// Generate a new issuer context with fresh issuer identity and issuance keys.
203    #[wasm_bindgen(js_name = generate)]
204    pub fn generate() -> Result<IssuerContext, JsError> {
205        Ok(Self {
206            inner: protocol::IssuerContext::generate()?,
207        })
208    }
209
210    /// Generate a new issuer context using a thread-local CSPRNG seeded from the system RNG.
211    #[wasm_bindgen(js_name = generateWithThreadRng)]
212    pub fn generate_with_thread_rng() -> Result<IssuerContext, JsError> {
213        Ok(Self {
214            inner: protocol::IssuerContext::generate_with_thread_rng()?,
215        })
216    }
217
218    /// Generate a new issuer context using direct system randomness for each keygen draw.
219    #[wasm_bindgen(js_name = generateWithSystemRng)]
220    pub fn generate_with_system_rng() -> Result<IssuerContext, JsError> {
221        Ok(Self {
222            inner: protocol::IssuerContext::generate_with_system_rng()?,
223        })
224    }
225
226    /// Import an issuer context from previously exported issuer secret keys.
227    #[wasm_bindgen(js_name = importSecretKey)]
228    pub fn import_secret_key(
229        #[wasm_bindgen(unchecked_param_type = "IssuerSecretKeys")] secret_key: JsValue,
230    ) -> Result<IssuerContext, JsError> {
231        let secret_key: protocol::IssuerSecretKeys = from_js(secret_key)?;
232        Ok(Self {
233            inner: protocol::IssuerContext::import_secret_key(&secret_key)?,
234        })
235    }
236
237    /// Export this issuer's secret keys for application-managed backup or restore.
238    #[wasm_bindgen(js_name = exportSecretKey, unchecked_return_type = "IssuerSecretKeys")]
239    pub fn export_secret_key(&self) -> Result<JsValue, JsError> {
240        to_js(&self.inner.export_secret_key()?)
241    }
242
243    /// Issue a blind signature response for a holder issuance request.
244    #[wasm_bindgen(js_name = issueCredential, unchecked_return_type = "IssuanceResponse")]
245    pub fn issue_credential(
246        &self,
247        #[wasm_bindgen(unchecked_param_type = "JsonValue")] info: JsValue,
248        #[wasm_bindgen(unchecked_param_type = "IssuanceRequest")] request: JsValue,
249    ) -> Result<JsValue, JsError> {
250        let info: serde_json::Value = from_js(info)?;
251        let request: protocol::IssuanceRequest = from_js(request)?;
252        to_js(&self.inner.issue_credential(info, &request)?)
253    }
254
255    /// Build and sign this issuer's public authority with its revocation locations.
256    #[wasm_bindgen(js_name = issuerAuthority, unchecked_return_type = "IssuerAuthority")]
257    pub fn issuer_authority(
258        &self,
259        #[wasm_bindgen(unchecked_param_type = "readonly RevocationLocation[]")] revocation: JsValue,
260    ) -> Result<JsValue, JsError> {
261        let revocation: Vec<protocol::RevocationLocation> = from_js(revocation)?;
262        to_js(&self.inner.issuer_authority(revocation)?)
263    }
264
265    /// Sign a revocation for a finalized credential issued by this issuer.
266    #[wasm_bindgen(js_name = revokeCredential, unchecked_return_type = "SignedRevocation")]
267    pub fn revoke_credential(
268        &self,
269        #[wasm_bindgen(unchecked_param_type = "SignedCredential")] credential: JsValue,
270    ) -> Result<JsValue, JsError> {
271        let credential: protocol::SignedCredential = from_js(credential)?;
272        to_js(&self.inner.revoke_credential(&credential)?)
273    }
274}
275
276#[wasm_bindgen]
277#[derive(Clone)]
278/// Holder-side context for generating holder identity keys.
279pub struct HolderContext {
280    inner: protocol::HolderContext,
281}
282
283#[wasm_bindgen]
284impl HolderContext {
285    /// Generate a new holder context with fresh holder identity keys.
286    #[wasm_bindgen(js_name = generate)]
287    pub fn generate() -> HolderContext {
288        Self {
289            inner: protocol::HolderContext::generate(),
290        }
291    }
292
293    /// Import a holder context from a previously exported holder secret key.
294    #[wasm_bindgen(js_name = importSecretKey)]
295    pub fn import_secret_key(secret_key: String) -> Result<HolderContext, JsError> {
296        Ok(Self {
297            inner: protocol::HolderContext::import_secret_key(&secret_key)?,
298        })
299    }
300
301    /// Export this holder's secret key for application-managed backup or restore.
302    #[wasm_bindgen(js_name = exportSecretKey)]
303    pub fn export_secret_key(&self) -> String {
304        self.inner.export_secret_key()
305    }
306
307    /// Return the holder's Nostr public key.
308    #[wasm_bindgen(getter, js_name = publicKey)]
309    pub fn public_key(&self) -> String {
310        self.inner.public_key().to_string()
311    }
312
313    /// Create a signed holder authorization for an auxiliary subject key and selected credential.
314    #[wasm_bindgen(js_name = authorizeCredentialUse, unchecked_return_type = "HolderAuthorization")]
315    pub fn authorize_credential_use(
316        &self,
317        #[wasm_bindgen(unchecked_param_type = "HolderAuthorizationRequest")] request: JsValue,
318        #[wasm_bindgen(unchecked_param_type = "SignedCredential")] credential: JsValue,
319    ) -> Result<JsValue, JsError> {
320        let request: protocol::HolderAuthorizationRequest = from_js(request)?;
321        let credential: protocol::SignedCredential = from_js(credential)?;
322        to_js(&self.inner.authorize_credential_use_at_time(
323            request,
324            &credential,
325            current_unix_timestamp()?,
326        )?)
327    }
328}
329
330#[wasm_bindgen]
331/// Holder-side pending issuance state required to finalize one issuer response.
332pub struct PendingIssuance {
333    inner: protocol::PendingIssuance,
334}
335
336#[wasm_bindgen]
337impl PendingIssuance {
338    /// Create a blinded holder issuance request and local pending issuance state.
339    #[wasm_bindgen(js_name = createRequest, unchecked_return_type = "PendingIssuanceResult")]
340    pub fn create_request(
341        #[wasm_bindgen(unchecked_param_type = "IssuerAuthority")] issuer_authority: JsValue,
342        #[wasm_bindgen(unchecked_param_type = "JsonValue")] info: JsValue,
343        #[wasm_bindgen(unchecked_param_type = "JsonValue")] blind_msg: JsValue,
344    ) -> Result<JsValue, JsError> {
345        let issuer_authority: protocol::IssuerAuthority = from_js(issuer_authority)?;
346        let info: serde_json::Value = from_js(info)?;
347        let blind_msg: serde_json::Value = from_js(blind_msg)?;
348        let (request, pending) = protocol::PendingIssuance::create_request(
349            &issuer_authority.issuer.issuance_key,
350            issuer_authority.issuer.issuer_id_pubkey,
351            info,
352            blind_msg,
353        )?;
354
355        let result = js_sys::Object::new();
356        js_sys::Reflect::set(&result, &JsValue::from_str("request"), &to_js(&request)?)
357            .map_err(reflect_error)?;
358        js_sys::Reflect::set(
359            &result,
360            &JsValue::from_str("pending"),
361            &JsValue::from(PendingIssuance { inner: pending }),
362        )
363        .map_err(reflect_error)?;
364        Ok(result.into())
365    }
366
367    /// Export this pending issuance state as a JSON string for application storage.
368    #[wasm_bindgen(js_name = exportState)]
369    pub fn export_state(&self) -> Result<String, JsError> {
370        Ok(self.inner.export_state()?)
371    }
372
373    /// Import pending issuance state previously returned by `exportState`.
374    #[wasm_bindgen(js_name = importState)]
375    pub fn import_state(state: String) -> Result<PendingIssuance, JsError> {
376        Ok(Self {
377            inner: protocol::PendingIssuance::import_state(&state)?,
378        })
379    }
380
381    /// Finalize an issuer response into a signed credential.
382    #[wasm_bindgen(js_name = finalize, unchecked_return_type = "SignedCredential")]
383    pub fn finalize(
384        self,
385        #[wasm_bindgen(unchecked_param_type = "IssuerAuthority")] issuer_authority: JsValue,
386        #[wasm_bindgen(unchecked_param_type = "IssuanceResponse")] response: JsValue,
387    ) -> Result<JsValue, JsError> {
388        let issuer_authority: protocol::IssuerAuthority = from_js(issuer_authority)?;
389        let response: protocol::IssuanceResponse = from_js(response)?;
390        if issuer_authority.issuer.issuer_id_pubkey != response.issuer_id {
391            return Err(protocol::CredentialsError::IssuerIdMismatch.into());
392        }
393        to_js(
394            &self
395                .inner
396                .finalize(&issuer_authority.issuer.issuance_key, &response)?,
397        )
398    }
399}
400
401#[wasm_bindgen]
402/// Verifier-side context containing trusted issuers and ingested revocations.
403pub struct VerificationContext {
404    inner: protocol::VerificationContext,
405}
406
407#[wasm_bindgen]
408impl VerificationContext {
409    /// Create an empty verification context.
410    #[wasm_bindgen(constructor)]
411    pub fn new() -> VerificationContext {
412        Self {
413            inner: protocol::VerificationContext::new(),
414        }
415    }
416
417    /// Verify and trust an issuer authority for future credential checks.
418    #[wasm_bindgen(js_name = addIssuerAuthority)]
419    pub fn add_issuer_authority(
420        &mut self,
421        #[wasm_bindgen(unchecked_param_type = "IssuerAuthority")] issuer_authority: JsValue,
422    ) -> Result<(), JsError> {
423        let issuer_authority: protocol::IssuerAuthority = from_js(issuer_authority)?;
424        Ok(self.inner.add_issuer_authority(&issuer_authority)?)
425    }
426
427    /// Verify and store a signed revocation from a trusted issuer.
428    #[wasm_bindgen(js_name = addRevocation)]
429    pub fn add_revocation(
430        &mut self,
431        #[wasm_bindgen(unchecked_param_type = "SignedRevocation")] revocation: JsValue,
432    ) -> Result<(), JsError> {
433        let revocation: protocol::SignedRevocation = from_js(revocation)?;
434        Ok(self.inner.add_revocation(&revocation)?)
435    }
436
437    /// Verify a finalized credential against trusted issuers and ingested revocations.
438    #[wasm_bindgen(js_name = verifyCredential)]
439    pub fn verify_credential(
440        &self,
441        #[wasm_bindgen(unchecked_param_type = "SignedCredential")] credential: JsValue,
442    ) -> Result<bool, JsError> {
443        let credential: protocol::SignedCredential = from_js(credential)?;
444        self.inner.verify_credential(&credential)?;
445        Ok(true)
446    }
447
448    /// Verify a credential and holder authorization.
449    #[wasm_bindgen(js_name = verifyCredentialAuthorization)]
450    pub fn verify_credential_authorization(
451        &self,
452        #[wasm_bindgen(unchecked_param_type = "SignedCredential")] credential: JsValue,
453        #[wasm_bindgen(unchecked_param_type = "HolderAuthorization")] authorization: JsValue,
454    ) -> Result<bool, JsError> {
455        let credential: protocol::SignedCredential = from_js(credential)?;
456        let authorization: protocol::HolderAuthorization = from_js(authorization)?;
457
458        self.inner.verify_credential_authorization_at_time(
459            &credential,
460            &authorization,
461            current_unix_timestamp()?,
462        )?;
463        Ok(true)
464    }
465}
466
467#[wasm_bindgen(typescript_custom_section)]
468const TYPESCRIPT_SCHEMAS_SURFACE: &'static str = r#"
469/** Parsed payload of a `fedi-trust-score-v1.0` badge. */
470export interface TrustScoreBadgeV1 {
471  /** Public result of attester-private scoring; acceptability is caller policy. */
472  readonly trust_level: number;
473  /** Holder Nostr public key bound by the revealed `blind_msg`, canonical lowercase hex. */
474  readonly holder_pubkey: string;
475}
476"#;
477
478/// The `info.schema` identifier for trust-score badges, revision v1.0.
479#[wasm_bindgen(js_name = trustScoreSchemaV1)]
480pub fn trust_score_schema_v1() -> String {
481    schemas::TRUST_SCORE_SCHEMA_V1.to_owned()
482}
483
484/// Build the issuance `info` value for a `fedi-trust-score-v1.0` badge.
485///
486/// Rejects anything but an integer in the documented `1..=12` trust model —
487/// no silent truncation or wrap-around of illegal inputs.
488#[wasm_bindgen(js_name = trustScoreInfoV1, unchecked_return_type = "JsonValue")]
489pub fn trust_score_info_v1(trust_level: f64) -> Result<JsValue, JsError> {
490    if !trust_level.is_finite() || trust_level.fract() != 0.0 || trust_level < 0.0 {
491        return Err(JsError::new("trust_level must be a non-negative integer"));
492    }
493    to_js(&schemas::trust_score_info_v1(trust_level as u64)?)
494}
495
496/// Build the issuance `blind_msg` value binding a `fedi-trust-score-v1.0` badge to a holder.
497#[wasm_bindgen(js_name = trustScoreBlindMsgV1, unchecked_return_type = "JsonValue")]
498pub fn trust_score_blind_msg_v1(holder_pubkey: String) -> Result<JsValue, JsError> {
499    let holder: protocol::HolderId = holder_pubkey
500        .parse()
501        .map_err(|_| JsError::new("holder_pubkey is not a valid Nostr public key"))?;
502    to_js(&schemas::trust_score_blind_msg_v1(&holder.0))
503}
504
505/// Parse a cryptographically verified credential as a `fedi-trust-score-v1.0` badge.
506///
507/// Schema validation only — verify the credential (and any holder authorization)
508/// with a `VerificationContext` first.
509#[wasm_bindgen(js_name = parseTrustScoreBadgeV1, unchecked_return_type = "TrustScoreBadgeV1")]
510pub fn parse_trust_score_badge_v1(
511    #[wasm_bindgen(unchecked_param_type = "SignedCredential")] credential: JsValue,
512) -> Result<JsValue, JsError> {
513    let credential: protocol::SignedCredential = from_js(credential)?;
514    let badge = schemas::parse_trust_score_badge_v1(&credential.credential)?;
515    to_js(&serde_json::json!({
516        "trust_level": badge.trust_level,
517        "holder_pubkey": badge.holder_pubkey.to_string(),
518    }))
519}