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}