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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 42 additions & 12 deletions rust/core/src/sig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, Error> {
policy.check_useful()?;
let seed: &[u8; 32] = raw.try_into().map_err(|_| {
Error::InvalidKey(format!(
"Ed25519 private keys are 32-byte seeds, got {} bytes",
Expand All @@ -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<Self, Error> {
policy.check_useful()?;
let (curve, hash) = variant_parts(variant)?;
let private = per_curve!(curve, SigPrivate, |ec, mint, name| {
ec::SigningKey::from_slice(raw)
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion rust/wasmtime/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
202 changes: 201 additions & 1 deletion rust/wasmtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
///
Expand Down Expand Up @@ -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<Resource<SigningKey>, Error> {
let retention = view
.ctx
.charge_retention(<Self as Minted>::payload_bytes(&material));
let Some(retention) = retention else {
return Err(Error::Other(crate::host::retention_message(
view.ctx.retention_limit_bytes(),
)));
};
view.table
.push(<Self as Minted>::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<Resource<VerifyingKey>, Error> {
let retention = view
.ctx
.charge_retention(<Self as Minted>::payload_bytes(&public));
let Some(retention) = retention else {
return Err(Error::Other(crate::host::retention_message(
view.ctx.retention_limit_bytes(),
)));
};
view.table
.push(<Self as Minted>::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`].
Expand Down
Loading