Skip to main content

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