diff --git a/examples/crypto-demo/Cargo.toml b/examples/crypto-demo/Cargo.toml index ec551f4..7344877 100644 --- a/examples/crypto-demo/Cargo.toml +++ b/examples/crypto-demo/Cargo.toml @@ -8,14 +8,15 @@ publish = false crate-type = ["cdylib"] [features] -# The RSA-OAEP key-transport check, forwarding to the SDK's -# `rsa-oaep-decrypt`. Off by default, exactly like that feature and the -# WIT gate behind it: enabling it adds the withheld-by-default RSA op -# interfaces to the component's imports, which only hosts that -# deliberately serve them can satisfy — the in-guest provider does not -# (class D), so the default build is what stays composable. The wasmtime -# and jco demo legs opt in. -rsa-oaep = ["polymorph-webcrypto-guest/rsa-oaep-decrypt"] +# The checks whose imports the in-guest provider withholds (class D): +# the RSA-OAEP key transport and the ECDSA signing-key mints. Off by +# default, exactly like the SDK feature and WIT gate behind the RSA +# half: enabling it adds withheld interfaces to the component's imports, +# which only hosts that deliberately serve them can satisfy — the +# default build is what stays composable. The wasmtime and jco demo legs +# run the opt-in artifact, named after the conformance architecture's +# host-only suite. +host-only = ["polymorph-webcrypto-guest/rsa-oaep-decrypt"] [dependencies] # Failure reporting inside the checks (`context`/`ensure!`); rendered with diff --git a/examples/crypto-demo/src/lib.rs b/examples/crypto-demo/src/lib.rs index 2396057..3d5e6cc 100644 --- a/examples/crypto-demo/src/lib.rs +++ b/examples/crypto-demo/src/lib.rs @@ -14,13 +14,14 @@ //! The `check(...)` names below are the inventory, and the integration //! tests assert the expected summary. //! -//! The `rsa-oaep` cargo feature (off by default, like the SDK feature it -//! forwards to) adds the key-transport check. A component's imports are -//! derived from the calls it makes, and the in-guest provider withholds -//! every RSA op interface (rust/guest-provider/README.md, class D), so -//! the default build is what composes with it — the hosts that -//! deliberately serve those interfaces (the Wasmtime test embedding, the -//! jco host on Node) run the opt-in build. +//! The `host-only` cargo feature (off by default) adds the checks whose +//! imports the in-guest provider withholds — the RSA-OAEP key transport +//! and the ECDSA signing-key mints (class D; +//! rust/guest-provider/README.md). A component's imports are derived +//! from the calls it makes, so the default build is what composes with +//! the provider; the hosts that deliberately serve every interface (the +//! Wasmtime test embedding, the jco host on Node) run the opt-in build, +//! named after the conformance architecture's host-only suite. wit_bindgen::generate!({ path: "wit", @@ -90,6 +91,16 @@ const ED25519_SIG: [u8; 64] = hexlower!( "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da\ 085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" ); +/// The same test's secret key (the RFC 8032 seed) — the private half of +/// `ED25519_PUBLIC`. +const ED25519_SECRET: [u8; 32] = + hexlower!("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"); +/// RFC 8410 SubjectPublicKeyInfo prefix for an Ed25519 public key; the +/// 32 raw key bytes follow. +const ED25519_SPKI_PREFIX: [u8; 12] = hexlower!("302a300506032b6570032100"); +/// RFC 8410 PKCS#8 (v1) prefix for an Ed25519 seed; the 32 seed bytes +/// follow. +const ED25519_PKCS8_PREFIX: [u8; 16] = hexlower!("302e020100300506032b657004220420"); // --- RFC 6979 A.2.5 (ECDSA P-256 + SHA-256, message "sample") ---------------- @@ -102,6 +113,24 @@ const ECDSA_SIG_R: [u8; 32] = hexlower!("efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716"); const ECDSA_SIG_S: [u8; 32] = hexlower!("f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8"); +/// The same appendix's private key `d` — the private half of +/// `ECDSA_PUBLIC_X`/`_Y`. Only the host-only build imports it (the +/// `ecdsa-sign` interface is withheld by the in-guest provider). +#[cfg(feature = "host-only")] +const ECDSA_PRIVATE: [u8; 32] = + hexlower!("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721"); +/// X.509 SubjectPublicKeyInfo prefix for a named-curve P-256 key; the +/// uncompressed point (`04 || x || y`) follows. +const P256_SPKI_PREFIX: [u8; 26] = + hexlower!("3059301306072a8648ce3d020106082a8648ce3d030107034200"); +/// PKCS#8 prefix for a P-256 private key carrying its public point: the +/// scalar `d` follows, then `P256_PKCS8_MID`, then `04 || x || y`. +#[cfg(feature = "host-only")] +const P256_PKCS8_PREFIX: [u8; 36] = + hexlower!("308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b0201010420"); +/// The bytes between `d` and the public point in the P-256 PKCS#8 form. +#[cfg(feature = "host-only")] +const P256_PKCS8_MID: [u8; 5] = hexlower!("a144034200"); // --- Wycheproof RSA-2048 + SHA-256: rsa_signature_2048_sha256_test.json // tcId 1 and rsa_pss_2048_sha256_mgf1_32_test.json tcId 1 (sLen 32). @@ -150,7 +179,7 @@ const RSA_PSS_SIG: [u8; 256] = hexlower!( // composable with the in-guest provider, which withholds these // interfaces. ------------------------------------------------------ -#[cfg(feature = "rsa-oaep")] +#[cfg(feature = "host-only")] const OAEP_PKCS8: [u8; 1217] = hexlower!( "308204bd020100300d06092a864886f70d0101010500048204a7308204a30201\ 000282010100a2b451a07d0aa5f96e455671513550514a8a5b462ebef717094f\ @@ -192,13 +221,13 @@ const OAEP_PKCS8: [u8; 1217] = hexlower!( 31241f5921b5ad3983fb54ef17be3b285367e50c999c67247b552fe4bfce945f\ 7b" ); -#[cfg(feature = "rsa-oaep")] +#[cfg(feature = "host-only")] const OAEP_PRIVATE_JWK: &str = r#"{"kty":"RSA","alg":"RSA-OAEP-256","n":"orRRoH0KpfluRVZxUTVQUUqKW0YuvvcXCU-h_ugiJOY3-XRtP3yv0xh42AMltu9aFwD2WQO0aUKeidbqyIRQl7WrOTGJ25JRLtincRoSU_rNIPecFegkfz0-QuRuSMmOJUov6XZTE6A-_48X4aApOXofomqNzib0kO2BKZYV2YFMItphBCjgnH2WWFlCZvXAIdD87KCNlFoSvoLeTR7Oa0wDFFtdNJXU7VQR64eNrwX9evw-Ca2g8RJkIvWQl1oZaYFvSGmLy7obTZyuedRg2Pn4Xnl1AF2bwixOWsD3waRdElaaYoB9O5oC5aUw53MGb0U9H1tMLpz3ggKD90K51Q","e":"AQAB","kid":"none","d":"JM3GIxf11ypva6bMljKJmwHR_yiGfXL2FoiZW8hVpOQgqEBSUAib2xPPjglUOCe3SLnSf7srTZ4gr4xaaoYnltGkzBitFupni8G9SoO7vpxeV0U7XOc4jkGjukzit3tEOKIp6VT3INrgNT3AiKyKdrJtwnb44beFHd1jmK0W_y54GVEjubA26UXDjJ0SQ09t92_iI1nrPhrJwBFnj8km-tOuR1pP__9V_rLRR-nIlPTA4ppZnnYkYkgtlov0J4CUX8DSwxxXPEQxuPT-i4xnvsgVq9RPeobtyhwjCHNzWNLCrl4uDi2t9zCYAmI3flixO32ZkgYKC8hwzP20qTGe4Q","p":"3EMQUPeC6JT7UkgkfZjLfVi40eJPO1XQQcVuTeCGsNW7AovaQu610jTVaB5YCdQV5qKJrUz794-Xj2w1gU9Q7r_xxbgKafeI6B5rq13ap4Np1lnRQ-xvF-eYE6V1z62cVpFWuQET4ukRCtnntIock0im5lMyEZEpDqNs-zpbGPE","q":"vRqB55d_mJgSInOuMiK1mOpfsZ606rw4MIpeMhlmA7LlAP-3n1uIaBZhHevEcvrEVUQHC-sFfJQTeKaGivO3oD0_mIDsR9XgiblPveVCq6mujXLFcIjXq_WxMfOQmPe8Fg-QU2q8lJL9Tgbz7XKZ1Ll7sDZ3IH2VZp8UDPvCDyU","dp":"qUtSiyjykVmRIdkZUv_Rx_IdfBR52Z1HiIX7Fhhw7hIYvwhHJhLb5Ul-jZxlBojgnHhpYa4-LDVNxIrjRRR1nEwjxFiEiJYdwGtBTmHA4ef7vSkj0xUy_iifltoiBxHljBQBmAjgBBQnaTO7B-TvubSps3ZWkXIFIJ8z8JUV18E","dq":"OvDnKpM67wn_JQPfeLr-1THAL_GivEN8VAzcvUrTVDXPURdjWWVDSAYpsRTKf3gP9--jLqDLbgANbZ6h8u9x_Zz5lIQioWVVfjfnVe3-cNkLkgUC60eLyYpj94jOOg-FbW7eclGjg7-o-kgKgaklr3s8xTjEurjJ91l_-2gBHY0","qi":"JkD7-8_vsWPueoe2SDpm7kH5VtkPqKeTm_wELuCSSxt5k9BEX3WNUZM-hRecAyCwyWi0ipHDi1vpI-EJfAxWL4jUIpS2onWbr6VCinTxJwh05F9vzGDyFgLeXszRQ88xJB9ZIbWtOYP7VO8XvjsoU2flDJmcZyR7VS_kv86UX3s"}"#; -#[cfg(feature = "rsa-oaep")] +#[cfg(feature = "host-only")] const OAEP_PUBLIC_JWK: &str = r#"{"kty":"RSA","alg":"RSA-OAEP-256","n":"orRRoH0KpfluRVZxUTVQUUqKW0YuvvcXCU-h_ugiJOY3-XRtP3yv0xh42AMltu9aFwD2WQO0aUKeidbqyIRQl7WrOTGJ25JRLtincRoSU_rNIPecFegkfz0-QuRuSMmOJUov6XZTE6A-_48X4aApOXofomqNzib0kO2BKZYV2YFMItphBCjgnH2WWFlCZvXAIdD87KCNlFoSvoLeTR7Oa0wDFFtdNJXU7VQR64eNrwX9evw-Ca2g8RJkIvWQl1oZaYFvSGmLy7obTZyuedRg2Pn4Xnl1AF2bwixOWsD3waRdElaaYoB9O5oC5aUw53MGb0U9H1tMLpz3ggKD90K51Q","e":"AQAB"}"#; -#[cfg(feature = "rsa-oaep")] +#[cfg(feature = "host-only")] const OAEP_MESSAGE: [u8; 20] = hexlower!("0000000000000000000000000000000000000000"); -#[cfg(feature = "rsa-oaep")] +#[cfg(feature = "host-only")] const OAEP_CIPHERTEXT: [u8; 256] = hexlower!( "207180c340658b5154ae45d2e4e7326a0997c683a26b595e536a29333c4b6614\ 9af85e029d5419a39e3a147b221516ffd86b6b4b66c3e0c4c49fe8c57a2f5c37\ @@ -247,8 +276,10 @@ impl Guest for Component { ecdsa_verify_known_answer().await, ) .await?; + #[cfg(feature = "host-only")] + check("ecdsa-sign-mints", ecdsa_sign_mints().await).await?; check("rsa-verify-known-answer", rsa_verify_known_answer().await).await?; - #[cfg(feature = "rsa-oaep")] + #[cfg(feature = "host-only")] check("rsa-oaep-key-transport", rsa_oaep_key_transport().await).await?; check("hkdf-rfc5869-derive", hkdf_derive().await).await?; check("hkdf-sha1-derive", hkdf_sha1_derive().await).await?; @@ -626,14 +657,55 @@ async fn ed25519_verify_check() -> Result<()> { key.verify(ED25519_MESSAGE, sig).await, Error::AuthenticationFailed, "corrupted signature verified", - ) + )?; + + // The same key in its other public encodings — the RFC 8410 SPKI and + // the RFC 8037 OKP JWK — each verifying the known signature. + let spki: Vec = [&ED25519_SPKI_PREFIX[..], &ED25519_PUBLIC[..]].concat(); + let from_spki = ed25519::import_verifying_key_spki(spki.clone()) + .await + .context("import-verifying-key-spki")?; + from_spki + .verify(ED25519_MESSAGE, ED25519_SIG.to_vec()) + .await + .context("SPKI-imported key rejected the known signature")?; + let jwk = format!( + r#"{{"kty":"OKP","crv":"Ed25519","x":"{}"}}"#, + b64url(&ED25519_PUBLIC) + ); + let from_jwk = ed25519::import_verifying_key_jwk(jwk) + .await + .context("import-verifying-key-jwk")?; + from_jwk + .verify(ED25519_MESSAGE, ED25519_SIG.to_vec()) + .await + .context("JWK-imported key rejected the known signature")?; + + // Public exports have no extractability gate: raw and SPKI round-trip + // byte-for-byte (both encodings are canonical), and the exported JWK + // re-imports to a key that still verifies. + ensure!( + key.export_key_raw().await.context("export-key-raw")? == ED25519_PUBLIC, + "export-key-raw did not round-trip" + ); + ensure!( + key.export_key_spki().await.context("export-key-spki")? == spki, + "export-key-spki did not round-trip" + ); + let exported = key.export_key_jwk().await.context("export-key-jwk")?; + ed25519::import_verifying_key_jwk(exported) + .await + .context("re-importing the exported JWK")? + .verify(ED25519_MESSAGE, ED25519_SIG.to_vec()) + .await + .context("the JWK-export round trip lost the key") } /// The signature wrappers end to end: generate through `ed25519`, sign /// through `SigningKey`, verify through `VerifyingKey`, and fail closed on /// a tampered signature. async fn ed25519_wrapper_roundtrip() -> Result<()> { - use polymorph_webcrypto_guest::{ed25519, SigningKeyOptions}; + use polymorph_webcrypto_guest::{aes_gcm, ed25519, AeadKeyOptions, SigningKeyOptions}; let (signing, verifying) = ed25519::generate_key(SigningKeyOptions { sign: true, extractable: false, @@ -659,7 +731,138 @@ async fn ed25519_wrapper_roundtrip() -> Result<()> { verifying.verify(payload, sig).await, Error::AuthenticationFailed, "tampered signature verified", + )?; + + // The private-key mints, from the RFC 8032 test-2 seed. Ed25519 is + // deterministic, so every correctly minted key signs the test message + // to the known signature byte-for-byte. + let exportable = SigningKeyOptions { + sign: true, + extractable: true, + }; + let pkcs8: Vec = [&ED25519_PKCS8_PREFIX[..], &ED25519_SECRET[..]].concat(); + let imported = ed25519::import_signing_key_pkcs8(pkcs8, exportable) + .await + .context("import-signing-key-pkcs8")?; + ensure!( + imported.sign(ED25519_MESSAGE).await? == ED25519_SIG, + "the PKCS#8-imported key's signature differs from RFC 8032 test 2" + ); + let private_jwk = format!( + r#"{{"kty":"OKP","crv":"Ed25519","x":"{}","d":"{}"}}"#, + b64url(&ED25519_PUBLIC), + b64url(&ED25519_SECRET) + ); + let from_jwk = ed25519::import_signing_key_jwk(private_jwk, exportable) + .await + .context("import-signing-key-jwk")?; + ensure!( + from_jwk.sign(ED25519_MESSAGE).await? == ED25519_SIG, + "the JWK-imported key's signature differs from RFC 8032 test 2" + ); + + // Private exports are extractability-gated: the generated key above + // was minted sealed and must stay so; the extractable import + // round-trips through both encodings (compared semantically — PKCS#8 + // v1/v2 forms differ across providers — via the deterministic + // signature). + expect_error!( + signing.export_key_pkcs8().await, + Error::NotExtractable, + "a non-extractable signing key exported its PKCS#8", + )?; + let reimported = ed25519::import_signing_key_pkcs8( + imported + .export_key_pkcs8() + .await + .context("export-key-pkcs8")?, + exportable, + ) + .await + .context("re-importing the exported PKCS#8")?; + ensure!( + reimported.sign(ED25519_MESSAGE).await? == ED25519_SIG, + "the PKCS#8-export round trip lost the key" + ); + let reimported = ed25519::import_signing_key_jwk( + imported.export_key_jwk().await.context("export-key-jwk")?, + exportable, ) + .await + .context("re-importing the exported JWK")?; + ensure!( + reimported.sign(ED25519_MESSAGE).await? == ED25519_SIG, + "the JWK-export round trip lost the key" + ); + + // The wrap tour: the signing key's material transits to a fresh key + // through wrap/unwrap without reaching this caller, in both + // serializations. The kek is an AEAD key: AES-KW takes only 8-byte + // multiples, which the variable-length private serializations do not + // promise. One fresh kek, one distinct nonce per wrap. + let kek = aes_gcm::generate_key( + AesVariant::Aes128, + AeadKeyOptions { + wrap: true, + unwrap: true, + ..Default::default() + }, + ) + .await + .context("aes-gcm generate-key")?; + let sealed = SigningKeyOptions { + sign: true, + extractable: false, + }; + let wrapped = kek + .wrap( + *b"demo-nonce-1", + *b"", + None, + imported + .to_wrap_input_pkcs8() + .await + .context("to-wrap-input-pkcs8")?, + ) + .await + .context("wrapping the PKCS#8 serialization")?; + let minted = ed25519::unwrap_signing_key_pkcs8( + kek.unwrap(*b"demo-nonce-1", *b"", None, wrapped) + .await + .context("aead-key.unwrap")?, + sealed, + ) + .await + .context("unwrap-signing-key-pkcs8")?; + ensure!( + minted.sign(ED25519_MESSAGE).await? == ED25519_SIG, + "the PKCS#8 wrap tour lost the key" + ); + let wrapped = kek + .wrap( + *b"demo-nonce-2", + *b"", + None, + imported + .to_wrap_input_jwk() + .await + .context("to-wrap-input-jwk")?, + ) + .await + .context("wrapping the JWK serialization")?; + let minted = ed25519::unwrap_signing_key_jwk( + kek.unwrap(*b"demo-nonce-2", *b"", None, wrapped) + .await + .context("aead-key.unwrap")?, + sealed, + ) + .await + .context("unwrap-signing-key-jwk")?; + ensure!( + minted.sign(ED25519_MESSAGE).await? == ED25519_SIG, + "the JWK wrap tour lost the key" + ); + Ok(()) } /// The RFC 6979 known answer: an imported P-256 public key reports its @@ -700,7 +903,185 @@ async fn ecdsa_verify_known_answer() -> Result<()> { key.verify(ECDSA_MESSAGE, sig).await, Error::AuthenticationFailed, "corrupted signature verified", + )?; + + // The same key in its other public encodings — the named-curve SPKI + // and the EC JWK — each verifying the known signature. + let known_sig = || { + let mut sig = ECDSA_SIG_R.to_vec(); + sig.extend(ECDSA_SIG_S); + sig + }; + let spki: Vec = [ + &P256_SPKI_PREFIX[..], + &[0x04], + &ECDSA_PUBLIC_X[..], + &ECDSA_PUBLIC_Y[..], + ] + .concat(); + let from_spki = ecdsa::import_verifying_key_spki(EcdsaVariant::P256Sha256, spki.clone()) + .await + .context("import-verifying-key-spki")?; + from_spki + .verify(ECDSA_MESSAGE, known_sig()) + .await + .context("SPKI-imported key rejected the known signature")?; + let jwk = format!( + r#"{{"kty":"EC","crv":"P-256","x":"{}","y":"{}"}}"#, + b64url(&ECDSA_PUBLIC_X), + b64url(&ECDSA_PUBLIC_Y) + ); + let from_jwk = ecdsa::import_verifying_key_jwk(EcdsaVariant::P256Sha256, jwk) + .await + .context("import-verifying-key-jwk")?; + from_jwk + .verify(ECDSA_MESSAGE, known_sig()) + .await + .context("JWK-imported key rejected the known signature")?; + + // Public exports: raw and SPKI round-trip byte-for-byte (the + // uncompressed point and named-curve encodings are canonical), and + // the exported JWK re-imports to a key that still verifies. + let mut point = vec![0x04]; + point.extend(ECDSA_PUBLIC_X); + point.extend(ECDSA_PUBLIC_Y); + ensure!( + key.export_key_raw().await.context("export-key-raw")? == point, + "export-key-raw did not round-trip" + ); + ensure!( + key.export_key_spki().await.context("export-key-spki")? == spki, + "export-key-spki did not round-trip" + ); + let exported = key.export_key_jwk().await.context("export-key-jwk")?; + ecdsa::import_verifying_key_jwk(EcdsaVariant::P256Sha256, exported) + .await + .context("re-importing the exported JWK")? + .verify(ECDSA_MESSAGE, known_sig()) + .await + .context("the JWK-export round trip lost the key") +} + +/// The ECDSA signing-key mints (host-only: the in-guest provider +/// withholds `ecdsa-sign` — class D). Signatures are not deterministic +/// across implementations (randomized-k and RFC 6979 both verify), so +/// each minted key is checked by verifying what it signs against the +/// RFC 6979 key's public half. +#[cfg(feature = "host-only")] +async fn ecdsa_sign_mints() -> Result<()> { + use polymorph_webcrypto_guest::ecdsa::{self, EcdsaVariant}; + use polymorph_webcrypto_guest::{aes_gcm, AeadKeyOptions, SigningKeyOptions}; + + let mut point = vec![0x04]; + point.extend(ECDSA_PUBLIC_X); + point.extend(ECDSA_PUBLIC_Y); + let verifying = ecdsa::import_verifying_key_raw(EcdsaVariant::P256Sha256, point) + .await + .context("import-verifying-key-raw")?; + let exportable = SigningKeyOptions { + sign: true, + extractable: true, + }; + + // RFC 6979 A.2.5's private key, in both import encodings. + let pkcs8: Vec = [ + &P256_PKCS8_PREFIX[..], + &ECDSA_PRIVATE[..], + &P256_PKCS8_MID[..], + &[0x04], + &ECDSA_PUBLIC_X[..], + &ECDSA_PUBLIC_Y[..], + ] + .concat(); + let signing = ecdsa::import_signing_key_pkcs8(EcdsaVariant::P256Sha256, pkcs8, exportable) + .await + .context("import-signing-key-pkcs8")?; + verifying + .verify(ECDSA_MESSAGE, signing.sign(ECDSA_MESSAGE).await?) + .await + .context("the PKCS#8-imported key's signature did not verify")?; + let private_jwk = format!( + r#"{{"kty":"EC","crv":"P-256","x":"{}","y":"{}","d":"{}"}}"#, + b64url(&ECDSA_PUBLIC_X), + b64url(&ECDSA_PUBLIC_Y), + b64url(&ECDSA_PRIVATE) + ); + let from_jwk = ecdsa::import_signing_key_jwk(EcdsaVariant::P256Sha256, private_jwk, exportable) + .await + .context("import-signing-key-jwk")?; + verifying + .verify(ECDSA_MESSAGE, from_jwk.sign(ECDSA_MESSAGE).await?) + .await + .context("the JWK-imported key's signature did not verify")?; + + // The wrap tour through both unwrap mints: the material transits + // wrap/unwrap without reaching this caller. The kek is an AEAD key + // (AES-KW takes only 8-byte multiples — the P-256 PKCS#8 form is + // not one); one fresh kek, one distinct nonce per wrap. + let kek = aes_gcm::generate_key( + polymorph_webcrypto_guest::aes_gcm::AesVariant::Aes128, + AeadKeyOptions { + wrap: true, + unwrap: true, + ..Default::default() + }, + ) + .await + .context("aes-gcm generate-key")?; + let sealed = SigningKeyOptions { + sign: true, + extractable: false, + }; + let wrapped = kek + .wrap( + *b"demo-nonce-1", + *b"", + None, + signing + .to_wrap_input_pkcs8() + .await + .context("to-wrap-input-pkcs8")?, + ) + .await + .context("wrapping the PKCS#8 serialization")?; + let minted = ecdsa::unwrap_signing_key_pkcs8( + EcdsaVariant::P256Sha256, + kek.unwrap(*b"demo-nonce-1", *b"", None, wrapped) + .await + .context("aead-key.unwrap")?, + sealed, ) + .await + .context("unwrap-signing-key-pkcs8")?; + verifying + .verify(ECDSA_MESSAGE, minted.sign(ECDSA_MESSAGE).await?) + .await + .context("the PKCS#8 wrap tour lost the key")?; + let wrapped = kek + .wrap( + *b"demo-nonce-2", + *b"", + None, + signing + .to_wrap_input_jwk() + .await + .context("to-wrap-input-jwk")?, + ) + .await + .context("wrapping the JWK serialization")?; + let minted = ecdsa::unwrap_signing_key_jwk( + EcdsaVariant::P256Sha256, + kek.unwrap(*b"demo-nonce-2", *b"", None, wrapped) + .await + .context("aead-key.unwrap")?, + sealed, + ) + .await + .context("unwrap-signing-key-jwk")?; + verifying + .verify(ECDSA_MESSAGE, minted.sign(ECDSA_MESSAGE).await?) + .await + .context("the JWK wrap tour lost the key") } /// The Wycheproof known answers, one per RSA signature algorithm over the @@ -780,7 +1161,7 @@ async fn rsa_verify_known_answer() -> Result<()> { /// under the label it was encrypted with, and a generated pair /// round-trips (RFC 8017's OAEP is randomized, so generation has no /// known-answer form — the round trip is the check). -#[cfg(feature = "rsa-oaep")] +#[cfg(feature = "host-only")] async fn rsa_oaep_key_transport() -> Result<()> { use polymorph_webcrypto_guest::rsa_oaep::{self, RsaModulus, RsaVariant}; use polymorph_webcrypto_guest::DecryptionKeyOptions; @@ -1283,4 +1664,8 @@ fn hex(bytes: &[u8]) -> String { data_encoding::HEXLOWER.encode(bytes) } +fn b64url(bytes: &[u8]) -> String { + data_encoding::BASE64URL_NOPAD.encode(bytes) +} + export!(Component); diff --git a/examples/jco-demo/package.json b/examples/jco-demo/package.json index 4871254..0745723 100644 --- a/examples/jco-demo/package.json +++ b/examples/jco-demo/package.json @@ -6,7 +6,7 @@ "description": "crypto-demo driver for the jco host (js/jco/webcrypto.js): transpiles the guest with jco and runs it on Node 24+.", "scripts": { "build:component": "cd ../.. && just demo::build-component", - "transpile": "node ../../scripts/jco-transpile.mjs transpile ../crypto-demo/build/crypto-demo-rsa-oaep.component.wasm --name crypto-demo --async-mode jspi --map 'polymorph:webcrypto/*@0.1.0=../../../js/jco/webcrypto.js#*' -o generated", + "transpile": "node ../../scripts/jco-transpile.mjs transpile ../crypto-demo/build/crypto-demo-host-only.component.wasm --name crypto-demo --async-mode jspi --map 'polymorph:webcrypto/*@0.1.0=../../../js/jco/webcrypto.js#*' -o generated", "start": "node --experimental-wasm-jspi src/run.mjs", "test": "node --experimental-wasm-jspi src/run.mjs" }, diff --git a/examples/justfile b/examples/justfile index cc26602..18664ea 100644 --- a/examples/justfile +++ b/examples/justfile @@ -4,17 +4,17 @@ import '../justfile.shared.just' -# Run the Wasmtime (native, RustCrypto) host demo — the opt-in rsa-oaep -# build: this host serves the withheld-by-default RSA op interfaces. +# Run the Wasmtime (native, RustCrypto) host demo — the opt-in host-only +# build: this host serves the interfaces the in-guest provider withholds. wasmtime: build-component cargo run --release --manifest-path '{{root}}/Cargo.toml' --bin wasmtime-demo-host -- \ - crypto-demo/build/crypto-demo-rsa-oaep.component.wasm + crypto-demo/build/crypto-demo-host-only.component.wasm # Compose the fully in-guest demo: the crypto-demo guest's polymorph:webcrypto # imports are satisfied by the in-guest provider's exports (`wac plug`), then # the CLI driver (async wasi:cli/run) is plugged on top, yielding one # self-contained component in target/crypto-demo-composed.wasm. The default -# build is the composable one — the opt-in `rsa-oaep` artifact imports +# build is the composable one — the opt-in host-only artifact imports # interfaces the provider withholds (class D) and cannot plug. compose: build-component (_compose "crypto-demo" "examples/crypto-demo/build/crypto-demo.component.wasm" "crypto-demo-driver") diff --git a/examples/wasmtime-demo/tests/demo.rs b/examples/wasmtime-demo/tests/demo.rs index 40752ea..eda8db4 100644 --- a/examples/wasmtime-demo/tests/demo.rs +++ b/examples/wasmtime-demo/tests/demo.rs @@ -23,7 +23,7 @@ fn run(dir: &Path, program: &str, args: &[&str]) { } /// Build the guest component through `just demo::build-component` — the single -/// definition of that build — and return the opt-in rsa-oaep artifact's +/// definition of that build — and return the opt-in host-only artifact's /// path (this host serves the withheld-by-default interfaces). The build /// runs once per test binary: the tests run in parallel, and a concurrent /// rebuild's `wasm-tools component new -o` truncates the component file in @@ -31,7 +31,7 @@ fn run(dir: &Path, program: &str, args: &[&str]) { fn build_component(workspace_root: &Path) -> PathBuf { static BUILD: Once = Once::new(); BUILD.call_once(|| run(workspace_root, "just", &["build-component"])); - workspace_root.join("examples/crypto-demo/build/crypto-demo-rsa-oaep.component.wasm") + workspace_root.join("examples/crypto-demo/build/crypto-demo-host-only.component.wasm") } #[tokio::test(flavor = "multi_thread")] diff --git a/justfile.shared.just b/justfile.shared.just index 2ad8be2..44a0348 100644 --- a/justfile.shared.just +++ b/justfile.shared.just @@ -17,16 +17,16 @@ chromium-enabled := if env('GITHUB_ACTIONS', '') == 'true' { '1' } else if env(' # Build the crypto-demo guest component into examples/crypto-demo/build/ — # both artifacts. The default build is crypto-demo.component.wasm, the -# artifact the in-guest composition plugs. The `--features rsa-oaep` -# build adds the key-transport check and with it the withheld-by-default -# RSA op interfaces, so it exists as the separate -# crypto-demo-rsa-oaep.component.wasm, run only by the hosts that -# deliberately serve those interfaces (the wasmtime-demo tests, the jco -# demo leg). Sequential on purpose: the two builds share the cargo -# target dir. The outputs are renamed into place: `wasm-tools component -# new -o` truncates in place, so a direct write would expose an empty or -# partial component to a concurrent reader (the wasmtime-demo tests load -# these paths). +# artifact the in-guest composition plugs. The `--features host-only` +# build adds the checks whose imports the in-guest provider withholds +# (class D: the RSA-OAEP transport, the ECDSA signing mints), so it +# exists as the separate crypto-demo-host-only.component.wasm, run only +# by the hosts that deliberately serve those interfaces (the +# wasmtime-demo tests, the jco demo leg). Sequential on purpose: the two +# builds share the cargo target dir. The outputs are renamed into place: +# `wasm-tools component new -o` truncates in place, so a direct write +# would expose an empty or partial component to a concurrent reader (the +# wasmtime-demo tests load these paths). [private] build-component: #!/usr/bin/env bash @@ -39,12 +39,12 @@ build-component: -o examples/crypto-demo/build/crypto-demo.component.wasm.tmp mv -f examples/crypto-demo/build/crypto-demo.component.wasm.tmp \ examples/crypto-demo/build/crypto-demo.component.wasm - cargo build --release -p crypto-demo --features rsa-oaep --target wasm32-unknown-unknown + cargo build --release -p crypto-demo --features host-only --target wasm32-unknown-unknown wasm-tools component new \ target/wasm32-unknown-unknown/release/crypto_demo.wasm \ - -o examples/crypto-demo/build/crypto-demo-rsa-oaep.component.wasm.tmp - mv -f examples/crypto-demo/build/crypto-demo-rsa-oaep.component.wasm.tmp \ - examples/crypto-demo/build/crypto-demo-rsa-oaep.component.wasm + -o examples/crypto-demo/build/crypto-demo-host-only.component.wasm.tmp + mv -f examples/crypto-demo/build/crypto-demo-host-only.component.wasm.tmp \ + examples/crypto-demo/build/crypto-demo-host-only.component.wasm # Build the in-guest provider component (RustCrypto entirely in-guest; it # exports the polymorph:webcrypto surface) into diff --git a/rust/guest/src/ecdsa.rs b/rust/guest/src/ecdsa.rs index 7d3eca9..6f2e0e5 100644 --- a/rust/guest/src/ecdsa.rs +++ b/rust/guest/src/ecdsa.rs @@ -1,6 +1,6 @@ //! `ecdsa-verify` / `ecdsa-sign` key creation. -use crate::{bindings, Error, SigningKey, SigningKeyOptions, VerifyingKey}; +use crate::{bindings, Error, SigningKey, SigningKeyOptions, UnwrapInput, VerifyingKey}; pub use crate::bindings::ecdsa_verify::EcdsaVariant; @@ -14,6 +14,35 @@ pub async fn import_verifying_key_raw( )) } +/// Import a public key as an X.509 SubjectPublicKeyInfo (DER). The curve +/// must be named by OID and match the declared variant's, or the import +/// fails [`Error::InvalidKey`]; whether a *compressed* point encoding is +/// accepted is implementation-defined — do not rely on either behavior +/// (see the WIT `import-verifying-key-spki` doc). +pub async fn import_verifying_key_spki( + variant: EcdsaVariant, + spki: impl Into>, +) -> Result { + Ok(VerifyingKey::from_raw( + bindings::ecdsa_verify::import_verifying_key_spki(variant, spki.into()).await?, + )) +} + +/// Import a public key as an EC public JWK (`kty: "EC"`, with `crv`, +/// `x`, and `y`; as JSON text). The JWK's `crv` must match the declared +/// variant's curve, and an `alg` member, when present, must be the +/// curve's JOSE signature alg (`"ES256"` for P-256, `"ES384"` for +/// P-384). See the WIT `mac-key.export-key-jwk` doc for the package-wide +/// JWK contract. +pub async fn import_verifying_key_jwk( + variant: EcdsaVariant, + jwk: impl Into, +) -> Result { + Ok(VerifyingKey::from_raw( + bindings::ecdsa_verify::import_verifying_key_jwk(variant, jwk.into()).await?, + )) +} + /// Generate a fresh random signing key of the declared variant, returning /// both halves. pub async fn generate_key( @@ -26,3 +55,68 @@ pub async fn generate_key( VerifyingKey::from_raw(verifying), )) } + +/// Import a signing key as a PKCS#8 PrivateKeyInfo (DER, the SEC1 +/// private-key body). The encoded curve must match the declared +/// variant's ([`Error::InvalidKey`]); an embedded public key, when +/// present, is validated against the scalar and never trusted on its +/// own. Returns only the signing key; the public half is imported +/// separately (there is no derive from a private import — see the WIT +/// `ecdsa-sign` interface doc). +pub async fn import_signing_key_pkcs8( + variant: EcdsaVariant, + pkcs8: impl Into>, + options: SigningKeyOptions, +) -> Result { + Ok(SigningKey::from_raw( + bindings::ecdsa_sign::import_signing_key_pkcs8(variant, pkcs8.into(), options.lower()) + .await?, + )) +} + +/// Import a signing key as an EC private JWK (`kty: "EC"`, with `crv`, +/// `d`, and the mandatory public coordinates `x`/`y`; as JSON text). +/// `crv` and `alg` are validated as in [`import_verifying_key_jwk`]. +/// +/// Security: implementations MAY validate `x`/`y` against `d`, and never +/// trust them for any operation. +pub async fn import_signing_key_jwk( + variant: EcdsaVariant, + jwk: impl Into, + options: SigningKeyOptions, +) -> Result { + Ok(SigningKey::from_raw( + bindings::ecdsa_sign::import_signing_key_jwk(variant, jwk.into(), options.lower()).await?, + )) +} + +/// Mint a signing key from unwrapped key material read as a PKCS#8 +/// PrivateKeyInfo, subject to [`import_signing_key_pkcs8`]'s contract. +/// Consumes the [`UnwrapInput`]; the minted key's usages and +/// extractability come from `options` alone. +pub async fn unwrap_signing_key_pkcs8( + variant: EcdsaVariant, + input: UnwrapInput, + options: SigningKeyOptions, +) -> Result { + Ok(SigningKey::from_raw( + bindings::ecdsa_sign::unwrap_signing_key_pkcs8(variant, input.into_raw(), options.lower()) + .await?, + )) +} + +/// Mint a signing key from unwrapped key material read as an EC private +/// JWK, subject to [`import_signing_key_jwk`]'s contract plus the +/// unwrap-path `use`/`key_ops` checks (see the WIT `README.md`, "JWK +/// contract"). Consumes the [`UnwrapInput`]; see +/// [`unwrap_signing_key_pkcs8`] for the options model. +pub async fn unwrap_signing_key_jwk( + variant: EcdsaVariant, + input: UnwrapInput, + options: SigningKeyOptions, +) -> Result { + Ok(SigningKey::from_raw( + bindings::ecdsa_sign::unwrap_signing_key_jwk(variant, input.into_raw(), options.lower()) + .await?, + )) +} diff --git a/rust/guest/src/ed25519.rs b/rust/guest/src/ed25519.rs index 82c6162..0889924 100644 --- a/rust/guest/src/ed25519.rs +++ b/rust/guest/src/ed25519.rs @@ -1,14 +1,37 @@ //! `ed25519-verify` / `ed25519-sign` key creation. -use crate::{bindings, Error, SigningKey, SigningKeyOptions, VerifyingKey}; +use crate::{bindings, Error, SigningKey, SigningKeyOptions, UnwrapInput, VerifyingKey}; -/// Import a 32-byte raw public key. +/// Import a 32-byte raw public key (RFC 8032 encoding). Material of any +/// other length fails [`Error::InvalidKey`]; a non-canonical or +/// small-order encoding is rejected here or at verification, per the WIT +/// interface's verification criterion. pub async fn import_verifying_key_raw(raw: impl Into>) -> Result { Ok(VerifyingKey::from_raw( bindings::ed25519_verify::import_verifying_key_raw(raw.into()).await?, )) } +/// Import a public key as an X.509 SubjectPublicKeyInfo (DER, RFC 8410). +/// The embedded point is subject to the same strict criterion as +/// [`import_verifying_key_raw`]. +pub async fn import_verifying_key_spki(spki: impl Into>) -> Result { + Ok(VerifyingKey::from_raw( + bindings::ed25519_verify::import_verifying_key_spki(spki.into()).await?, + )) +} + +/// Import a public key as an RFC 8037 OKP public JWK (`kty: "OKP"`, +/// `crv: "Ed25519"`, `x`; as JSON text). An `alg` member, when present, +/// must be `"Ed25519"` or `"EdDSA"`. The same strict point criterion as +/// [`import_verifying_key_raw`] applies; see the WIT +/// `mac-key.export-key-jwk` doc for the package-wide JWK contract. +pub async fn import_verifying_key_jwk(jwk: impl Into) -> Result { + Ok(VerifyingKey::from_raw( + bindings::ed25519_verify::import_verifying_key_jwk(jwk.into()).await?, + )) +} + /// Generate a fresh random signing key, returning both halves. pub async fn generate_key(options: SigningKeyOptions) -> Result<(SigningKey, VerifyingKey), Error> { let (signing, verifying) = bindings::ed25519_sign::generate_key(options.lower()).await?; @@ -17,3 +40,58 @@ pub async fn generate_key(options: SigningKeyOptions) -> Result<(SigningKey, Ver VerifyingKey::from_raw(verifying), )) } + +/// Import a signing key as a PKCS#8 PrivateKeyInfo (DER, RFC 8410: the +/// 32-byte seed in a CurvePrivateKey). Returns only the signing key; the +/// public half is imported separately (there is no derive from a private +/// import — see the WIT `ed25519-sign` interface doc). +pub async fn import_signing_key_pkcs8( + pkcs8: impl Into>, + options: SigningKeyOptions, +) -> Result { + Ok(SigningKey::from_raw( + bindings::ed25519_sign::import_signing_key_pkcs8(pkcs8.into(), options.lower()).await?, + )) +} + +/// Import a signing key as an RFC 8037 OKP private JWK (`kty: "OKP"`, +/// `crv: "Ed25519"`, with `x` and `d` both required; as JSON text). An +/// `alg` member, when present, must be `"Ed25519"` or `"EdDSA"`. +/// +/// Security: implementations MAY reject a JWK whose `x` is not the +/// public key of `d`, and never trust `x` for any operation. +pub async fn import_signing_key_jwk( + jwk: impl Into, + options: SigningKeyOptions, +) -> Result { + Ok(SigningKey::from_raw( + bindings::ed25519_sign::import_signing_key_jwk(jwk.into(), options.lower()).await?, + )) +} + +/// Mint a signing key from unwrapped key material read as a PKCS#8 +/// PrivateKeyInfo, subject to [`import_signing_key_pkcs8`]'s contract. +/// Consumes the [`UnwrapInput`]; the minted key's usages and +/// extractability come from `options` alone. +pub async fn unwrap_signing_key_pkcs8( + input: UnwrapInput, + options: SigningKeyOptions, +) -> Result { + Ok(SigningKey::from_raw( + bindings::ed25519_sign::unwrap_signing_key_pkcs8(input.into_raw(), options.lower()).await?, + )) +} + +/// Mint a signing key from unwrapped key material read as an OKP private +/// JWK, subject to [`import_signing_key_jwk`]'s contract plus the +/// unwrap-path `use`/`key_ops` checks (see the WIT `README.md`, "JWK +/// contract"). Consumes the [`UnwrapInput`]; see +/// [`unwrap_signing_key_pkcs8`] for the options model. +pub async fn unwrap_signing_key_jwk( + input: UnwrapInput, + options: SigningKeyOptions, +) -> Result { + Ok(SigningKey::from_raw( + bindings::ed25519_sign::unwrap_signing_key_jwk(input.into_raw(), options.lower()).await?, + )) +} diff --git a/rust/guest/src/lib.rs b/rust/guest/src/lib.rs index 7102ad4..4736b9a 100644 --- a/rust/guest/src/lib.rs +++ b/rust/guest/src/lib.rs @@ -772,8 +772,9 @@ impl KwKeyOptions { pub struct SigningKeyOptions { /// Whether the minted key may `sign`. pub sign: bool, - /// Whether the minted key's material may be exported (by future - /// format-specific exports; there is no export operation today). + /// Whether the minted key's material may be exported + /// ([`SigningKey::export_key_jwk`], [`SigningKey::export_key_pkcs8`], + /// and the wrap inputs). pub extractable: bool, } @@ -1321,6 +1322,19 @@ impl VerifyingKey { pub async fn export_key_raw(&self) -> Result, Error> { self.0.export_key_raw().await.map_err(Error::from) } + + /// The public key as an X.509 SubjectPublicKeyInfo (DER), with the + /// same fallibility as [`export_key_raw`](Self::export_key_raw). + pub async fn export_key_spki(&self) -> Result, Error> { + self.0.export_key_spki().await.map_err(Error::from) + } + + /// The public key as a JWK (JSON text — an RFC 8037 OKP public key + /// for Ed25519, an EC public key for ECDSA), with the same + /// fallibility as [`export_key_raw`](Self::export_key_raw). + pub async fn export_key_jwk(&self) -> Result { + self.0.export_key_jwk().await.map_err(Error::from) + } } /// A `signature.signing-key`: private-key signing. @@ -1363,11 +1377,15 @@ impl SigningKey { self.0.algorithm_public_exponent() } - /// Whether the private key material may be exported. There is - /// currently no export operation — extractability is mint-time - /// recorded policy that future format-specific exports and - /// platform-backed key storage honor (see the WIT - /// `signing-key.extractable` doc). + /// Whether the private key material may be exported by + /// [`export_key_jwk`](Self::export_key_jwk) / + /// [`export_key_pkcs8`](Self::export_key_pkcs8) and the wrap inputs — + /// mint-time recorded policy, which platform-backed key storage also + /// honors. + /// + /// Asking is not the same as exporting: interrogating extractability + /// through an export alone would hand you the material whenever the + /// answer is yes. pub fn extractable(&self) -> bool { self.0.extractable() } @@ -1378,6 +1396,43 @@ impl SigningKey { pub fn can_sign(&self) -> bool { self.0.can_sign() } + + /// The private key as a JWK (JSON text — an RFC 8037 OKP private key + /// for Ed25519, an EC private key for ECDSA); fails with + /// [`Error::NotExtractable`] unless the key was minted extractable. + /// Extractability is an API property, not a physical one: the + /// guarantee is that components holding only the handle cannot obtain + /// the material through this API. + pub async fn export_key_jwk(&self) -> Result { + self.0.export_key_jwk().await.map_err(Error::from) + } + + /// The private key as a PKCS#8 PrivateKeyInfo (DER), behind the same + /// extractability gate as [`export_key_jwk`](Self::export_key_jwk). + pub async fn export_key_pkcs8(&self) -> Result, Error> { + self.0.export_key_pkcs8().await.map_err(Error::from) + } + + /// The private-key JWK serialization as a [`WrapInput`] for wrapping + /// under another key — the material transits neither caller. Behind + /// the same extractability gate as + /// [`export_key_jwk`](Self::export_key_jwk). + pub async fn to_wrap_input_jwk(&self) -> Result { + self.0 + .to_wrap_input_jwk() + .await + .map(WrapInput::from_raw) + .map_err(Error::from) + } + + /// The PKCS#8 serialization as a [`WrapInput`], behind the same gate. + pub async fn to_wrap_input_pkcs8(&self) -> Result { + self.0 + .to_wrap_input_pkcs8() + .await + .map(WrapInput::from_raw) + .map_err(Error::from) + } } /// A `public-encryption.encryption-key`: the public half of asymmetric