Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libwebauthn/examples/webauthn_prf_hid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
let challenge: [u8; 32] = thread_rng().gen();

let extensions = MakeCredentialsRequestExtensions {
prf: Some(MakeCredentialPrfInput { _eval: None }),
prf: Some(MakeCredentialPrfInput { eval: None }),
..Default::default()
};

Expand Down
2 changes: 1 addition & 1 deletion libwebauthn/src/ops/u2f.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ impl UpgradableResponse<MakeCredentialResponse, MakeCredentialRequest> for Regis
enterprise_attestation: None,
large_blob_key: None,
};
Ok(resp.into_make_credential_output(request, None))
Ok(resp.into_make_credential_output(request, None, None))
}
}

Expand Down
8 changes: 6 additions & 2 deletions libwebauthn/src/ops/webauthn/get_assertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,15 @@ use crate::{
use super::timeout::DEFAULT_TIMEOUT;
use super::{DowngradableRequest, RelyingPartyId, SignRequest, UserVerificationRequirement};

#[derive(Debug, Default, Clone, Serialize, PartialEq)]
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct PRFValue {
#[serde(with = "serde_bytes")]
pub first: [u8; 32],
#[serde(skip_serializing_if = "Option::is_none", with = "serde_bytes")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_bytes"
)]
pub second: Option<[u8; 32]>,
}

Expand Down
60 changes: 51 additions & 9 deletions libwebauthn/src/ops/webauthn/make_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ use std::time::Duration;

use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy;
use serde::{Deserialize, Serialize};
use serde_json::{self, Value as JsonValue};
use sha2::{Digest, Sha256};
use tracing::{debug, instrument, trace};

Expand Down Expand Up @@ -68,6 +67,7 @@ impl MakeCredentialsResponseUnsignedExtensions {
signed_extensions: &Option<Ctap2MakeCredentialsResponseExtensions>,
request: &MakeCredentialRequest,
info: Option<&Ctap2GetInfoResponse>,
auth_data: Option<&crate::transport::AuthTokenData>,
) -> MakeCredentialsResponseUnsignedExtensions {
let mut hmac_create_secret = None;
let mut prf = None;
Expand All @@ -79,8 +79,26 @@ impl MakeCredentialsResponseUnsignedExtensions {
hmac_create_secret = signed_extensions.hmac_secret;
}
if incoming_ext.prf.is_some() {
// Decrypt hmac-secret-mc output if available
let mc_results = signed_extensions.hmac_secret_mc.as_ref().and_then(|x| {
if let Some(auth_data) = auth_data {
let uv_proto = auth_data.protocol_version.create_protocol_object();
x.decrypt_output(&auth_data.shared_secret, &uv_proto)
} else {
None
}
});

let results = mc_results.map(|decrypted| {
super::PRFValue {
first: decrypted.output1,
second: decrypted.output2,
}
});

prf = Some(MakeCredentialPrfOutput {
enabled: signed_extensions.hmac_secret,
results,
});
}
}
Expand Down Expand Up @@ -278,19 +296,20 @@ impl WebAuthnIDL<MakeCredentialRequestParsingError> for MakeCredentialRequest {
type InnerModel = PublicKeyCredentialCreationOptionsJSON;
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct MakeCredentialPrfInput {
/// The `eval` field is parsed but not used during credential creation.
/// PRF evaluation only occurs during assertion (getAssertion), not registration.
/// We parse it here to accept valid WebAuthn JSON input without errors.
#[serde(rename = "eval")]
pub _eval: Option<JsonValue>,
/// PRF eval values for hmac-secret-mc (CTAP 2.2).
/// At MC time, only a single eval value is supported (no eval_by_credential).
#[serde(default)]
pub eval: Option<super::PRFValue>,
}

#[derive(Debug, Default, Clone, Serialize, PartialEq)]
pub struct MakeCredentialPrfOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub results: Option<super::PRFValue>,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
Expand Down Expand Up @@ -605,17 +624,40 @@ mod tests {
let req_json = json_field_add(
REQUEST_BASE_JSON,
"extensions",
r#"{"prf": {"eval": {"first": "second"}}}"#,
r#"{"prf": {}}"#,
);

let req: MakeCredentialRequest =
MakeCredentialRequest::from_json(&rpid, &req_json).unwrap();
assert!(matches!(
req.extensions,
Some(MakeCredentialsRequestExtensions { prf: Some(_), .. })
Some(MakeCredentialsRequestExtensions {
prf: Some(MakeCredentialPrfInput { eval: None }),
..
})
));
}

#[test]
fn test_request_from_json_prf_extension_with_eval() {
let rpid = RelyingPartyId::try_from("example.org").unwrap();
// PRF eval values as JSON arrays of bytes (serde_bytes format)
let first_bytes = (1..=32)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let eval_json = format!(r#"{{"prf": {{"eval": {{"first": [{}]}}}}}}"#, first_bytes);
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &eval_json);

let req: MakeCredentialRequest =
MakeCredentialRequest::from_json(&rpid, &req_json).unwrap();
let prf = req.extensions.unwrap().prf.unwrap();
assert!(prf.eval.is_some());
let eval = prf.eval.unwrap();
assert_eq!(eval.first, (1..=32).collect::<Vec<u8>>().as_slice());
assert!(eval.second.is_none());
}

#[test]
fn test_request_from_json_unknown_pub_key_cred_params() {
let rpid = RelyingPartyId::try_from("example.org").unwrap();
Expand Down
52 changes: 5 additions & 47 deletions libwebauthn/src/proto/ctap2/model/get_assertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ use crate::{
GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, PRFValue,
},
pin::PinUvAuthProtocol,
proto::ctap2::cbor::Value,
proto::{
ctap2::cbor::Value,
extensions::prf::{prf_value_to_hmac_input, CalculatedHMACGetSecretInput},
},
transport::AuthTokenData,
webauthn::{Error, PlatformError},
};
Expand All @@ -17,11 +20,9 @@ use super::{
Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialUserEntity,
Ctap2UserVerifiableRequest,
};
use cosey::PublicKey;
use serde::{Deserialize, Serialize};
use serde_bytes::ByteBuf;
use serde_indexed::{DeserializeIndexed, SerializeIndexed};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap};
use tracing::error;

