diff --git a/rust/core/src/sig.rs b/rust/core/src/sig.rs index 568fb261..26bbadf4 100644 --- a/rust/core/src/sig.rs +++ b/rust/core/src/sig.rs @@ -849,8 +849,10 @@ pub struct SigningKeyMaterial { impl SigningKeyMaterial { /// Import a 32-byte RFC 8032 seed, rendering `invalid-key` for wrong - /// lengths (the `ed25519-sign.import-signing-key` contract). + /// lengths and `not-permitted` for a policy granting no usage (the mint + /// rule every signing-key constructor enforces). pub fn import_ed25519_seed(raw: &[u8], policy: SigningPolicy) -> Result { + policy.check_useful()?; let seed: &[u8; 32] = raw.try_into().map_err(|_| { Error::InvalidKey(format!( "Ed25519 private keys are 32-byte seeds, got {} bytes", @@ -877,14 +879,16 @@ impl SigningKeyMaterial { } /// Import a raw big-endian scalar for the declared variant, rendering - /// `invalid-key` for wrong lengths and out-of-range scalars (the - /// `ecdsa-sign.import-signing-key` contract). + /// `invalid-key` for wrong lengths and out-of-range scalars, and + /// `not-permitted` for a policy granting no usage (the mint rule every + /// signing-key constructor enforces). #[cfg(not(target_family = "wasm"))] pub fn import_ecdsa_scalar( variant: EcdsaVariant, raw: &[u8], policy: SigningPolicy, ) -> Result { + policy.check_useful()?; let (curve, hash) = variant_parts(variant)?; let private = per_curve!(curve, SigPrivate, |ec, mint, name| { ec::SigningKey::from_slice(raw) @@ -1002,15 +1006,17 @@ impl SigningKeyMaterial { Ok((EcdsaCurve::P384, _)) => 48, Err(err) => return Ok(Err(err)), }; - // Bound the retries. Both rejections `import_ecdsa_scalar` can - // report — an out-of-range scalar and a length mismatch — arrive as - // `InvalidKey`, so the loop cannot tell "draw again" from "this can - // never succeed" by matching. Unbounded retrying therefore couples - // it to the invariant that `scalar_len` matches the variant: true - // today, and an infinite loop inside a host call if a future variant - // breaks it. A draw is rejected with probability under 2^-32 for - // these curves, so exhausting eight attempts is not sampling luck — - // it is that invariant failing, and saying so beats hanging. + // Bound the retries. The policy check above excludes `NotPermitted` + // from this loop, so both rejections `import_ecdsa_scalar` can still + // report here — an out-of-range scalar and a length mismatch — + // arrive as `InvalidKey`, and the loop cannot tell "draw again" from + // "this can never succeed" by matching. Unbounded retrying therefore + // couples it to the invariant that `scalar_len` matches the variant: + // true today, and an infinite loop inside a host call if a future + // variant breaks it. A draw is rejected with probability under + // 2^-32 for these curves, so exhausting eight attempts is not + // sampling luck — it is that invariant failing, and saying so beats + // hanging. const ATTEMPTS: usize = 8; for _ in 0..ATTEMPTS { let mut raw = Zeroizing::new(vec![0u8; scalar_len]); @@ -1463,6 +1469,30 @@ mod tests { } } + /// A policy granting no usage is rejected before the seed is even + /// parsed, for a seed that is otherwise well formed. + #[test] + fn ed25519_seed_import_rejects_a_useless_policy() { + match SigningKeyMaterial::import_ed25519_seed(&[7u8; 32], SigningPolicy::default()) { + Err(Error::NotPermitted(_)) => {} + other => panic!("expected not-permitted, got {other:?}"), + } + } + + /// A policy granting no usage is rejected before the scalar is even + /// parsed, for an in-range scalar. + #[test] + fn ecdsa_scalar_import_rejects_a_useless_policy() { + match SigningKeyMaterial::import_ecdsa_scalar( + EcdsaVariant::P256Sha256, + &[7u8; 32], + SigningPolicy::default(), + ) { + Err(Error::NotPermitted(_)) => {} + other => panic!("expected not-permitted, got {other:?}"), + } + } + /// The SEC1 shape guard renders the curated diagnostic for each of its /// clauses alone — wrong length with the right leading byte, and the /// right length with a compressed-point leading byte — rather than diff --git a/rust/wasmtime/src/host.rs b/rust/wasmtime/src/host.rs index 58209992..7fe761ba 100644 --- a/rust/wasmtime/src/host.rs +++ b/rust/wasmtime/src/host.rs @@ -93,7 +93,7 @@ fn rng_trap(what: &str) -> impl Fn(polymorph_webcrypto_core::RngError) -> wasmti // --- shared operation shapes --------------------------------------------------- /// The message for a mint the retention budget cannot admit. -fn retention_message(limit: u64) -> String { +pub(crate) fn retention_message(limit: u64) -> String { format!( "minted resources exceed the retention limit ({limit} bytes); see \ WasiWebcryptoCtx::set_retention_limit" diff --git a/rust/wasmtime/src/lib.rs b/rust/wasmtime/src/lib.rs index b718e665..95c37fdc 100644 --- a/rust/wasmtime/src/lib.rs +++ b/rust/wasmtime/src/lib.rs @@ -10,6 +10,12 @@ //! and calls [`add_to_linker`] to satisfy the full `polymorph:webcrypto` //! package surface with RustCrypto implementations. //! +//! An embedder that already holds key material in its own process — loaded +//! from a platform keystore, generated by another library — can place it in +//! the store's table as typed handles rather than round-tripping it through +//! a serialized import call: see [`SigningKey::from_material`] and +//! [`VerifyingKey::from_material`]. +//! //! [`wasmtime_wasi_http::p3`]: https://docs.rs/wasmtime-wasi-http pub mod bindings; @@ -18,7 +24,12 @@ mod limits; pub mod standalone; mod streams; -use wasmtime::component::{HasData, Linker, ResourceTable}; +use wasmtime::component::{HasData, Linker, Resource, ResourceTable}; + +/// The shared core's key-material and policy types, re-exported for the +/// embedder key constructors ([`SigningKey::from_material`], +/// [`VerifyingKey::from_material`]). +pub use polymorph_webcrypto_core::{Error, SigPublic, SigningKeyMaterial, SigningPolicy}; /// Configuration and per-store state for the WebCrypto host. /// @@ -530,6 +541,195 @@ minted_resources! { // material — a key reaching a log line cannot leak (asserted by the // `debug_redacts_key_material` tests here and in the core). +impl SigningKey { + /// Place embedder-supplied signing key material in the store's table as + /// a `signature.signing-key` handle. + /// + /// The returned handle is indistinguishable from one a guest minted: it + /// lives in the same table under the same retention accounting (charged + /// here, released when the resource leaves the table), and the + /// existing getters (`can-sign`, `extractable`, the `algorithm-*` + /// family) answer from `material`'s algorithm binding and policy. It is + /// what an embedder's own host-implemented import returns to the guest + /// when its `bindgen!` maps the `polymorph:webcrypto/signature` resources + /// onto these types via `with`. + /// + /// `material` already carries its algorithm binding and policy from its + /// core constructor (for example + /// [`SigningKeyMaterial::import_ed25519_seed`]), so admission — key + /// validation, the at-least-one-usage mint rule — happened there. + /// + /// An embedder wanting the pair calls [`SigningKeyMaterial::public`] + /// before moving `material` here, then wraps the derived public half + /// with [`VerifyingKey::from_material`] — the doctest below does both. + /// + /// # Errors + /// + /// Fails when the store's retention budget cannot admit the resource + /// (the same recoverable condition a guest mint reports), or when the + /// resource table cannot accept the push. + /// + /// # Examples + /// + /// ``` + /// use wasmtime::component::ResourceTable; + /// use polymorph_webcrypto_wasmtime::{ + /// SigningKey, SigningKeyMaterial, SigningPolicy, VerifyingKey, WasiWebcryptoCtx, + /// WasiWebcryptoCtxView, + /// }; + /// + /// # fn main() -> Result<(), polymorph_webcrypto_wasmtime::Error> { + /// let mut ctx = WasiWebcryptoCtx::new(); + /// let mut table = ResourceTable::new(); + /// let mut view = WasiWebcryptoCtxView { + /// ctx: &mut ctx, + /// table: &mut table, + /// }; + /// + /// // An obviously synthetic seed, not real key material. + /// let seed = [7u8; 32]; + /// let policy = SigningPolicy { + /// sign: true, + /// extractable: false, + /// }; + /// let material = SigningKeyMaterial::import_ed25519_seed(&seed, policy)?; + /// let public = material.public(); + /// let signing_key = SigningKey::from_material(&mut view, material)?; + /// let verifying_key = VerifyingKey::from_material(&mut view, public)?; + /// # let _ = (signing_key, verifying_key); + /// # Ok(()) + /// # } + /// ``` + pub fn from_material( + view: &mut WasiWebcryptoCtxView<'_>, + material: SigningKeyMaterial, + ) -> Result, Error> { + let retention = view + .ctx + .charge_retention(::payload_bytes(&material)); + let Some(retention) = retention else { + return Err(Error::Other(crate::host::retention_message( + view.ctx.retention_limit_bytes(), + ))); + }; + view.table + .push(::minted(material, retention)) + .map_err(|err| Error::Other(format!("resource table: {err}"))) + } +} + +impl VerifyingKey { + /// Place embedder-supplied public key material in the store's table as + /// a `signature.verifying-key` handle. See + /// [`SigningKey::from_material`] for the full contract this shares + /// (retention accounting, table identity, the getters answering from + /// `public`); that method's doctest builds one via the signing-key + /// pair. + /// + /// # Errors + /// + /// Fails when the store's retention budget cannot admit the resource, + /// or when the resource table cannot accept the push. + pub fn from_material( + view: &mut WasiWebcryptoCtxView<'_>, + public: SigPublic, + ) -> Result, Error> { + let retention = view + .ctx + .charge_retention(::payload_bytes(&public)); + let Some(retention) = retention else { + return Err(Error::Other(crate::host::retention_message( + view.ctx.retention_limit_bytes(), + ))); + }; + view.table + .push(::minted(public, retention)) + .map_err(|err| Error::Other(format!("resource table: {err}"))) + } +} + +#[cfg(test)] +mod embedder_key_tests { + use super::{ + Error, SigningKey, SigningKeyMaterial, SigningPolicy, VerifyingKey, WasiWebcryptoCtx, + WasiWebcryptoCtxView, + }; + use crate::bindings::webcrypto::signature::{HostSigningKey, HostVerifyingKey}; + use wasmtime::component::{Resource, ResourceTable}; + + fn seed_policy() -> SigningPolicy { + SigningPolicy { + sign: true, + extractable: false, + } + } + + /// A handle from [`SigningKey::from_material`]/[`VerifyingKey::from_material`] + /// answers the same sync getters a guest-minted handle does, and holds + /// the injected material: `sign`/`verify` round-trip through it. + #[test] + fn injected_pair_answers_getters_and_signs() { + let mut ctx = WasiWebcryptoCtx::new(); + let mut table = ResourceTable::new(); + let mut view = WasiWebcryptoCtxView { + ctx: &mut ctx, + table: &mut table, + }; + + let seed = [7u8; 32]; + let material = SigningKeyMaterial::import_ed25519_seed(&seed, seed_policy()).unwrap(); + let public = material.public(); + let sk = SigningKey::from_material(&mut view, material).unwrap(); + let vk = VerifyingKey::from_material(&mut view, public).unwrap(); + + assert_eq!( + HostSigningKey::algorithm_name(&mut view, Resource::new_own(sk.rep())).unwrap(), + "Ed25519" + ); + assert!(HostSigningKey::can_sign(&mut view, Resource::new_own(sk.rep())).unwrap()); + assert!(!HostSigningKey::extractable(&mut view, Resource::new_own(sk.rep())).unwrap()); + assert_eq!( + HostVerifyingKey::algorithm_name(&mut view, Resource::new_own(vk.rep())).unwrap(), + "Ed25519" + ); + + let sig = view.table.get(&sk).unwrap().material.sign(b"msg").unwrap(); + view.table + .get(&vk) + .unwrap() + .public + .verify(b"msg", &sig) + .unwrap(); + } + + /// Injection charges the same retention pool guest mints do: two + /// injections exhaust a two-floor budget, the third fails with the + /// mint's recoverable `other` message, and deleting a handle readmits. + #[test] + fn injected_pair_shares_retention_accounting() { + let mut ctx = WasiWebcryptoCtx::new(); + ctx.set_retention_limit(Some(crate::limits::RETENTION_FLOOR * 2)); + let mut table = ResourceTable::new(); + let mut view = WasiWebcryptoCtxView { + ctx: &mut ctx, + table: &mut table, + }; + + let material = + |seed: [u8; 32]| SigningKeyMaterial::import_ed25519_seed(&seed, seed_policy()).unwrap(); + + let sk1 = SigningKey::from_material(&mut view, material([7u8; 32])).unwrap(); + let _sk2 = SigningKey::from_material(&mut view, material([8u8; 32])).unwrap(); + match SigningKey::from_material(&mut view, material([9u8; 32])) { + Err(Error::Other(msg)) => assert!(msg.contains("retention limit")), + other => panic!("expected a retention-exhausted Other error, got {other:?}"), + } + + view.table.delete(sk1).unwrap(); + assert!(SigningKey::from_material(&mut view, material([9u8; 32])).is_ok()); + } +} + /// Add the `polymorph:webcrypto` interfaces implemented by this crate — `types`, /// the primitive kinds (`mac`, `aead`, `digest`, `signature`), and /// the algorithm minting interfaces — to the provided [`Linker`].