mid_types/lib.rs
1#![cfg_attr(not(feature = "std"), no_std)]
2#![forbid(unsafe_code)]
3//! # `mid-types` — the mID wire format, `no_std`
4//!
5//! The types every mID party agrees on — the JWT payload, the embedded
6//! genesis roster and roster chain, the verification methods — and the
7//! canonical bytes their signatures cover. `mid-issuer` builds them,
8//! `mid-verify` checks them, and a device that signs its own genesis roster
9//! and self-issued token (the Janus ESP32 family) carries only this crate
10//! and a verifier. `no_std` + `alloc`; `std` (default) only forwards to
11//! serde and serde_json.
12//!
13//! Every field name here MUST exactly match the JSON serialization the
14//! verifier and the JS SDK expect (ADR 0005 Decision 1). Changing a field
15//! name is a wire-format break.
16
17extern crate alloc;
18
19pub mod canonical;
20
21use alloc::collections::BTreeMap;
22use alloc::string::String;
23use alloc::vec::Vec;
24
25use serde::{Deserialize, Serialize};
26
27// ─── Request side ──────────────────────────────────────────────────────────
28
29/// The relying party's sign-in request, posted into the wallet by the
30/// `@mata/mid` SDK on the RP page.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct RpRequest {
33 /// The RP's bare origin, e.g. `"https://acme.com"`. The wallet verifies
34 /// this matches the page's actual origin before issuing.
35 pub rp_origin: String,
36 /// RP-issued single-use nonce. The wallet echoes it in the JWT for
37 /// replay defense.
38 pub nonce: String,
39 /// Per-claim categorization (required/optional/custom).
40 pub claims: ClaimRequest,
41}
42
43/// Categorized claim request from the RP.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ClaimRequest {
46 /// Claims that block sign-in if denied. Almost always just `["did"]`.
47 pub required: Vec<String>,
48 /// Claims the user can include or skip.
49 #[serde(default)]
50 pub optional: Vec<String>,
51 /// Arbitrary keys outside the standard catalog — looked up in the
52 /// wallet's local `profile_kv`.
53 #[serde(default)]
54 pub custom: BTreeMap<String, CustomClaimRequest>,
55}
56
57/// Per-key custom-claim metadata.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct CustomClaimRequest {
60 /// Always true in v1 — custom claims cannot be required (the wallet
61 /// doesn't know what they mean to the RP).
62 pub optional: bool,
63 /// Optional human-readable description the consent UI surfaces.
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub description: Option<String>,
66}
67
68// ─── Identity snapshot ─────────────────────────────────────────────────────
69
70/// The wallet's identity state at the moment of sign-in, assembled by the
71/// caller from `mata-identity` plus the `mid_user_consents` Y-CRDT lookup.
72///
73/// This crate consumes the snapshot read-only — it never mutates wallet
74/// state.
75#[derive(Debug, Clone)]
76pub struct IdentitySnapshot {
77 /// The user's DID, e.g. `"did:mata:p3k7q...8x2h"`.
78 pub did: String,
79 /// The genesis roster envelope, self-signed at signup by the genesis
80 /// device's key. Held in wallet storage forever.
81 pub genesis_roster: EmbeddedGenesisRoster,
82 /// The wallet-signed roster mutations from genesis to current. May be
83 /// empty if the user has only ever had the genesis device.
84 pub roster_chain: Vec<EmbeddedRosterChainEntry>,
85 /// The verification method for the device that's about to sign this
86 /// mID JWT. MUST appear in either the genesis roster (if `roster_chain`
87 /// is empty) or in the head roster chain entry.
88 pub current_verification_method: EmbeddedVerificationMethod,
89 /// Pre-built claim values keyed by claim name. The caller looks each
90 /// claim up against the wallet's local stores (profile, achievements,
91 /// computed level ratings) and populates this map BEFORE calling
92 /// [`crate::build_mid_jwt`].
93 ///
94 /// Claims the user denied at the consent screen MUST be absent from
95 /// this map — the issuer trusts the caller's filtering.
96 pub approved_claims: BTreeMap<String, ClaimValue>,
97}
98
99// ─── JWT payload ───────────────────────────────────────────────────────────
100
101/// The mID JWT payload — the JSON object that's base64url-encoded as the
102/// second segment of the JWS compact-form token.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct MidJwtPayload {
105 /// Issuer — the user's DID.
106 pub iss: String,
107 /// Subject — same as `iss` for self-issued tokens.
108 pub sub: String,
109 /// Audience — the RP's bare origin from the request.
110 pub aud: String,
111 /// Issued-at, Unix seconds.
112 pub iat: u64,
113 /// Expiry, Unix seconds. v1 ships short-lived (~1 hour) tokens.
114 pub exp: u64,
115 /// RP-supplied nonce, echoed for replay defense.
116 pub nonce: String,
117 /// Claim values approved by the user, keyed by claim name.
118 pub claims: BTreeMap<String, ClaimValue>,
119 /// Self-signed genesis roster (signed by the key encoded in the DID).
120 pub embedded_genesis_roster: EmbeddedGenesisRoster,
121 /// Chain of wallet-signed roster mutations from genesis to current.
122 pub embedded_roster_chain: Vec<EmbeddedRosterChainEntry>,
123 /// The device key signing this JWT — must appear in the head roster.
124 pub embedded_verification_method: EmbeddedVerificationMethod,
125}
126
127/// A single claim value with provenance metadata.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ClaimValue {
130 /// The claim's value. Free-form per RFC 7519; common shapes are
131 /// strings, numbers, booleans.
132 pub value: serde_json::Value,
133 /// Provenance — `self` in v1; v2 may add `third_party_vc`.
134 pub attested_by: AttestedBy,
135 /// Only set on the `email` claim if the user completed signup OTP.
136 /// Historical fact, not a MATA signature.
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub verified_at_signup: Option<bool>,
139 /// Only set on derived claims (level ratings) — the wall-clock the
140 /// wallet computed the score at.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub computed_at: Option<u64>,
143 /// Only set on derived claims — pins the formula generation. RPs can
144 /// reject older versions.
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub formula_version: Option<String>,
147}
148
149/// Attestation provenance for a claim.
150///
151/// v1 ships only `self`. The enum is open for future provenance categories
152/// without breaking the wire format.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154pub enum AttestedBy {
155 /// User-supplied, signed by the user's M5 device key as part of the
156 /// outer JWT signature. MATA never witnessed this value.
157 #[serde(rename = "self")]
158 SelfAttested,
159}
160
161// ─── Embedded roster + verification method ─────────────────────────────────
162
163/// The genesis roster envelope — version 1, self-signed by the genesis
164/// device's signing key (the key whose public form is encoded in the DID
165/// identifier).
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct EmbeddedGenesisRoster {
168 /// Always 1 for the genesis envelope.
169 pub version: u64,
170 /// The DID this roster belongs to.
171 pub did: String,
172 /// Verification methods present in the genesis roster. v1 wallets ship
173 /// a one-entry genesis roster (just the genesis device); future
174 /// versions could initialize multi-device genesis.
175 pub verification_methods: Vec<VerificationMethod>,
176 /// When the genesis roster was signed, Unix seconds.
177 pub signed_at: u64,
178 /// Signature by the genesis key over the canonical envelope bytes.
179 /// Verifiable against the public key recovered from the DID.
180 /// base64url-encoded `r || s` (64 bytes total).
181 pub self_signed_by_genesis_key: String,
182}
183
184/// One entry in the wallet-signed roster chain. Each entry is signed by a
185/// verification method present in the prior entry's roster.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct EmbeddedRosterChainEntry {
188 /// Monotonically increasing across the chain. genesis is version 1; the
189 /// first chain entry is version 2.
190 pub version: u64,
191 /// The DID this roster belongs to.
192 pub did: String,
193 /// Verification methods after this mutation.
194 pub verification_methods: Vec<VerificationMethod>,
195 /// When this mutation was signed, Unix seconds.
196 pub signed_at: u64,
197 /// Signature by a verification method present in the PRIOR roster.
198 /// base64url-encoded `r || s` (64 bytes total).
199 pub signed_by_signer_in_prior_roster: String,
200 /// The `kid` of the verification method in the prior roster that
201 /// produced this signature. Lets the verifier skip a scan.
202 pub signer_kid: String,
203}
204
205/// A verification method = a public key bound to a DID. Same shape as the
206/// W3C DID document `verificationMethod[]` entry, minimum-fields form.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct VerificationMethod {
209 /// Fully-qualified ID, e.g. `"did:mata:abc...#dev-laptop"`.
210 pub id: String,
211 /// Always `"EcdsaSecp256r1VerificationKey2019"` in v1.
212 #[serde(rename = "type")]
213 pub vm_type: String,
214 /// The DID that controls this verification method.
215 pub controller: String,
216 /// Multibase-encoded public key. v1 wallets use `z6Mk...` base58btc
217 /// per the multibase spec.
218 pub public_key_multibase: String,
219}
220
221/// The current device's verification method — the key that's about to sign
222/// the JWT. Slim form (just the key + identity), no extra DID-document
223/// metadata. Must appear in the head roster of the chain.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct EmbeddedVerificationMethod {
226 /// Fully-qualified ID, e.g. `"did:mata:abc...#dev-laptop"`.
227 pub id: String,
228 /// Always `"EcdsaSecp256r1VerificationKey2019"` in v1.
229 #[serde(rename = "type")]
230 pub vm_type: String,
231 /// The DID that controls this verification method.
232 pub controller: String,
233 /// Multibase-encoded public key.
234 pub public_key_multibase: String,
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn attested_by_serializes_as_lowercase_self() {
243 let cv = ClaimValue {
244 value: serde_json::Value::String("tim@example.com".into()),
245 attested_by: AttestedBy::SelfAttested,
246 verified_at_signup: Some(true),
247 computed_at: None,
248 formula_version: None,
249 };
250 let json = serde_json::to_string(&cv).unwrap();
251 assert!(
252 json.contains(r#""attested_by":"self""#),
253 "attested_by must serialize as the lowercase string 'self' per ADR 0005 Decision 1, got: {json}"
254 );
255 assert!(
256 json.contains(r#""verified_at_signup":true"#),
257 "verified_at_signup must be present when Some, got: {json}"
258 );
259 }
260
261 #[test]
262 fn claim_value_omits_optional_fields_when_none() {
263 let cv = ClaimValue {
264 value: serde_json::Value::String("Tim".into()),
265 attested_by: AttestedBy::SelfAttested,
266 verified_at_signup: None,
267 computed_at: None,
268 formula_version: None,
269 };
270 let json = serde_json::to_string(&cv).unwrap();
271 assert!(!json.contains("verified_at_signup"), "absent when None");
272 assert!(!json.contains("computed_at"), "absent when None");
273 assert!(!json.contains("formula_version"), "absent when None");
274 }
275
276 #[test]
277 fn verification_method_type_field_serializes_as_type() {
278 let vm = VerificationMethod {
279 id: "did:mata:abc#dev".into(),
280 vm_type: "EcdsaSecp256r1VerificationKey2019".into(),
281 controller: "did:mata:abc".into(),
282 public_key_multibase: "z6Mk...".into(),
283 };
284 let json = serde_json::to_string(&vm).unwrap();
285 assert!(
286 json.contains(r#""type":"EcdsaSecp256r1VerificationKey2019""#),
287 "vm_type must serialize as JSON field 'type' per the DID document standard, got: {json}"
288 );
289 }
290}