cred: isolate scripts in WASI and move RSA signing to AWS-LC
This commit is contained in:
+80
-80
@@ -8,14 +8,28 @@ use ed25519_dalek::{Signer, SigningKey};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rsa::{
|
||||
pkcs1::DecodeRsaPrivateKey,
|
||||
pkcs8::{DecodePrivateKey, EncodePublicKey},
|
||||
pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey},
|
||||
traits::PublicKeyParts,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sha2::{Sha384, Sha512};
|
||||
use sha2::{Sha256, Sha384, Sha512};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Default, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Jwk {
|
||||
kty: Zeroizing<String>,
|
||||
crv: Zeroizing<String>,
|
||||
k: Option<Zeroizing<String>>,
|
||||
d: Option<Zeroizing<String>>,
|
||||
x: Option<Zeroizing<String>>,
|
||||
y: Option<Zeroizing<String>>,
|
||||
n: Option<Zeroizing<String>>,
|
||||
e: Option<Zeroizing<String>>,
|
||||
p: Option<Zeroizing<String>>,
|
||||
q: Option<Zeroizing<String>>,
|
||||
}
|
||||
|
||||
pub(super) enum Key {
|
||||
Ed(SigningKey),
|
||||
Ec(p256::ecdsa::SigningKey),
|
||||
@@ -51,7 +65,7 @@ pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Resul
|
||||
};
|
||||
match key.key_data() {
|
||||
ssh_key::private::KeypairData::Ed25519(k) => {
|
||||
Key::Ed(SigningKey::from_bytes(&k.private.to_bytes()))
|
||||
Key::Ed(SigningKey::from_bytes(&Zeroizing::new(k.private.to_bytes())))
|
||||
}
|
||||
ssh_key::private::KeypairData::Ecdsa(
|
||||
k @ ssh_key::private::EcdsaKeypair::NistP256 { .. },
|
||||
@@ -63,11 +77,16 @@ pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Resul
|
||||
// ssh-key 0.6.7's TryFrom<&RsaKeypair> accidentally passes p twice.
|
||||
// Convert the parsed components explicitly, preserving p and q.
|
||||
let int = |n: &ssh_key::Mpint| rsa::BigUint::try_from(n).map_err(|_| invalid_key());
|
||||
let n = int(&k.public.n)?;
|
||||
let e = int(&k.public.e)?;
|
||||
let mut d = Zeroizing::new(int(&k.private.d)?);
|
||||
let mut p = Zeroizing::new(int(&k.private.p)?);
|
||||
let mut q = Zeroizing::new(int(&k.private.q)?);
|
||||
let key = rsa::RsaPrivateKey::from_components(
|
||||
int(&k.public.n)?,
|
||||
int(&k.public.e)?,
|
||||
int(&k.private.d)?,
|
||||
vec![int(&k.private.p)?, int(&k.private.q)?],
|
||||
n,
|
||||
e,
|
||||
std::mem::take(&mut *d),
|
||||
vec![std::mem::take(&mut *p), std::mem::take(&mut *q)],
|
||||
)
|
||||
.map_err(|_| invalid_key())?;
|
||||
key.validate().map_err(|_| invalid_key())?;
|
||||
@@ -94,40 +113,39 @@ pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Resul
|
||||
_ => return Err(invalid_key()),
|
||||
}
|
||||
} else if value.starts_with('{') {
|
||||
let j: serde_json::Value = serde_json::from_str(value).map_err(|_| invalid_key())?;
|
||||
let bytes = |name: &str| -> Result<Zeroizing<Vec<u8>>> {
|
||||
decode(j[name].as_str().ok_or_else(invalid_key)?)
|
||||
let j: Jwk = serde_json::from_str(value).map_err(|_| invalid_key())?;
|
||||
let bytes = |value: &Option<Zeroizing<String>>| -> Result<Zeroizing<Vec<u8>>> {
|
||||
decode(value.as_ref().ok_or_else(invalid_key)?)
|
||||
};
|
||||
match j["kty"].as_str() {
|
||||
Some("oct") => Key::Mac(bytes("k")?),
|
||||
Some("OKP") if j["crv"] == "Ed25519" => {
|
||||
let d = bytes("d")?;
|
||||
match j.kty.as_str() {
|
||||
"oct" => Key::Mac(bytes(&j.k)?),
|
||||
"OKP" if j.crv.as_str() == "Ed25519" => {
|
||||
let d = bytes(&j.d)?;
|
||||
let k = SigningKey::from_bytes(d.as_slice().try_into().map_err(|_| invalid_key())?);
|
||||
if bytes("x")?.as_slice() != k.verifying_key().as_bytes() {
|
||||
if bytes(&j.x)?.as_slice() != k.verifying_key().as_bytes() {
|
||||
return Err(invalid_key());
|
||||
}
|
||||
Key::Ed(k)
|
||||
}
|
||||
Some("EC") if j["crv"] == "P-256" => {
|
||||
"EC" if j.crv.as_str() == "P-256" => {
|
||||
let k =
|
||||
p256::ecdsa::SigningKey::from_slice(&bytes("d")?).map_err(|_| invalid_key())?;
|
||||
p256::ecdsa::SigningKey::from_slice(&bytes(&j.d)?).map_err(|_| invalid_key())?;
|
||||
let point = k.verifying_key().to_encoded_point(false);
|
||||
if bytes("x")?.as_slice() != point.x().ok_or_else(invalid_key)?.as_slice()
|
||||
|| bytes("y")?.as_slice() != point.y().ok_or_else(invalid_key)?.as_slice()
|
||||
if bytes(&j.x)?.as_slice() != point.x().ok_or_else(invalid_key)?.as_slice()
|
||||
|| bytes(&j.y)?.as_slice() != point.y().ok_or_else(invalid_key)?.as_slice()
|
||||
{
|
||||
return Err(invalid_key());
|
||||
}
|
||||
Key::Ec(k)
|
||||
}
|
||||
Some("RSA") => {
|
||||
let int = |name| -> Result<rsa::BigUint> {
|
||||
Ok(rsa::BigUint::from_bytes_be(&bytes(name)?))
|
||||
};
|
||||
"RSA" => {
|
||||
// Decode every component before creating private integers.
|
||||
let [n, e, d, p, q] = [
|
||||
bytes(&j.n)?, bytes(&j.e)?, bytes(&j.d)?, bytes(&j.p)?, bytes(&j.q)?,
|
||||
];
|
||||
let int = |bytes: &[u8]| rsa::BigUint::from_bytes_be(bytes);
|
||||
let k = rsa::RsaPrivateKey::from_components(
|
||||
int("n")?,
|
||||
int("e")?,
|
||||
int("d")?,
|
||||
vec![int("p")?, int("q")?],
|
||||
int(&n), int(&e), int(&d), vec![int(&p), int(&q)],
|
||||
)
|
||||
.map_err(|_| invalid_key())?;
|
||||
k.validate().map_err(|_| invalid_key())?;
|
||||
@@ -190,7 +208,7 @@ pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8
|
||||
let s: p256::ecdsa::Signature = k.sign(payload);
|
||||
s.to_bytes().to_vec()
|
||||
}
|
||||
Key::Rsa(k) => rsa_signature(k, format, payload, &mut rsa::rand_core::OsRng)?,
|
||||
Key::Rsa(k) => rsa_signature(k, format, payload)?,
|
||||
Key::Mac(k) => {
|
||||
macro_rules! mac {
|
||||
($h:ty) => {{
|
||||
@@ -207,26 +225,25 @@ pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8
|
||||
}
|
||||
})
|
||||
}
|
||||
fn rsa_signature(
|
||||
key: &rsa::RsaPrivateKey,
|
||||
format: &str,
|
||||
payload: &[u8],
|
||||
rng: &mut impl rsa::rand_core::CryptoRngCore,
|
||||
) -> Result<Vec<u8>> {
|
||||
if format == "rsa-sha2-512" {
|
||||
key.sign_with_rng(
|
||||
rng,
|
||||
rsa::Pkcs1v15Sign::new::<Sha512>(),
|
||||
&Sha512::digest(payload),
|
||||
)
|
||||
fn rsa_signature(key: &rsa::RsaPrivateKey, format: &str, payload: &[u8]) -> Result<Vec<u8>> {
|
||||
// SecretDocument zeroizes the DER after AWS-LC imports the private key.
|
||||
let der = key.to_pkcs8_der().map_err(|_| invalid_key())?;
|
||||
let key = aws_lc_rs::signature::RsaKeyPair::from_pkcs8(der.as_bytes())
|
||||
.map_err(|_| invalid_key())?;
|
||||
let algorithm = if format == "rsa-sha2-512" {
|
||||
&aws_lc_rs::signature::RSA_PKCS1_SHA512
|
||||
} else {
|
||||
key.sign_with_rng(
|
||||
rng,
|
||||
rsa::Pkcs1v15Sign::new::<Sha256>(),
|
||||
&Sha256::digest(payload),
|
||||
)
|
||||
}
|
||||
.map_err(|_| invalid_key())
|
||||
&aws_lc_rs::signature::RSA_PKCS1_SHA256
|
||||
};
|
||||
let mut signature = vec![0; key.public_modulus_len()];
|
||||
key.sign(
|
||||
algorithm,
|
||||
&aws_lc_rs::rand::SystemRandom::new(),
|
||||
payload,
|
||||
&mut signature,
|
||||
)
|
||||
.map_err(|_| invalid_key())?;
|
||||
Ok(signature)
|
||||
}
|
||||
|
||||
fn string(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
@@ -322,41 +339,24 @@ pub(super) fn public(key: &Key, output: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod blinding_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rsa::rand_core::{CryptoRng, OsRng, RngCore};
|
||||
|
||||
struct Counting(usize);
|
||||
impl CryptoRng for Counting {}
|
||||
impl RngCore for Counting {
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
self.0 += 4;
|
||||
OsRng.next_u32()
|
||||
}
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.0 += 8;
|
||||
OsRng.next_u64()
|
||||
}
|
||||
fn fill_bytes(&mut self, dest: &mut [u8]) {
|
||||
self.0 += dest.len();
|
||||
OsRng.fill_bytes(dest);
|
||||
}
|
||||
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> std::result::Result<(), rsa::rand_core::Error> {
|
||||
self.0 += dest.len();
|
||||
OsRng.try_fill_bytes(dest)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rsa_uses_blinding_randomness() {
|
||||
let key = rsa::RsaPrivateKey::new(&mut OsRng, 1024).unwrap();
|
||||
let mut rng = Counting(0);
|
||||
for format in ["rs256", "rsa-sha2-256", "rsa-sha2-512"] {
|
||||
let before = rng.0;
|
||||
rsa_signature(&key, format, b"synthetic", &mut rng).unwrap();
|
||||
assert!(
|
||||
rng.0 > before,
|
||||
"RSA signing must consume blinding randomness"
|
||||
);
|
||||
fn rsa_backend_rejects_weak_keys() {
|
||||
let key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 1024).unwrap();
|
||||
assert!(rsa_signature(&key, "rsa-sha2-256", b"synthetic").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwk_private_fields_have_zeroizing_ownership() {
|
||||
let jwk: Jwk = serde_json::from_str(r#"{"kty":"oct","k":"SYNTHETIC"}"#).unwrap();
|
||||
for field in [
|
||||
&jwk.k, &jwk.d, &jwk.p, &jwk.q, &jwk.x, &jwk.y, &jwk.n, &jwk.e,
|
||||
] {
|
||||
let _: &Option<Zeroizing<String>> = field;
|
||||
}
|
||||
let _: &Zeroizing<String> = &jwk.kty;
|
||||
let _: &Zeroizing<String> = &jwk.crv;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,4 +33,4 @@ pub mod script;
|
||||
pub mod keys;
|
||||
pub mod sign;
|
||||
pub mod oauth2;
|
||||
pub mod v8;
|
||||
pub mod quickjs;
|
||||
|
||||
@@ -156,9 +156,7 @@ impl Cache {
|
||||
http: &impl Http,
|
||||
deadline: Instant,
|
||||
) -> Result<Token, Error> {
|
||||
// Baseline preserves the old unchecked policy until the regression runs.
|
||||
let _ = allowed(item, request);
|
||||
if Instant::now() >= deadline {
|
||||
if !allowed(item, request) || Instant::now() >= deadline {
|
||||
return Err(Error);
|
||||
}
|
||||
let key = (item.id.clone(), item.version);
|
||||
@@ -183,6 +181,9 @@ impl Cache {
|
||||
item: item.id.clone(),
|
||||
};
|
||||
let response = http.send_once(request, deadline).await?;
|
||||
if !response.status.is_success() {
|
||||
return Err(Error);
|
||||
}
|
||||
let token = decode(&response.body)?;
|
||||
let mut state = self.state.lock().map_err(|_| Error)?;
|
||||
if state.epoch != epoch || Instant::now() >= deadline {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Credential scripts run in a fresh, memory-limited WASI instance. Only two calls run at once.
|
||||
use super::script::{Error, ErrorKind, Limits, Script};
|
||||
use std::{
|
||||
sync::{Arc, Condvar, Mutex, OnceLock, mpsc},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use wasmtime::{
|
||||
Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDeadline,
|
||||
component::{Component, Linker, ResourceTable},
|
||||
};
|
||||
use wasmtime_wasi::{
|
||||
WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView,
|
||||
clocks::HostWallClock,
|
||||
p2::{
|
||||
bindings::sync::CommandPre,
|
||||
pipe::{MemoryInputPipe, MemoryOutputPipe},
|
||||
},
|
||||
random::Deterministic,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
struct State {
|
||||
wasi: WasiCtx,
|
||||
table: ResourceTable,
|
||||
limits: StoreLimits,
|
||||
}
|
||||
impl WasiView for State {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView {
|
||||
ctx: &mut self.wasi,
|
||||
table: &mut self.table,
|
||||
}
|
||||
}
|
||||
}
|
||||
struct FixedClock;
|
||||
impl HostWallClock for FixedClock {
|
||||
fn resolution(&self) -> Duration {
|
||||
Duration::from_millis(1)
|
||||
}
|
||||
fn now(&self) -> Duration {
|
||||
Duration::ZERO
|
||||
}
|
||||
}
|
||||
struct Compiled {
|
||||
engine: Engine,
|
||||
pre: CommandPre<State>,
|
||||
active: Mutex<usize>,
|
||||
available: Condvar,
|
||||
}
|
||||
struct Slot<'a>(&'a Compiled);
|
||||
impl Drop for Slot<'_> {
|
||||
fn drop(&mut self) {
|
||||
*self.0.active.lock().unwrap() -= 1;
|
||||
self.0.available.notify_one();
|
||||
}
|
||||
}
|
||||
impl Compiled {
|
||||
fn new() -> Result<Arc<Self>, Error> {
|
||||
let mut config = Config::new();
|
||||
config.epoch_interruption(true);
|
||||
// The cache also keeps warm test runs from compiling the component again.
|
||||
config.cache(wasmtime::Cache::new(Default::default()).ok());
|
||||
let engine = Engine::new(&config).map_err(|_| failure(ErrorKind::Engine))?;
|
||||
let component = Component::new(&engine, include_bytes!("../../cred-script/script.wasm"))
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
let mut linker = Linker::new(&engine);
|
||||
wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
let pre = CommandPre::new(
|
||||
linker
|
||||
.instantiate_pre(&component)
|
||||
.map_err(|_| failure(ErrorKind::Engine))?,
|
||||
)
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
Ok(Arc::new(Self {
|
||||
engine,
|
||||
pre,
|
||||
active: Mutex::new(0),
|
||||
available: Condvar::new(),
|
||||
}))
|
||||
}
|
||||
fn acquire(&self, deadline: Instant) -> Result<Slot<'_>, Error> {
|
||||
let mut active = self.active.lock().map_err(|_| failure(ErrorKind::Engine))?;
|
||||
while *active >= 2 {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.ok_or_else(|| failure(ErrorKind::Limit))?;
|
||||
(active, _) = self
|
||||
.available
|
||||
.wait_timeout(active, remaining)
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(failure(ErrorKind::Limit));
|
||||
}
|
||||
*active += 1;
|
||||
Ok(Slot(self))
|
||||
}
|
||||
}
|
||||
static COMPILED: OnceLock<Result<Arc<Compiled>, Error>> = OnceLock::new();
|
||||
|
||||
/// Construct once at startup. Compilation is cached; each evaluation gets a fresh Store.
|
||||
#[derive(Clone)]
|
||||
pub struct QuickJs {
|
||||
compiled: Result<Arc<Compiled>, Error>,
|
||||
}
|
||||
impl Default for QuickJs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
compiled: COMPILED.get_or_init(Compiled::new).clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn failure(kind: ErrorKind) -> Error {
|
||||
Error {
|
||||
kind,
|
||||
message: match kind {
|
||||
ErrorKind::Limit => "script time, memory or stack limit exceeded",
|
||||
ErrorKind::Syntax => "invalid script or JSON",
|
||||
ErrorKind::Throw => "script threw or rejected",
|
||||
ErrorKind::NonJson => "result is not JSON",
|
||||
ErrorKind::Engine => "script engine failure",
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
impl Script for QuickJs {
|
||||
fn evaluate(
|
||||
&self,
|
||||
source: &str,
|
||||
input: &str,
|
||||
export: Option<&str>,
|
||||
limits: Limits,
|
||||
) -> Result<String, Error> {
|
||||
let compiled = self.compiled.as_ref().map_err(Clone::clone)?;
|
||||
let time = limits.max_time_ms.unwrap_or(100).clamp(1, 1000);
|
||||
let heap = limits
|
||||
.max_memory_bytes
|
||||
.unwrap_or(8 * 1024 * 1024)
|
||||
.clamp(256 * 1024, 32 * 1024 * 1024);
|
||||
let deadline = Instant::now() + Duration::from_millis(time);
|
||||
let _slot = compiled.acquire(deadline)?;
|
||||
if source
|
||||
.len()
|
||||
.saturating_add(input.len())
|
||||
.saturating_add(export.map_or(0, str::len))
|
||||
> heap
|
||||
{
|
||||
return Err(failure(ErrorKind::Limit));
|
||||
}
|
||||
// WASI's synchronous adapter must not enter a caller's Tokio runtime.
|
||||
std::thread::scope(|scope| {
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("cred-script".into())
|
||||
.spawn_scoped(scope, move || {
|
||||
#[derive(serde::Serialize)]
|
||||
struct Request<'a> {
|
||||
source: &'a str,
|
||||
input: &'a str,
|
||||
export: Option<&'a str>,
|
||||
max_time_ms: u64,
|
||||
max_memory_bytes: usize,
|
||||
}
|
||||
let wire = Zeroizing::new(
|
||||
serde_json::to_vec(&Request {
|
||||
source,
|
||||
input,
|
||||
export,
|
||||
max_time_ms: time,
|
||||
max_memory_bytes: heap,
|
||||
})
|
||||
.map_err(|_| failure(ErrorKind::Engine))?,
|
||||
);
|
||||
let memory = heap + 8 * 1024 * 1024;
|
||||
let output = MemoryOutputPipe::new(memory);
|
||||
let mut wasi = WasiCtxBuilder::new();
|
||||
wasi.stdin(MemoryInputPipe::new(bytes::Bytes::from_owner(wire)))
|
||||
.stdout(output.clone())
|
||||
.wall_clock(FixedClock)
|
||||
.secure_random(Deterministic::new(vec![0]))
|
||||
.insecure_random(Deterministic::new(vec![0]))
|
||||
.insecure_random_seed(0)
|
||||
.allow_tcp(false)
|
||||
.allow_udp(false)
|
||||
.allow_ip_name_lookup(false);
|
||||
let mut store = Store::new(
|
||||
&compiled.engine,
|
||||
State {
|
||||
wasi: wasi.build(),
|
||||
table: ResourceTable::new(),
|
||||
limits: StoreLimitsBuilder::new()
|
||||
.memory_size(memory)
|
||||
.memories(1)
|
||||
.trap_on_grow_failure(true)
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
store.limiter(|state| &mut state.limits);
|
||||
store.epoch_deadline_callback(move |_| {
|
||||
if Instant::now() >= deadline {
|
||||
Err(wasmtime::Trap::Interrupt.into())
|
||||
} else {
|
||||
Ok(UpdateDeadline::Continue(1))
|
||||
}
|
||||
});
|
||||
store.set_epoch_deadline(1);
|
||||
let (done, wait) = mpsc::channel();
|
||||
let engine = compiled.engine.clone();
|
||||
let watchdog = std::thread::Builder::new()
|
||||
.name("cred-script-deadline".into())
|
||||
.spawn(move || {
|
||||
if wait
|
||||
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
||||
.is_err()
|
||||
{
|
||||
engine.increment_epoch();
|
||||
}
|
||||
})
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
let result = compiled
|
||||
.pre
|
||||
.instantiate(&mut store)
|
||||
.and_then(|command| command.wasi_cli_run().call_run(&mut store));
|
||||
let _ = done.send(());
|
||||
let _ = watchdog.join();
|
||||
if Instant::now() >= deadline || !matches!(result, Ok(Ok(()))) {
|
||||
return Err(failure(ErrorKind::Limit));
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Response {
|
||||
result: Option<String>,
|
||||
kind: Option<String>,
|
||||
}
|
||||
let response: Response = serde_json::from_slice(&output.contents())
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
if Instant::now() >= deadline {
|
||||
return Err(failure(ErrorKind::Limit));
|
||||
}
|
||||
if let Some(result) = response.result {
|
||||
return Ok(result);
|
||||
}
|
||||
Err(failure(match response.kind.as_deref() {
|
||||
Some("Limit") => ErrorKind::Limit,
|
||||
Some("Syntax") => ErrorKind::Syntax,
|
||||
Some("Throw") => ErrorKind::Throw,
|
||||
Some("NonJson") => ErrorKind::NonJson,
|
||||
_ => ErrorKind::Engine,
|
||||
}))
|
||||
})
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
worker.join().map_err(|_| failure(ErrorKind::Engine))?
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn concurrency_is_bounded_and_queue_uses_the_deadline() {
|
||||
let runtime = QuickJs::default();
|
||||
let compiled = runtime.compiled.as_ref().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
let first = compiled.acquire(deadline).unwrap();
|
||||
let second = compiled.acquire(deadline).unwrap();
|
||||
assert!(
|
||||
matches!(compiled.acquire(Instant::now()), Err(error) if error.kind == ErrorKind::Limit)
|
||||
);
|
||||
drop(first);
|
||||
let third = compiled.acquire(deadline).unwrap();
|
||||
assert_eq!(*compiled.active.lock().unwrap(), 2);
|
||||
drop((second, third));
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
//! Fresh, bounded V8 isolates for credential scripts. Each call owns a dedicated worker thread.
|
||||
use super::script::{Error, ErrorKind, Limits, Script};
|
||||
use deno_core::{JsRuntime, RuntimeOptions, v8};
|
||||
use zeroize::Zeroizing;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct V8;
|
||||
fn failure(kind: ErrorKind) -> Error {
|
||||
Error {
|
||||
kind,
|
||||
message: match kind {
|
||||
ErrorKind::Limit => "script time, memory or stack limit exceeded",
|
||||
ErrorKind::Syntax => "invalid script or JSON",
|
||||
ErrorKind::Throw => "script threw or rejected",
|
||||
ErrorKind::NonJson => "result is not JSON",
|
||||
ErrorKind::Engine => "script engine failure",
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
impl Script for V8 {
|
||||
fn evaluate(
|
||||
&self,
|
||||
source: &str,
|
||||
input: &str,
|
||||
export: Option<&str>,
|
||||
limits: Limits,
|
||||
) -> Result<String, Error> {
|
||||
let source = source.to_owned();
|
||||
let input = Zeroizing::new(input.to_owned());
|
||||
let export = export.unwrap_or("default").to_owned();
|
||||
std::thread::spawn(move || evaluate(source, input, export, limits))
|
||||
.join()
|
||||
.map_err(|_| failure(ErrorKind::Engine))?
|
||||
}
|
||||
}
|
||||
fn evaluate(
|
||||
source: String,
|
||||
input: Zeroizing<String>,
|
||||
export: String,
|
||||
limits: Limits,
|
||||
) -> Result<String, Error> {
|
||||
let heap = limits
|
||||
.max_memory_bytes
|
||||
.unwrap_or(8 * 1024 * 1024)
|
||||
.clamp(256 * 1024, 32 * 1024 * 1024);
|
||||
// V8 uses Tokio for internal delayed tasks, even without guest extensions.
|
||||
let executor = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
let _entered = executor.enter();
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
// Bootstrap before imposing the caller's budget: V8 cannot recover from
|
||||
// bootstrap OOM because the runtime cannot install its callback yet.
|
||||
create_params: Some(v8::CreateParams::default().heap_limits(0, 32 * 1024 * 1024)),
|
||||
..Default::default()
|
||||
});
|
||||
if runtime.v8_isolate().get_heap_statistics().total_heap_size() > heap {
|
||||
return Err(failure(ErrorKind::Limit));
|
||||
}
|
||||
// Removing a temporary callback sets the heap limit after bootstrap.
|
||||
runtime.add_near_heap_limit_callback(|current, _| current);
|
||||
runtime.remove_near_heap_limit_callback(heap);
|
||||
let handle = runtime.v8_isolate().thread_safe_handle();
|
||||
let limited = Arc::new(AtomicBool::new(false));
|
||||
let flag = limited.clone();
|
||||
let heap_handle = handle.clone();
|
||||
runtime.add_near_heap_limit_callback(move |current, _| {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
heap_handle.terminate_execution();
|
||||
// V8 needs emergency headroom to unwind after termination.
|
||||
current + 16 * 1024 * 1024
|
||||
});
|
||||
let (done, wait) = mpsc::channel();
|
||||
let flag = limited.clone();
|
||||
let watchdog = std::thread::spawn(move || {
|
||||
if wait
|
||||
.recv_timeout(Duration::from_millis(
|
||||
limits.max_time_ms.unwrap_or(100).clamp(1, 1000),
|
||||
))
|
||||
.is_err()
|
||||
{
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
handle.terminate_execution();
|
||||
}
|
||||
});
|
||||
let task = async {
|
||||
runtime
|
||||
.execute_script("sandbox.js", BOOTSTRAP)
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
let input = {
|
||||
deno_core::scope!(scope, &mut runtime);
|
||||
v8::tc_scope!(let scope, scope);
|
||||
let text = v8::String::new(scope, &input).ok_or_else(|| failure(ErrorKind::Limit))?;
|
||||
let value = v8::json::parse(scope, text).ok_or_else(|| failure(ErrorKind::Syntax))?;
|
||||
v8::Global::new(scope, value)
|
||||
};
|
||||
let spec = deno_core::ModuleSpecifier::parse("file:///credential.js").unwrap();
|
||||
let module = runtime
|
||||
.load_main_es_module_from_code(&spec, source)
|
||||
.await
|
||||
.map_err(|_| failure(ErrorKind::Syntax))?;
|
||||
let evaluated = runtime.mod_evaluate(module);
|
||||
runtime
|
||||
.with_event_loop_promise(evaluated, Default::default())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("call stack") {
|
||||
failure(ErrorKind::Limit)
|
||||
} else {
|
||||
failure(ErrorKind::Throw)
|
||||
}
|
||||
})?;
|
||||
let ns = runtime
|
||||
.get_module_namespace(module)
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
{
|
||||
deno_core::scope!(scope, &mut runtime);
|
||||
let ns = v8::Local::new(scope, ns);
|
||||
let global = scope.get_current_context().global(scope);
|
||||
let key =
|
||||
v8::String::new(scope, "__module").ok_or_else(|| failure(ErrorKind::Limit))?;
|
||||
global.set(scope, key.into(), ns.into());
|
||||
let input = v8::Local::new(scope, input);
|
||||
let key = v8::String::new(scope, "__input").ok_or_else(|| failure(ErrorKind::Limit))?;
|
||||
global.set(scope, key.into(), input);
|
||||
}
|
||||
let code = format!(
|
||||
r#"(async()=>{{
|
||||
const f=__module[{}];
|
||||
if(typeof f!=='function') return JSON.stringify({{kind:'Engine'}});
|
||||
let value;
|
||||
try {{ value=await f(__input); }} catch(e) {{ const message=String(e?.message); return JSON.stringify({{kind: e instanceof RangeError && message.includes('call stack') ? 'Limit':'Throw'}}); }}
|
||||
try {{ const text=JSON.stringify(value); return JSON.stringify(text===undefined?{{kind:'NonJson'}}:{{text}}); }} catch(e) {{ return JSON.stringify({{kind:e instanceof RangeError && String(e.message).includes('call stack') ? 'Limit':'NonJson'}}); }}
|
||||
}})()"#,
|
||||
serde_json::to_string(&export).unwrap()
|
||||
);
|
||||
let value = runtime
|
||||
.execute_script("call.js", code)
|
||||
.map_err(|_| failure(ErrorKind::Throw))?;
|
||||
let resolve = runtime.resolve(value);
|
||||
let value = runtime
|
||||
.with_event_loop_promise(resolve, Default::default())
|
||||
.await
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
let text = {
|
||||
deno_core::scope!(scope, &mut runtime);
|
||||
v8::Local::new(scope, value).to_rust_string_lossy(scope)
|
||||
};
|
||||
let result: serde_json::Value =
|
||||
serde_json::from_str(&text).map_err(|_| failure(ErrorKind::Engine))?;
|
||||
match result["text"].as_str() {
|
||||
Some(s) => Ok(s.to_owned()),
|
||||
None => Err(failure(match result["kind"].as_str() {
|
||||
Some("NonJson") => ErrorKind::NonJson,
|
||||
Some("Throw") => ErrorKind::Throw,
|
||||
Some("Limit") => ErrorKind::Limit,
|
||||
_ => ErrorKind::Engine,
|
||||
})),
|
||||
}
|
||||
};
|
||||
let result = executor.block_on(task);
|
||||
let _ = done.send(());
|
||||
let _ = watchdog.join();
|
||||
if limited.load(Ordering::SeqCst) {
|
||||
Err(failure(ErrorKind::Limit))
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
const BOOTSTRAP: &str = r#"
|
||||
delete globalThis.Date; delete Math.random;
|
||||
for (const name of ['Deno','__bootstrap','console','performance']) delete globalThis[name];
|
||||
const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
globalThis.btoa = value => {
|
||||
const s=String(value); let out='';
|
||||
for(let i=0;i<s.length;i+=3){
|
||||
const a=s.charCodeAt(i), b=s.charCodeAt(i+1), c=s.charCodeAt(i+2);
|
||||
if(a>255||b>255||c>255) throw Error('invalid base64 input');
|
||||
const n=(a<<16)|((b||0)<<8)|(c||0);
|
||||
out+=alphabet[(n>>>18)&63]+alphabet[(n>>>12)&63]+(i+1<s.length?alphabet[(n>>>6)&63]:'=')+(i+2<s.length?alphabet[n&63]:'=');
|
||||
} return out;
|
||||
};
|
||||
globalThis.atob = value => {
|
||||
let s=String(value).replace(/[\t\n\f\r ]/g,'');
|
||||
if(s.length%4===0) s=s.replace(/==?$/,'');
|
||||
if(s.length%4===1||/[^A-Za-z0-9+/]/.test(s)) throw Error('invalid base64 input');
|
||||
let out='', bits=0, n=0;
|
||||
for(const c of s){n=(n<<6)|alphabet.indexOf(c);bits+=6;if(bits>=8){bits-=8;out+=String.fromCharCode((n>>>bits)&255);}}
|
||||
return out;
|
||||
};
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn script_input_zeroizes_on_drop() {
|
||||
let _: fn(String, zeroize::Zeroizing<String>, String, Limits) -> Result<String, Error> =
|
||||
evaluate;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user