diff --git a/Cargo.lock b/Cargo.lock index a3f3c165e..df013958c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -466,6 +466,7 @@ dependencies = [ "uuid", "whoami", "windows-sys 0.61.2", + "x509-cert", "zeroize", ] @@ -1110,10 +1111,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1412,6 +1426,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -4847,6 +4867,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -5703,6 +5744,18 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x509-cert" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 982a3045d..341aa23f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,9 +80,12 @@ libc = "0.2" # see src/ssh/security_key for why no ssh-sk-helper style subprocess exists. libloading = "0.8" # The built-in `SecurityKeyProvider internal` (src/ssh/security_key/internal) -# implements CTAPHID, CTAP2 getAssertion/clientPIN and the U2F fallback in -# tree; these three only supply the PIN-protocol crypto and are already in the -# dependency graph through russh/ssh-key, so they add no crate. +# implements CTAPHID, CTAP2 enrollment/signing/credential management and U2F. +# Crypto primitives are shared with the existing russh/ssh-key dependency graph. +# Validate authenticator-provided Ed25519 keys; already in the ssh-key graph. +ed25519-dalek = "3.0" +# Parse authenticator attestation certificates without handwritten ASN.1. +x509-cert = { version = "0.3", default-features = false, features = ["std"] } p256 = { version = "0.14", features = ["ecdh", "ecdsa"] } aes = "0.9" cbc = { version = "0.2", features = ["alloc"] } @@ -126,9 +129,6 @@ hidapi = { version = "2.6.7", default-features = false, features = ["macos-share windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } [dev-dependencies] -# The mock FIDO authenticator signs Ed25519 assertions; already in the graph -# through ssh-key. -ed25519-dalek = "3.0" tempfile = "3.27.0" mockito = "1.7.2" tokio-test = "0.4" diff --git a/README.md b/README.md index 8ba28b837..e7ecee89e 100644 --- a/README.md +++ b/README.md @@ -602,7 +602,8 @@ See [examples/mpi_exit_code.sh](examples/mpi_exit_code.sh) and [examples/health_ bssh supports multiple authentication methods: ### SSH Key Authentication -- **Default keys**: Automatically tries `~/.ssh/id_ed25519`, `~/.ssh/id_ed25519_sk`, `~/.ssh/id_rsa`, `~/.ssh/id_ecdsa`, `~/.ssh/id_ecdsa_sk`, `~/.ssh/id_dsa` (the `*_sk` FIDO keys sign with an attached authenticator through the built-in `SecurityKeyProvider internal`, or an sk-api library if configured; on Linux the user needs access to the `/dev/hidraw*` device, usually through the FIDO udev rules of the `libfido2` packages; enrollment, resident keys and biometric-only UV tokens are not supported, and Windows needs an sk-api library) +- **Default keys**: Automatically tries `~/.ssh/id_ed25519`, `~/.ssh/id_ed25519_sk`, `~/.ssh/id_rsa`, `~/.ssh/id_ecdsa`, `~/.ssh/id_ecdsa_sk`, `~/.ssh/id_dsa` (the `*_sk` FIDO keys sign with an attached authenticator through the built-in `SecurityKeyProvider internal`, or an sk-api library if configured; on Linux the user needs access to the `/dev/hidraw*` device, usually through the FIDO udev rules of the `libfido2` packages; CTAP 2.1 biometric UV tokens are supported, and Windows needs an sk-api library) +- **Security-key enrollment**: `bssh-keygen -t ed25519-sk` or `-t ecdsa-sk` creates OpenSSH keys using the built-in USB HID provider. Add `-O resident -O application=ssh:work -O user=alice` for a resident credential, `-O verify-required` for PIN verification on use, or `-O no-touch-required` where supported. Download resident keys with `bssh-keygen -K` into the current directory; this prompts for the authenticator PIN. Select an external OpenSSH sk-api library with `-w /path/to/provider.so`, or an environment-configured provider with `-w '$SSH_SK_PROVIDER'`. The key file contains an opaque credential handle; the authenticator is still required for signing. Enrollment supports `-O device=PATH` and `-O challenge=FILE` (1 byte–1 MiB). Resident filenames use portable escaping and numeric suffixes to keep duplicate application/user credentials distinct. Attestation-file output is not supported. Built-in resident enrollment requires a PIN and CTAP2 credential management (`credMgmt` or `credentialMgmtPreview`) to check for an existing application/user credential. Resident and `verify-required` enrollment also require `credProtect`; unsupported authenticators fail explicitly. - **Custom key**: Use `-i` flag to specify a key file - **Encrypted keys**: Automatically detects and prompts for passphrase diff --git a/docs/man/bssh-keygen.1 b/docs/man/bssh-keygen.1 index 9807d45d8..b68c13657 100644 --- a/docs/man/bssh-keygen.1 +++ b/docs/man/bssh-keygen.1 @@ -13,10 +13,15 @@ bssh-keygen \- SSH key pair generation tool [\fB\-C\fR \fIcomment\fR] [\fB\-y\fR] [\fB\-q\fR] +[\fB\-w\fR \fIprovider\fR] +[\fB\-O\fR \fIoption\fR] +.br +.B bssh-keygen +\fB\-K\fR [\fB\-w\fR \fIprovider\fR] [\fB\-O\fR device=\fIpath\fR] .SH DESCRIPTION .B bssh-keygen -generates SSH key pairs in OpenSSH format. It supports Ed25519 (recommended) and RSA algorithms. +generates SSH key pairs in OpenSSH format. It supports Ed25519 (recommended), RSA, and authenticator-backed ed25519-sk and ecdsa-sk algorithms. Generated keys are compatible with OpenSSH and can be used for SSH authentication. By default, bssh-keygen generates an Ed25519 key pair and saves it to @@ -30,6 +35,10 @@ Specifies the type of key to generate. Supported types: .IP \fBed25519\fR 10 (default, recommended) Ed25519 elliptic curve key. Provides strong security with compact key size and fast operations. +.IP \fBed25519-sk\fR 10 +Ed25519 FIDO security key. Requires a compatible attached authenticator. +.IP \fBecdsa-sk\fR 10 +ECDSA P-256 FIDO security key. .IP \fBrsa\fR 10 RSA key. Use \fB-b\fR to specify key size. Supported for compatibility with legacy systems. .RE @@ -60,6 +69,47 @@ Default: "bssh-keygen" Example: \fB-C "user@hostname"\fR +.TP +.BR \-w ", " \-\-provider " " \fIPROVIDER\fR +Security-key provider. Defaults to \fBinternal\fR, the built-in USB HID provider. +May be a trusted shared library implementing OpenSSH sk-api, or a quoted +\fI$VARIABLE\fR naming an environment variable containing the provider setting. +External providers execute inside the process. Only load trusted libraries. + +.TP +.B \-O OPTION +Security-key option; may be repeated with different option names: +.RS +.IP \fBapplication=ssh:NAME\fR 10 +Application identifier. Must begin with ssh:. Default: ssh:. +.IP \fBresident\fR 10 +Create a discoverable credential that can later be downloaded using \fB-K\fR. +.IP \fBverify-required\fR 10 +Require user verification (normally a PIN) when signing. +.IP \fBno-touch-required\fR 10 +Do not request user presence for signatures. The authenticator or server may +still require a touch. Use the corresponding server authorized_keys option. +.IP \fBuser=NAME\fR 10 +User name for resident credential enrollment. +.IP \fBchallenge=FILE\fR 10 +Read enrollment challenge bytes from a regular file (1 byte to 1 MiB). +The default is a random 32-byte challenge. +.IP \fBdevice=PATH\fR 10 +Select a specific authenticator device. Also accepted with \fB-K\fR. +.RE +.PP +\fB-O write-attestation=FILE\fR is explicitly unsupported. Unknown, duplicate, +or malformed options fail before authenticator access. + +.TP +.BR \-K ", " \-\-download-resident +Prompt for the authenticator PIN and download resident SSH credentials into +the current directory. Filenames follow OpenSSH's id_ed25519_sk_rk or +id_ecdsa_sk_rk naming with application/user suffixes. Unsafe path characters +are escaped. Numeric suffixes distinguish credentials with duplicate or +case-equivalent application/user filenames, including public-key path aliases. Existing key files require +confirmation unless \fB-y\fR is specified. Cannot be combined with \fB-f\fR. + .TP .BR \-y ", " \-\-yes Overwrite existing key files without prompting for confirmation. @@ -108,6 +158,23 @@ Better resistance to implementation errors .PP RSA support is provided for compatibility with legacy systems. +.SS FIDO Security Keys +The private file stores an opaque handle rather than the authenticator's private +signing key. Keep the file private; the attached authenticator is still required +to authenticate. Defaults are ~/.ssh/id_ed25519_sk and ~/.ssh/id_ecdsa_sk. +Enrollment may prompt for a PIN and require a touch. Resident download always +prompts for a PIN. Device and provider support determines available algorithms +and resident-key operations. Built-in resident enrollment requires a PIN and +CTAP2 credential management (credMgmt or credentialMgmtPreview), used to check +for an existing application/user credential before enrollment. Both resident +and verify-required enrollment require the credProtect extension. Unsupported +authenticators fail explicitly instead of creating a credential with weaker +protection. The current ssh-key encoder supports credential +handles of up to 255 bytes; larger handles fail with an explicit diagnostic. +On Linux, the user needs access to the authenticator's /dev/hidraw device, +normally configured by the FIDO udev rules. The built-in provider supports +Linux and macOS; other platforms require an external provider library. + .SH OUTPUT .B bssh-keygen creates two files: @@ -161,6 +228,13 @@ $ bssh-keygen -C "john@workstation" $ bssh-keygen -t rsa -b 4096 .fi +.SS Enroll a Resident Security Key +.nf +$ bssh-keygen -t ed25519-sk -O resident -O application=ssh:work -O user=alice +$ bssh-keygen -t ecdsa-sk -O verify-required -w internal +$ bssh-keygen -K +.fi + .SS Generate Key Non-Interactively .nf $ bssh-keygen -f /tmp/deploy_key -C "deploy@server" -y -q @@ -255,3 +329,10 @@ There is NO WARRANTY, to the extent permitted by law. .BR ssh (1) Project homepage: https://github.com/lablup/bssh + +.SH ATTESTATION +The internal provider verifies signatures on packed and FIDO U2F registration +responses using the supplied public key or attestation certificate. +It also accepts the unsigned "none" attestation format, as OpenSSH does. +Certificate chains, certificate lifetime and vendor trust are not checked. +Attestation export with write-attestation is unsupported. diff --git a/src/bin/bssh_keygen.rs b/src/bin/bssh_keygen.rs index 2339d6320..dcdd41b29 100644 --- a/src/bin/bssh_keygen.rs +++ b/src/bin/bssh_keygen.rs @@ -46,7 +46,7 @@ use std::path::PathBuf; #[command(version)] #[command(about = "Generate SSH key pairs in OpenSSH format", long_about = None)] struct Cli { - /// Key type: ed25519 (recommended) or rsa + /// Key type: ed25519 (recommended), rsa, ed25519-sk, or ecdsa-sk #[arg( short = 't', long = "type", @@ -72,6 +72,20 @@ struct Cli { #[arg(short = 'C', long = "comment", value_name = "COMMENT")] comment: Option, + /// Download resident security keys to the current directory (prompts for PIN) + #[arg(short = 'K', long = "download-resident", conflicts_with = "output")] + download_resident: bool, + + /// Security-key provider: internal (default), library path, or $VARIABLE + #[arg(short = 'w', long = "provider", value_name = "PROVIDER")] + provider: Option, + + /// Security-key option: application=ssh:NAME, resident, verify-required, + /// no-touch-required, user=NAME, challenge=FILE, or device=PATH. + /// write-attestation=FILE is explicitly unsupported. + #[arg(short = 'O', value_name = "OPTION")] + sk_options: Vec, + /// Overwrite existing files without prompting #[arg(short = 'y', long = "yes")] yes: bool, @@ -93,11 +107,19 @@ fn main() -> Result<()> { logging::init_logging_console_only(cli.verbose); } + if cli.download_resident || matches!(cli.key_type.as_str(), "ed25519-sk" | "ecdsa-sk") { + return run_security_key(&cli); + } + anyhow::ensure!( + cli.provider.is_none() && cli.sk_options.is_empty(), + "-w and -O require a security-key type or -K" + ); + // Validate key type early let key_type = cli.key_type.to_lowercase(); if !matches!(key_type.as_str(), "ed25519" | "rsa") { anyhow::bail!( - "Unknown key type: '{}'. Supported types: ed25519 (recommended), rsa", + "Unknown key type: '{}'. Supported types: ed25519 (recommended), rsa, ed25519-sk, ecdsa-sk", cli.key_type ); } @@ -190,138 +212,143 @@ fn create_ssh_directory(path: &PathBuf) -> Result<()> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - use clap::CommandFactory; - use tempfile::tempdir; - - #[test] - fn test_cli_parsing() { - // Verify CLI structure is valid - Cli::command().debug_assert(); - - let help = Cli::command().render_long_help().to_string(); - for internal_reference in ["issue #", "PR #", "/issues/", "/pull/"] { - assert!( - !help.contains(internal_reference), - "help contains internal tracking reference {internal_reference:?}" - ); - } - } - - #[test] - fn test_cli_defaults() { - let cli = Cli::try_parse_from(["bssh-keygen"]).unwrap(); - assert_eq!(cli.key_type, "ed25519"); - assert_eq!(cli.bits, 4096); - assert!(cli.output.is_none()); - assert!(cli.comment.is_none()); - assert!(!cli.yes); - assert!(!cli.quiet); +fn confirm_security_key_output(path: &std::path::Path, yes: bool) -> Result> { + use keygen::security_key::{check_output, public_path}; + // Inspect both files, including dangling links, before any prompt. + check_output(path, true)?; + if yes { + return Ok(Some(true)); } - - #[test] - fn test_cli_ed25519() { - let cli = Cli::try_parse_from(["bssh-keygen", "-t", "ed25519"]).unwrap(); - assert_eq!(cli.key_type, "ed25519"); - } - - #[test] - fn test_cli_rsa() { - let cli = Cli::try_parse_from(["bssh-keygen", "-t", "rsa", "-b", "2048"]).unwrap(); - assert_eq!(cli.key_type, "rsa"); - assert_eq!(cli.bits, 2048); - } - - #[test] - fn test_cli_output_file() { - let cli = Cli::try_parse_from(["bssh-keygen", "-f", "/tmp/my_key"]).unwrap(); - assert_eq!(cli.output, Some(PathBuf::from("/tmp/my_key"))); - } - - #[test] - fn test_cli_comment() { - let cli = Cli::try_parse_from(["bssh-keygen", "-C", "user@host"]).unwrap(); - assert_eq!(cli.comment, Some("user@host".to_string())); - } - - #[test] - fn test_cli_flags() { - let cli = Cli::try_parse_from(["bssh-keygen", "-y", "-q"]).unwrap(); - assert!(cli.yes); - assert!(cli.quiet); + if !path.exists() && !public_path(path).exists() { + return Ok(Some(false)); } + print!( + "{} or its public key already exists. Overwrite? (y/n) ", + path.display() + ); + io::stdout().flush()?; + let mut response = String::new(); + io::stdin().read_line(&mut response)?; + Ok(response.trim().eq_ignore_ascii_case("y").then_some(true)) +} - #[test] - fn test_cli_verbose() { - let cli = Cli::try_parse_from(["bssh-keygen", "-vvv"]).unwrap(); - assert_eq!(cli.verbose, 3); +fn run_security_key(cli: &Cli) -> Result<()> { + use bssh::ssh::security_key::{ + SecurityKeyError, SkAlgorithm, open_provider, resolve_provider_from_env, + }; + use keygen::security_key::{self as sk, EnrollmentOptions}; + use zeroize::Zeroizing; + + let options = EnrollmentOptions::parse(&cli.sk_options)?; + if cli.download_resident { + anyhow::ensure!( + cli.sk_options + .iter() + .all(|option| option.starts_with("device=")), + "-K only accepts the device security-key option" + ); + let provider = open_provider(&resolve_provider_from_env(cli.provider.as_deref()))?; + let pin = Zeroizing::new(rpassword::prompt_password("Enter PIN for authenticator: ")?); + if !cli.quiet { + println!("You may need to touch your authenticator to authorize key download."); + } + let keys = provider.load_resident_keys(Some(pin.as_str()), options.device.as_deref())?; + drop(pin); + if keys.is_empty() && !cli.quiet { + println!("No keys to download"); + } + let directory = std::env::current_dir()?; + let names = sk::resident_filenames(&keys); + for (key, name) in keys.into_iter().zip(names) { + let path = directory.join(name); + let Some(overwrite) = confirm_security_key_output(&path, cli.yes)? else { + break; + }; + let saved = sk::save_resident_at(key, &path, overwrite)?; + if !cli.quiet { + println!( + "Saved security key to {}\n{}", + saved.path.display(), + saved.fingerprint + ); + } + } + return Ok(()); } - - #[test] - fn test_cli_full_options() { - let cli = Cli::try_parse_from([ - "bssh-keygen", - "-t", - "rsa", - "-b", - "4096", - "-f", - "/tmp/test_key", - "-C", - "test@example.com", - "-y", - "-v", - ]) - .unwrap(); - - assert_eq!(cli.key_type, "rsa"); - assert_eq!(cli.bits, 4096); - assert_eq!(cli.output, Some(PathBuf::from("/tmp/test_key"))); - assert_eq!(cli.comment, Some("test@example.com".to_string())); - assert!(cli.yes); - assert!(!cli.quiet); - assert_eq!(cli.verbose, 1); + let (algorithm, basename) = match cli.key_type.as_str() { + "ed25519-sk" => (SkAlgorithm::Ed25519, "id_ed25519_sk"), + "ecdsa-sk" => (SkAlgorithm::EcdsaSha2NistP256, "id_ecdsa_sk"), + _ => anyhow::bail!("Unsupported security-key type"), + }; + let output = match &cli.output { + Some(path) => path.clone(), + None => { + let directory = dirs::home_dir() + .context("Cannot determine home directory")? + .join(".ssh"); + if !directory.exists() { + create_ssh_directory(&directory)?; + } + directory.join(basename) + } + }; + if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) { + std::fs::create_dir_all(parent)?; } - - #[test] - fn test_cli_long_options() { - let cli = Cli::try_parse_from([ - "bssh-keygen", - "--type", - "ed25519", - "--file", - "/tmp/key", - "--comment", - "my key", - "--yes", - "--quiet", - ]) - .unwrap(); - - assert_eq!(cli.key_type, "ed25519"); - assert_eq!(cli.output, Some(PathBuf::from("/tmp/key"))); - assert_eq!(cli.comment, Some("my key".to_string())); - assert!(cli.yes); - assert!(cli.quiet); + let Some(overwrite) = confirm_security_key_output(&output, cli.yes)? else { + return Ok(()); + }; + let challenge = options.challenge_bytes()?; + let request = options.request(algorithm, &challenge); + request.validate()?; + let provider = open_provider(&resolve_provider_from_env(cli.provider.as_deref()))?; + if !cli.quiet { + println!("You may need to touch your authenticator to authorize key generation."); } - - #[test] - fn test_create_ssh_directory() { - let temp_dir = tempdir().unwrap(); - let ssh_dir = temp_dir.path().join(".ssh"); - - let result = create_ssh_directory(&ssh_dir); - assert!(result.is_ok()); - assert!(ssh_dir.exists()); - - #[cfg(unix)] + let result = sk::generate( + provider.as_ref(), + &request, + None, + &output, + cli.comment.as_deref(), + overwrite, + ); + let saved = match result { + Err(error) + if matches!( + error.downcast_ref::(), + Some(SecurityKeyError::PinRequired) + ) => { - use std::os::unix::fs::PermissionsExt; - let metadata = std::fs::metadata(&ssh_dir).unwrap(); - let permissions = metadata.permissions(); - assert_eq!(permissions.mode() & 0o777, 0o700); + let pin = Zeroizing::new(rpassword::prompt_password("Enter PIN for authenticator: ")?); + sk::generate( + provider.as_ref(), + &request, + Some(pin.as_str()), + &output, + cli.comment.as_deref(), + overwrite, + )? } + result => result?, + }; + if !cli.quiet { + println!( + "Your identification has been saved in {}", + saved.path.display() + ); + println!( + "Your public key has been saved in {}", + sk::public_path(&saved.path).display() + ); + println!( + "The key fingerprint is:\n{}\n\nPublic key:\n{}", + saved.fingerprint, saved.public_key_openssh + ); } + Ok(()) } + +#[cfg(test)] +#[path = "tests/keygen.rs"] +mod tests; diff --git a/src/bin/tests/keygen.rs b/src/bin/tests/keygen.rs new file mode 100644 index 000000000..e7c3e778b --- /dev/null +++ b/src/bin/tests/keygen.rs @@ -0,0 +1,214 @@ +use super::*; +use clap::CommandFactory; +use tempfile::tempdir; + +#[test] +fn test_cli_parsing() { + // Verify CLI structure is valid + Cli::command().debug_assert(); + + let help = Cli::command().render_long_help().to_string(); + for internal_reference in ["issue #", "PR #", "/issues/", "/pull/"] { + assert!( + !help.contains(internal_reference), + "help contains internal tracking reference {internal_reference:?}" + ); + } +} + +#[test] +fn test_cli_defaults() { + let cli = Cli::try_parse_from(["bssh-keygen"]).unwrap(); + assert_eq!(cli.key_type, "ed25519"); + assert_eq!(cli.bits, 4096); + assert!(cli.output.is_none()); + assert!(cli.comment.is_none()); + assert!(!cli.yes); + assert!(!cli.quiet); +} + +#[test] +fn test_cli_ed25519() { + let cli = Cli::try_parse_from(["bssh-keygen", "-t", "ed25519"]).unwrap(); + assert_eq!(cli.key_type, "ed25519"); +} + +#[test] +fn test_cli_rsa() { + let cli = Cli::try_parse_from(["bssh-keygen", "-t", "rsa", "-b", "2048"]).unwrap(); + assert_eq!(cli.key_type, "rsa"); + assert_eq!(cli.bits, 2048); +} + +#[test] +fn test_cli_output_file() { + let cli = Cli::try_parse_from(["bssh-keygen", "-f", "/tmp/my_key"]).unwrap(); + assert_eq!(cli.output, Some(PathBuf::from("/tmp/my_key"))); +} + +#[test] +fn test_cli_comment() { + let cli = Cli::try_parse_from(["bssh-keygen", "-C", "user@host"]).unwrap(); + assert_eq!(cli.comment, Some("user@host".to_string())); +} + +#[test] +fn test_cli_flags() { + let cli = Cli::try_parse_from(["bssh-keygen", "-y", "-q"]).unwrap(); + assert!(cli.yes); + assert!(cli.quiet); +} + +#[test] +fn test_cli_verbose() { + let cli = Cli::try_parse_from(["bssh-keygen", "-vvv"]).unwrap(); + assert_eq!(cli.verbose, 3); +} + +#[test] +fn test_cli_full_options() { + let cli = Cli::try_parse_from([ + "bssh-keygen", + "-t", + "rsa", + "-b", + "4096", + "-f", + "/tmp/test_key", + "-C", + "test@example.com", + "-y", + "-v", + ]) + .unwrap(); + + assert_eq!(cli.key_type, "rsa"); + assert_eq!(cli.bits, 4096); + assert_eq!(cli.output, Some(PathBuf::from("/tmp/test_key"))); + assert_eq!(cli.comment, Some("test@example.com".to_string())); + assert!(cli.yes); + assert!(!cli.quiet); + assert_eq!(cli.verbose, 1); +} + +#[test] +fn test_cli_long_options() { + let cli = Cli::try_parse_from([ + "bssh-keygen", + "--type", + "ed25519", + "--file", + "/tmp/key", + "--comment", + "my key", + "--yes", + "--quiet", + ]) + .unwrap(); + + assert_eq!(cli.key_type, "ed25519"); + assert_eq!(cli.output, Some(PathBuf::from("/tmp/key"))); + assert_eq!(cli.comment, Some("my key".to_string())); + assert!(cli.yes); + assert!(cli.quiet); +} + +#[test] +fn test_create_ssh_directory() { + let temp_dir = tempdir().unwrap(); + let ssh_dir = temp_dir.path().join(".ssh"); + + let result = create_ssh_directory(&ssh_dir); + assert!(result.is_ok()); + assert!(ssh_dir.exists()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(&ssh_dir).unwrap(); + let permissions = metadata.permissions(); + assert_eq!(permissions.mode() & 0o777, 0o700); + } +} + +#[test] +fn cli_help_and_security_key_options() { + Cli::command().debug_assert(); + let help = Cli::command().render_long_help().to_string(); + for option in [ + "ed25519-sk", + "ecdsa-sk", + "-K", + "-w", + "-O", + "resident", + "verify-required", + "challenge=FILE", + "write-attestation=FILE", + ] { + assert!(help.contains(option), "Missing help for {option}"); + } + let cli = Cli::try_parse_from([ + "bssh-keygen", + "-t", + "ed25519-sk", + "-w", + "internal", + "-O", + "resident", + "-O", + "application=ssh:test", + ]) + .unwrap(); + assert_eq!(cli.key_type, "ed25519-sk"); + assert_eq!(cli.sk_options, ["resident", "application=ssh:test"]); + assert!( + Cli::try_parse_from(["bssh-keygen", "-K"]) + .unwrap() + .download_resident + ); + assert!(Cli::try_parse_from(["bssh-keygen", "-K", "-f", "key"]).is_err()); +} + +#[test] +fn cli_software_key_defaults() { + let cli = Cli::try_parse_from(["bssh-keygen"]).unwrap(); + assert_eq!(cli.key_type, "ed25519"); + assert_eq!(cli.bits, 4096); + assert!(cli.output.is_none()); + assert!(!cli.download_resident); + let cli = Cli::try_parse_from(["bssh-keygen", "-t", "rsa", "-b", "2048", "-y", "-q", "-vvv"]) + .unwrap(); + assert_eq!(cli.bits, 2048); + assert!(cli.yes && cli.quiet); + assert_eq!(cli.verbose, 3); +} + +#[test] +fn ssh_directory_permissions() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(".ssh"); + create_ssh_directory(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } +} + +#[test] +fn new_key_confirmation_does_not_authorize_later_overwrite() { + let dir = tempdir().unwrap(); + let path = dir.path().join("key"); + assert_eq!( + confirm_security_key_output(&path, false).unwrap(), + Some(false) + ); + assert_eq!( + confirm_security_key_output(&path, true).unwrap(), + Some(true) + ); +} diff --git a/src/keygen/mod.rs b/src/keygen/mod.rs index 19e01f762..2d84eb579 100644 --- a/src/keygen/mod.rs +++ b/src/keygen/mod.rs @@ -34,6 +34,7 @@ pub mod ed25519; pub mod rsa; +pub mod security_key; use anyhow::Result; use std::path::Path; diff --git a/src/keygen/security_key.rs b/src/keygen/security_key.rs new file mode 100644 index 000000000..48bc1cf1d --- /dev/null +++ b/src/keygen/security_key.rs @@ -0,0 +1,339 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +//! Authenticator enrollment and OpenSSH security-key serialization. + +use anyhow::{Context, Result, bail, ensure}; +use rand::RngExt; +use ssh_key::{HashAlg, LineEnding, PrivateKey, private, public}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use zeroize::Zeroizing; + +use crate::ssh::security_key::{ + SK_FLAG_RESIDENT_KEY, SK_FLAG_USER_PRESENCE_REQUIRED, SK_FLAG_USER_VERIFICATION_REQUIRED, + SecurityKeyProvider, SkAlgorithm, SkEnrollRequest, SkEnrollResponse, SkResidentKey, +}; + +#[path = "sk_files.rs"] +mod files; +pub use files::{check_output, public_path}; + +/// Parsed OpenSSH `-O` security-key options. +#[derive(Debug, Clone)] +pub struct EnrollmentOptions { + pub application: String, + pub flags: u8, + pub user: Option, + pub device: Option, + pub challenge: Option, +} + +impl Default for EnrollmentOptions { + fn default() -> Self { + Self { + application: "ssh:".into(), + flags: SK_FLAG_USER_PRESENCE_REQUIRED, + user: None, + device: None, + challenge: None, + } + } +} + +impl EnrollmentOptions { + /// Reject unknown options before opening or touching an authenticator. + pub fn parse(options: &[String]) -> Result { + let mut result = Self::default(); + let mut seen = std::collections::HashSet::new(); + for option in options { + let (name, value) = option + .split_once('=') + .map_or((option.as_str(), None), |(n, v)| (n, Some(v))); + ensure!(seen.insert(name), "Duplicate security-key option: {name}"); + match (name, value) { + ("resident", None) => result.flags |= SK_FLAG_RESIDENT_KEY, + ("verify-required", None) => result.flags |= SK_FLAG_USER_VERIFICATION_REQUIRED, + ("no-touch-required", None) => result.flags &= !SK_FLAG_USER_PRESENCE_REQUIRED, + ("application", Some(value)) + if value.starts_with("ssh:") && !value.contains('\0') => + { + result.application = value.into() + } + ("user", Some(value)) if !value.is_empty() && !value.contains('\0') => { + result.user = Some(value.into()) + } + ("device", Some(value)) if !value.is_empty() && !value.contains('\0') => { + result.device = Some(value.into()) + } + ("challenge", Some(value)) if !value.is_empty() => { + result.challenge = Some(value.into()) + } + ("write-attestation", _) => { + bail!("Security-key option write-attestation is not supported") + } + _ => bail!("Invalid or unsupported security-key option: {name}"), + } + } + Ok(result) + } + + /// Read at most 1 MiB of challenge data, or generate a random challenge. + pub fn challenge_bytes(&self) -> Result>> { + let mut challenge = Zeroizing::new(Vec::new()); + if let Some(path) = &self.challenge { + let mut open = std::fs::OpenOptions::new(); + open.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + open.custom_flags(libc::O_NONBLOCK); + } + let file = open + .open(path) + .context("Cannot open security-key challenge file")?; + ensure!( + file.metadata()?.is_file(), + "Challenge must be a regular file" + ); + file.take(1024 * 1024 + 1).read_to_end(&mut challenge)?; + ensure!( + !challenge.is_empty() && challenge.len() <= 1024 * 1024, + "Challenge must contain 1 to 1048576 bytes" + ); + } else { + challenge.resize(32, 0); + rand::rng().fill(challenge.as_mut_slice()); + } + Ok(challenge) + } + + pub fn request<'a>( + &'a self, + algorithm: SkAlgorithm, + challenge: &'a [u8], + ) -> SkEnrollRequest<'a> { + SkEnrollRequest { + algorithm, + challenge, + application: &self.application, + flags: self.flags, + user: self.user.as_deref(), + device: self.device.as_deref(), + } + } +} + +/// Saved credential metadata. Private handles and PEM are deliberately not returned. +#[derive(Debug, Clone)] +pub struct SavedSecurityKey { + pub path: PathBuf, + pub fingerprint: String, + pub public_key_openssh: String, +} + +/// Enroll with an already opened provider, then save the OpenSSH credential. +pub fn generate( + provider: &dyn SecurityKeyProvider, + request: &SkEnrollRequest<'_>, + pin: Option<&str>, + output: &Path, + comment: Option<&str>, + overwrite: bool, +) -> Result { + request.validate()?; + check_output(output, overwrite)?; + let response = provider.enroll(request, pin)?; + save( + request.algorithm, + request.application, + response, + output, + comment.unwrap_or("bssh-keygen"), + overwrite, + ) +} + +/// Save one resident credential with its application as the default comment. +pub fn save_resident( + key: SkResidentKey, + directory: &Path, + overwrite: bool, +) -> Result { + let path = directory.join(resident_filename(&key)); + save_resident_at(key, &path, overwrite) +} + +/// Save a resident credential to a caller-selected, collision-free output path. +pub fn save_resident_at( + key: SkResidentKey, + output: &Path, + overwrite: bool, +) -> Result { + save( + key.algorithm, + &key.application, + key.key, + output, + &key.application, + overwrite, + ) +} + +/// Match OpenSSH's resident key names; hex-escape unsafe device-supplied components. +pub fn resident_filename(key: &SkResidentKey) -> String { + let application = key + .application + .strip_prefix("ssh://") + .or_else(|| key.application.strip_prefix("ssh:")) + .unwrap_or(&key.application); + let mut suffix = safe_component(application.as_bytes()); + let user_length = key + .user_id + .iter() + .rposition(|byte| *byte != 0) + .map_or(0, |i| i + 1); + if user_length > 0 { + suffix.push('_'); + suffix.push_str(&safe_component(&key.user_id[..user_length])); + } + // Bound untrusted device data to a portable filename length. A digest also + // prevents different truncated applications/user IDs from sharing a path. + if suffix.len() > 180 { + use sha2::{Digest, Sha256}; + suffix = Sha256::digest(suffix.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + } + let algorithm = match key.algorithm { + SkAlgorithm::Ed25519 => "ed25519", + SkAlgorithm::EcdsaSha2NistP256 => "ecdsa", + }; + format!( + "id_{algorithm}_sk_rk{}{}", + if suffix.is_empty() { "" } else { "_" }, + suffix + ) +} + +/// Allocate one portable filename per credential, reserving both private and +/// public names under case-insensitive comparison. Numeric suffixes retain the +/// OpenSSH basename while avoiding aliases on macOS and Windows filesystems. +pub fn resident_filenames(keys: &[SkResidentKey]) -> Vec { + let mut used = std::collections::HashSet::new(); + keys.iter() + .map(|key| { + let base = resident_filename(key); + let mut name = base.clone(); + let mut suffix = 2_u64; + loop { + let normalized = name.to_ascii_lowercase(); + let public = format!("{normalized}.pub"); + if !used.contains(&normalized) && !used.contains(&public) { + used.insert(normalized); + used.insert(public); + return name; + } + name = format!("{base}_{suffix}"); + suffix += 1; + } + }) + .collect() +} + +fn safe_component(bytes: &[u8]) -> String { + // ASCII eliminates Unicode normalization aliases; no colon, whitespace or + // terminal dot can become a Windows stream name or normalized path alias. + if bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(byte)) + && !bytes.windows(2).any(|pair| pair == b"..") + && !bytes.ends_with(b".") + { + String::from_utf8_lossy(bytes).into_owned() + } else { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() + } +} + +fn save( + algorithm: SkAlgorithm, + application: &str, + response: SkEnrollResponse, + output: &Path, + comment: &str, + overwrite: bool, +) -> Result { + ensure!( + application.starts_with("ssh:") && !application.contains('\0'), + "Invalid security-key application" + ); + ensure!( + !comment.contains(['\n', '\r', '\0']), + "Key comment contains control characters" + ); + ensure!( + !response.key_handle.is_empty() && response.key_handle.len() <= 255, + "Security-key handle must contain 1 to 255 bytes (ssh-key serialization limit)" + ); + let key_data = match algorithm { + SkAlgorithm::Ed25519 => { + let bytes: [u8; 32] = response + .public_key + .as_slice() + .try_into() + .context("Invalid Ed25519 security-key public key")?; + let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&bytes) + .context("Invalid Ed25519 security-key public key")?; + ensure!( + !verifying_key.is_weak(), + "Weak Ed25519 security-key public key" + ); + let public = public::SkEd25519::new(public::Ed25519PublicKey(bytes), application); + private::KeypairData::SkEd25519(private::SkEd25519::new( + public, + response.flags, + response.key_handle.to_vec(), + )?) + } + SkAlgorithm::EcdsaSha2NistP256 => { + ensure!( + response.public_key.len() == 65 && response.public_key[0] == 4, + "Invalid P-256 security-key public key" + ); + let public::EcdsaPublicKey::NistP256(point) = + public::EcdsaPublicKey::from_sec1_bytes(&response.public_key)? + else { + bail!("Invalid P-256 security-key public key") + }; + let public = public::SkEcdsaSha2NistP256::new(point, application); + private::KeypairData::SkEcdsaSha2NistP256(private::SkEcdsaSha2NistP256::new( + public, + response.flags, + response.key_handle.to_vec(), + )?) + } + }; + // ssh-key owns its internal handle copy; all buffers owned by this module + // remain zeroizing and no credential/private key is exposed in Debug. + let private = PrivateKey::new(key_data, comment)?; + let pem = private.to_openssh(LineEnding::LF)?; + let public = private.public_key(); + let result = SavedSecurityKey { + path: output.into(), + fingerprint: public.fingerprint(HashAlg::Sha256).to_string(), + public_key_openssh: public.to_openssh()?, + }; + files::write_pair( + output, + pem.as_bytes(), + result.public_key_openssh.as_bytes(), + overwrite, + )?; + Ok(result) +} + +#[cfg(test)] +#[path = "sk_tests.rs"] +mod tests; diff --git a/src/keygen/sk_files.rs b/src/keygen/sk_files.rs new file mode 100644 index 000000000..738ab1dbe --- /dev/null +++ b/src/keygen/sk_files.rs @@ -0,0 +1,95 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{Context, Result, ensure}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub fn public_path(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".pub"); + name.into() +} + +/// Refuse non-regular destinations and check both halves before enrollment. +pub fn check_output(path: &Path, overwrite: bool) -> Result<()> { + for path in [path.to_path_buf(), public_path(path)] { + match fs::symlink_metadata(&path) { + Ok(metadata) => { + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "Refusing non-regular key destination: {}", + path.display() + ); + ensure!(overwrite, "Key file already exists: {}", path.display()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("Cannot inspect key destination"), + } + } + Ok(()) +} + +struct Temporary(PathBuf); +impl Drop for Temporary { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +fn stage(path: &Path, content: &[u8], public: bool) -> Result { + let parent = path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let temporary = parent.join(format!(".bssh-keygen-{:032x}", rand::random::())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(if public { 0o644 } else { 0o600 }); + } + let mut file = options + .open(&temporary) + .context("Cannot create temporary key file")?; + let temporary = Temporary(temporary); + file.write_all(content)?; + if public { + file.write_all(b"\n")?; + } + file.sync_all()?; + Ok(temporary) +} + +pub(super) fn write_pair( + path: &Path, + private: &[u8], + public: &[u8], + overwrite: bool, +) -> Result<()> { + check_output(path, overwrite)?; + let pub_path = public_path(path); + let private_temp = stage(path, private, false)?; + let public_temp = stage(&pub_path, public, true)?; + // Rename replaces the directory entry, never follows a symlink or writes + // through a hard link. For no-overwrite, hard_link atomically refuses a + // destination introduced after the preflight check. + if overwrite { + check_output(path, true)?; + fs::rename(&private_temp.0, path)?; + fs::rename(&public_temp.0, &pub_path)?; + } else { + fs::hard_link(&private_temp.0, path)?; + // Do not unlink a published private path on failure: another writer + // could have replaced that directory entry in the meantime. + fs::hard_link(&public_temp.0, &pub_path).with_context(|| { + format!( + "Cannot save public key; private key retained at {}", + path.display() + ) + })?; + } + Ok(()) +} diff --git a/src/keygen/sk_tests.rs b/src/keygen/sk_tests.rs new file mode 100644 index 000000000..b42ed0115 --- /dev/null +++ b/src/keygen/sk_tests.rs @@ -0,0 +1,348 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::ssh::security_key::{SecurityKeyError, SkSignRequest, SkSignResponse}; +use std::fs; +use tempfile::tempdir; + +struct MockProvider; +impl SecurityKeyProvider for MockProvider { + fn sign( + &self, + _: &SkSignRequest<'_>, + _: Option<&str>, + ) -> std::result::Result { + Err(SecurityKeyError::Unsupported) + } + fn enroll( + &self, + request: &SkEnrollRequest<'_>, + pin: Option<&str>, + ) -> std::result::Result { + if pin != Some("1234") { + return Err(SecurityKeyError::PinRequired); + } + assert_eq!(request.challenge, b"challenge"); + assert_eq!(request.user, Some("alice")); + assert_eq!(request.device, Some("device")); + Ok(response(request.algorithm, request.flags)) + } + fn describe(&self) -> String { + "mock".into() + } +} + +fn response(algorithm: SkAlgorithm, flags: u8) -> SkEnrollResponse { + let public_key = match algorithm { + SkAlgorithm::Ed25519 => ed25519_dalek::SigningKey::from_bytes(&[0x42; 32]) + .verifying_key() + .to_bytes() + .to_vec(), + SkAlgorithm::EcdsaSha2NistP256 => { + // P-256 generator, uncompressed SEC1 point. + let hex = "046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c2964fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"; + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) + .collect() + } + }; + SkEnrollResponse { + flags, + public_key, + key_handle: Zeroizing::new(vec![7; 64]), + } +} + +fn resident(application: &str, user: &[u8]) -> SkResidentKey { + SkResidentKey { + algorithm: SkAlgorithm::Ed25519, + application: application.into(), + key: response( + SkAlgorithm::Ed25519, + SK_FLAG_USER_PRESENCE_REQUIRED | SK_FLAG_RESIDENT_KEY, + ), + user_id: user.into(), + } +} + +#[test] +fn enrollment_roundtrips_both_algorithms_and_openssh_reads_them() { + let directory = tempdir().unwrap(); + let options = EnrollmentOptions::parse(&[ + "user=alice".into(), + "device=device".into(), + "application=ssh:test".into(), + "resident".into(), + "verify-required".into(), + ]) + .unwrap(); + for algorithm in [SkAlgorithm::Ed25519, SkAlgorithm::EcdsaSha2NistP256] { + let path = directory + .path() + .join(format!("key{}", algorithm.api_code())); + let request = options.request(algorithm, b"challenge"); + let error = generate(&MockProvider, &request, None, &path, None, false).unwrap_err(); + assert_eq!( + error.downcast_ref::(), + Some(&SecurityKeyError::PinRequired) + ); + assert!(!path.exists()); + let saved = generate( + &MockProvider, + &request, + Some("1234"), + &path, + Some("alice"), + false, + ) + .unwrap(); + let key = PrivateKey::from_openssh(fs::read(&path).unwrap()).unwrap(); + assert_eq!( + key.public_key().to_openssh().unwrap(), + saved.public_key_openssh + ); + match key.key_data() { + private::KeypairData::SkEd25519(key) => { + assert_eq!(key.public().application(), "ssh:test"); + assert_eq!(key.flags(), options.flags); + assert_eq!(key.key_handle(), &[7; 64]); + } + private::KeypairData::SkEcdsaSha2NistP256(key) => { + assert_eq!(key.public().application(), "ssh:test"); + assert_eq!(key.flags(), options.flags); + assert_eq!(key.key_handle(), &[7; 64]); + } + _ => panic!("wrong key type"), + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + // Keep tests portable if OpenSSH is not installed; errors from an + // installed binary are mandatory failures. + match std::process::Command::new("ssh-keygen") + .args(["-y", "-f"]) + .arg(&path) + .output() + { + Ok(output) => { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let parsed = + ssh_key::PublicKey::from_openssh(std::str::from_utf8(&output.stdout).unwrap()) + .unwrap(); + assert_eq!(parsed.key_data(), key.public_key().key_data()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("Cannot invoke ssh-keygen: {error}"), + } + } +} + +#[test] +fn resident_names_match_openssh_and_cannot_escape_directory() { + for (application, user, expected) in [ + ("ssh:", &b""[..], "id_ed25519_sk_rk"), + ("ssh:work", &b"alice\0\0"[..], "id_ed25519_sk_rk_work_alice"), + ("ssh://work", &b""[..], "id_ed25519_sk_rk_work"), + ("ssh:", &b"alice"[..], "id_ed25519_sk_rk__alice"), + ("ssh:work", &b"../x"[..], "id_ed25519_sk_rk_work_2e2e2f78"), + ] { + assert_eq!(resident_filename(&resident(application, user)), expected); + } + for app in ["ssh:../../escape", "ssh:/tmp/key", "ssh:bad\nname"] { + let name = resident_filename(&resident(app, b"user\\..\xff")); + assert_eq!(Path::new(&name).components().count(), 1); + assert!(!name.contains("..")); + assert!(!name.contains('\n')); + } + let directory = tempdir().unwrap(); + let key = resident("ssh:work", b"alice"); + let saved = save_resident(key.clone(), directory.path(), false).unwrap(); + assert!(saved.public_key_openssh.ends_with("ssh:work")); + assert!(save_resident(key.clone(), directory.path(), false).is_err()); + save_resident(key, directory.path(), true).unwrap(); +} + +#[test] +fn options_and_challenge_are_validated_before_device_access() { + for option in [ + "application=web:site", + "application=ssh:x\0", + "unknown", + "write-attestation=file", + "resident=yes", + "user=", + "challenge=", + ] { + assert!( + EnrollmentOptions::parse(&[option.into()]).is_err(), + "{option}" + ); + } + assert!(EnrollmentOptions::parse(&["resident".into(), "resident".into()]).is_err()); + let options = + EnrollmentOptions::parse(&["no-touch-required".into(), "verify-required".into()]).unwrap(); + assert_eq!(options.flags, SK_FLAG_USER_VERIFICATION_REQUIRED); + assert_eq!(options.challenge_bytes().unwrap().len(), 32); + let dir = tempdir().unwrap(); + let path = dir.path().join("challenge"); + let options = EnrollmentOptions { + challenge: Some(path.clone()), + ..Default::default() + }; + for bytes in [vec![], vec![0; 1024 * 1024 + 1]] { + fs::write(&path, bytes).unwrap(); + assert!(options.challenge_bytes().is_err()); + } + fs::write(path, b"custom challenge").unwrap(); + assert_eq!(&**options.challenge_bytes().unwrap(), b"custom challenge"); +} + +#[test] +fn rejects_malformed_handle_and_public_key_without_creating_files() { + let dir = tempdir().unwrap(); + for size in [0, 256] { + let mut key = resident("ssh:", b""); + key.key.key_handle = Zeroizing::new(vec![7; size]); + assert!(save_resident(key, dir.path(), false).is_err()); + } + let mut key = resident("ssh:", b""); + key.key.public_key.pop(); + assert!(save_resident(key, dir.path(), false).is_err()); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); +} + +#[cfg(unix)] +#[test] +fn refuses_symlinks_in_either_key_destination_and_replaces_hardlinks_safely() { + use std::os::unix::fs::symlink; + let dir = tempdir().unwrap(); + let victim = dir.path().join("victim"); + fs::write(&victim, b"untouched").unwrap(); + let key = resident("ssh:", b""); + let path = dir.path().join(resident_filename(&key)); + for destination in [&path, &public_path(&path)] { + symlink(&victim, destination).unwrap(); + assert!(save_resident(key.clone(), dir.path(), true).is_err()); + assert_eq!(fs::read(&victim).unwrap(), b"untouched"); + fs::remove_file(destination).unwrap(); + } + fs::hard_link(&victim, &path).unwrap(); + save_resident(key, dir.path(), true).unwrap(); + assert_eq!(fs::read(victim).unwrap(), b"untouched"); +} + +#[test] +fn enrollment_never_overwrites_a_file_created_during_device_access() { + struct RacingProvider(PathBuf); + impl SecurityKeyProvider for RacingProvider { + fn sign( + &self, + _: &SkSignRequest<'_>, + _: Option<&str>, + ) -> std::result::Result { + Err(SecurityKeyError::Unsupported) + } + fn enroll( + &self, + request: &SkEnrollRequest<'_>, + _: Option<&str>, + ) -> std::result::Result { + fs::write(&self.0, b"other writer").unwrap(); + Ok(response(request.algorithm, request.flags)) + } + fn describe(&self) -> String { + "racing mock".into() + } + } + let dir = tempdir().unwrap(); + let output = dir.path().join("key"); + let options = EnrollmentOptions::default(); + let request = options.request(SkAlgorithm::Ed25519, b"challenge"); + let provider = RacingProvider(output.clone()); + assert!(generate(&provider, &request, None, &output, None, false).is_err()); + assert_eq!(fs::read(&output).unwrap(), b"other writer"); + assert!(!public_path(&output).exists()); +} + +#[test] +fn resident_allocation_preserves_every_duplicate_and_portable_alias() { + let dir = tempdir().unwrap(); + let mut keys = vec![ + resident("ssh:work", b"alice"), + resident("ssh:work", b"alice"), + resident("ssh:WORK", b"ALICE"), + resident("ssh:work_alice.pub", b""), + resident("ssh:work_alice_2", b""), + resident("ssh:a:b", b""), + resident("ssh:613a62", b""), + resident("ssh:trailing.", b"user "), + resident("ssh:\u{00e9}", b""), + resident("ssh:e\u{0301}", b""), + ]; + for (index, key) in keys.iter_mut().enumerate() { + key.key.key_handle = Zeroizing::new(vec![index as u8 + 1; 32]); + } + let names = resident_filenames(&keys); + assert_eq!(names, resident_filenames(&keys)); + assert_eq!(names[0], "id_ed25519_sk_rk_work_alice"); + assert_eq!(names[1], "id_ed25519_sk_rk_work_alice_2"); + assert_eq!(names[2], "id_ed25519_sk_rk_WORK_ALICE_3"); + let mut normalized = std::collections::HashSet::new(); + for (index, (key, name)) in keys.into_iter().zip(&names).enumerate() { + assert!(name.is_ascii()); + assert!(!name.contains([':', ' ', '/', '\\'])); + assert!(!name.ends_with('.')); + assert!(normalized.insert(name.to_ascii_lowercase())); + assert!(normalized.insert(format!("{}.pub", name.to_ascii_lowercase()))); + // Even -y must leave a distinct keypair for every returned credential. + let path = dir.path().join(name); + save_resident_at(key, &path, true).unwrap(); + let decoded = PrivateKey::from_openssh(fs::read(path).unwrap()).unwrap(); + let private::KeypairData::SkEd25519(key) = decoded.key_data() else { + panic!("wrong key type") + }; + assert_eq!(key.key_handle(), &[index as u8 + 1; 32]); + } + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), names.len() * 2); + // Later credentials did not overwrite earlier files. + for (index, name) in names.iter().enumerate() { + let decoded = PrivateKey::from_openssh(fs::read(dir.path().join(name)).unwrap()).unwrap(); + let private::KeypairData::SkEd25519(key) = decoded.key_data() else { + panic!("wrong key type") + }; + assert_eq!(key.key_handle(), &[index as u8 + 1; 32]); + } +} + +#[test] +fn malformed_or_weak_ed25519_provider_keys_are_rejected() { + let dir = tempdir().unwrap(); + let malformed = (0..=255) + .map(|byte| vec![byte; 32]) + .find(|bytes| { + ed25519_dalek::VerifyingKey::from_bytes(bytes.as_slice().try_into().unwrap()).is_err() + }) + .unwrap(); + for bytes in [malformed, vec![0; 32]] { + let mut key = resident("ssh:", b""); + key.key.public_key = bytes; + let error = save_resident(key, dir.path(), false).unwrap_err(); + assert!( + error + .to_string() + .contains("Ed25519 security-key public key") + ); + } + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); +} diff --git a/src/ssh/security_key/enrollment.rs b/src/ssh/security_key/enrollment.rs new file mode 100644 index 000000000..f10082877 --- /dev/null +++ b/src/ssh/security_key/enrollment.rs @@ -0,0 +1,94 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::{ + SK_FLAG_USER_PRESENCE_REQUIRED, SK_FLAG_USER_VERIFICATION_REQUIRED, SecurityKeyError, + SkAlgorithm, +}; +use std::fmt; +use zeroize::Zeroizing; + +/// Request a discoverable credential during enrollment. +pub const SK_FLAG_RESIDENT_KEY: u8 = 0x20; + +/// Inputs to authenticator credential creation. Challenge bytes are hashed by the provider. +#[derive(Debug, Clone)] +pub struct SkEnrollRequest<'a> { + pub algorithm: SkAlgorithm, + pub challenge: &'a [u8], + pub application: &'a str, + pub flags: u8, + pub user: Option<&'a str>, + pub device: Option<&'a str>, +} + +impl SkEnrollRequest<'_> { + pub fn validate(&self) -> Result<(), SecurityKeyError> { + if !self.application.starts_with("ssh:") + || self.application.len() > 1024 + || self.application.as_bytes().contains(&0) + { + return Err(SecurityKeyError::InvalidRequest( + "-O application= must start with ssh:, contain no NUL and fit in 1024 bytes", + )); + } + if self + .user + .is_some_and(|user| user.is_empty() || user.len() > 31 || user.as_bytes().contains(&0)) + { + return Err(SecurityKeyError::InvalidRequest( + "-O user= must contain 1 to 31 bytes and no NUL", + )); + } + if self.device.is_some_and(|device| { + device.is_empty() || device.len() > 4096 || device.as_bytes().contains(&0) + }) { + return Err(SecurityKeyError::InvalidRequest( + "-O device= must contain 1 to 4096 bytes and no NUL", + )); + } + if self.challenge.is_empty() || self.challenge.len() > 1024 * 1024 { + return Err(SecurityKeyError::InvalidRequest( + "challenge must contain 1 to 1048576 bytes", + )); + } + if self.flags + & !(SK_FLAG_USER_PRESENCE_REQUIRED + | SK_FLAG_USER_VERIFICATION_REQUIRED + | SK_FLAG_RESIDENT_KEY) + != 0 + { + return Err(SecurityKeyError::InvalidRequest( + "unsupported enrollment flags", + )); + } + Ok(()) + } +} + +/// Public credential and opaque private handle returned by enrollment. +#[derive(Clone, PartialEq, Eq)] +pub struct SkEnrollResponse { + pub flags: u8, + /// 32 raw Ed25519 bytes or a 65-byte uncompressed SEC1 P-256 point. + pub public_key: Vec, + pub key_handle: Zeroizing>, +} + +impl fmt::Debug for SkEnrollResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SkEnrollResponse") + .field("flags", &self.flags) + .field("public_key", &self.public_key) + .finish_non_exhaustive() + } +} + +/// One downloaded resident credential. User identifiers are untrusted device data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkResidentKey { + pub algorithm: SkAlgorithm, + pub application: String, + pub key: SkEnrollResponse, + pub user_id: Vec, +} diff --git a/src/ssh/security_key/external.rs b/src/ssh/security_key/external.rs index 8de3b593c..4d001368f 100644 --- a/src/ssh/security_key/external.rs +++ b/src/ssh/security_key/external.rs @@ -15,8 +15,7 @@ //! `dlopen` binding to an external `sk-api.h` middleware library. //! //! The ABI is the pinned OpenSSH 10.3 `sk-api.h` (version `0x000a0000`). -//! Only `sk_sign` is called; `sk_enroll` and `sk_load_resident_keys` must -//! exist (as `sshsk_open` requires) but enrollment stays with `ssh-keygen`. +//! Signing, enrollment, and resident-key discovery use typed ABI bindings. //! //! A library is opened with `RTLD_NOW | RTLD_LOCAL`, as `sshsk_open` opens it //! with `RTLD_NOW`, so an unresolved symbol fails the load instead of @@ -37,9 +36,14 @@ use libloading::{Library, Symbol}; use zeroize::Zeroizing; use super::{ - SecurityKeyError, SecurityKeyProvider, SkSignRequest, SkSignResponse, check_api_version, + SecurityKeyError, SecurityKeyProvider, SkEnrollRequest, SkEnrollResponse, SkResidentKey, + SkSignRequest, SkSignResponse, check_api_version, }; +#[path = "external_enroll.rs"] +mod enrollment; +use enrollment::{SkEnrollFn, SkLoadResidentFn}; + /// Upper bound on a single signature component the provider may hand back. /// Real values are 32 to 64 bytes; anything larger is a corrupt response. const MAX_COMPONENT_LEN: usize = 4096; @@ -74,6 +78,8 @@ type SkSignFn = unsafe extern "C" fn( /// are never dropped, so the library is never unmapped while `sign` exists. struct LoadedLibrary { sign: SkSignFn, + enroll: SkEnrollFn, + load_resident: SkLoadResidentFn, /// Held so the code `sign` points into stays mapped. _library: Library, } @@ -109,9 +115,11 @@ impl ExternalProvider { path: path.to_path_buf(), reason: describe_load_error(&error), })?; - let sign = bind_symbols(path, &library)?; + let (sign, enroll, load_resident) = bind_symbols(path, &library)?; Ok(LoadedLibrary { sign, + enroll, + load_resident, _library: library, }) })?; @@ -181,8 +189,7 @@ fn describe_load_error(error: &libloading::Error) -> String { } /// Required `sk-api.h` entry points besides `sk_api_version` and `sk_sign`. -/// They are looked up, never called: `sshsk_open` refuses a library that -/// lacks any of them, and so does bssh. +/// `sshsk_open` refuses a library that lacks any of them, and so does bssh. const REQUIRED_OPERATIONS: [&[u8]; 2] = [b"sk_enroll\0", b"sk_load_resident_keys\0"]; /// The provider checks `sshsk_open` performs, with the library accesses @@ -212,7 +219,10 @@ where } /// Resolve and validate the sk-api entry points the way `sshsk_open` does. -fn bind_symbols(path: &Path, library: &Library) -> Result { +fn bind_symbols( + path: &Path, + library: &Library, +) -> Result<(SkSignFn, SkEnrollFn, SkLoadResidentFn), SecurityKeyError> { // SAFETY: the symbol is declared in sk-api.h as `uint32_t sk_api_version(void)`; // the type annotation matches that prototype exactly. let api_version: Symbol<'_, SkApiVersionFn> = unsafe { library.get(b"sk_api_version\0") } @@ -238,7 +248,20 @@ fn bind_symbols(path: &Path, library: &Library) -> Result = + unsafe { library.get(b"sk_enroll\0") }.map_err(|error| SecurityKeyError::NotAProvider { + path: path.to_path_buf(), + reason: error.to_string(), + })?; + // SAFETY: same ABI and lifetime guarantees as for sk_enroll above. + let resident: Symbol<'_, SkLoadResidentFn> = unsafe { library.get(b"sk_load_resident_keys\0") } + .map_err(|error| SecurityKeyError::NotAProvider { + path: path.to_path_buf(), + reason: error.to_string(), + })?; + Ok((*sign, *enroll, *resident)) } impl SecurityKeyProvider for ExternalProvider { @@ -302,6 +325,22 @@ impl SecurityKeyProvider for ExternalProvider { copied } + fn enroll( + &self, + request: &SkEnrollRequest<'_>, + pin: Option<&str>, + ) -> Result { + enrollment::enroll(self.loaded.enroll, request, pin) + } + + fn load_resident_keys( + &self, + pin: Option<&str>, + device: Option<&str>, + ) -> Result, SecurityKeyError> { + enrollment::load_resident(self.loaded.load_resident, pin, device) + } + fn describe(&self) -> String { self.path.display().to_string() } diff --git a/src/ssh/security_key/external_enroll.rs b/src/ssh/security_key/external_enroll.rs new file mode 100644 index 000000000..cc4e4352d --- /dev/null +++ b/src/ssh/security_key/external_enroll.rs @@ -0,0 +1,419 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +//! Enrollment ABI and ownership for the pinned OpenSSH `sk-api.h`. +//! +//! Providers are trusted native code: pointer allocation sizes cannot be +//! verified here. Bounds reject malformed lengths before slicing or wiping; +//! pointers must still refer to distinct malloc allocations as the ABI requires. + +use std::ffi::{CStr, CString, c_char, c_int, c_void}; +use std::ptr; + +use zeroize::{Zeroize, Zeroizing}; + +use super::super::{ + SK_FLAG_RESIDENT_KEY, SK_FLAG_USER_PRESENCE_REQUIRED, SK_FLAG_USER_VERIFICATION_REQUIRED, + SecurityKeyError, SkAlgorithm, SkEnrollRequest, SkEnrollResponse, SkResidentKey, +}; +use super::pin_buffer; + +const MAX_BLOB: usize = 1024 * 1024; +const MAX_RESIDENT_KEYS: usize = 1024; +const MAX_APPLICATION: usize = 4096; + +#[repr(C)] +pub(super) struct RawEnrollResponse { + flags: u8, + public_key: *mut u8, + public_key_len: usize, + key_handle: *mut u8, + key_handle_len: usize, + signature: *mut u8, + signature_len: usize, + attestation_cert: *mut u8, + attestation_cert_len: usize, + authdata: *mut u8, + authdata_len: usize, +} + +#[repr(C)] +pub(super) struct RawResidentKey { + alg: u32, + slot: usize, + application: *mut c_char, + key: RawEnrollResponse, + flags: u8, + user_id: *mut u8, + user_id_len: usize, +} + +#[repr(C)] +pub(super) struct RawOption { + name: *mut c_char, + value: *mut c_char, + required: u8, +} + +pub(super) type SkEnrollFn = unsafe extern "C" fn( + u32, + *const u8, + usize, + *const c_char, + u8, + *const c_char, + *mut *mut RawOption, + *mut *mut RawEnrollResponse, +) -> c_int; +pub(super) type SkLoadResidentFn = unsafe extern "C" fn( + *const c_char, + *mut *mut RawOption, + *mut *mut *mut RawResidentKey, + *mut usize, +) -> c_int; + +/// Own the strings, structs, and NULL-terminated array for the entire call. +struct Options { + _strings: Vec, + entries: Vec, + pointers: Vec<*mut RawOption>, +} + +impl Options { + fn new(device: Option<&str>, user: Option<&str>) -> Result { + let mut strings = Vec::new(); + let mut entries = Vec::new(); + for (name, value) in [("device", device), ("user", user)] { + let Some(value) = value else { continue }; + let name = CString::new(name) + .map_err(|_| SecurityKeyError::InvalidRequest("invalid option name"))?; + let value = CString::new(value) + .map_err(|_| SecurityKeyError::InvalidRequest("provider option contains NUL"))?; + entries.push(RawOption { + name: name.as_ptr().cast_mut(), + value: value.as_ptr().cast_mut(), + required: 0, + }); + strings.extend([name, value]); + } + // All entries are in place before taking addresses, so reallocations + // cannot invalidate the pointer array. + let mut pointers: Vec<_> = entries.iter_mut().map(ptr::from_mut).collect(); + pointers.push(ptr::null_mut()); + Ok(Self { + _strings: strings, + entries, + pointers, + }) + } + + fn as_ptr(&mut self) -> *mut *mut RawOption { + if self.entries.is_empty() { + ptr::null_mut() + } else { + self.pointers.as_mut_ptr() + } + } +} + +pub(super) fn enroll( + operation: SkEnrollFn, + request: &SkEnrollRequest<'_>, + pin: Option<&str>, +) -> Result { + request.validate()?; + let application = CString::new(request.application) + .map_err(|_| SecurityKeyError::InvalidRequest("application contains NUL"))?; + let mut options = Options::new(request.device, request.user)?; + let pin = pin.map(pin_buffer).transpose()?; + let mut response = EnrollGuard(ptr::null_mut()); + // SAFETY: inputs and out-pointer live for the full call; types match sk-api.h. + let code = unsafe { + operation( + request.algorithm.api_code(), + request.challenge.as_ptr(), + request.challenge.len(), + application.as_ptr(), + request.flags, + pin.as_ref().map_or(ptr::null(), |p| p.as_ptr().cast()), + options.as_ptr(), + &mut response.0, + ) + }; + drop(pin); + if code != 0 { + return Err(SecurityKeyError::from_sk_code(code)); + } + if response.0.is_null() { + return Err(SecurityKeyError::InvalidResponse( + "missing enrollment response", + )); + } + // SAFETY: provider transferred ownership of an initialized response; + // the guard frees it on success and on every validation failure. + unsafe { copy_enrollment(&*response.0, request.algorithm) } +} + +pub(super) fn load_resident( + operation: SkLoadResidentFn, + pin: Option<&str>, + device: Option<&str>, +) -> Result, SecurityKeyError> { + let mut options = Options::new(device, None)?; + let pin = pin.map(pin_buffer).transpose()?; + let mut response = ResidentGuard { + pointer: ptr::null_mut(), + count: 0, + }; + // SAFETY: arguments match sk-api.h and remain valid for the whole call. + let code = unsafe { + operation( + pin.as_ref().map_or(ptr::null(), |p| p.as_ptr().cast()), + options.as_ptr(), + &mut response.pointer, + &mut response.count, + ) + }; + drop(pin); + if code != 0 { + return Err(SecurityKeyError::from_sk_code(code)); + } + if response.count > MAX_RESIDENT_KEYS || (response.count != 0 && response.pointer.is_null()) { + return Err(SecurityKeyError::InvalidResponse( + "invalid resident key array", + )); + } + let mut result = Vec::with_capacity(response.count); + for index in 0..response.count { + // SAFETY: count was bounded and provider owns an array of that length. + let pointer = unsafe { *response.pointer.add(index) }; + if pointer.is_null() { + return Err(SecurityKeyError::InvalidResponse("null resident key")); + } + // SAFETY: non-null array elements are initialized sk_resident_key objects. + let raw = unsafe { &*pointer }; + // SAFETY: application is an ABI C string; scanning is bounded. + let application = unsafe { copy_application(raw.application) }?; + // OpenSSH filters unrelated RPs and algorithms instead of failing all downloads. + if !application.starts_with("ssh:") { + continue; + } + let algorithm = match raw.alg { + 0 => SkAlgorithm::EcdsaSha2NistP256, + 1 => SkAlgorithm::Ed25519, + _ => continue, + }; + // SAFETY: embedded enrollment response and user ID use the same ABI ownership. + let mut key = unsafe { copy_enrollment(&raw.key, algorithm) }?; + key.flags = SK_FLAG_USER_PRESENCE_REQUIRED + | SK_FLAG_RESIDENT_KEY + | (raw.flags & SK_FLAG_USER_VERIFICATION_REQUIRED); + // SAFETY: pointer/length pairs are bounded before copying. + if raw.user_id_len == 0 || raw.user_id_len > 64 { + return Err(SecurityKeyError::InvalidResponse( + "invalid resident user ID length", + )); + } + let user_id = + unsafe { copy_blob(raw.user_id, raw.user_id_len, "invalid resident user ID") }? + .to_vec(); + result.push(SkResidentKey { + algorithm, + application, + key, + user_id, + }); + } + Ok(result) +} + +fn check_blob(pointer: *const u8, len: usize, name: &'static str) -> Result<(), SecurityKeyError> { + if len > MAX_BLOB || (len > 0 && pointer.is_null()) { + return Err(SecurityKeyError::InvalidResponse(name)); + } + Ok(()) +} + +/// The pointer must be NULL or address the declared readable allocation. +unsafe fn copy_blob( + pointer: *const u8, + len: usize, + name: &'static str, +) -> Result>, SecurityKeyError> { + check_blob(pointer, len, name)?; + if len == 0 { + return Ok(Zeroizing::new(Vec::new())); + } + // SAFETY: caller supplies allocated memory and check_blob bounds the length. + Ok(Zeroizing::new( + unsafe { std::slice::from_raw_parts(pointer, len) }.to_vec(), + )) +} + +/// The provider must supply a valid NUL-terminated C string or NULL. +unsafe fn copy_application(pointer: *const c_char) -> Result { + if pointer.is_null() { + return Err(SecurityKeyError::InvalidResponse( + "missing resident application", + )); + } + // SAFETY: the ABI requires a C string; strnlen stops at the first NUL. + let len = unsafe { libc::strnlen(pointer, MAX_APPLICATION) }; + if len == MAX_APPLICATION { + return Err(SecurityKeyError::InvalidResponse( + "resident application too long", + )); + } + // SAFETY: strnlen found a terminator at len, so len+1 bytes are readable. + let bytes = unsafe { std::slice::from_raw_parts(pointer.cast(), len + 1) }; + CStr::from_bytes_with_nul(bytes) + .ok() + .and_then(|s| s.to_str().ok()) + .map(str::to_owned) + .ok_or(SecurityKeyError::InvalidResponse( + "resident application is not UTF-8", + )) +} + +/// All fields follow sk-api allocation and initialization requirements. +unsafe fn copy_enrollment( + raw: &RawEnrollResponse, + algorithm: SkAlgorithm, +) -> Result { + for (pointer, len) in [ + (raw.signature, raw.signature_len), + (raw.attestation_cert, raw.attestation_cert_len), + (raw.authdata, raw.authdata_len), + ] { + check_blob(pointer, len, "invalid enrollment attestation field")?; + } + let expected = match algorithm { + SkAlgorithm::Ed25519 => 32, + SkAlgorithm::EcdsaSha2NistP256 => 65, + }; + if raw.public_key_len != expected || raw.key_handle_len == 0 || raw.key_handle_len > 4096 { + return Err(SecurityKeyError::InvalidResponse( + "invalid enrollment public key or handle length", + )); + } + // SAFETY: bounded copy helpers validate each provider-owned pointer/length pair. + let public_key = unsafe { + copy_blob( + raw.public_key, + raw.public_key_len, + "invalid enrollment public key", + ) + }? + .to_vec(); + if algorithm == SkAlgorithm::EcdsaSha2NistP256 + && (public_key[0] != 4 || p256::PublicKey::from_sec1_bytes(&public_key).is_err()) + { + return Err(SecurityKeyError::InvalidResponse( + "invalid enrollment P-256 point", + )); + } + if algorithm == SkAlgorithm::Ed25519 { + let bytes: &[u8; 32] = public_key.as_slice().try_into().map_err(|_| { + SecurityKeyError::InvalidResponse("invalid enrollment Ed25519 key length") + })?; + let key = ed25519_dalek::VerifyingKey::from_bytes(bytes) + .map_err(|_| SecurityKeyError::InvalidResponse("invalid enrollment Ed25519 point"))?; + if key.is_weak() { + return Err(SecurityKeyError::InvalidResponse( + "weak enrollment Ed25519 key", + )); + } + } + // SAFETY: same ownership contract as the public-key component. + let key_handle = unsafe { + copy_blob( + raw.key_handle, + raw.key_handle_len, + "invalid enrollment key handle", + ) + }?; + Ok(SkEnrollResponse { + flags: raw.flags, + public_key, + key_handle, + }) +} + +struct EnrollGuard(*mut RawEnrollResponse); +impl Drop for EnrollGuard { + fn drop(&mut self) { + if self.0.is_null() { + return; + } + // SAFETY: guard exclusively owns this provider-allocated response. + unsafe { + free_enrollment_fields(&mut *self.0); + wipe_free(self.0.cast(), size_of::()); + } + } +} + +struct ResidentGuard { + pointer: *mut *mut RawResidentKey, + count: usize, +} +impl Drop for ResidentGuard { + fn drop(&mut self) { + if self.pointer.is_null() { + return; + } + // A corrupt count gives no safe array extent: free the outer allocation + // without traversing it. Native middleware must uphold allocation sizes. + if self.count <= MAX_RESIDENT_KEYS { + for index in 0..self.count { + // SAFETY: bounded ABI array whose ownership the provider transferred. + let pointer = unsafe { *self.pointer.add(index) }; + if pointer.is_null() { + continue; + } + // SAFETY: each non-null element and its fields are owned malloc allocations. + unsafe { + let raw = &mut *pointer; + libc::free(raw.application.cast()); + wipe_free(raw.user_id, raw.user_id_len); + free_enrollment_fields(&mut raw.key); + wipe_free(pointer.cast(), size_of::()); + } + } + } + // SAFETY: the outer array itself is a provider malloc allocation. + unsafe { libc::free(self.pointer.cast()) }; + } +} + +/// Fields must own distinct malloc allocations, or be NULL. +unsafe fn free_enrollment_fields(raw: &mut RawEnrollResponse) { + for (pointer, len) in [ + (raw.public_key, raw.public_key_len), + (raw.key_handle, raw.key_handle_len), + (raw.signature, raw.signature_len), + (raw.attestation_cert, raw.attestation_cert_len), + (raw.authdata, raw.authdata_len), + ] { + // SAFETY: provider transferred each allocation; wipe_free bounds the wipe. + unsafe { wipe_free(pointer, len) }; + } +} + +/// Pointer must be a malloc allocation with the declared length, or NULL. +unsafe fn wipe_free(pointer: *mut u8, len: usize) { + if pointer.is_null() { + return; + } + if len > 0 && len <= MAX_BLOB { + // SAFETY: valid declared allocation with bounded length. Zeroize uses + // volatile writes so clearing handles/PIN-related material is not elided. + unsafe { std::slice::from_raw_parts_mut(pointer, len) }.zeroize(); + } + // SAFETY: the pointer belongs to the ABI's C allocator. + unsafe { libc::free(pointer.cast::()) }; +} + +#[cfg(test)] +#[path = "external_enroll_tests.rs"] +mod tests; diff --git a/src/ssh/security_key/external_enroll_tests.rs b/src/ssh/security_key/external_enroll_tests.rs new file mode 100644 index 000000000..99811b7de --- /dev/null +++ b/src/ssh/security_key/external_enroll_tests.rs @@ -0,0 +1,387 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[test] +fn options_reject_nuls_before_calling_native_code() { + assert!(Options::new(Some("device\0suffix"), None).is_err()); + assert!(Options::new(None, Some("user\0suffix")).is_err()); + assert!(Options::new(None, None).unwrap().as_ptr().is_null()); +} + +/// Compile independently declared C structs to test ABI layout, including +/// padding and the embedded enrollment response in resident credentials. +#[cfg(target_os = "linux")] +mod compiled { + use super::*; + use crate::ssh::security_key::SecurityKeyProvider; + use crate::ssh::security_key::external::ExternalProvider; + + const PROVIDER: &str = r#" +#include +#include +#include +#include +#ifdef __GLIBC__ +/* LD_PRELOAD in the cleanup subprocess intercepts Rust's libc::free too. */ +extern void __libc_free(void *); +static _Thread_local void *watched[256]; +static _Thread_local size_t watched_len[256]; +static _Thread_local int dirty, live, freed; +static void watch(void *p, size_t n) { + for (size_t i = 0; i < 256; i++) if (!watched[i]) { + watched[i] = p; watched_len[i] = n; live++; return; + } + abort(); +} +void free(void *p) { + if (p) for (size_t i = 0; i < 256; i++) if (watched[i] == p) { + for (size_t j = 0; j < watched_len[i]; j++) + if (((uint8_t *)p)[j]) { dirty++; break; } + watched[i] = NULL; live--; freed++; break; + } + __libc_free(p); +} +int bssh_fixture_live(void) { return live; } +int bssh_fixture_dirty(void) { return dirty; } +int bssh_fixture_freed(void) { return freed; } +#else +static void watch(void *p, size_t n) { (void)p; (void)n; } +#endif +struct sk_option { char *name; char *value; uint8_t required; }; +struct sk_enroll_response { + uint8_t flags; + uint8_t *public_key; size_t public_key_len; + uint8_t *key_handle; size_t key_handle_len; + uint8_t *signature; size_t signature_len; + uint8_t *attestation_cert; size_t attestation_cert_len; + uint8_t *authdata; size_t authdata_len; +}; +struct sk_resident_key { + uint32_t alg; size_t slot; char *application; + struct sk_enroll_response key; + uint8_t flags; uint8_t *user_id; size_t user_id_len; +}; +static const uint8_t p256[] = { +0x04,0x6b,0x17,0xd1,0xf2,0xe1,0x2c,0x42,0x47,0xf8,0xbc,0xe6,0xe5,0x63,0xa4,0x40,0xf2, +0x77,0x03,0x7d,0x81,0x2d,0xeb,0x33,0xa0,0xf4,0xa1,0x39,0x45,0xd8,0x98,0xc2,0x96, +0x4f,0xe3,0x42,0xe2,0xfe,0x1a,0x7f,0x9b,0x8e,0xe7,0xeb,0x4a,0x7c,0x0f,0x9e,0x16, +0x2b,0xce,0x33,0x57,0x6b,0x31,0x5e,0xce,0xcb,0xb6,0x40,0x68,0x37,0xbf,0x51,0xf5 +}; +static const uint8_t ed25519[] = {0xd7,0x5a,0x98,0x01,0x82,0xb1,0x0a,0xb7,0xd5,0x4b,0xfe,0xd3,0xc9,0x64,0x07,0x3a,0x0e,0xe1,0x72,0xf3,0xda,0xa6,0x23,0x25,0xaf,0x02,0x1a,0x68,0xf7,0x07,0x51,0x1a}; +static uint8_t *bytes(size_t size) { + uint8_t *p = malloc(size); assert(p); memset(p, 0x5a, size); return p; +} +static void populate(struct sk_enroll_response *r, uint32_t alg) { + r->flags = 0x25; + r->public_key_len = alg == 0 ? 65 : 32; + r->public_key = bytes(r->public_key_len); + if (alg == 0) memcpy(r->public_key, p256, 65); else memcpy(r->public_key, ed25519, 32); + r->key_handle = bytes(19); r->key_handle_len = 19; watch(r->key_handle, 19); + r->signature = bytes(64); r->signature_len = 64; + r->attestation_cert = bytes(100); r->attestation_cert_len = 100; + r->authdata = bytes(37); r->authdata_len = 37; +} +static const char *options(struct sk_option **opts, int user) { + if (!opts) return "default"; + assert(opts[0] && !strcmp(opts[0]->name, "device") && !opts[0]->required); + if (user) { + assert(opts[1] && !strcmp(opts[1]->name, "user") && !opts[1]->required); + assert(!strcmp(opts[1]->value, "alice") && !opts[2]); + } else assert(!opts[1]); + return opts[0]->value; +} +static void corrupt(struct sk_enroll_response *r, const char *mode) { + if (!strcmp(mode, "null-public")) { free(r->public_key); r->public_key = NULL; } + if (!strcmp(mode, "oversize-handle")) r->key_handle_len = SIZE_MAX; + if (!strcmp(mode, "empty-handle")) r->key_handle_len = 0; + if (!strcmp(mode, "null-attestation")) { free(r->authdata); r->authdata = NULL; } + if (!strcmp(mode, "oversize-attestation")) r->signature_len = SIZE_MAX; + if (!strcmp(mode, "bad-point")) memset(r->public_key, 0, r->public_key_len); +} +uint32_t sk_api_version(void) { return 0x000a0000; } +int sk_sign(void) { return -2; } +int sk_enroll(uint32_t alg, const uint8_t *challenge, size_t challenge_len, + const char *application, uint8_t flags, const char *pin, + struct sk_option **opts, struct sk_enroll_response **out) { + const char *mode = options(opts, 1); + assert(alg == 0 || alg == 1); + assert(challenge_len == 9 && !memcmp(challenge, "challenge", 9)); + assert(!strcmp(application, "ssh:fixture") && flags == 0x21); + assert(pin && !strcmp(pin, "1234")); watch((void *)pin, strlen(pin) + 1); + if (!strcmp(mode, "missing")) return 0; + *out = calloc(1, sizeof(**out)); assert(*out); populate(*out, alg); + corrupt(*out, mode); + return !strcmp(mode, "pin-error") ? -3 : 0; +} +int sk_load_resident_keys(const char *pin, struct sk_option **opts, + struct sk_resident_key ***out, size_t *count) { + const char *mode = options(opts, 0); + assert(pin && !strcmp(pin, "1234")); watch((void *)pin, strlen(pin) + 1); + if (!strcmp(mode, "empty")) return 0; + if (!strcmp(mode, "null-array")) { *count = 1; return 0; } + if (!strcmp(mode, "oversize-array")) { + *out = calloc(1, sizeof(**out)); *count = SIZE_MAX; return 0; + } + *count = 2; *out = calloc(*count, sizeof(**out)); assert(*out); + for (size_t i = 0; i < *count; i++) { + if (!strcmp(mode, "null-entry") && i == 1) continue; + struct sk_resident_key *r = calloc(1, sizeof(*r)); assert(r); + (*out)[i] = r; r->alg = i; r->slot = 100 + i; + r->application = strdup("ssh:fixture"); populate(&r->key, i); + r->flags = i == 0 ? 4 : 0; + r->key.flags = 0xff; /* Must use resident flags, not these flags. */ + r->user_id = bytes(5); r->user_id_len = 5; + if (!strcmp(mode, "filtered")) { + if (i == 0) { free(r->application); r->application = strdup("https:example"); } + else r->alg = 99; + } + if (!strcmp(mode, "bad-user")) { free(r->user_id); r->user_id = NULL; } + if (!strcmp(mode, "null-application")) { free(r->application); r->application = NULL; } + if (!strcmp(mode, "long-application")) { + free(r->application); r->application = (char *)bytes(4097); r->application[4096] = 0; + } + if (!strcmp(mode, "nonutf8-application")) r->application[4] = (char)0xff; + corrupt(&r->key, mode); + } + return !strcmp(mode, "pin-error") ? -3 : 0; +} +"#; + + fn provider() -> Option<(tempfile::TempDir, ExternalProvider)> { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("enroll.c"); + let library = directory.path().join("enroll.so"); + std::fs::write(&source, PROVIDER).unwrap(); + let output = match std::process::Command::new("cc") + .args(["-shared", "-fPIC", "-Wall", "-Wextra", "-Werror", "-o"]) + .arg(&library) + .arg(source) + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + eprintln!("C compiler unavailable; skipping compiled ABI test"); + return None; + } + Err(error) => panic!("could not run C compiler: {error}"), + }; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let provider = ExternalProvider::load(&library).unwrap(); + Some((directory, provider)) + } + + fn request(mode: &str, algorithm: SkAlgorithm) -> SkEnrollRequest<'_> { + SkEnrollRequest { + algorithm, + challenge: b"challenge", + application: "ssh:fixture", + flags: 0x21, + device: Some(mode), + user: Some("alice"), + } + } + + #[test] + fn enrolls_both_algorithms_and_downloads_resident_keys_through_real_abi() { + let Some((_directory, provider)) = provider() else { + return; + }; + for algorithm in [SkAlgorithm::Ed25519, SkAlgorithm::EcdsaSha2NistP256] { + let result = provider + .enroll(&request("default", algorithm), Some("1234")) + .unwrap(); + assert_eq!(result.flags, 0x25); + assert_eq!(&result.key_handle[..], &[0x5a; 19]); + assert_eq!( + result.public_key.len(), + if algorithm == SkAlgorithm::Ed25519 { + 32 + } else { + 65 + } + ); + } + let keys = provider + .load_resident_keys(Some("1234"), Some("default")) + .unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].algorithm, SkAlgorithm::EcdsaSha2NistP256); + assert_eq!(keys[1].algorithm, SkAlgorithm::Ed25519); + assert_eq!(keys[0].key.flags, 0x25); + assert_eq!(keys[1].key.flags, 0x21); + for key in keys { + assert_eq!(key.application, "ssh:fixture"); + assert_eq!(&key.key.key_handle[..], &[0x5a; 19]); + assert_eq!(key.user_id, [0x5a; 5]); + } + for device in [None, Some("default")] { + assert_eq!( + provider + .load_resident_keys(Some("1234"), device) + .unwrap() + .len(), + 2 + ); + } + for mode in ["empty", "filtered"] { + assert!( + provider + .load_resident_keys(Some("1234"), Some(mode)) + .unwrap() + .is_empty() + ); + } + } + + #[test] + fn rejects_malformed_enrollment_and_resident_responses() { + let Some((_directory, provider)) = provider() else { + return; + }; + for mode in [ + "missing", + "null-public", + "oversize-handle", + "empty-handle", + "null-attestation", + "oversize-attestation", + "bad-point", + ] { + assert!( + matches!( + provider.enroll(&request(mode, SkAlgorithm::EcdsaSha2NistP256), Some("1234")), + Err(SecurityKeyError::InvalidResponse(_)) + ), + "{mode}" + ); + } + assert!(matches!( + provider.enroll(&request("bad-point", SkAlgorithm::Ed25519), Some("1234")), + Err(SecurityKeyError::InvalidResponse(_)) + )); + for mode in [ + "null-array", + "oversize-array", + "null-entry", + "null-public", + "oversize-handle", + "empty-handle", + "null-attestation", + "oversize-attestation", + "bad-user", + "null-application", + "long-application", + "nonutf8-application", + "bad-point", + ] { + assert!( + matches!( + provider.load_resident_keys(Some("1234"), Some(mode)), + Err(SecurityKeyError::InvalidResponse(_)) + ), + "{mode}" + ); + } + assert_eq!( + provider + .enroll(&request("pin-error", SkAlgorithm::Ed25519), Some("1234")) + .unwrap_err(), + SecurityKeyError::PinRequired + ); + assert_eq!( + provider + .load_resident_keys(Some("1234"), Some("pin-error")) + .unwrap_err(), + SecurityKeyError::PinRequired + ); + } + + #[test] + fn invalid_inputs_never_enter_the_provider() { + let Some((_directory, provider)) = provider() else { + return; + }; + let mut request = request("default", SkAlgorithm::Ed25519); + assert!(provider.enroll(&request, Some("12\0")).is_err()); + assert!(provider.load_resident_keys(Some("12\0"), None).is_err()); + assert!( + provider + .load_resident_keys(Some("1234"), Some("dev\0")) + .is_err() + ); + request.user = Some("alice\0"); + assert!(provider.enroll(&request, Some("1234")).is_err()); + request.user = Some("alice"); + request.challenge = b""; + assert!(provider.enroll(&request, Some("1234")).is_err()); + } + + #[cfg(target_env = "gnu")] + #[test] + fn cleanup_zeroizes_handles_and_pins_on_success_and_error() { + const CHILD: &str = "BSSH_SK_CLEANUP_FIXTURE"; + if let Some(path) = std::env::var_os(CHILD) { + let provider = ExternalProvider::load(std::path::Path::new(&path)).unwrap(); + for mode in ["default", "pin-error", "bad-point", "null-attestation"] { + let _ = + provider.enroll(&request(mode, SkAlgorithm::EcdsaSha2NistP256), Some("1234")); + let _ = provider.load_resident_keys(Some("1234"), Some(mode)); + // SAFETY: fixture symbols have no-argument int prototypes; + // provider lifetime keeps their library mapped. + unsafe { + for symbol in [ + b"bssh_fixture_live\0".as_slice(), + b"bssh_fixture_dirty\0".as_slice(), + ] { + let count = provider + ._loaded_for_tests() + .get:: c_int>(symbol) + .unwrap(); + assert_eq!(count(), 0, "{mode}: {}", String::from_utf8_lossy(symbol)); + } + let freed = provider + ._loaded_for_tests() + .get:: c_int>(b"bssh_fixture_freed\0") + .unwrap(); + assert!(freed() >= 5, "free interposition did not run"); + } + } + return; + } + let Some((directory, _provider)) = provider() else { + return; + }; + let library = directory.path().join("enroll.so"); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + concat!( + module_path!(), + "::cleanup_zeroizes_handles_and_pins_on_success_and_error" + ) + .strip_prefix("bssh::") + .unwrap(), + "--nocapture", + ]) + .env(CHILD, &library) + .env("LD_PRELOAD", &library) + .output() + .unwrap(); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "cleanup child did not run" + ); + } +} diff --git a/src/ssh/security_key/internal/attestation.rs b/src/ssh/security_key/internal/attestation.rs new file mode 100644 index 000000000..7a7d785f9 --- /dev/null +++ b/src/ssh/security_key/internal/attestation.rs @@ -0,0 +1,268 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +//! Verify registration signatures without imposing vendor/CA trust policy. +//! +//! Signature inputs follow WebAuthn §8.2 (packed) and §8.6 (FIDO U2F): +//! . +//! Certificates are parsed by RustCrypto x509-cert; their subject public key +//! authenticates the response. Certificate chain, lifetime, vendor metadata, +//! and attestation certificate profile checks are intentionally not trust gates. + +use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256Key, signature::Verifier}; +use x509_cert::{ + Certificate, + der::{Decode, asn1::ObjectIdentifier}, +}; +use zeroize::Zeroizing; + +use super::{CtapError, cbor::Value}; +use crate::ssh::security_key::SkAlgorithm; + +const EC_PUBLIC_KEY: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); +const PRIME256V1: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7"); +const ED25519: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.101.112"); +const MAX_CERTIFICATE: usize = 64 * 1024; + +pub(super) fn verify_ctap( + map: &Value, + auth_data: &[u8], + challenge_hash: &[u8; 32], + credential_algorithm: SkAlgorithm, + credential_public_key: &[u8], +) -> Result<(), CtapError> { + let format = map + .get_int(1) + .and_then(Value::as_text) + .ok_or(CtapError::Protocol("missing attestation format"))?; + let statement = map + .get_int(3) + .filter(|v| v.as_map().is_some()) + .ok_or(CtapError::Protocol("missing attestation statement"))?; + match format { + "none" => { + if statement.as_map().is_some_and(|pairs| pairs.is_empty()) { + Ok(()) + } else { + Err(CtapError::Protocol( + "none attestation statement is not empty", + )) + } + } + "packed" => { + let algorithm = statement + .get(&Value::from("alg")) + .and_then(Value::as_int) + .ok_or(CtapError::Protocol("packed attestation algorithm missing"))?; + let signature = signature(statement)?; + if statement.get(&Value::from("ecdaaKeyId")).is_some() { + return Err(CtapError::Unsupported("ECDAA attestation")); + } + let mut signed = Zeroizing::new(Vec::with_capacity(auth_data.len() + 32)); + signed.extend_from_slice(auth_data); + signed.extend_from_slice(challenge_hash); + if statement.get(&Value::from("x5c")).is_some() { + validate_fields(statement, &["alg", "sig", "x5c"])?; + verify_certificate( + certificate(statement, false)?, + algorithm, + &signed, + signature, + ) + } else { + validate_fields(statement, &["alg", "sig"])?; + let expected = match credential_algorithm { + SkAlgorithm::EcdsaSha2NistP256 => -7, + SkAlgorithm::Ed25519 => -8, + }; + if algorithm != expected { + return Err(CtapError::Protocol( + "self attestation algorithm differs from credential", + )); + } + verify_signature(algorithm, credential_public_key, &signed, signature) + } + } + "fido-u2f" => { + validate_fields(statement, &["sig", "x5c"])?; + if credential_algorithm != SkAlgorithm::EcdsaSha2NistP256 { + return Err(CtapError::Protocol( + "U2F attestation requires a P-256 credential", + )); + } + if auth_data.len() < 55 || auth_data[32] & 0x40 == 0 || auth_data[37..53] != [0; 16] { + return Err(CtapError::Protocol("invalid U2F attested credential data")); + } + let length = usize::from(u16::from_be_bytes([auth_data[53], auth_data[54]])); + let handle = auth_data + .get(55..55 + length) + .ok_or(CtapError::Protocol("truncated U2F credential ID"))?; + let application_hash = auth_data[..32] + .try_into() + .map_err(|_| CtapError::Protocol("missing U2F application hash"))?; + verify_u2f( + certificate(statement, true)?, + signature(statement)?, + application_hash, + challenge_hash, + handle, + credential_public_key, + ) + } + _ => Err(CtapError::Unsupported("attestation format")), + } +} + +pub(super) fn verify_u2f( + certificate: &[u8], + signature: &[u8], + application_hash: &[u8; 32], + challenge_hash: &[u8; 32], + key_handle: &[u8], + public_key: &[u8], +) -> Result<(), CtapError> { + if key_handle.is_empty() + || key_handle.len() > 4096 + || public_key.len() != 65 + || public_key[0] != 4 + { + return Err(CtapError::Protocol( + "invalid U2F registration key or handle", + )); + } + P256Key::from_sec1_bytes(public_key) + .map_err(|_| CtapError::Protocol("invalid U2F registration public point"))?; + let mut signed = Zeroizing::new(Vec::with_capacity(130 + key_handle.len())); + signed.push(0); + signed.extend_from_slice(application_hash); + signed.extend_from_slice(challenge_hash); + signed.extend_from_slice(key_handle); + signed.extend_from_slice(public_key); + verify_certificate(certificate, -7, &signed, signature) +} + +fn validate_fields(statement: &Value, fields: &[&str]) -> Result<(), CtapError> { + let pairs = statement + .as_map() + .ok_or(CtapError::Protocol("attestation statement is not a map"))?; + if pairs.len() != fields.len() + || pairs + .iter() + .any(|(key, _)| !key.as_text().is_some_and(|key| fields.contains(&key))) + { + return Err(CtapError::Protocol( + "unexpected attestation statement fields", + )); + } + Ok(()) +} + +fn signature(statement: &Value) -> Result<&[u8], CtapError> { + statement + .get(&Value::from("sig")) + .and_then(Value::as_bytes) + .filter(|value| !value.is_empty() && value.len() <= 1024) + .ok_or(CtapError::Protocol( + "missing or invalid attestation signature", + )) +} + +fn parse_certificate(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_CERTIFICATE { + return Err(CtapError::Protocol( + "attestation certificate length invalid", + )); + } + Certificate::from_der(bytes) + .map_err(|_| CtapError::Protocol("invalid attestation certificate DER")) +} + +fn certificate(statement: &Value, single: bool) -> Result<&[u8], CtapError> { + let chain = statement + .get(&Value::from("x5c")) + .and_then(Value::as_array) + .filter(|chain| !chain.is_empty() && chain.len() <= 8 && (!single || chain.len() == 1)) + .ok_or(CtapError::Protocol("invalid attestation certificate array"))?; + // Parse supplied intermediates too, without treating their presence as trust. + for entry in &chain[1..] { + parse_certificate( + entry + .as_bytes() + .ok_or(CtapError::Protocol("attestation certificate is not bytes"))?, + )?; + } + chain[0] + .as_bytes() + .ok_or(CtapError::Protocol("attestation certificate is not bytes")) +} + +fn verify_certificate( + certificate: &[u8], + algorithm: i64, + message: &[u8], + signature: &[u8], +) -> Result<(), CtapError> { + let certificate = parse_certificate(certificate)?; + let spki = certificate.tbs_certificate().subject_public_key_info(); + let key = spki + .subject_public_key + .as_bytes() + .ok_or(CtapError::Protocol( + "attestation certificate public key has unused bits", + ))?; + match algorithm { + -7 => { + let curve = spki + .algorithm + .parameters + .as_ref() + .and_then(|p| p.decode_as::().ok()); + if spki.algorithm.oid != EC_PUBLIC_KEY || curve != Some(PRIME256V1) { + return Err(CtapError::Protocol("attestation certificate is not P-256")); + } + } + -8 => { + if spki.algorithm.oid != ED25519 || spki.algorithm.parameters.is_some() { + return Err(CtapError::Protocol( + "attestation certificate is not Ed25519", + )); + } + } + _ => return Err(CtapError::Unsupported("attestation signature algorithm")), + } + verify_signature(algorithm, key, message, signature) +} + +fn verify_signature( + algorithm: i64, + public_key: &[u8], + message: &[u8], + signature: &[u8], +) -> Result<(), CtapError> { + match algorithm { + -7 => { + let key = P256Key::from_sec1_bytes(public_key) + .map_err(|_| CtapError::Protocol("invalid attestation P-256 key"))?; + let signature = P256Signature::from_der(signature) + .map_err(|_| CtapError::Protocol("invalid attestation ECDSA signature DER"))?; + key.verify(message, &signature) + .map_err(|_| CtapError::Protocol("attestation signature verification failed")) + } + -8 => { + let key = public_key + .try_into() + .ok() + .and_then(|bytes| ed25519_dalek::VerifyingKey::from_bytes(bytes).ok()) + .ok_or(CtapError::Protocol("invalid attestation Ed25519 key"))?; + let signature = ed25519_dalek::Signature::from_slice(signature) + .map_err(|_| CtapError::Protocol("invalid attestation Ed25519 signature"))?; + key.verify_strict(message, &signature) + .map_err(|_| CtapError::Protocol("attestation signature verification failed")) + } + _ => Err(CtapError::Unsupported("attestation signature algorithm")), + } +} + +#[cfg(test)] +#[path = "attestation_tests.rs"] +pub(crate) mod tests; diff --git a/src/ssh/security_key/internal/attestation_tests.rs b/src/ssh/security_key/internal/attestation_tests.rs new file mode 100644 index 000000000..5ad54d4bf --- /dev/null +++ b/src/ssh/security_key/internal/attestation_tests.rs @@ -0,0 +1,353 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use base64::Engine as _; +use p256::{ + ecdsa::{SigningKey, signature::Signer as _}, + pkcs8::DecodePrivateKey, +}; + +// Public, test-only signing keys and self-signed certificates, generated with +// OpenSSL. They authenticate synthetic protocol fixtures, never real credentials. +const P256_CERT: &str = "MIIBojCCAUegAwIBAgIUGH2GgwBlr++SRGRGPm+eQPg0wNIwCgYIKoZIzj0EAwIwJTEjMCEGA1UEAwwaYnNzaCB0ZXN0LW9ubHkgYXR0ZXN0YXRpb24wIBcNMjYxMDIyMTQ0OTU3WhgPMjEyNjA5MjgxNDQ5NTdaMCUxIzAhBgNVBAMMGmJzc2ggdGVzdC1vbmx5IGF0dGVzdGF0aW9uMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQs6GoyklrhfvBzpHLr09aXYQkylQ6lGzHVoCkNVzvvfx19pi9Z8rE4lbRfuKyhLqXDS6YUjreFkVIQQNF/k0w6NTMFEwHQYDVR0OBBYEFBjwWj5yauN2zv262wr6Yi8aUE8xMB8GA1UdIwQYMBaAFBjwWj5yauN2zv262wr6Yi8aUE8xMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIhANI12ioSxLsYNfBvRc1eqD3ULU7wMCdjJanMnaQaWAviAiEAo9Gwi/CpNU+qb51iQziH36mtSLQQrw0148cviXPFw+o="; +const P256_KEY: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgF+Z7aoMbqE180lIKDyTYT7fc79yLHzffyBwoOJ2L4gGhRANCAARCzoajKSWuF+8HOkcuvT1pdhCTKVDqUbMdWgKQ1XO+9/HX2mL1nysTiVtF+4rKEupcNLphSOt4WRUhBA0X+TTD"; +const ED_CERT: &str = "MIIBcTCCASOgAwIBAgIUQdxYJOaq9/zHyxxnua6wmjLLQ24wBQYDK2VwMC0xKzApBgNVBAMMImJzc2ggdGVzdC1vbmx5IEVkMjU1MTkgYXR0ZXN0YXRpb24wIBcNMjYxMDIyMTQ1MjMxWhgPMjEyNjA5MjgxNDUyMzFaMC0xKzApBgNVBAMMImJzc2ggdGVzdC1vbmx5IEVkMjU1MTkgYXR0ZXN0YXRpb24wKjAFBgMrZXADIQB2qkF2U/ahnLgFb8Z5JSI/3qapne8APiB+6ZPHfmv72aNTMFEwHQYDVR0OBBYEFLHlEOSDQVzpgWs2UaedOdhzwmaEMB8GA1UdIwQYMBaAFLHlEOSDQVzpgWs2UaedOdhzwmaEMA8GA1UdEwEB/wQFMAMBAf8wBQYDK2VwA0EA+6Z3T2pqOIyZ+Ky21YXS8Tx9Dpcl87QMnpm5GYeUCdzn7N72kDeLMBU7SR+KBI0skkdAaOcuAbZq6gR81IeOBA=="; +const ED_KEY: &str = "MC4CAQAwBQYDK2VwBCIEIGEisOqGQF5S10r98q+Tg7QfG3H+udmpXR6ruTnAPz/g"; + +fn decode(value: &str) -> Vec { + base64::engine::general_purpose::STANDARD + .decode(value) + .unwrap() +} + +fn p256_key() -> SigningKey { + SigningKey::from_pkcs8_der(&decode(P256_KEY)).unwrap() +} + +fn ed_key() -> ed25519_dalek::SigningKey { + ed25519_dalek::SigningKey::from_pkcs8_der(&decode(ED_KEY)).unwrap() +} + +fn sign_p256(message: &[u8]) -> Vec { + let signature: P256Signature = p256_key().sign(message); + signature.to_der().to_bytes().to_vec() +} + +/// Produce a real U2F registration attestation for the mock authenticator. +pub(crate) fn u2f_attestation( + application_hash: &[u8; 32], + challenge_hash: &[u8; 32], + key_handle: &[u8], + public_key: &[u8], +) -> (Vec, Vec) { + let mut signed = vec![0]; + signed.extend(application_hash); + signed.extend(challenge_hash); + signed.extend(key_handle); + signed.extend(public_key); + (decode(P256_CERT), sign_p256(&signed)) +} + +fn attestation(format: &str, fields: Vec<(&str, Value)>) -> Value { + Value::Map(vec![ + (Value::Int(1), Value::from(format)), + ( + Value::Int(3), + Value::Map( + fields + .into_iter() + .map(|(k, v)| (Value::from(k), v)) + .collect(), + ), + ), + ]) +} + +fn packed(algorithm: i64, signature: Vec, certificate: Option>) -> Value { + let mut fields = vec![ + ("alg", Value::Int(algorithm)), + ("sig", Value::Bytes(signature)), + ]; + if let Some(certificate) = certificate { + fields.push(("x5c", Value::Array(vec![Value::Bytes(certificate)]))); + } + attestation("packed", fields) +} + +#[test] +fn packed_self_attestation_verifies_both_algorithms_and_rejects_mutations() { + let data = b"complete authenticator data"; + let hash = [3; 32]; + let message = [data.as_slice(), &hash].concat(); + for algorithm in [SkAlgorithm::EcdsaSha2NistP256, SkAlgorithm::Ed25519] { + let (code, public, signature) = match algorithm { + SkAlgorithm::EcdsaSha2NistP256 => ( + -7, + p256_key().verifying_key().to_sec1_bytes().to_vec(), + sign_p256(&message), + ), + SkAlgorithm::Ed25519 => ( + -8, + ed_key().verifying_key().to_bytes().to_vec(), + ed_key().sign(&message).to_bytes().to_vec(), + ), + }; + let map = packed(code, signature.clone(), None); + verify_ctap(&map, data, &hash, algorithm, &public).unwrap(); + assert!(verify_ctap(&map, data, &[4; 32], algorithm, &public).is_err()); + assert!( + verify_ctap( + &map, + b"mutated authenticator data", + &hash, + algorithm, + &public + ) + .is_err() + ); + let mut wrong_public = public.clone(); + wrong_public[10] ^= 1; + assert!(verify_ctap(&map, data, &hash, algorithm, &wrong_public).is_err()); + let other = if code == -7 { + SkAlgorithm::Ed25519 + } else { + SkAlgorithm::EcdsaSha2NistP256 + }; + assert!(verify_ctap(&map, data, &hash, other, &public).is_err()); + let mut signature = signature; + let last = signature.len() - 1; + signature[last] ^= 1; + assert!( + verify_ctap( + &packed(code, signature, None), + data, + &hash, + algorithm, + &public + ) + .is_err() + ); + } +} + +#[test] +fn certificate_attestation_uses_its_key_and_binds_all_authenticator_data() { + let data = b"authenticator data including handle and credential public key"; + let hash = [5; 32]; + let message = [data.as_slice(), &hash].concat(); + for (code, certificate, signature) in [ + (-7, decode(P256_CERT), sign_p256(&message)), + ( + -8, + decode(ED_CERT), + ed_key().sign(&message).to_bytes().to_vec(), + ), + ] { + let map = packed(code, signature.clone(), Some(certificate.clone())); + // Attestation key algorithm need not match the credential algorithm. + verify_ctap(&map, data, &hash, SkAlgorithm::Ed25519, &[7; 32]).unwrap(); + assert!(verify_ctap(&map, data, &[6; 32], SkAlgorithm::Ed25519, &[7; 32]).is_err()); + assert!( + verify_ctap( + &map, + b"altered authData", + &hash, + SkAlgorithm::Ed25519, + &[7; 32] + ) + .is_err() + ); + let mut signature = signature; + let last = signature.len() - 1; + signature[last] ^= 1; + assert!( + verify_ctap( + &packed(code, signature, Some(certificate)), + data, + &hash, + SkAlgorithm::Ed25519, + &[7; 32] + ) + .is_err() + ); + } + let signature = sign_p256(&message); + for certificate in [ + vec![], + vec![0x30, 0], + [decode(P256_CERT), vec![0]].concat(), + decode(ED_CERT), + ] { + assert!( + verify_ctap( + &packed(-7, signature.clone(), Some(certificate)), + data, + &hash, + SkAlgorithm::Ed25519, + &[7; 32] + ) + .is_err() + ); + } + assert!( + verify_ctap( + &packed(-257, signature, Some(decode(P256_CERT))), + data, + &hash, + SkAlgorithm::Ed25519, + &[7; 32] + ) + .is_err() + ); +} + +#[test] +fn u2f_registration_binds_application_challenge_handle_and_public_key() { + let app = [1; 32]; + let challenge = [2; 32]; + let handle = [3; 17]; + let public = p256_key().verifying_key().to_sec1_bytes(); + let (certificate, signature) = u2f_attestation(&app, &challenge, &handle, &public); + verify_u2f(&certificate, &signature, &app, &challenge, &handle, &public).unwrap(); + assert!( + verify_u2f( + &certificate, + &signature, + &[8; 32], + &challenge, + &handle, + &public + ) + .is_err() + ); + assert!(verify_u2f(&certificate, &signature, &app, &[8; 32], &handle, &public).is_err()); + assert!( + verify_u2f( + &certificate, + &signature, + &app, + &challenge, + &[8; 17], + &public + ) + .is_err() + ); + let other = SigningKey::from_slice(&[8; 32]).unwrap(); + assert!( + verify_u2f( + &certificate, + &signature, + &app, + &challenge, + &handle, + &other.verifying_key().to_sec1_bytes() + ) + .is_err() + ); + let mut signature = signature; + let last = signature.len() - 1; + signature[last] ^= 1; + assert!(verify_u2f(&certificate, &signature, &app, &challenge, &handle, &public).is_err()); +} + +#[test] +fn ctap_fido_u2f_uses_the_u2f_signed_message() { + let app = [1; 32]; + let challenge = [2; 32]; + let handle = [3; 17]; + let public = p256_key().verifying_key().to_sec1_bytes(); + let (certificate, signature) = u2f_attestation(&app, &challenge, &handle, &public); + let map = attestation( + "fido-u2f", + vec![ + ("sig", Value::Bytes(signature)), + ("x5c", Value::Array(vec![Value::Bytes(certificate)])), + ], + ); + let mut data = app.to_vec(); + data.push(0x41); + data.extend([0; 20]); + data.extend((handle.len() as u16).to_be_bytes()); + data.extend(handle); + verify_ctap( + &map, + &data, + &challenge, + SkAlgorithm::EcdsaSha2NistP256, + &public, + ) + .unwrap(); + assert!( + verify_ctap( + &map, + &data, + &[9; 32], + SkAlgorithm::EcdsaSha2NistP256, + &public + ) + .is_err() + ); + assert!(verify_ctap(&map, &data, &challenge, SkAlgorithm::Ed25519, &public).is_err()); + data[37] = 1; + assert!( + verify_ctap( + &map, + &data, + &challenge, + SkAlgorithm::EcdsaSha2NistP256, + &public + ) + .is_err() + ); + for truncated in [&data[..3], &data[..54], &data[..60]] { + assert!( + verify_ctap( + &map, + truncated, + &challenge, + SkAlgorithm::EcdsaSha2NistP256, + &public + ) + .is_err() + ); + } +} + +#[test] +fn malformed_statements_and_unknown_formats_are_rejected() { + let verify = |map: Value| verify_ctap(&map, b"data", &[0; 32], SkAlgorithm::Ed25519, &[0; 32]); + verify(attestation("none", vec![])).unwrap(); + for map in [ + Value::Map(vec![]), + attestation("none", vec![("sig", Value::Bytes(vec![]))]), + attestation("tpm", vec![]), + attestation("packed", vec![]), + attestation( + "packed", + vec![ + ("alg", Value::Int(-8)), + ("sig", Value::Bytes(vec![0; 64])), + ("x5c", Value::Array(vec![])), + ], + ), + attestation( + "packed", + vec![ + ("alg", Value::Int(-8)), + ("sig", Value::Bytes(vec![0; 64])), + ("x5c", Value::Null), + ], + ), + attestation( + "packed", + vec![ + ("alg", Value::Int(-8)), + ("sig", Value::Bytes(vec![0; 64])), + ("ecdaaKeyId", Value::Bytes(vec![1])), + ], + ), + ] { + assert!(verify(map).is_err()); + } +} diff --git a/src/ssh/security_key/internal/cbor.rs b/src/ssh/security_key/internal/cbor.rs index 760301cdb..517f627aa 100644 --- a/src/ssh/security_key/internal/cbor.rs +++ b/src/ssh/security_key/internal/cbor.rs @@ -20,6 +20,7 @@ //! rejected on input, which is what libfido2's parser does as well. use super::CtapError; +use zeroize::{Zeroize, Zeroizing}; /// Nesting depth beyond which input is treated as hostile. const MAX_DEPTH: usize = 16; @@ -38,6 +39,28 @@ pub enum Value { Null, } +// Credential IDs and PIN authentication data can occur anywhere in a CBOR tree. +impl Zeroize for Value { + fn zeroize(&mut self) { + match self { + Self::Bytes(bytes) => bytes.zeroize(), + Self::Text(text) => text.zeroize(), + Self::Array(items) => items.iter_mut().for_each(Zeroize::zeroize), + Self::Map(pairs) => pairs.iter_mut().for_each(|(key, value)| { + key.zeroize(); + value.zeroize(); + }), + _ => {} + } + } +} + +impl Drop for Value { + fn drop(&mut self) { + self.zeroize(); + } +} + impl Value { #[must_use] pub fn as_int(&self) -> Option { @@ -186,7 +209,7 @@ fn encode_into(value: &Value, out: &mut Vec) { Value::Map(pairs) => { let mut encoded = pairs .iter() - .map(|(key, value)| (encode(key), encode(value))) + .map(|(key, value)| (Zeroizing::new(encode(key)), Zeroizing::new(encode(value)))) .collect::>(); // CTAP2 canonical order: major type, then length, then bytes. // The major type is the top three bits of the first byte, so a @@ -208,14 +231,20 @@ fn encode_into(value: &Value, out: &mut Vec) { /// Decode one item that must span the whole input. pub fn decode(input: &[u8]) -> Result { - let mut cursor = Cursor { input, offset: 0 }; - let value = cursor.item(0)?; - if cursor.offset != input.len() { + let (value, consumed) = decode_prefix(input)?; + if consumed != input.len() { return Err(CtapError::Cbor("trailing bytes after item")); } Ok(value) } +/// Decode the first item in authenticator data containing a COSE key and extensions. +pub fn decode_prefix(input: &[u8]) -> Result<(Value, usize), CtapError> { + let mut cursor = Cursor { input, offset: 0 }; + let value = cursor.item(0)?; + Ok((value, cursor.offset)) +} + struct Cursor<'a> { input: &'a [u8], offset: usize, @@ -305,6 +334,9 @@ impl Cursor<'_> { for _ in 0..len { let key = self.item(depth + 1)?; let value = self.item(depth + 1)?; + if pairs.iter().any(|(existing, _)| existing == &key) { + return Err(CtapError::Cbor("duplicate map key")); + } pairs.push((key, value)); } Value::Map(pairs) diff --git a/src/ssh/security_key/internal/ctap2.rs b/src/ssh/security_key/internal/ctap2.rs index a0069a077..abcd7a4cc 100644 --- a/src/ssh/security_key/internal/ctap2.rs +++ b/src/ssh/security_key/internal/ctap2.rs @@ -98,6 +98,7 @@ pub struct AuthenticatorInfo { pub versions: Vec, pub options: BTreeMap, pub pin_protocols: Vec, + pub extensions: Vec, } impl AuthenticatorInfo { @@ -147,6 +148,16 @@ pub fn parse_get_info(response: &[u8]) -> Result { versions, options, pin_protocols, + extensions: map + .get_int(2) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|value| value.as_text().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(), }) } @@ -330,6 +341,22 @@ pub fn encode_client_pin_get_token( platform_key: &CosePublicKey, pin_hash_enc: &[u8], permissions: Option<(i64, &str)>, +) -> Vec { + encode_client_pin_scoped_token( + protocol, + platform_key, + pin_hash_enc, + permissions.map(|(bits, rp)| (bits, Some(rp))), + ) +} + +/// A credential-management token deliberately has no relying-party restriction. +#[must_use] +pub fn encode_client_pin_scoped_token( + protocol: u64, + platform_key: &CosePublicKey, + pin_hash_enc: &[u8], + permissions: Option<(i64, Option<&str>)>, ) -> Vec { let subcommand = if permissions.is_some() { PIN_SUB_GET_PIN_TOKEN_WITH_PERMISSIONS @@ -344,7 +371,9 @@ pub fn encode_client_pin_get_token( ]; if let Some((bits, rp_id)) = permissions { pairs.push((Value::Int(9), Value::Int(bits))); - pairs.push((Value::Int(10), Value::from(rp_id))); + if let Some(rp_id) = rp_id { + pairs.push((Value::Int(10), Value::from(rp_id))); + } } let mut out = vec![CMD_CLIENT_PIN]; out.extend(cbor::encode(&Value::Map(pairs))); diff --git a/src/ssh/security_key/internal/device.rs b/src/ssh/security_key/internal/device.rs index 4e45bd4a1..a1dc64b15 100644 --- a/src/ssh/security_key/internal/device.rs +++ b/src/ssh/security_key/internal/device.rs @@ -404,3 +404,6 @@ impl Authenticator { } } } + +#[path = "enrollment_device.rs"] +mod enrollment; diff --git a/src/ssh/security_key/internal/enrollment_device.rs b/src/ssh/security_key/internal/enrollment_device.rs new file mode 100644 index 000000000..da32a6520 --- /dev/null +++ b/src/ssh/security_key/internal/enrollment_device.rs @@ -0,0 +1,253 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +//! Stateful credential operations, sharing the signing channel and PIN agreement. + +use super::*; +use crate::ssh::security_key::internal::cbor::{self, Value}; +use crate::ssh::security_key::internal::enrollment_wire as wire; +use crate::ssh::security_key::{ + SK_FLAG_RESIDENT_KEY, SK_FLAG_USER_VERIFICATION_REQUIRED, SkAlgorithm, SkEnrollRequest, + SkEnrollResponse, SkResidentKey, +}; + +impl Authenticator { + pub(crate) fn enroll( + &mut self, + request: &SkEnrollRequest<'_>, + pin: Option<&str>, + deadline: Instant, + rng: &mut R, + ) -> Result { + let user = wire::user_id(request.user)?; + let hash: [u8; 32] = Sha256::digest(request.challenge).into(); + let Some(info) = self.info.clone() else { + return self.enroll_u2f(request, &hash, pin, deadline); + }; + if wire::protection(request.flags).is_some() + && !info.extensions.iter().any(|v| v == "credProtect") + { + return Err(CtapError::Unsupported( + "-O resident and -O verify-required need authenticator credProtect support", + )); + } + if request.flags & SK_FLAG_RESIDENT_KEY != 0 { + if info.option("rk") != Some(true) { + return Err(CtapError::Unsupported( + "-O resident requires discoverable credentials", + )); + } + // Never silently replace a resident key with the same RP/user ID. + // Enumeration verifies the PIN and detects an existing credential. + let keys = self.resident_keys(pin, deadline, rng)?; + if keys + .iter() + .any(|key| key.application == request.application && key.user_id == user) + { + return Err(CtapError::Protocol( + "resident credential already exists for application and user", + )); + } + } + let wants_uv = request.flags & SK_FLAG_USER_VERIFICATION_REQUIRED != 0; + if pin.is_none() && wants_uv && info.option("uv") != Some(true) { + return Err(CtapError::Status(ctap2::status::PIN_REQUIRED)); + } + let auth = if let Some(pin) = pin { + let (token, protocol) = + self.enrollment_pin_token(&info, Some(request.application), 1, pin, deadline, rng)?; + Some(( + Zeroizing::new(authenticate_with(protocol, &token, &hash)), + protocol, + )) + } else if wants_uv && info.supports_permissions() { + let (platform, secret, protocol) = self.key_agreement(&info, deadline, rng)?; + let encoded = ctap2::encode_client_pin_get_uv_token( + protocol.number(), + platform.public_key(), + (1, request.application), + ); + let reply = Zeroizing::new(self.channel.transact( + CMD_CBOR, + &encoded, + Some(remaining(deadline)?), + )?); + let token = secret.decrypt(&ctap2::parse_pin_token(&reply)?)?; + Some(( + Zeroizing::new(authenticate_with(protocol, &token, &hash)), + protocol, + )) + } else { + None + }; + let encoded = wire::make_credential( + request, + &hash, + &user, + auth.as_ref() + .map(|(param, protocol)| (¶m[..], *protocol)), + wants_uv, + ); + let reply = Zeroizing::new(self.channel.transact( + CMD_CBOR, + &encoded, + Some(remaining(deadline)?), + )?); + wire::parse_created(&reply, request) + } + + fn enrollment_pin_token( + &mut self, + info: &AuthenticatorInfo, + rp: Option<&str>, + permission: i64, + pin: &str, + deadline: Instant, + rng: &mut R, + ) -> Result<(Zeroizing>, PinProtocol), CtapError> { + let (platform, secret, protocol) = self.key_agreement(info, deadline, rng)?; + let hash = secret.encrypt_pin_hash(pin, rng)?; + let encoded = Zeroizing::new(ctap2::encode_client_pin_scoped_token( + protocol.number(), + platform.public_key(), + &hash, + info.supports_permissions().then_some((permission, rp)), + )); + let reply = Zeroizing::new(self.channel.transact( + CMD_CBOR, + &encoded, + Some(remaining(deadline)?), + )?); + let token = secret.decrypt(&ctap2::parse_pin_token(&reply)?)?; + if !matches!(token.len(), 16 | 32) { + return Err(CtapError::Protocol("invalid PIN token length")); + } + Ok((token, protocol)) + } + + pub(crate) fn resident_keys( + &mut self, + pin: Option<&str>, + deadline: Instant, + rng: &mut R, + ) -> Result, CtapError> { + let info = self.info.clone().ok_or(CtapError::Unsupported( + "-K requires CTAP2 credential management; U2F has no resident credentials", + ))?; + let command = if info.option("credMgmt") == Some(true) { + 0x0a + } else if info.option("credentialMgmtPreview") == Some(true) { + 0x41 + } else { + return Err(CtapError::Unsupported( + "-K and -O resident require CTAP2 credential management", + )); + }; + let pin = pin.ok_or(CtapError::Status(ctap2::status::PIN_REQUIRED))?; + let (token, protocol) = self.enrollment_pin_token(&info, None, 4, pin, deadline, rng)?; + let first = match self.management(command, 2, None, Some((&token, protocol)), deadline) { + Err(CtapError::Status(ctap2::status::NO_CREDENTIALS)) => return Ok(Vec::new()), + result => result?, + }; + let count = wire::count(&first, 5)?; + let mut parties = vec![wire::relying_party(&first)?]; + // Finish RP enumeration before starting another stateful command. + for _ in 1..count { + let map = self.management(command, 3, None, None, deadline)?; + let party = wire::relying_party(&map)?; + if parties.iter().any(|(_, hash)| hash == &party.1) { + return Err(CtapError::Protocol("duplicate resident RP")); + } + parties.push(party); + } + let mut keys: Vec = Vec::new(); + for (application, hash) in parties { + if !application.starts_with("ssh:") { + continue; + } + let params = Value::Map(vec![(Value::Int(1), Value::from(hash.as_slice()))]); + let first = + self.management(command, 4, Some(params), Some((&token, protocol)), deadline)?; + let count = wire::count(&first, 9)?; + if keys.len() + count > wire::MAX_RESIDENT_KEYS { + return Err(CtapError::Protocol("too many resident credentials")); + } + keys.push(wire::resident(&first, &application)?); + for _ in 1..count { + let map = self.management(command, 5, None, None, deadline)?; + let key = wire::resident(&map, &application)?; + if keys.iter().any(|old| { + old.application == key.application && old.key.key_handle == key.key.key_handle + }) { + return Err(CtapError::Protocol("duplicate resident credential")); + } + keys.push(key); + } + } + Ok(keys) + } + + fn management( + &mut self, + command: u8, + subcommand: u8, + params: Option, + auth: Option<(&[u8], PinProtocol)>, + deadline: Instant, + ) -> Result, CtapError> { + let encoded = wire::management(command, subcommand, params, auth); + let reply = Zeroizing::new(self.channel.transact( + CMD_CBOR, + &encoded, + Some(remaining(deadline)?), + )?); + Ok(Zeroizing::new(cbor::decode(ctap2::response_body(&reply)?)?)) + } + + fn enroll_u2f( + &mut self, + request: &SkEnrollRequest<'_>, + hash: &[u8; 32], + pin: Option<&str>, + deadline: Instant, + ) -> Result { + if request.algorithm != SkAlgorithm::EcdsaSha2NistP256 { + return Err(CtapError::Unsupported( + "ed25519-sk requires CTAP2; U2F only supports ecdsa-sk", + )); + } + if request.flags & crate::ssh::security_key::SK_FLAG_USER_PRESENCE_REQUIRED == 0 { + return Err(CtapError::Unsupported( + "U2F does not support -O no-touch-required", + )); + } + if pin.is_some() + || request.flags & (SK_FLAG_RESIDENT_KEY | SK_FLAG_USER_VERIFICATION_REQUIRED) != 0 + { + return Err(CtapError::Unsupported( + "U2F does not support PIN, -O resident or -O verify-required", + )); + } + let application: [u8; 32] = Sha256::digest(request.application.as_bytes()).into(); + let mut data = Vec::from(hash.as_slice()); + data.extend(application); + let encoded = u2f::encode_apdu(u2f::INS_REGISTER, 0, &data); + loop { + let reply = Zeroizing::new(self.channel.transact( + CMD_MSG, + &encoded, + Some(remaining(deadline)?), + )?); + let (data, status) = u2f::split_status(&reply)?; + match status { + u2f::SW_NO_ERROR => { + return u2f::parse_registration(data, request.flags, &application, hash); + } + u2f::SW_CONDITIONS_NOT_SATISFIED => { + std::thread::sleep(U2F_PACE.min(remaining(deadline)?)) + } + other => return Err(CtapError::Apdu(other)), + } + } + } +} diff --git a/src/ssh/security_key/internal/enrollment_provider.rs b/src/ssh/security_key/internal/enrollment_provider.rs new file mode 100644 index 000000000..d4cc8a9f9 --- /dev/null +++ b/src/ssh/security_key/internal/enrollment_provider.rs @@ -0,0 +1,74 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::ssh::security_key::{SkEnrollRequest, SkEnrollResponse, SkResidentKey}; + +impl InternalProvider { + fn enrollment_device( + &self, + path: Option<&str>, + deadline: Instant, + ) -> Result { + let mut rng = rand::rng(); + if let Some(path) = path { + let devices = select::fido_devices(&*self.backend)?; + let info = devices + .iter() + .find(|info| info.path == path) + .ok_or(SecurityKeyError::DeviceNotFound)?; + return device::Authenticator::open(&*self.backend, info, deadline, &mut rng) + .map_err(Into::into); + } + select::probe( + &*self.backend, + Probe::ByTouch, + &self.timing, + deadline, + &mut rng, + )? + .ok_or(SecurityKeyError::DeviceNotFound) + } + + pub(super) fn enroll_credential( + &self, + request: &SkEnrollRequest<'_>, + pin: Option<&str>, + ) -> Result { + request.validate()?; + let deadline = Instant::now() + self.timing.sign_timeout; + let mut device = self.enrollment_device(request.device, deadline)?; + let result = device.enroll(request, pin, deadline, &mut rand::rng()); + self.finish_enrollment(&mut device, result, deadline) + } + + pub(super) fn download_resident( + &self, + pin: Option<&str>, + path: Option<&str>, + ) -> Result, SecurityKeyError> { + let deadline = Instant::now() + self.timing.sign_timeout; + let mut device = self.enrollment_device(path, deadline)?; + let result = device.resident_keys(pin, deadline, &mut rand::rng()); + self.finish_enrollment(&mut device, result, deadline) + } + + fn finish_enrollment( + &self, + device: &mut device::Authenticator, + result: Result, + deadline: Instant, + ) -> Result { + result.map_err(|error| { + let error = past_deadline(error, deadline); + if error == CtapError::DeadlineExceeded { + device.cancel(); + } + match error { + // Preserve named optional-feature and U2F diagnostics for keygen. + CtapError::Unsupported(reason) => SecurityKeyError::Failed(reason.to_owned()), + other => other.into(), + } + }) + } +} diff --git a/src/ssh/security_key/internal/enrollment_tests.rs b/src/ssh/security_key/internal/enrollment_tests.rs new file mode 100644 index 000000000..62a62af82 --- /dev/null +++ b/src/ssh/security_key/internal/enrollment_tests.rs @@ -0,0 +1,336 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::cbor::Value; +use super::mock::enrollment::Resident; +use super::mock::{Credential, CredentialKey, Fido2Config, MockAuthenticator, Protocol, Request}; +use super::tests::{DATA, provider_with, public_key, verify}; +use super::*; +use crate::ssh::security_key::{SK_FLAG_RESIDENT_KEY, SkEnrollRequest}; + +fn request(algorithm: SkAlgorithm, flags: u8) -> SkEnrollRequest<'static> { + SkEnrollRequest { + algorithm, + challenge: b"challenge from keygen", + application: "ssh:example", + flags, + user: Some("alice"), + device: None, + } +} + +fn resident_config(protocol: u64, preview: bool) -> Fido2Config { + let mut config = Fido2Config::with_pin("1234"); + config.pin_protocols = vec![protocol]; + config.options.insert("rk".into(), true); + config.options.insert("testCredProtect".into(), true); + config.options.insert( + if preview { + "credentialMgmtPreview" + } else { + "credMgmt" + } + .into(), + true, + ); + if preview { + config.options.remove("pinUvAuthToken"); + } + config +} + +#[test] +fn enroll_each_algorithm_then_sign_using_returned_handle() { + for algorithm in [SkAlgorithm::Ed25519, SkAlgorithm::EcdsaSha2NistP256] { + let (provider, devices) = provider_with(vec![( + "/dev/key", + MockAuthenticator::new(Protocol::Fido2(Fido2Config::plain()), None), + )]); + let request = request(algorithm, 1); + let enrolled = provider.enroll(&request, None).unwrap(); + assert_eq!(enrolled.flags, 1); + assert_eq!( + enrolled.public_key.len(), + if algorithm == SkAlgorithm::Ed25519 { + 32 + } else { + 65 + } + ); + let signed = provider + .sign( + &SkSignRequest { + algorithm, + data: DATA, + application: request.application, + key_handle: &enrolled.key_handle, + flags: enrolled.flags, + }, + None, + ) + .unwrap(); + verify( + &public_key(&devices[0].lock().unwrap()), + &crate::ssh::security_key::encode_signature(algorithm, &signed).unwrap(), + ); + let device = devices[0].lock().unwrap(); + let Request::MakeCredential(map) = device + .requests + .iter() + .find(|v| matches!(v, Request::MakeCredential(_))) + .unwrap() + else { + unreachable!() + }; + use sha2::{Digest, Sha256}; + assert_eq!( + map.get_int(1).unwrap().as_bytes().unwrap(), + Sha256::digest(request.challenge).as_slice() + ); + assert!( + map.get_int(7).is_none(), + "makeCredential must not send an up option" + ); + } +} + +#[test] +fn pin_protocols_and_preview_enroll_download_and_refuse_replacement() { + for (protocol, preview) in [(1, false), (2, false), (1, true)] { + let (provider, devices) = provider_with(vec![( + "/dev/key", + MockAuthenticator::new(Protocol::Fido2(resident_config(protocol, preview)), None), + )]); + let request = request(SkAlgorithm::Ed25519, 1 | 4 | SK_FLAG_RESIDENT_KEY); + assert_eq!( + provider.enroll(&request, None).unwrap_err(), + SecurityKeyError::PinRequired + ); + let created = provider.enroll(&request, Some("1234")).unwrap(); + let keys = provider.load_resident_keys(Some("1234"), None).unwrap(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].key, created); + assert_eq!(keys[0].application, request.application); + assert_eq!( + keys[0].user_id, + enrollment_wire::user_id(Some("alice")).unwrap() + ); + assert!( + provider + .enroll(&request, Some("1234")) + .unwrap_err() + .to_string() + .contains("already exists") + ); + let device = devices[0].lock().unwrap(); + assert_eq!( + device + .requests + .iter() + .filter(|r| matches!(r, Request::MakeCredential(_))) + .count(), + 1 + ); + if !preview { + let token_messages = device + .pin_messages + .iter() + .filter(|m| m.get_int(2).and_then(Value::as_int) == Some(9)) + .collect::>(); + assert!(token_messages.iter().any( + |m| m.get_int(9).and_then(Value::as_int) == Some(4) && m.get_int(10).is_none() + )); + assert!( + token_messages + .iter() + .any(|m| m.get_int(9).and_then(Value::as_int) == Some(1) + && m.get_int(10).and_then(Value::as_text) == Some("ssh:example")) + ); + } + } +} + +#[test] +fn no_touch_flags_and_biometric_make_credential_tokens_are_preserved() { + for protocol in [1, 2] { + let mut config = resident_config(protocol, false); + config.options.insert("uv".into(), true); + let (provider, devices) = provider_with(vec![( + "/dev/key", + MockAuthenticator::new(Protocol::Fido2(config), None), + )]); + let result = provider + .enroll(&request(SkAlgorithm::EcdsaSha2NistP256, 4), None) + .unwrap(); + assert_eq!(result.flags, 4); + let device = devices[0].lock().unwrap(); + assert!( + device + .pin_messages + .iter() + .any(|m| m.get_int(2).and_then(Value::as_int) == Some(6) + && m.get_int(9).and_then(Value::as_int) == Some(1)) + ); + let Request::MakeCredential(map) = device + .requests + .iter() + .find(|r| matches!(r, Request::MakeCredential(_))) + .unwrap() + else { + unreachable!() + }; + assert!( + map.get_int(7).is_none(), + "token proof omits uv, up remains omitted even with no-touch-required" + ); + } +} + +#[test] +fn protected_enrollment_fails_closed_without_credprotect_or_valid_pin() { + for flags in [1 | SK_FLAG_RESIDENT_KEY, 1 | 4] { + let (provider, devices) = provider_with(vec![( + "/dev/key", + MockAuthenticator::new(Protocol::Fido2(Fido2Config::plain()), None), + )]); + assert!( + provider + .enroll(&request(SkAlgorithm::Ed25519, flags), None) + .unwrap_err() + .to_string() + .contains("credProtect") + ); + assert!( + !devices[0] + .lock() + .unwrap() + .requests + .iter() + .any(|r| matches!(r, Request::MakeCredential(_))) + ); + } + let (provider, devices) = provider_with(vec![( + "/dev/key", + MockAuthenticator::new(Protocol::Fido2(resident_config(2, false)), None), + )]); + assert_eq!( + provider + .enroll(&request(SkAlgorithm::Ed25519, 1 | 4), Some("wrong")) + .unwrap_err(), + SecurityKeyError::PinRequired + ); + assert!( + !devices[0] + .lock() + .unwrap() + .requests + .iter() + .any(|r| matches!(r, Request::MakeCredential(_))) + ); +} + +#[test] +fn u2f_registers_p256_retries_presence_and_rejects_ed25519_and_resident() { + let mut device = MockAuthenticator::new(Protocol::U2f, None); + device.u2f_presence_retries = 1; + let (provider, devices) = provider_with(vec![("/dev/key", device)]); + let result = provider + .enroll(&request(SkAlgorithm::EcdsaSha2NistP256, 1), None) + .unwrap(); + assert_eq!(result.public_key.len(), 65); + assert_eq!(&result.key_handle[..], &[0x42, 0x43, 0x44]); + assert_eq!( + devices[0] + .lock() + .unwrap() + .requests + .iter() + .filter(|r| matches!(r, Request::U2fRegister { .. })) + .count(), + 2 + ); + assert!( + provider + .enroll(&request(SkAlgorithm::Ed25519, 1), None) + .unwrap_err() + .to_string() + .contains("ed25519-sk requires CTAP2") + ); + assert!( + provider + .load_resident_keys(Some("1234"), None) + .unwrap_err() + .to_string() + .contains("U2F") + ); +} + +#[test] +fn resident_enumeration_finishes_all_rps_before_enumerating_credentials() { + let mut device = MockAuthenticator::new(Protocol::Fido2(resident_config(2, false)), None); + for (index, application) in ["https://example.test", "ssh:one", "ssh:one", "ssh:two"] + .into_iter() + .enumerate() + { + device.resident_credentials.push(Resident { + credential: Credential { + rp_id: application.into(), + id: vec![index as u8], + key: CredentialKey::ed25519(2 + index as u8), + }, + user: vec![index as u8], + protection: 2, + }); + } + let (provider, devices) = provider_with(vec![("/dev/key", device)]); + let keys = provider + .load_resident_keys(Some("1234"), Some("/dev/key")) + .unwrap(); + assert_eq!(keys.len(), 3); + assert!(keys.iter().all(|k| k.application.starts_with("ssh:"))); + let device = devices[0].lock().unwrap(); + let commands = device + .requests + .iter() + .filter_map(|r| match r { + Request::CredentialManagement(map) => map.get_int(1).and_then(Value::as_int), + _ => None, + }) + .collect::>(); + assert_eq!(commands, vec![2, 3, 3, 4, 5, 4]); +} + +#[test] +fn resident_count_is_bounded_before_any_followup_and_explicit_device_is_honored() { + let mut device = MockAuthenticator::new(Protocol::Fido2(resident_config(2, false)), None); + let mut reply = vec![0]; + reply.extend(cbor::encode(&Value::Map(vec![( + Value::Int(5), + Value::Int(1025), + )]))); + device.management_reply = Some(reply); + let (provider, devices) = provider_with(vec![("/dev/key", device)]); + assert!( + provider + .load_resident_keys(Some("1234"), None) + .unwrap_err() + .to_string() + .contains("count") + ); + assert_eq!( + devices[0] + .lock() + .unwrap() + .requests + .iter() + .filter(|r| matches!(r, Request::CredentialManagement(_))) + .count(), + 1 + ); + assert_eq!( + provider + .load_resident_keys(Some("1234"), Some("/dev/missing")) + .unwrap_err(), + SecurityKeyError::DeviceNotFound + ); +} diff --git a/src/ssh/security_key/internal/enrollment_wire.rs b/src/ssh/security_key/internal/enrollment_wire.rs new file mode 100644 index 000000000..0a6f9a2bb --- /dev/null +++ b/src/ssh/security_key/internal/enrollment_wire.rs @@ -0,0 +1,328 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +//! CTAP credential creation and read-only credential-management messages. +//! CTAP 2.1 sections 6.1 and 6.8; OpenSSH sk-usbhid.c credential formats. + +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use super::CtapError; +use super::cbor::{self, Value}; +use super::ctap2; +use super::pin_protocol::{PinProtocol, authenticate_with}; +use crate::ssh::security_key::{ + SK_FLAG_RESIDENT_KEY, SK_FLAG_USER_PRESENCE_REQUIRED, SK_FLAG_USER_VERIFICATION_REQUIRED, + SkAlgorithm, SkEnrollRequest, SkEnrollResponse, SkResidentKey, +}; + +pub(super) const MAX_RESIDENT_KEYS: usize = 1024; + +pub(super) fn user_id(user: Option<&str>) -> Result<[u8; 32], CtapError> { + let mut id = [0; 32]; + if let Some(user) = user { + if user.len() >= id.len() || user.as_bytes().contains(&0) { + return Err(CtapError::Unsupported( + "-O user= must contain at most 31 bytes and no NUL", + )); + } + id[..user.len()].copy_from_slice(user.as_bytes()); + } + Ok(id) +} + +pub(super) fn protection(flags: u8) -> Option { + if flags & SK_FLAG_USER_VERIFICATION_REQUIRED != 0 { + Some(3) + } else if flags & SK_FLAG_RESIDENT_KEY != 0 { + Some(2) + } else { + None + } +} + +pub(super) fn make_credential( + request: &SkEnrollRequest<'_>, + hash: &[u8; 32], + user: &[u8; 32], + auth: Option<(&[u8], PinProtocol)>, + uv: bool, +) -> Zeroizing> { + let algorithm = match request.algorithm { + SkAlgorithm::Ed25519 => -8, + SkAlgorithm::EcdsaSha2NistP256 => -7, + }; + let mut pairs = vec![ + (Value::Int(1), Value::from(hash.as_slice())), + ( + Value::Int(2), + Value::Map(vec![(Value::from("id"), Value::from(request.application))]), + ), + ( + Value::Int(3), + Value::Map(vec![ + (Value::from("id"), Value::from(user.as_slice())), + (Value::from("name"), Value::from("openssh")), + (Value::from("displayName"), Value::from("openssh")), + ]), + ), + ( + Value::Int(4), + Value::Array(vec![Value::Map(vec![ + (Value::from("type"), Value::from("public-key")), + (Value::from("alg"), Value::Int(algorithm)), + ])]), + ), + ]; + if let Some(policy) = protection(request.flags) { + pairs.push(( + Value::Int(6), + Value::Map(vec![(Value::from("credProtect"), Value::Int(policy))]), + )); + } + let mut options = Vec::new(); + if request.flags & SK_FLAG_RESIDENT_KEY != 0 { + options.push((Value::from("rk"), Value::Bool(true))); + } + if uv && auth.is_none() { + options.push((Value::from("uv"), Value::Bool(true))); + } + if !options.is_empty() { + pairs.push((Value::Int(7), Value::Map(options))); + } + if let Some((param, protocol)) = auth { + pairs.push((Value::Int(8), Value::from(param))); + pairs.push((Value::Int(9), Value::Int(protocol.number() as i64))); + } + let value = Zeroizing::new(Value::Map(pairs)); + let mut encoded = Zeroizing::new(vec![ctap2::CMD_MAKE_CREDENTIAL]); + encoded.extend(cbor::encode(&value)); + encoded +} + +/// Strictly match the algorithm, curve, coordinates and actual public point. +pub(super) fn public_key(value: &Value) -> Result<(SkAlgorithm, Vec), CtapError> { + let integer = |key| value.get_int(key).and_then(Value::as_int); + let x = value + .get_int(-2) + .and_then(Value::as_bytes) + .filter(|v| v.len() == 32) + .ok_or(CtapError::Protocol( + "credential public key x missing or not 32 bytes", + ))?; + match (integer(1), integer(3), integer(-1)) { + (Some(1), Some(-8), Some(6)) => { + let bytes: [u8; 32] = x + .try_into() + .map_err(|_| CtapError::Protocol("invalid Ed25519 public key"))?; + let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes) + .map_err(|_| CtapError::Protocol("invalid Ed25519 public key"))?; + if key.is_weak() { + return Err(CtapError::Protocol("weak Ed25519 public key")); + } + Ok((SkAlgorithm::Ed25519, x.to_vec())) + } + (Some(2), Some(-7), Some(1)) => { + let y = value + .get_int(-3) + .and_then(Value::as_bytes) + .filter(|v| v.len() == 32) + .ok_or(CtapError::Protocol( + "credential public key y missing or not 32 bytes", + ))?; + let mut point = vec![4]; + point.extend(x); + point.extend(y); + p256::PublicKey::from_sec1_bytes(&point) + .map_err(|_| CtapError::Protocol("invalid P-256 public point"))?; + Ok((SkAlgorithm::EcdsaSha2NistP256, point)) + } + _ => Err(CtapError::Unsupported("credential public key algorithm")), + } +} + +pub(super) fn parse_created( + response: &[u8], + request: &SkEnrollRequest<'_>, +) -> Result { + let map = Zeroizing::new(cbor::decode(ctap2::response_body(response)?)?); + let data = map + .get_int(2) + .and_then(Value::as_bytes) + .filter(|v| v.len() >= 55) + .ok_or(CtapError::Protocol( + "makeCredential missing attested credential data", + ))?; + if data[..32] != Sha256::digest(request.application.as_bytes())[..] || data[32] & 0x41 != 0x41 { + return Err(CtapError::Protocol( + "makeCredential RP hash, presence or attested-data flag mismatch", + )); + } + if request.flags & SK_FLAG_USER_VERIFICATION_REQUIRED != 0 && data[32] & 4 == 0 { + return Err(CtapError::Protocol( + "makeCredential did not verify the user", + )); + } + let len = usize::from(u16::from_be_bytes([data[53], data[54]])); + let end = 55 + len; + if len == 0 || len > 4096 || end >= data.len() { + return Err(CtapError::Protocol( + "invalid enrollment credential ID length", + )); + } + let (key, consumed) = cbor::decode_prefix(&data[end..])?; + let (algorithm, public_key) = public_key(&key)?; + if algorithm != request.algorithm { + return Err(CtapError::Protocol( + "enrollment algorithm differs from request", + )); + } + let trailing = &data[end + consumed..]; + let extensions = if data[32] & 0x80 != 0 { + let extensions = cbor::decode(trailing)?; + if extensions.as_map().is_none() { + return Err(CtapError::Protocol( + "authenticator extensions are not a map", + )); + } + Some(extensions) + } else if trailing.is_empty() { + None + } else { + return Err(CtapError::Protocol( + "unexpected trailing authenticator data", + )); + }; + if let Some(required) = protection(request.flags) { + let actual = extensions + .as_ref() + .and_then(|v| v.get(&Value::from("credProtect"))) + .and_then(Value::as_int); + if actual != Some(required) { + return Err(CtapError::Protocol( + "authenticator did not enforce requested credProtect policy", + )); + } + } + let challenge_hash: [u8; 32] = Sha256::digest(request.challenge).into(); + super::attestation::verify_ctap(&map, data, &challenge_hash, algorithm, &public_key)?; + Ok(SkEnrollResponse { + flags: request.flags, + public_key, + key_handle: Zeroizing::new(data[55..end].to_vec()), + }) +} + +pub(super) fn management( + command: u8, + subcommand: u8, + params: Option, + auth: Option<(&[u8], PinProtocol)>, +) -> Zeroizing> { + let mut pairs = vec![(Value::Int(1), Value::Int(i64::from(subcommand)))]; + let mut message = Zeroizing::new(vec![subcommand]); + if let Some(params) = params { + message.extend(cbor::encode(¶ms)); + pairs.push((Value::Int(2), params)); + } + if let Some((token, protocol)) = auth { + pairs.push((Value::Int(3), Value::Int(protocol.number() as i64))); + pairs.push(( + Value::Int(4), + Value::Bytes(authenticate_with(protocol, token, &message)), + )); + } + let value = Zeroizing::new(Value::Map(pairs)); + let mut encoded = Zeroizing::new(vec![command]); + encoded.extend(cbor::encode(&value)); + encoded +} + +pub(super) fn count(map: &Value, key: i64) -> Result { + map.get_int(key) + .and_then(Value::as_int) + .and_then(|v| usize::try_from(v).ok()) + .filter(|v| *v > 0 && *v <= MAX_RESIDENT_KEYS) + .ok_or(CtapError::Protocol( + "resident enumeration count missing or outside 1..1024", + )) +} + +pub(super) fn relying_party(map: &Value) -> Result<(String, [u8; 32]), CtapError> { + let id = map + .get_int(3) + .and_then(|v| v.get(&Value::from("id"))) + .and_then(Value::as_text) + .filter(|id| !id.is_empty() && id.len() <= 1024 && !id.as_bytes().contains(&0)) + .ok_or(CtapError::Protocol("resident RP ID missing or invalid"))?; + let hash: [u8; 32] = map + .get_int(4) + .and_then(Value::as_bytes) + .and_then(|b| b.try_into().ok()) + .ok_or(CtapError::Protocol("resident RP hash missing or invalid"))?; + // Truncated RP IDs cannot reconstruct a usable SSH key; fail closed. + if id.starts_with("ssh:") && hash != <[u8; 32]>::from(Sha256::digest(id.as_bytes())) { + return Err(CtapError::Protocol( + "resident RP ID hash mismatch (possibly truncated)", + )); + } + Ok((id.to_owned(), hash)) +} + +pub(super) fn resident(map: &Value, application: &str) -> Result { + let descriptor = map.get_int(7).ok_or(CtapError::Protocol( + "resident credential descriptor missing", + ))?; + if descriptor + .get(&Value::from("type")) + .and_then(Value::as_text) + != Some("public-key") + { + return Err(CtapError::Protocol("invalid resident credential type")); + } + let handle = descriptor + .get(&Value::from("id")) + .and_then(Value::as_bytes) + .filter(|v| !v.is_empty() && v.len() <= 4096) + .ok_or(CtapError::Protocol( + "resident credential handle missing or invalid", + ))?; + let user_id = map + .get_int(6) + .and_then(|v| v.get(&Value::from("id"))) + .and_then(Value::as_bytes) + .filter(|v| !v.is_empty() && v.len() <= 64) + .ok_or(CtapError::Protocol("resident user ID missing or invalid"))?; + let key = map + .get_int(8) + .ok_or(CtapError::Protocol("resident public key missing"))?; + let (algorithm, public_key) = public_key(key)?; + let policy = map + .get_int(10) + .map(|value| { + value + .as_int() + .ok_or(CtapError::Protocol("invalid resident credProtect type")) + }) + .transpose()?; + if policy.is_some_and(|v| !(1..=3).contains(&v)) { + return Err(CtapError::Protocol("invalid resident credProtect policy")); + } + let flags = SK_FLAG_RESIDENT_KEY + | SK_FLAG_USER_PRESENCE_REQUIRED + | if policy == Some(3) { + SK_FLAG_USER_VERIFICATION_REQUIRED + } else { + 0 + }; + Ok(SkResidentKey { + algorithm, + application: application.to_owned(), + key: SkEnrollResponse { + flags, + public_key, + key_handle: Zeroizing::new(handle.to_vec()), + }, + user_id: user_id.to_vec(), + }) +} diff --git a/src/ssh/security_key/internal/enrollment_wire_tests.rs b/src/ssh/security_key/internal/enrollment_wire_tests.rs new file mode 100644 index 000000000..3ea673fd5 --- /dev/null +++ b/src/ssh/security_key/internal/enrollment_wire_tests.rs @@ -0,0 +1,224 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +use super::cbor::{self, Value}; +use super::enrollment_wire as wire; +use crate::ssh::security_key::{SkAlgorithm, SkEnrollRequest}; +use sha2::{Digest, Sha256}; + +fn request() -> SkEnrollRequest<'static> { + SkEnrollRequest { + algorithm: SkAlgorithm::Ed25519, + application: "ssh:example", + challenge: b"challenge", + flags: 1, + user: None, + device: None, + } +} + +fn cose() -> Value { + let public = ed25519_dalek::SigningKey::from_bytes(&[1; 32]) + .verifying_key() + .to_bytes(); + Value::Map(vec![ + (Value::Int(1), Value::Int(1)), + (Value::Int(3), Value::Int(-8)), + (Value::Int(-1), Value::Int(6)), + (Value::Int(-2), Value::Bytes(public.to_vec())), + ]) +} + +fn authenticator_data() -> Vec { + let mut data = Sha256::digest(request().application).to_vec(); + data.push(0x41); + data.extend([0; 20]); + data.extend([0, 3, 1, 2, 3]); + data.extend(cbor::encode(&cose())); + data +} + +fn reply(data: &[u8]) -> Vec { + let mut response = vec![0]; + response.extend(cbor::encode(&Value::Map(vec![ + (Value::Int(1), Value::from("none")), + (Value::Int(2), Value::Bytes(data.to_vec())), + (Value::Int(3), Value::Map(vec![])), + ]))); + response +} + +#[test] +fn every_truncated_enrollment_prefix_is_rejected() { + let complete = reply(&authenticator_data()); + assert!(wire::parse_created(&complete, &request()).is_ok()); + for end in 0..complete.len() { + assert!( + wire::parse_created(&complete[..end], &request()).is_err(), + "length {end}" + ); + } + for end in 0..authenticator_data().len() { + assert!(wire::parse_created(&reply(&authenticator_data()[..end]), &request()).is_err()); + } +} + +#[test] +fn enrollment_rejects_wrong_rp_flags_handle_curve_and_trailing_data() { + for (offset, value) in [(0, 0), (32, 0x40), (32, 1), (53, 0xff), (54, 0xff), (54, 0)] { + let mut data = authenticator_data(); + data[offset] = value; + assert!( + wire::parse_created(&reply(&data), &request()).is_err(), + "offset {offset}" + ); + } + let mut data = authenticator_data(); + data.push(0xa0); + assert!(wire::parse_created(&reply(&data), &request()).is_err()); + let mut non_map = authenticator_data(); + non_map[32] |= 0x80; + non_map.push(0); + assert!(wire::parse_created(&reply(&non_map), &request()).is_err()); + let mut requested = request(); + requested.algorithm = SkAlgorithm::EcdsaSha2NistP256; + assert!(wire::parse_created(&reply(&authenticator_data()), &requested).is_err()); + requested = request(); + requested.flags |= 4; + assert!(wire::parse_created(&reply(&authenticator_data()), &requested).is_err()); + let wrong_curve = Value::Map(vec![ + (Value::Int(1), Value::Int(1)), + (Value::Int(3), Value::Int(-8)), + (Value::Int(-1), Value::Int(1)), + (Value::Int(-2), Value::Bytes(vec![0; 32])), + ]); + assert!(wire::public_key(&wrong_curve).is_err()); +} + +#[test] +fn required_credprotect_must_be_returned_and_enforced() { + let mut requested = request(); + requested.flags |= 0x20; + let mut data = authenticator_data(); + assert!(wire::parse_created(&reply(&data), &requested).is_err()); + data[32] |= 0x80; + for policy in [1, 2, 3] { + let mut extended = data.clone(); + extended.extend(cbor::encode(&Value::Map(vec![( + Value::from("credProtect"), + Value::Int(policy), + )]))); + assert_eq!( + wire::parse_created(&reply(&extended), &requested).is_ok(), + policy == 2 + ); + } +} + +#[test] +fn resident_parser_rejects_missing_fields_and_unbounded_counts() { + for count in [-1, 0, 1025, i64::MAX] { + assert!(wire::count(&Value::Map(vec![(Value::Int(5), Value::Int(count))]), 5).is_err()); + } + assert!(wire::resident(&Value::Map(vec![]), "ssh:").is_err()); + let party = Value::Map(vec![ + ( + Value::Int(3), + Value::Map(vec![(Value::from("id"), Value::from("ssh:example"))]), + ), + (Value::Int(4), Value::Bytes(vec![0; 32])), + ]); + assert!(wire::relying_party(&party).is_err()); + let duplicate = [0xa2, 0x01, 0x02, 0x01, 0x03]; + assert!(cbor::decode(&duplicate).is_err()); +} + +#[test] +fn user_option_is_bounded_and_matches_openssh_nul_padding() { + assert_eq!(wire::user_id(None).unwrap(), [0; 32]); + let id = wire::user_id(Some("alice")).unwrap(); + assert_eq!(&id[..5], b"alice"); + assert_eq!(&id[5..], &[0; 27]); + assert!(wire::user_id(Some(&"a".repeat(32))).is_err()); + assert!(wire::user_id(Some("a\0b")).is_err()); +} + +#[test] +fn packed_signature_verification_is_integrated_into_enrollment_parser() { + use ed25519_dalek::Signer; + let data = authenticator_data(); + let mut signed = data.clone(); + signed.extend(Sha256::digest(request().challenge)); + let signature = ed25519_dalek::SigningKey::from_bytes(&[1; 32]) + .sign(&signed) + .to_bytes(); + for corrupt in [false, true] { + let mut signature = signature.to_vec(); + if corrupt { + signature[0] ^= 1; + } + let mut response = vec![0]; + response.extend(cbor::encode(&Value::Map(vec![ + (Value::Int(1), Value::from("packed")), + (Value::Int(2), Value::Bytes(data.clone())), + ( + Value::Int(3), + Value::Map(vec![ + (Value::from("alg"), Value::Int(-8)), + (Value::from("sig"), Value::Bytes(signature)), + ]), + ), + ]))); + assert_eq!(wire::parse_created(&response, &request()).is_ok(), !corrupt); + } +} + +#[test] +fn u2f_parser_verifies_registration_binding_and_rejects_truncated_der() { + let point = p256::ecdsa::SigningKey::from_slice(&[2; 32]) + .unwrap() + .verifying_key() + .to_sec1_point(false); + let application = [3; 32]; + let challenge = [4; 32]; + let handle = [5, 6, 7]; + let (certificate, signature) = super::attestation::tests::u2f_attestation( + &application, + &challenge, + &handle, + point.as_bytes(), + ); + let mut data = vec![5]; + data.extend(point.as_bytes()); + data.push(3); + data.extend(handle); + data.extend(certificate); + data.extend(signature); + assert!(super::u2f::parse_registration(&data, 1, &application, &challenge).is_ok()); + assert!(super::u2f::parse_registration(&data, 1, &application, &[9; 32]).is_err()); + for end in 0..data.len() { + assert!(super::u2f::parse_registration(&data[..end], 1, &application, &challenge).is_err()); + } + *data.last_mut().unwrap() ^= 1; + assert!(super::u2f::parse_registration(&data, 1, &application, &challenge).is_err()); +} + +#[test] +fn resident_protection_type_must_not_silently_drop_verification_requirement() { + let map = Value::Map(vec![ + ( + Value::Int(6), + Value::Map(vec![(Value::from("id"), Value::Bytes(vec![1]))]), + ), + ( + Value::Int(7), + Value::Map(vec![ + (Value::from("id"), Value::Bytes(vec![1])), + (Value::from("type"), Value::from("public-key")), + ]), + ), + (Value::Int(8), cose()), + (Value::Int(10), Value::Bool(true)), + ]); + assert!(wire::resident(&map, "ssh:").is_err()); +} diff --git a/src/ssh/security_key/internal/mock.rs b/src/ssh/security_key/internal/mock.rs index 750565342..4dbede5f6 100644 --- a/src/ssh/security_key/internal/mock.rs +++ b/src/ssh/security_key/internal/mock.rs @@ -34,9 +34,12 @@ use super::ctaphid::{ }; use super::pin_protocol::PlatformKeyAgreement; +#[path = "mock_enrollment.rs"] +pub(super) mod enrollment; #[path = "mock_handlers.rs"] mod handlers; +#[derive(Clone)] pub enum CredentialKey { Ed25519(ed25519_dalek::SigningKey), P256(p256::ecdsa::SigningKey), @@ -62,6 +65,7 @@ impl CredentialKey { } } +#[derive(Clone)] pub struct Credential { pub rp_id: String, pub id: Vec, @@ -84,6 +88,8 @@ pub enum Request { protocol: i64, permissions: Option<(i64, String)>, }, + MakeCredential(Value), + CredentialManagement(Value), TouchProbe, U2fCheck, U2fSign, @@ -168,6 +174,13 @@ pub struct MockAuthenticator { /// Answer `getPinUvAuthTokenUsingUvWithPermissions` with this status. pub uv_token_status: Option, pub requests: Vec, + pub pin_messages: Vec, + pub resident_credentials: Vec, + pub enrollment_reply: Option>, + pub management_reply: Option>, + rp_cursor: usize, + credential_cursor: usize, + selected_rp: Option, pub cancels: usize, cid: [u8; 4], assembler: Assembler, @@ -193,6 +206,13 @@ impl MockAuthenticator { forced_status: None, uv_token_status: None, requests: Vec::new(), + pin_messages: Vec::new(), + resident_credentials: Vec::new(), + enrollment_reply: None, + management_reply: None, + rp_cursor: 0, + credential_cursor: 0, + selected_rp: None, cancels: 0, cid: [0x0b, 0x55, 0x47, 0x01], assembler: Assembler::default(), diff --git a/src/ssh/security_key/internal/mock_enrollment.rs b/src/ssh/security_key/internal/mock_enrollment.rs new file mode 100644 index 000000000..9754c875f --- /dev/null +++ b/src/ssh/security_key/internal/mock_enrollment.rs @@ -0,0 +1,274 @@ +// Copyright 2026 Lablup Inc. and Jeongkyu Shin +// SPDX-License-Identifier: Apache-2.0 + +//! Stateful authenticator enrollment mocks; PIN MACs checked independently. + +use super::super::pin_protocol::{PinProtocol, authenticate_with}; +use super::super::u2f; +use super::*; +use sha2::{Digest, Sha256}; + +pub struct Resident { + pub credential: Credential, + pub user: Vec, + pub protection: i64, +} + +impl CredentialKey { + pub(super) fn cose(&self) -> Value { + match self { + Self::Ed25519(key) => Value::Map(vec![ + (Value::Int(1), Value::Int(1)), + (Value::Int(3), Value::Int(-8)), + (Value::Int(-1), Value::Int(6)), + ( + Value::Int(-2), + Value::Bytes(key.verifying_key().to_bytes().to_vec()), + ), + ]), + Self::P256(key) => { + let point = key.verifying_key().to_sec1_point(false); + Value::Map(vec![ + (Value::Int(1), Value::Int(2)), + (Value::Int(3), Value::Int(-7)), + (Value::Int(-1), Value::Int(1)), + ( + Value::Int(-2), + Value::Bytes(point.as_bytes()[1..33].to_vec()), + ), + ( + Value::Int(-3), + Value::Bytes(point.as_bytes()[33..].to_vec()), + ), + ]) + } + } + } +} + +impl MockAuthenticator { + pub(super) fn handle_enrollment(&mut self, body: &[u8]) -> Option> { + let map = cbor::decode(body).ok()?; + self.requests.push(Request::MakeCredential(map.clone())); + if let Some(reply) = &self.enrollment_reply { + return Some(reply.clone()); + } + let rp = map + .get_int(2)? + .get(&Value::from("id"))? + .as_text()? + .to_owned(); + let hash = map.get_int(1)?.as_bytes()?; + let user = map + .get_int(3)? + .get(&Value::from("id"))? + .as_bytes()? + .to_vec(); + let algorithm = map + .get_int(4)? + .as_array()? + .first()? + .get(&Value::from("alg"))? + .as_int()?; + let protocol = map.get_int(9).and_then(Value::as_int).map(|v| { + if v == 2 { + PinProtocol::V2 + } else { + PinProtocol::V1 + } + }); + let mut flags = 0x41; + if let Some(protocol) = protocol { + if map.get_int(8)?.as_bytes()? != authenticate_with(protocol, &self.pin_token, hash) { + return Some(vec![status::PIN_AUTH_INVALID]); + } + flags |= 4; + } else if map + .get_int(7) + .and_then(|v| v.get(&Value::from("uv"))) + .and_then(Value::as_bool) + == Some(true) + { + flags |= 4; + } + let policy = map + .get_int(6) + .and_then(|v| v.get(&Value::from("credProtect"))) + .and_then(Value::as_int); + if policy.is_some() { + flags |= 0x80; + } + let key = if algorithm == -8 { + CredentialKey::ed25519(12) + } else if algorithm == -7 { + CredentialKey::p256(12) + } else { + return Some(vec![status::UNSUPPORTED_ALGORITHM]); + }; + let id = vec![0x42, 0x43, self.resident_credentials.len() as u8]; + let mut auth = Sha256::digest(rp.as_bytes()).to_vec(); + auth.push(flags); + auth.extend([0; 20]); + auth.extend((id.len() as u16).to_be_bytes()); + auth.extend(&id); + auth.extend(cbor::encode(&key.cose())); + if let Some(policy) = policy { + auth.extend(cbor::encode(&Value::Map(vec![( + Value::from("credProtect"), + Value::Int(policy), + )]))); + } + let credential = Credential { rp_id: rp, id, key }; + if map + .get_int(7) + .and_then(|v| v.get(&Value::from("rk"))) + .and_then(Value::as_bool) + == Some(true) + { + self.resident_credentials.push(Resident { + credential: credential.clone(), + user, + protection: policy.unwrap_or(1), + }); + } + self.credential = Some(credential); + Some(ok(&Value::Map(vec![ + (Value::Int(1), Value::from("none")), + (Value::Int(2), Value::Bytes(auth)), + (Value::Int(3), Value::Map(vec![])), + ]))) + } + + pub(super) fn handle_management(&mut self, body: &[u8]) -> Option> { + let map = cbor::decode(body).ok()?; + self.requests + .push(Request::CredentialManagement(map.clone())); + if let Some(reply) = &self.management_reply { + return Some(reply.clone()); + } + let sub = map.get_int(1)?.as_int()?; + if matches!(sub, 2 | 4) { + let protocol = if map.get_int(3)?.as_int()? == 2 { + PinProtocol::V2 + } else { + PinProtocol::V1 + }; + let mut message = vec![sub as u8]; + if let Some(params) = map.get_int(2) { + message.extend(cbor::encode(params)); + } + if map.get_int(4)?.as_bytes()? != authenticate_with(protocol, &self.pin_token, &message) + { + return Some(vec![status::PIN_AUTH_INVALID]); + } + } else if map.get_int(3).is_some() || map.get_int(4).is_some() { + return Some(vec![status::INVALID_OPTION]); + } + let parties = self + .resident_credentials + .iter() + .map(|r| r.credential.rp_id.clone()) + .collect::>() + .into_iter() + .collect::>(); + match sub { + 2 | 3 => { + if sub == 2 { + self.rp_cursor = 0; + } else { + self.rp_cursor += 1; + } + let Some(rp) = parties.get(self.rp_cursor) else { + return Some(vec![status::NO_CREDENTIALS]); + }; + let mut pairs = vec![ + ( + Value::Int(3), + Value::Map(vec![(Value::from("id"), Value::from(rp.as_str()))]), + ), + ( + Value::Int(4), + Value::Bytes(Sha256::digest(rp.as_bytes()).to_vec()), + ), + ]; + if sub == 2 { + pairs.push((Value::Int(5), Value::Int(parties.len() as i64))); + } + Some(ok(&Value::Map(pairs))) + } + 4 | 5 => { + if sub == 4 { + let hash = map.get_int(2)?.get_int(1)?.as_bytes()?; + self.selected_rp = parties + .into_iter() + .find(|rp| Sha256::digest(rp.as_bytes()).as_slice() == hash); + self.credential_cursor = 0; + } else { + self.credential_cursor += 1; + } + let credentials = self + .resident_credentials + .iter() + .filter(|r| Some(&r.credential.rp_id) == self.selected_rp.as_ref()) + .collect::>(); + let resident = credentials.get(self.credential_cursor)?; + let mut pairs = vec![ + ( + Value::Int(6), + Value::Map(vec![( + Value::from("id"), + Value::Bytes(resident.user.clone()), + )]), + ), + ( + Value::Int(7), + Value::Map(vec![ + ( + Value::from("id"), + Value::Bytes(resident.credential.id.clone()), + ), + (Value::from("type"), Value::from("public-key")), + ]), + ), + (Value::Int(8), resident.credential.key.cose()), + (Value::Int(10), Value::Int(resident.protection)), + ]; + if sub == 4 { + pairs.push((Value::Int(9), Value::Int(credentials.len() as i64))); + } + Some(ok(&Value::Map(pairs))) + } + _ => Some(vec![0x01]), + } + } + + pub(super) fn handle_registration(&mut self, data: &[u8], p1: u8) -> Option> { + self.requests.push(Request::U2fRegister { p1 }); + if let Some(reply) = &self.enrollment_reply { + return Some(reply.clone()); + } + if self.u2f_presence_retries > 0 { + self.u2f_presence_retries -= 1; + return Some(u2f::SW_CONDITIONS_NOT_SATISFIED.to_be_bytes().to_vec()); + } + let key = CredentialKey::p256(15); + let CredentialKey::P256(signing) = &key else { + unreachable!() + }; + let mut reply = vec![5]; + reply.extend(signing.verifying_key().to_sec1_point(false).as_bytes()); + reply.extend([3, 0x42, 0x43, 0x44]); + let challenge: [u8; 32] = data[..32].try_into().unwrap(); + let application: [u8; 32] = data[32..64].try_into().unwrap(); + let (certificate, signature) = super::super::attestation::tests::u2f_attestation( + &application, + &challenge, + &[0x42, 0x43, 0x44], + &reply[1..66], + ); + reply.extend(certificate); + reply.extend(signature); + reply.extend(u2f::SW_NO_ERROR.to_be_bytes()); + Some(reply) + } +} diff --git a/src/ssh/security_key/internal/mock_handlers.rs b/src/ssh/security_key/internal/mock_handlers.rs index 31c3b3163..0024fb50a 100644 --- a/src/ssh/security_key/internal/mock_handlers.rs +++ b/src/ssh/security_key/internal/mock_handlers.rs @@ -34,6 +34,14 @@ impl MockAuthenticator { ctap2::CMD_GET_INFO => { self.requests.push(Request::GetInfo); let mut pairs = vec![ + ( + Value::Int(2), + Value::Array(if config.options.get("testCredProtect") == Some(&true) { + vec![Value::from("credProtect")] + } else { + vec![] + }), + ), ( Value::Int(1), Value::Array(vec![Value::from("FIDO_2_0"), Value::from("FIDO_2_1")]), @@ -68,6 +76,21 @@ impl MockAuthenticator { } ctap2::CMD_GET_ASSERTION => self.handle_get_assertion(body), ctap2::CMD_CLIENT_PIN => self.handle_client_pin(body), + 0x0a | 0x41 => self.handle_management(body), + ctap2::CMD_MAKE_CREDENTIAL + if cbor::decode(body) + .ok() + .and_then(|v| { + v.get_int(2) + .and_then(|rp| rp.get(&Value::from("id"))) + .and_then(Value::as_text) + .map(str::to_owned) + }) + .as_deref() + != Some("localhost") => + { + self.handle_enrollment(body) + } ctap2::CMD_MAKE_CREDENTIAL => { self.requests.push(Request::TouchProbe); self.touch_on_probe.then(|| vec![self.touch_status]) @@ -156,13 +179,14 @@ impl MockAuthenticator { fn handle_client_pin(&mut self, body: &[u8]) -> Option> { let map = cbor::decode(body).expect("valid clientPIN CBOR"); + self.pin_messages.push(map.clone()); let protocol_number = map.get_int(1)?.as_int()?; let subcommand = map.get_int(2)?.as_int()?; let permissions = map .get_int(9) .and_then(Value::as_int) .and_then(|bits| Some((bits, map.get_int(10)?.as_text()?.to_string()))); - let has_permissions = permissions.is_some(); + let has_permissions = map.get_int(9).is_some(); self.requests.push(Request::ClientPin { subcommand, protocol: protocol_number, @@ -226,6 +250,7 @@ impl MockAuthenticator { let len = usize::from(u16::from_be_bytes([apdu[5], apdu[6]])); let data = &apdu[7..7 + len]; match ins { + u2f::INS_REGISTER if data.iter().any(|v| *v != 0) => self.handle_registration(data, p1), u2f::INS_REGISTER => { self.requests.push(Request::U2fRegister { p1 }); Some(if self.touch_on_probe { diff --git a/src/ssh/security_key/internal/mod.rs b/src/ssh/security_key/internal/mod.rs index 0b96d8a8c..f699c3df9 100644 --- a/src/ssh/security_key/internal/mod.rs +++ b/src/ssh/security_key/internal/mod.rs @@ -23,8 +23,7 @@ //! [`InternalProvider`], which is `sk_sign` behind the //! [`SecurityKeyProvider`] trait. Only [`hidapi_backend`] touches the OS. //! -//! Only signing exists. Enrollment (`ssh-keygen -t ed25519-sk`) and resident -//! key loading (`ssh-keygen -K`) stay with OpenSSH's tools. +//! Enrollment and resident credential downloads share these transports and PIN protocols. //! //! One deliberate deviation from libfido2: a whole `sk_sign` call is bounded //! by [`SIGN_TIMEOUT`] (120 s, sshd's default `LoginGraceTime`). libfido2 @@ -36,10 +35,13 @@ //! (on CTAP2 devices), the signature fails with "timed out waiting for the //! security key", and authentication moves on to the next identity. +mod attestation; pub mod cbor; pub mod ctap2; pub mod ctaphid; pub mod device; +mod enrollment_provider; +mod enrollment_wire; #[cfg(any(target_os = "linux", target_os = "macos"))] mod hidapi_backend; #[cfg(test)] @@ -251,6 +253,22 @@ impl SecurityKeyProvider for InternalProvider { pack_signature(request.algorithm, &assertion) } + fn enroll( + &self, + request: &super::SkEnrollRequest<'_>, + pin: Option<&str>, + ) -> Result { + self.enroll_credential(request, pin) + } + + fn load_resident_keys( + &self, + pin: Option<&str>, + device: Option<&str>, + ) -> Result, SecurityKeyError> { + self.download_resident(pin, device) + } + fn describe(&self) -> String { "internal".to_string() } @@ -320,3 +338,9 @@ mod uv_token_tests; #[cfg(test)] #[path = "robustness_tests.rs"] mod robustness_tests; + +#[cfg(test)] +mod enrollment_tests; + +#[cfg(test)] +mod enrollment_wire_tests; diff --git a/src/ssh/security_key/internal/u2f.rs b/src/ssh/security_key/internal/u2f.rs index 27a7eb3b5..e93e7aafa 100644 --- a/src/ssh/security_key/internal/u2f.rs +++ b/src/ssh/security_key/internal/u2f.rs @@ -113,3 +113,56 @@ pub fn parse_authenticate(data: &[u8]) -> Result { #[cfg(test)] #[path = "u2f_tests.rs"] mod tests; + +/// Parse REGISTER's reserved byte, public point, handle, certificate and signature. +/// Attestation is verified against the supplied certificate and not exported. +pub(crate) fn parse_registration( + data: &[u8], + flags: u8, + application_hash: &[u8; 32], + challenge_hash: &[u8; 32], +) -> Result { + if data.len() < 68 || data[0] != 5 { + return Err(CtapError::Protocol("truncated or invalid U2F registration")); + } + let public_key = &data[1..66]; + p256::PublicKey::from_sec1_bytes(public_key) + .map_err(|_| CtapError::Protocol("invalid U2F public point"))?; + let len = usize::from(data[66]); + let end = 67 + len; + if len == 0 || end >= data.len() { + return Err(CtapError::Protocol( + "invalid U2F registration handle length", + )); + } + let certificate_len = der_sequence_len(&data[end..])?; + let signature = &data[end + certificate_len..]; + p256::ecdsa::Signature::from_der(signature) + .map_err(|_| CtapError::Protocol("invalid U2F registration signature"))?; + super::attestation::verify_u2f( + &data[end..end + certificate_len], + signature, + application_hash, + challenge_hash, + &data[67..end], + public_key, + )?; + Ok(super::super::SkEnrollResponse { + flags, + public_key: public_key.to_vec(), + key_handle: zeroize::Zeroizing::new(data[67..end].to_vec()), + }) +} + +fn der_sequence_len(data: &[u8]) -> Result { + use x509_cert::{ + Certificate, + der::{Decode, Reader, SliceReader}, + }; + let mut reader = + SliceReader::new(data).map_err(|_| CtapError::Protocol("invalid U2F certificate data"))?; + Certificate::decode(&mut reader) + .map_err(|_| CtapError::Protocol("invalid U2F attestation certificate"))?; + usize::try_from(reader.position()) + .map_err(|_| CtapError::Protocol("U2F certificate length overflow")) +} diff --git a/src/ssh/security_key/mod.rs b/src/ssh/security_key/mod.rs index bb732794d..c8734355e 100644 --- a/src/ssh/security_key/mod.rs +++ b/src/ssh/security_key/mod.rs @@ -41,7 +41,9 @@ //! bssh holds in memory, including decrypted keys and passwords for other //! hosts. Only configure a provider library you would trust with them. +mod enrollment; mod external; +pub use enrollment::{SK_FLAG_RESIDENT_KEY, SkEnrollRequest, SkEnrollResponse, SkResidentKey}; pub mod internal; mod signature; @@ -184,6 +186,24 @@ pub trait SecurityKeyProvider: Send + Sync { pin: Option<&str>, ) -> Result; + /// Create an authenticator-hosted credential. + fn enroll( + &self, + _request: &SkEnrollRequest<'_>, + _pin: Option<&str>, + ) -> Result { + Err(SecurityKeyError::Unsupported) + } + + /// Enumerate SSH resident credentials after PIN verification. + fn load_resident_keys( + &self, + _pin: Option<&str>, + _device: Option<&str>, + ) -> Result, SecurityKeyError> { + Err(SecurityKeyError::Unsupported) + } + /// Human-readable origin for diagnostics. fn describe(&self) -> String; }