Expand Down Expand Up @@ -319,57 +320,14 @@ impl Ctap2GetAssertionRequestExtensions {

// 5. If ev is not null:
if let Some(ev) = ev {
// SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first).
let mut prefix = String::from("WebAuthn PRF").into_bytes();
prefix.push(0x00);

let mut input = HMACGetSecretInput::default();
// 5.1 Let salt1 be the value of SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first).
let mut salt1_input = prefix.clone();
salt1_input.extend(ev.first);

let mut hasher = Sha256::default();
hasher.update(salt1_input);
let salt1_hash = hasher.finalize().to_vec();
input.salt1.copy_from_slice(&salt1_hash[..32]);

// 5.2 If ev.second is present, let salt2 be the value of SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.second).
if let Some(second) = ev.second {
let mut salt2_input = prefix.clone();
salt2_input.extend(second);
let mut hasher = Sha256::default();
hasher.update(salt2_input);
let salt2_hash = hasher.finalize().to_vec();
let mut salt2 = [0u8; 32];
salt2.copy_from_slice(&salt2_hash[..32]);
input.salt2 = Some(salt2);
};

Ok(Some(input))
Ok(Some(prf_value_to_hmac_input(ev)))
} else {
// We don't have a usable PRF, so we don't do any HMAC
Ok(None)
}
}
}

#[derive(Debug, Clone, SerializeIndexed)]
pub struct CalculatedHMACGetSecretInput {
// keyAgreement(0x01): public key of platform key-agreement key.
#[serde(index = 0x01)]
pub public_key: PublicKey,
// saltEnc(0x02): Encryption of the one or two salts
#[serde(index = 0x02)]
pub salt_enc: ByteBuf,
// saltAuth(0x03): authenticate(shared secret, saltEnc)
#[serde(index = 0x03)]
pub salt_auth: ByteBuf,
// pinUvAuthProtocol(0x04): (optional) as selected when getting the shared secret. CTAP2.1 platforms MUST include this parameter if the value of pinUvAuthProtocol is not 1.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(index = 0x04)]
pub pin_auth_proto: Option<u32>,
}

