iota_sdk_types/crypto/passkey.rs
1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2025 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use super::{Secp256r1PublicKey, Secp256r1Signature, SimpleSignature};
6
7/// A passkey authenticator.
8///
9/// # BCS
10///
11/// The BCS serialized form for this type is defined by the following ABNF:
12///
13/// ```text
14/// passkey-bcs = bytes ; where the contents of the bytes are
15/// ; defined by <passkey>
16/// passkey = passkey-flag
17/// bytes ; passkey authenticator data
18/// client-data-json ; valid json
19/// simple-signature ; required to be a secp256r1 signature
20///
21/// client-data-json = string ; valid json
22/// ```
23///
24/// See [CollectedClientData](https://www.w3.org/TR/webauthn-2/#dictdef-collectedclientdata) for
25/// the required json-schema for the `client-data-json` rule. In addition, IOTA
26/// currently requires that the `CollectedClientData.type` field is required to
27/// be `webauthn.get`.
28///
29/// Note: Due to historical reasons, signatures are serialized slightly
30/// different from the majority of the types in IOTA. In particular if a
31/// signature is ever embedded in another structure it generally is serialized
32/// as `bytes` meaning it has a length prefix that defines the length of
33/// the completely serialized signature.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct PasskeyAuthenticator {
36 /// The secp256r1 public key for this passkey.
37 public_key: Secp256r1PublicKey,
38 /// The secp256r1 signature from the passkey.
39 signature: Secp256r1Signature,
40 /// Parsed base64url decoded challenge bytes from
41 /// `client_data_json.challenge`.
42 challenge: Vec<u8>,
43 /// Opaque authenticator data for this passkey signature.
44 ///
45 /// See [Authenticator Data](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data) for
46 /// more information on this field.
47 authenticator_data: Vec<u8>,
48 /// Structured, unparsed, JSON for this passkey signature.
49 ///
50 /// See [CollectedClientData](https://www.w3.org/TR/webauthn-2/#dictdef-collectedclientdata)
51 /// for more information on this field.
52 client_data_json: String,
53}
54
55impl PasskeyAuthenticator {
56 /// Opaque authenticator data for this passkey signature.
57 ///
58 /// See [Authenticator Data](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data) for
59 /// more information on this field.
60 pub fn authenticator_data(&self) -> &[u8] {
61 &self.authenticator_data
62 }
63
64 /// Structured, unparsed, JSON for this passkey signature.
65 ///
66 /// See [CollectedClientData](https://www.w3.org/TR/webauthn-2/#dictdef-collectedclientdata)
67 /// for more information on this field.
68 pub fn client_data_json(&self) -> &str {
69 &self.client_data_json
70 }
71
72 /// The parsed challenge message for this passkey signature.
73 ///
74 /// This is parsed by decoding the base64url data from the
75 /// `client_data_json.challenge` field.
76 pub fn challenge(&self) -> &[u8] {
77 &self.challenge
78 }
79
80 /// The passkey signature.
81 pub fn signature(&self) -> SimpleSignature {
82 SimpleSignature::Secp256r1 {
83 signature: self.signature,
84 public_key: self.public_key,
85 }
86 }
87
88 /// The passkey public key
89 pub fn public_key(&self) -> PasskeyPublicKey {
90 PasskeyPublicKey::new(self.public_key)
91 }
92}
93
94/// Public key of a `PasskeyAuthenticator`.
95///
96/// This is used to derive the onchain `Address` for a `PasskeyAuthenticator`.
97///
98/// # BCS
99///
100/// The BCS serialized form for this type is defined by the following ABNF:
101///
102/// ```text
103/// passkey-public-key = passkey-flag secp256r1-public-key
104/// ```
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct PasskeyPublicKey(Secp256r1PublicKey);
107
108impl PasskeyPublicKey {
109 pub fn new(public_key: Secp256r1PublicKey) -> Self {
110 Self(public_key)
111 }
112
113 /// The underlying `Secp256r1PublicKey` for this passkey.
114 pub fn inner(&self) -> &Secp256r1PublicKey {
115 &self.0
116 }
117}
118
119#[cfg(feature = "serde")]
120#[cfg_attr(doc_cfg, doc(cfg(feature = "serde")))]
121mod serialization {
122 use std::borrow::Cow;
123
124 use serde::{Deserialize, Deserializer, Serialize, Serializer};
125 use serde_with::{Bytes, DeserializeAs};
126
127 use super::*;
128 use crate::{SignatureScheme, SimpleSignature, crypto::SignatureFromBytesError};
129
130 #[derive(serde::Serialize)]
131 struct AuthenticatorRef<'a> {
132 authenticator_data: &'a Vec<u8>,
133 client_data_json: &'a String,
134 signature: SimpleSignature,
135 }
136
137 #[derive(serde::Deserialize)]
138 #[serde(rename = "PasskeyAuthenticator")]
139 #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
140 struct Authenticator {
141 authenticator_data: Vec<u8>,
142 client_data_json: String,
143 signature: SimpleSignature,
144 }
145
146 #[cfg(feature = "schemars")]
147 impl schemars::JsonSchema for PasskeyAuthenticator {
148 fn schema_name() -> String {
149 Authenticator::schema_name()
150 }
151
152 fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
153 Authenticator::json_schema(gen)
154 }
155 }
156
157 impl Serialize for PasskeyAuthenticator {
158 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
159 where
160 S: Serializer,
161 {
162 if serializer.is_human_readable() {
163 let authenticator_ref = AuthenticatorRef {
164 authenticator_data: &self.authenticator_data,
165 client_data_json: &self.client_data_json,
166 signature: SimpleSignature::Secp256r1 {
167 signature: self.signature,
168 public_key: self.public_key,
169 },
170 };
171
172 authenticator_ref.serialize(serializer)
173 } else {
174 let bytes = self.to_bytes();
175 serializer.serialize_bytes(&bytes)
176 }
177 }
178 }
179
180 impl<'de> Deserialize<'de> for PasskeyAuthenticator {
181 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
182 where
183 D: Deserializer<'de>,
184 {
185 if deserializer.is_human_readable() {
186 let authenticator = Authenticator::deserialize(deserializer)?;
187 Self::try_from_raw(authenticator)
188 } else {
189 let bytes: Cow<'de, [u8]> = Bytes::deserialize_as(deserializer)?;
190 Self::from_serialized_bytes(bytes)
191 }
192 .map_err(serde::de::Error::custom)
193 }
194 }
195
196 impl PasskeyAuthenticator {
197 pub fn new(
198 authenticator_data: Vec<u8>,
199 client_data_json: String,
200 signature: SimpleSignature,
201 ) -> Option<Self> {
202 Self::try_from_raw(Authenticator {
203 authenticator_data,
204 client_data_json,
205 signature,
206 })
207 .ok()
208 }
209
210 fn try_from_raw(
211 Authenticator {
212 authenticator_data,
213 client_data_json,
214 signature,
215 }: Authenticator,
216 ) -> Result<Self, SignatureFromBytesError> {
217 let SimpleSignature::Secp256r1 {
218 signature,
219 public_key,
220 } = signature
221 else {
222 return Err(SignatureFromBytesError::new(
223 "expected passkey with secp256r1 signature",
224 ));
225 };
226
227 let CollectedClientData {
228 ty: _,
229 challenge,
230 origin: _,
231 } = serde_json::from_str(&client_data_json).map_err(SignatureFromBytesError::new)?;
232
233 // decode unpadded url endoded base64 data per spec:
234 // https://w3c.github.io/webauthn/#base64url-encoding
235 let challenge = <base64ct::Base64UrlUnpadded as base64ct::Encoding>::decode_vec(
236 &challenge,
237 )
238 .map_err(|e| {
239 SignatureFromBytesError::new(format!(
240 "unable to decode base64urlunpadded into 3-byte intent and 32-byte digest: {e}"
241 ))
242 })?;
243
244 Ok(Self {
245 public_key,
246 signature,
247 challenge,
248 authenticator_data,
249 client_data_json,
250 })
251 }
252
253 pub fn from_serialized_bytes(
254 bytes: impl AsRef<[u8]>,
255 ) -> Result<Self, SignatureFromBytesError> {
256 let bytes = bytes.as_ref();
257 let flag =
258 SignatureScheme::from_byte(*bytes.first().ok_or_else(|| {
259 SignatureFromBytesError::new("missing signature scheme flag")
260 })?)
261 .map_err(SignatureFromBytesError::new)?;
262 if flag != SignatureScheme::Passkey {
263 return Err(SignatureFromBytesError::new("invalid passkey flag"));
264 }
265 let bcs_bytes = &bytes[1..];
266
267 let authenticator = bcs::from_bytes(bcs_bytes).map_err(SignatureFromBytesError::new)?;
268
269 Self::try_from_raw(authenticator)
270 }
271
272 pub(crate) fn to_bytes(&self) -> Vec<u8> {
273 let authenticator_ref = AuthenticatorRef {
274 authenticator_data: &self.authenticator_data,
275 client_data_json: &self.client_data_json,
276 signature: SimpleSignature::Secp256r1 {
277 signature: self.signature,
278 public_key: self.public_key,
279 },
280 };
281
282 let mut buf = Vec::new();
283 buf.push(SignatureScheme::Passkey as u8);
284
285 bcs::serialize_into(&mut buf, &authenticator_ref).expect("serialization cannot fail");
286 buf
287 }
288 }
289
290 /// The client data represents the contextual bindings of both the Relying
291 /// Party and the client. It is a key-value mapping whose keys are
292 /// strings. Values can be any type that has a valid encoding in JSON.
293 ///
294 /// > Note: The [`CollectedClientData`] may be extended in the future.
295 /// > Therefore it’s critical when
296 /// > parsing to be tolerant of unknown keys and of any reordering of the
297 /// > keys
298 ///
299 /// This struct conforms to the JSON byte serialization format expected of
300 /// `CollectedClientData`, detailed in section [5.8.1.1 Serialization]
301 /// of the WebAuthn spec. Namely the following requirements:
302 ///
303 /// * `type`, `challenge`, `origin`, `crossOrigin` must always be present in
304 /// the serialized format _in that order_.
305 ///
306 /// <https://w3c.github.io/webauthn/#dictionary-client-data>
307 ///
308 /// [5.8.1.1 Serialization]: https://w3c.github.io/webauthn/#clientdatajson-serialization
309 #[derive(Debug, Clone, Serialize, Deserialize)]
310 #[serde(rename_all = "camelCase")]
311 pub struct CollectedClientData {
312 /// This member contains the value [`ClientDataType::Create`] when
313 /// creating new credentials, and [`ClientDataType::Get`] when
314 /// getting an assertion from an existing credential. The purpose
315 /// of this member is to prevent certain types of signature confusion
316 /// attacks (where an attacker substitutes one legitimate
317 /// signature for another).
318 #[serde(rename = "type")]
319 pub ty: ClientDataType,
320 /// This member contains the base64url encoding of the challenge
321 /// provided by the Relying Party. See the [Cryptographic
322 /// Challenges] security consideration.
323 ///
324 /// [Cryptographic Challenges]: https://w3c.github.io/webauthn/#sctn-cryptographic-challenges
325 ///
326 /// https://w3c.github.io/webauthn/#base64url-encoding
327 ///
328 /// The term Base64url Encoding refers to the base64 encoding using the
329 /// URL- and filename-safe character set defined in Section 5 of
330 /// [RFC4648], with all trailing '=' characters omitted
331 /// (as permitted by Section 3.2) and without the inclusion of any line
332 /// breaks, whitespace, or other additional characters.
333 pub challenge: String,
334 /// This member contains the fully qualified origin of the requester, as
335 /// provided to the authenticator by the client, in the syntax
336 /// defined by [RFC6454].
337 ///
338 /// [RFC6454]: https://www.rfc-editor.org/rfc/rfc6454
339 pub origin: String,
340 // /// This OPTIONAL member contains the inverse of the sameOriginWithAncestors argument
341 // value that /// was passed into the internal method
342 // #[serde(default, serialize_with = "truthiness")]
343 // #[serde(rename = "type")]
344 // pub cross_origin: Option<bool>,
345 }
346
347 /// Used to limit the values of [`CollectedClientData::ty`] and serializes
348 /// to static strings.
349 #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
350 pub enum ClientDataType {
351 /// Serializes to the string `"webauthn.get"`
352 ///
353 /// Passkey's in IOTA only support the value `"webauthn.get"`, other
354 /// values will be rejected.
355 #[serde(rename = "webauthn.get")]
356 Get,
357 // /// Serializes to the string `"webauthn.create"`
358 // #[serde(rename = "webauthn.create")]
359 // Create,
360 // /// Serializes to the string `"payment.get"`
361 // /// This variant is part of the Secure Payment Confirmation specification
362 // ///
363 // /// See <https://www.w3.org/TR/secure-payment-confirmation/#client-extension-processing-authentication>
364 // #[serde(rename = "payment.get")]
365 // PaymentGet,
366 }
367}
368
369#[cfg(feature = "proptest")]
370impl proptest::arbitrary::Arbitrary for PasskeyAuthenticator {
371 type Parameters = ();
372 type Strategy = proptest::strategy::BoxedStrategy<Self>;
373
374 fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
375 use proptest::{collection::vec, prelude::*};
376 use serialization::{ClientDataType, CollectedClientData};
377
378 (
379 any::<Secp256r1PublicKey>(),
380 any::<Secp256r1Signature>(),
381 vec(any::<u8>(), 32),
382 vec(any::<u8>(), 0..32),
383 )
384 .prop_map(
385 |(public_key, signature, challenge_bytes, authenticator_data)| {
386 let challenge =
387 <base64ct::Base64UrlUnpadded as base64ct::Encoding>::encode_string(
388 &challenge_bytes,
389 );
390 let client_data_json = serde_json::to_string(&CollectedClientData {
391 ty: ClientDataType::Get,
392 challenge,
393 origin: "http://example.com".to_owned(),
394 })
395 .unwrap();
396
397 Self {
398 public_key,
399 signature,
400 challenge: challenge_bytes,
401 authenticator_data,
402 client_data_json,
403 }
404 },
405 )
406 .boxed()
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use crate::UserSignature;
413
414 #[test]
415 fn base64_encoded_passkey_user_signature() {
416 let b64 = "BiVYDmenOnqS+thmz5m5SrZnWaKXZLVxgh+rri6LHXs25B0AAAAAnQF7InR5cGUiOiJ3ZWJhdXRobi5nZXQiLCAiY2hhbGxlbmdlIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyIsImNyb3NzT3JpZ2luIjpmYWxzZSwgInVua25vd24iOiAidW5rbm93biJ9YgJMwqcOmZI7F/N+K5SMe4DRYCb4/cDWW68SFneSHoD2GxKKhksbpZ5rZpdrjSYABTCsFQQBpLORzTvbj4edWKd/AsEBeovrGvHR9Ku7critg6k7qvfFlPUngujXfEzXd8Eg";
417
418 let sig = UserSignature::from_base64(b64).unwrap();
419 assert!(matches!(sig, UserSignature::Passkey(_)));
420 }
421}