#[derive(Debug, Clone, DeserializeIndexed)]
pub struct Ctap2GetAssertionResponse {
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down
98 changes: 91 additions & 7 deletions libwebauthn/src/proto/ctap2/model/make_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,24 @@ use super::{
use crate::{
fido::AuthenticatorData,
ops::webauthn::{
CredentialProtectionPolicy, MakeCredentialLargeBlobExtension, MakeCredentialRequest,
MakeCredentialResponse, MakeCredentialsRequestExtensions,
MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement,
CredentialProtectionPolicy, Ctap2HMACGetSecretOutput, HMACGetSecretInput,
MakeCredentialLargeBlobExtension, MakeCredentialRequest, MakeCredentialResponse,
MakeCredentialsRequestExtensions, MakeCredentialsResponseUnsignedExtensions,
ResidentKeyRequirement,
},
pin::PinUvAuthProtocol,
proto::CtapError,
proto::{
extensions::prf::{prf_value_to_hmac_input, CalculatedHMACGetSecretInput},
CtapError,
},
transport::AuthTokenData,
webauthn::Error,
};
use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy;
use serde::{Deserialize, Serialize};
use serde_bytes::ByteBuf;
use serde_indexed::{DeserializeIndexed, SerializeIndexed};
use tracing::warn;
use tracing::{error, warn};

#[derive(Debug, Default, Clone, Copy, Serialize)]
pub struct Ctap2MakeCredentialOptions {
Expand Down Expand Up @@ -189,6 +194,12 @@ pub struct Ctap2MakeCredentialsRequestExtensions {
// Thanks, FIDO-spec for this consistent naming scheme...
#[serde(rename = "hmac-secret", skip_serializing_if = "Option::is_none")]
pub hmac_secret: Option<bool>,
// CTAP 2.2 hmac-secret-mc: allows sending salts at MakeCredential time
#[serde(rename = "hmac-secret-mc", skip_serializing_if = "Option::is_none")]
pub hmac_secret_mc: Option<CalculatedHMACGetSecretInput>,
// Internal: stores PRF eval input for hmac-secret-mc calculation
#[serde(skip)]
pub(crate) prf_input: Option<HMACGetSecretInput>,
}

impl Ctap2MakeCredentialsRequestExtensions {
Expand All @@ -198,6 +209,40 @@ impl Ctap2MakeCredentialsRequestExtensions {
&& self.large_blob_key.is_none()
&& self.min_pin_length.is_none()
&& self.hmac_secret.is_none()
&& self.hmac_secret_mc.is_none()
}

pub fn calculate_hmac_secret_mc(
&mut self,
auth_data: &AuthTokenData,
) {
let input = match self.prf_input.take() {
None => return,
Some(i) => i,
};

let uv_proto = auth_data.protocol_version.create_protocol_object();
let public_key = auth_data.key_agreement.clone();

let mut salts = input.salt1.to_vec();
if let Some(salt2) = input.salt2 {
salts.extend(salt2);
}
let salt_enc = if let Ok(res) = uv_proto.encrypt(&auth_data.shared_secret, &salts) {
ByteBuf::from(res)
} else {
error!("Failed to encrypt HMAC salts with shared secret! Skipping hmac-secret-mc");
return;
};

let salt_auth = ByteBuf::from(uv_proto.authenticate(&auth_data.shared_secret, &salt_enc));

self.hmac_secret_mc = Some(CalculatedHMACGetSecretInput {
public_key,
salt_enc,
salt_auth,
pin_auth_proto: Some(auth_data.protocol_version as u32),
});
}
}

Expand Down Expand Up @@ -260,12 +305,32 @@ impl Ctap2MakeCredentialsRequestExtensions {
None
};

// hmac-secret-mc: If the authenticator supports hmac-secret-mc and PRF eval is provided,
// prepare the salts for encryption (will be calculated later when shared secret is available).
let hmac_secret_mc_supported = info
.extensions
.as_ref()
.map(|e| e.contains(&String::from("hmac-secret-mc")))
.unwrap_or_default();

let prf_input = if hmac_secret_mc_supported {
requested_extensions
.prf
.as_ref()
.and_then(|prf| prf.eval.as_ref())
.map(prf_value_to_hmac_input)
} else {
None
};

Ok(Ctap2MakeCredentialsRequestExtensions {
cred_blob: requested_extensions
.cred_blob
.as_ref()
.map(|inner| inner.0.clone()),
hmac_secret,
hmac_secret_mc: None, // Calculated later when shared secret is available
prf_input,
cred_protect: requested_extensions
.cred_protect
.as_ref()
Expand Down Expand Up @@ -301,12 +366,14 @@ impl Ctap2MakeCredentialResponse {
self,
request: &MakeCredentialRequest,
info: Option<&Ctap2GetInfoResponse>,
auth_data: Option<&AuthTokenData>,
) -> MakeCredentialResponse {
let unsigned_extensions_output =
MakeCredentialsResponseUnsignedExtensions::from_signed_extensions(
&self.authenticator_data.extensions,
request,
info,
auth_data,
);
MakeCredentialResponse {
format: self.format,
Expand Down Expand Up @@ -358,8 +425,18 @@ impl Ctap2UserVerifiableRequest for Ctap2MakeCredentialRequest {
// No-op
}

fn needs_shared_secret(&self, _get_info_response: &Ctap2GetInfoResponse) -> bool {
false
fn needs_shared_secret(&self, get_info_response: &Ctap2GetInfoResponse) -> bool {
let hmac_secret_mc_supported = get_info_response
.extensions
.as_ref()
.map(|e| e.contains(&String::from("hmac-secret-mc")))
.unwrap_or_default();
let hmac_secret_mc_requested = self
.extensions
.as_ref()
.map(|e| e.prf_input.is_some())
.unwrap_or_default();
hmac_secret_mc_requested && hmac_secret_mc_supported
}
}

Expand All @@ -378,6 +455,13 @@ pub struct Ctap2MakeCredentialsResponseExtensions {
skip_serializing_if = "Option::is_none"
)]
pub hmac_secret: Option<bool>,
// CTAP 2.2 hmac-secret-mc: encrypted HMAC output from MakeCredential
#[serde(
rename = "hmac-secret-mc",
default,
skip_serializing_if = "Option::is_none"
)]
pub hmac_secret_mc: Option<Ctap2HMACGetSecretOutput>,
// Current min PIN lenght
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_pin_length: Option<u32>,
Expand Down
Loading