cred: add pure scripts, signing, and OAuth refresh; validation blocked
This commit is contained in:
Generated
+555
-44
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,26 @@ wasmtime-wasi-http = "=49.0.3"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_yaml_ng = "0.10.0"
|
||||
rustls = "0.23"
|
||||
chacha20poly1305 = "0.10"
|
||||
zeroize = "1"
|
||||
hex = "0.4"
|
||||
http = "1"
|
||||
url = "2"
|
||||
thiserror = "2"
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
p256 = { version = "0.13", features = ["ecdh", "ecdsa", "pem"] }
|
||||
ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] }
|
||||
rsa = { version = "0.9", features = ["sha2"] }
|
||||
aes-gcm = "0.10"
|
||||
hkdf = "0.12"
|
||||
ssh-key = { version = "0.6.7", features = ["encryption", "ed25519", "ecdsa", "rsa", "p256"] }
|
||||
uuid = { version = "1.29.0", features = ["v4"] }
|
||||
webauthn-rs = "0.5"
|
||||
openssl = { version = "0.10.82", features = ["vendored"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
wat = "1"
|
||||
webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] }
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Key readers and signature/public-key codecs. No grant or storage policy here.
|
||||
use super::sign::{Result, SignResult, error, invalid_key};
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rsa::{
|
||||
pkcs1::DecodeRsaPrivateKey,
|
||||
pkcs8::{DecodePrivateKey, EncodePublicKey},
|
||||
traits::PublicKeyParts,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sha2::{Sha384, Sha512};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub(super) enum Key {
|
||||
Ed(SigningKey),
|
||||
Ec(p256::ecdsa::SigningKey),
|
||||
Rsa(rsa::RsaPrivateKey),
|
||||
Mac(Zeroizing<Vec<u8>>),
|
||||
}
|
||||
fn decode(s: &str) -> Result<Zeroizing<Vec<u8>>> {
|
||||
Ok(Zeroizing::new(
|
||||
STANDARD
|
||||
.decode(s)
|
||||
.or_else(|_| URL_SAFE_NO_PAD.decode(s))
|
||||
.map_err(|_| invalid_key())?,
|
||||
))
|
||||
}
|
||||
fn family(format: &str) -> &str {
|
||||
match format {
|
||||
"ed25519-raw" => "ed25519",
|
||||
"rs256" | "rsa-sha2-256" | "rsa-sha2-512" => "rsa",
|
||||
s if s.starts_with("hmac-") => "hmac",
|
||||
s => s,
|
||||
}
|
||||
}
|
||||
pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Result<Key> {
|
||||
let value = value.trim();
|
||||
let family = family(format);
|
||||
let key = if value.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----") {
|
||||
let key = ssh_key::PrivateKey::from_openssh(value).map_err(|_| invalid_key())?;
|
||||
let key = if key.is_encrypted() {
|
||||
key.decrypt(passphrase.ok_or_else(invalid_key)?)
|
||||
.map_err(|_| invalid_key())?
|
||||
} else {
|
||||
key
|
||||
};
|
||||
match key.key_data() {
|
||||
ssh_key::private::KeypairData::Ed25519(k) => {
|
||||
Key::Ed(SigningKey::from_bytes(&k.private.to_bytes()))
|
||||
}
|
||||
ssh_key::private::KeypairData::Ecdsa(
|
||||
k @ ssh_key::private::EcdsaKeypair::NistP256 { .. },
|
||||
) => Key::Ec(
|
||||
p256::ecdsa::SigningKey::from_slice(k.private_key_bytes())
|
||||
.map_err(|_| invalid_key())?,
|
||||
),
|
||||
ssh_key::private::KeypairData::Rsa(k) => {
|
||||
// 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 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)?],
|
||||
)
|
||||
.map_err(|_| invalid_key())?;
|
||||
key.validate().map_err(|_| invalid_key())?;
|
||||
Key::Rsa(key)
|
||||
}
|
||||
_ => return Err(invalid_key()),
|
||||
}
|
||||
} else if value.starts_with("-----BEGIN ") {
|
||||
match family {
|
||||
"ed25519" => Key::Ed(SigningKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?),
|
||||
"es256" => {
|
||||
let key = if value.starts_with("-----BEGIN EC PRIVATE KEY-----") {
|
||||
p256::SecretKey::from_sec1_pem(value).map_err(|_| invalid_key())?
|
||||
} else {
|
||||
p256::SecretKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?
|
||||
};
|
||||
Key::Ec(key.into())
|
||||
}
|
||||
"rsa" => Key::Rsa(if value.starts_with("-----BEGIN RSA PRIVATE KEY-----") {
|
||||
rsa::RsaPrivateKey::from_pkcs1_pem(value).map_err(|_| invalid_key())?
|
||||
} else {
|
||||
rsa::RsaPrivateKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?
|
||||
}),
|
||||
_ => 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)?)
|
||||
};
|
||||
match j["kty"].as_str() {
|
||||
Some("oct") => Key::Mac(bytes("k")?),
|
||||
Some("OKP") if j["crv"] == "Ed25519" => {
|
||||
let d = bytes("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() {
|
||||
return Err(invalid_key());
|
||||
}
|
||||
Key::Ed(k)
|
||||
}
|
||||
Some("EC") if j["crv"] == "P-256" => {
|
||||
let k =
|
||||
p256::ecdsa::SigningKey::from_slice(&bytes("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()
|
||||
{
|
||||
return Err(invalid_key());
|
||||
}
|
||||
Key::Ec(k)
|
||||
}
|
||||
Some("RSA") => {
|
||||
let int = |name| -> Result<rsa::BigUint> {
|
||||
Ok(rsa::BigUint::from_bytes_be(&bytes(name)?))
|
||||
};
|
||||
let k = rsa::RsaPrivateKey::from_components(
|
||||
int("n")?,
|
||||
int("e")?,
|
||||
int("d")?,
|
||||
vec![int("p")?, int("q")?],
|
||||
)
|
||||
.map_err(|_| invalid_key())?;
|
||||
k.validate().map_err(|_| invalid_key())?;
|
||||
Key::Rsa(k)
|
||||
}
|
||||
_ => return Err(invalid_key()),
|
||||
}
|
||||
} else {
|
||||
let bytes = decode(value)?;
|
||||
match family {
|
||||
"ed25519" => Key::Ed(SigningKey::from_bytes(
|
||||
bytes.as_slice().try_into().map_err(|_| invalid_key())?,
|
||||
)),
|
||||
"es256" => {
|
||||
Key::Ec(p256::ecdsa::SigningKey::from_slice(&bytes).map_err(|_| invalid_key())?)
|
||||
}
|
||||
"hmac" => Key::Mac(bytes),
|
||||
_ => return Err(invalid_key()),
|
||||
}
|
||||
};
|
||||
if !matches!(
|
||||
(&key, family),
|
||||
(Key::Ed(_), "ed25519")
|
||||
| (Key::Ec(_), "es256")
|
||||
| (Key::Rsa(_), "rsa")
|
||||
| (Key::Mac(_), "hmac")
|
||||
) {
|
||||
return Err(invalid_key());
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
pub(super) fn check(format: &str, padding: Option<&str>, hash: Option<&str>) -> Result<()> {
|
||||
match format {
|
||||
"ed25519" | "ed25519-raw" | "es256" | "hmac-sha256" | "hmac-sha384" | "hmac-sha512"
|
||||
if padding.is_none() && hash.is_none() =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
"rs256" | "rsa-sha2-256" | "rsa-sha2-512"
|
||||
if padding == Some("pkcs1v15")
|
||||
&& hash
|
||||
== Some(if format == "rsa-sha2-512" {
|
||||
"sha512"
|
||||
} else {
|
||||
"sha256"
|
||||
}) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(error(
|
||||
"invalid_params",
|
||||
"unknown algorithm or inconsistent padding/hash; RSA requires padding=pkcs1v15 and explicit sha256/sha512 matching the algorithm",
|
||||
)),
|
||||
}
|
||||
}
|
||||
pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8>> {
|
||||
Ok(match key {
|
||||
Key::Ed(k) => k.sign(payload).to_bytes().to_vec(),
|
||||
Key::Ec(k) => {
|
||||
let s: p256::ecdsa::Signature = k.sign(payload);
|
||||
s.to_bytes().to_vec()
|
||||
}
|
||||
Key::Rsa(k) => if format == "rsa-sha2-512" {
|
||||
k.sign(rsa::Pkcs1v15Sign::new::<Sha512>(), &Sha512::digest(payload))
|
||||
} else {
|
||||
k.sign(rsa::Pkcs1v15Sign::new::<Sha256>(), &Sha256::digest(payload))
|
||||
}
|
||||
.map_err(|_| invalid_key())?,
|
||||
Key::Mac(k) => {
|
||||
macro_rules! mac {
|
||||
($h:ty) => {{
|
||||
let mut mac = Hmac::<$h>::new_from_slice(k).map_err(|_| invalid_key())?;
|
||||
mac.update(payload);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}};
|
||||
}
|
||||
match format {
|
||||
"hmac-sha384" => mac!(Sha384),
|
||||
"hmac-sha512" => mac!(Sha512),
|
||||
_ => mac!(Sha256),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn string(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
fn mpint(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
let bytes = &bytes[bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len())..];
|
||||
let mut positive = Vec::new();
|
||||
if bytes.first().is_some_and(|b| b & 128 != 0) {
|
||||
positive.push(0);
|
||||
}
|
||||
positive.extend_from_slice(bytes);
|
||||
string(out, &positive);
|
||||
}
|
||||
fn wire_signature(format: &str, signature: &[u8]) -> Result<Vec<u8>> {
|
||||
let (algorithm, signature) = match format {
|
||||
"ed25519" | "ed25519-raw" => ("ssh-ed25519", signature.to_vec()),
|
||||
"rs256" | "rsa-sha2-256" => ("rsa-sha2-256", signature.to_vec()),
|
||||
"rsa-sha2-512" => ("rsa-sha2-512", signature.to_vec()),
|
||||
"es256" => {
|
||||
let mut s = Vec::new();
|
||||
mpint(&mut s, &signature[..32]);
|
||||
mpint(&mut s, &signature[32..]);
|
||||
("ecdsa-sha2-nistp256", s)
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
"invalid_params",
|
||||
"SSH wire output requires an asymmetric key",
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
string(&mut out, algorithm.as_bytes());
|
||||
string(&mut out, &signature);
|
||||
Ok(out)
|
||||
}
|
||||
pub(super) fn output(format: &str, output: &str, signature: Vec<u8>) -> Result<SignResult> {
|
||||
let (signature, bytes) = match output {
|
||||
"raw" => (String::new(), Some(signature)),
|
||||
"hex" => (hex::encode(signature), None),
|
||||
"jws" => (URL_SAFE_NO_PAD.encode(signature), None),
|
||||
"ssh" => (STANDARD.encode(wire_signature(format, &signature)?), None),
|
||||
"base64" => (STANDARD.encode(signature), None),
|
||||
_ => return Err(error("invalid_params", "unknown output format")),
|
||||
};
|
||||
Ok(SignResult { signature, bytes })
|
||||
}
|
||||
pub(super) fn public(key: &Key, output: &str) -> Result<String> {
|
||||
let raw = match key {
|
||||
Key::Ed(k) => k.verifying_key().to_bytes().to_vec(),
|
||||
Key::Ec(k) => k
|
||||
.verifying_key()
|
||||
.to_encoded_point(false)
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
Key::Rsa(k) => k
|
||||
.to_public_key()
|
||||
.to_public_key_der()
|
||||
.map_err(|_| invalid_key())?
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
Key::Mac(_) => return Err(error("invalid_params", "symmetric keys have no public key")),
|
||||
};
|
||||
Ok(match output {
|
||||
"base64" => STANDARD.encode(&raw),
|
||||
"hex" => hex::encode(&raw),
|
||||
"pem" => match key {
|
||||
Key::Ed(k) => k.verifying_key().to_public_key_pem(Default::default()),
|
||||
Key::Ec(k) => k.verifying_key().to_public_key_pem(Default::default()),
|
||||
Key::Rsa(k) => k.to_public_key().to_public_key_pem(Default::default()),
|
||||
_ => unreachable!(),
|
||||
}.map_err(|_| invalid_key())?,
|
||||
"jwk" => match key {
|
||||
Key::Ed(_) => json!({"kty":"OKP","crv":"Ed25519","x":URL_SAFE_NO_PAD.encode(&raw)}),
|
||||
Key::Ec(_) => json!({"kty":"EC","crv":"P-256","x":URL_SAFE_NO_PAD.encode(&raw[1..33]),"y":URL_SAFE_NO_PAD.encode(&raw[33..])}),
|
||||
Key::Rsa(k) => json!({"kty":"RSA","n":URL_SAFE_NO_PAD.encode(k.n().to_bytes_be()),"e":URL_SAFE_NO_PAD.encode(k.e().to_bytes_be())}),
|
||||
_ => unreachable!(),
|
||||
}.to_string(),
|
||||
"ssh" => {
|
||||
let mut out = Vec::new();
|
||||
match key {
|
||||
Key::Ed(_) => { string(&mut out, b"ssh-ed25519"); string(&mut out, &raw); },
|
||||
Key::Ec(_) => { string(&mut out, b"ecdsa-sha2-nistp256"); string(&mut out, b"nistp256"); string(&mut out, &raw); },
|
||||
Key::Rsa(k) => { string(&mut out, b"ssh-rsa"); mpint(&mut out, &k.e().to_bytes_be()); mpint(&mut out, &k.n().to_bytes_be()); },
|
||||
_ => unreachable!(),
|
||||
}
|
||||
STANDARD.encode(out)
|
||||
}
|
||||
_ => return Err(error("invalid_params", "unknown public key output")),
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,36 @@
|
||||
//! The credentials manager (P2, plan §4.4): items, grants, requests and audit in the host,
|
||||
//! outside the world. Programs in the world never see secret values.
|
||||
//!
|
||||
//! `Cred::open` loads `cred.sqlite` and the host master key. Read methods return public snapshots.
|
||||
//! `Caller` carries a binary path and the nearest-first conversation ancestry.
|
||||
//! `types::Subject` binds grants to a conversation subtree or a binary path.
|
||||
//! `file_request` normalizes item identities and replays keyed requests. Edit requests carry public diffs.
|
||||
//! `approve` and `deny` trust the route layer's verified approver identity.
|
||||
//! That identity can be `None` during bootstrap, before any passkey registration.
|
||||
//! Each decision commits one SQLite transaction. `subscribe` reports committed decisions across threads.
|
||||
//! Subscribers must read SQLite after a lag or restart. The durable task layer calls `expire_requests`.
|
||||
//! `inject` transforms one request's URL and headers. The body stays outside this library.
|
||||
//! An unmatched request forwards unchanged. An unauthorized match names the item and its filed request.
|
||||
//! `audit_sign` records signature metadata without the signed payload.
|
||||
pub mod entry;
|
||||
pub mod webauthn;
|
||||
pub mod store;
|
||||
pub mod schema;
|
||||
pub mod seal;
|
||||
pub mod items;
|
||||
pub mod types;
|
||||
pub mod grants;
|
||||
pub mod requests;
|
||||
pub mod edits;
|
||||
pub mod summary;
|
||||
pub mod audit;
|
||||
pub mod inject;
|
||||
pub mod error;
|
||||
pub use store::Cred;
|
||||
pub use error::{Error, Result};
|
||||
pub use types::Caller;
|
||||
pub mod script;
|
||||
pub mod keys;
|
||||
pub mod sign;
|
||||
pub mod oauth2;
|
||||
pub mod v8;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! OAuth2 refresh exchange and a process-local cache keyed by item and version.
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Error;
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("OAuth refresh failed or returned an invalid response")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
/// HTTP adapter. Implementations must reject unsuccessful HTTP status codes.
|
||||
pub trait Http: Sync {
|
||||
fn send(
|
||||
&self,
|
||||
request: &Request,
|
||||
deadline: Instant,
|
||||
) -> impl std::future::Future<Output = Result<Zeroizing<Vec<u8>>, Error>> + Send;
|
||||
}
|
||||
/// Contains credentials. Deliberately does not implement Debug or Serialize.
|
||||
pub struct Request {
|
||||
pub url: String,
|
||||
pub method: &'static str,
|
||||
pub headers: Vec<(&'static str, String)>,
|
||||
pub body: Zeroizing<String>,
|
||||
}
|
||||
pub fn request(
|
||||
token_url: &str,
|
||||
refresh_token: &str,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> Result<Request, Error> {
|
||||
let target = url::Url::parse(token_url).map_err(|_| Error)?;
|
||||
if target.scheme() != "https"
|
||||
|| !target.username().is_empty()
|
||||
|| target.password().is_some()
|
||||
|| target.fragment().is_some()
|
||||
|| [refresh_token, client_id, client_secret].contains(&"")
|
||||
{
|
||||
return Err(Error);
|
||||
}
|
||||
let body = url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("grant_type", "refresh_token")
|
||||
.append_pair("refresh_token", refresh_token)
|
||||
.append_pair("client_id", client_id)
|
||||
.append_pair("client_secret", client_secret)
|
||||
.finish();
|
||||
Ok(Request {
|
||||
url: token_url.into(),
|
||||
method: "POST",
|
||||
headers: vec![
|
||||
("Content-Type", "application/x-www-form-urlencoded".into()),
|
||||
("Content-Length", body.len().to_string()),
|
||||
],
|
||||
body: Zeroizing::new(body),
|
||||
})
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Token {
|
||||
pub value: Zeroizing<String>,
|
||||
pub scopes: Option<Vec<String>>,
|
||||
pub expires: Instant,
|
||||
}
|
||||
pub fn decode(bytes: &[u8]) -> Result<Token, Error> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Response {
|
||||
access_token: String,
|
||||
token_type: String,
|
||||
expires_in: u64,
|
||||
scope: Option<String>,
|
||||
}
|
||||
let r: Response = serde_json::from_slice(bytes).map_err(|_| Error)?;
|
||||
if r.access_token.is_empty()
|
||||
|| !r.token_type.eq_ignore_ascii_case("bearer")
|
||||
|| r.expires_in == 0
|
||||
|| http::header::HeaderValue::from_str(&format!("Bearer {}", r.access_token)).is_err()
|
||||
{
|
||||
return Err(Error);
|
||||
}
|
||||
Ok(Token {
|
||||
value: Zeroizing::new(r.access_token),
|
||||
scopes: r
|
||||
.scope
|
||||
.map(|s| s.split_whitespace().map(str::to_owned).collect()),
|
||||
expires: Instant::now() + Duration::from_secs(r.expires_in.min(86400).saturating_sub(30)),
|
||||
})
|
||||
}
|
||||
/// The caller owns synchronization and endpoint policy. No lock crosses an HTTP call here.
|
||||
#[derive(Default)]
|
||||
pub struct Cache {
|
||||
tokens: BTreeMap<(String, i64), Token>,
|
||||
}
|
||||
impl Cache {
|
||||
pub fn clear(&mut self) {
|
||||
self.tokens.clear();
|
||||
}
|
||||
pub async fn refresh(
|
||||
&mut self,
|
||||
item: &str,
|
||||
version: i64,
|
||||
request: &Request,
|
||||
http: &impl Http,
|
||||
deadline: Instant,
|
||||
) -> Result<Token, Error> {
|
||||
let key = (item.to_owned(), version);
|
||||
if let Some(t) = self.tokens.get(&key).filter(|t| t.expires > Instant::now()) {
|
||||
return Ok(t.clone());
|
||||
}
|
||||
self.tokens.retain(|(id, _), _| id != item);
|
||||
let token = decode(&http.send(request, deadline).await?)?;
|
||||
self.tokens.insert(key, token.clone());
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Pure signing and public-key export over decrypted field values. No storage or grant policy.
|
||||
use super::keys;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Error {
|
||||
pub kind: &'static str,
|
||||
pub message: &'static str,
|
||||
}
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.message)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for Error {}
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
pub(super) fn error(kind: &'static str, message: &'static str) -> Error {
|
||||
Error { kind, message }
|
||||
}
|
||||
pub(super) fn invalid_key() -> Error {
|
||||
error("invalid_key", "invalid signing key encoding or length")
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SignResult {
|
||||
pub signature: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Sign bytes. RSA requires explicit, matching padding and digest parameters.
|
||||
pub fn sign(
|
||||
value: &str,
|
||||
passphrase: Option<&str>,
|
||||
format: &str,
|
||||
payload: &[u8],
|
||||
output: &str,
|
||||
padding: Option<&str>,
|
||||
hash: Option<&str>,
|
||||
) -> Result<SignResult> {
|
||||
keys::check(format, padding, hash)?;
|
||||
let key = keys::read(value, format, passphrase)?;
|
||||
keys::output(format, output, keys::sign_key(&key, format, payload)?)
|
||||
}
|
||||
|
||||
/// Export only the public part of an asymmetric key.
|
||||
pub fn public_key(
|
||||
value: &str,
|
||||
passphrase: Option<&str>,
|
||||
format: &str,
|
||||
output: &str,
|
||||
) -> Result<String> {
|
||||
keys::public(&keys::read(value, format, passphrase)?, output)
|
||||
}
|
||||
|
||||
/// Generate a P-256 private key in PKCS#8 PEM format for ES256.
|
||||
pub fn generate_es256() -> Result<zeroize::Zeroizing<String>> {
|
||||
use p256::pkcs8::EncodePrivateKey;
|
||||
p256::SecretKey::random(&mut rsa::rand_core::OsRng)
|
||||
.to_pkcs8_pem(Default::default())
|
||||
.map_err(|_| invalid_key())
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! 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 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 = 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: 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);
|
||||
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 = runtime
|
||||
.execute_script(
|
||||
"input.js",
|
||||
format!("JSON.parse({})", serde_json::to_string(&input).unwrap()),
|
||||
)
|
||||
.map_err(|_| failure(ErrorKind::Syntax))?;
|
||||
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 = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.map_err(|_| failure(ErrorKind::Engine))?
|
||||
.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;
|
||||
};
|
||||
"#;
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Refresh form encoding, response validation, and revision-aware cache behavior.
|
||||
use sausage::cred::oauth2::*;
|
||||
use std::{
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
struct Fake(AtomicUsize);
|
||||
impl Http for Fake {
|
||||
async fn send(&self, request: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
|
||||
assert_eq!(request.method, "POST");
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Zeroizing::new(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#.to_vec()))
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn encoding_and_cache() {
|
||||
let req = request(
|
||||
"https://tokens.example.test/token",
|
||||
"REFRESH&=+",
|
||||
"CLIENT",
|
||||
"SECRET&=+",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
req.body.as_str()
|
||||
== "grant_type=refresh_token&refresh_token=REFRESH%26%3D%2B&client_id=CLIENT&client_secret=SECRET%26%3D%2B"
|
||||
);
|
||||
assert_eq!(req.headers[1].1, req.body.len().to_string());
|
||||
let fake = Fake(AtomicUsize::new(0));
|
||||
let mut cache = Cache::default();
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
let t = cache.refresh("id", 1, &req, &fake, deadline).await.unwrap();
|
||||
assert_eq!(t.scopes.unwrap(), vec!["one", "two"]);
|
||||
cache.refresh("id", 1, &req, &fake, deadline).await.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 1);
|
||||
cache.refresh("id", 2, &req, &fake, deadline).await.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 2);
|
||||
cache.clear();
|
||||
cache.refresh("id", 2, &req, &fake, deadline).await.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 3);
|
||||
for url in [
|
||||
"http://tokens.example.test/token",
|
||||
"https://user@tokens.example.test/token",
|
||||
"https://tokens.example.test/token#x",
|
||||
] {
|
||||
assert!(request(url, "r", "c", "s").is_err());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn expiry_and_failure() {
|
||||
struct Expired(AtomicUsize);
|
||||
impl Http for Expired {
|
||||
async fn send(&self, _: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Zeroizing::new(
|
||||
br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#.to_vec(),
|
||||
))
|
||||
}
|
||||
}
|
||||
struct Failed;
|
||||
impl Http for Failed {
|
||||
async fn send(&self, _: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
|
||||
Err(Error)
|
||||
}
|
||||
}
|
||||
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
|
||||
let mut cache = Cache::default();
|
||||
let http = Expired(AtomicUsize::new(0));
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
cache.refresh("id", 1, &req, &http, deadline).await.unwrap();
|
||||
cache.refresh("id", 1, &req, &http, deadline).await.unwrap();
|
||||
assert_eq!(http.0.load(Ordering::SeqCst), 2);
|
||||
assert!(
|
||||
cache
|
||||
.refresh("id", 2, &req, &Failed, deadline)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
cache.refresh("id", 2, &req, &http, deadline).await.unwrap();
|
||||
assert_eq!(http.0.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation() {
|
||||
for data in [
|
||||
br#"{"error":"invalid_grant","secret":"DO NOT ECHO"}"#.as_slice(),
|
||||
br#"{"access_token":"","token_type":"Bearer","expires_in":3600}"#,
|
||||
br#"{"access_token":"x","token_type":"MAC","expires_in":3600}"#,
|
||||
br#"{"access_token":"x","token_type":"Bearer","expires_in":0}"#,
|
||||
br#"{"access_token":"x\r\ny","token_type":"Bearer","expires_in":3600}"#,
|
||||
] {
|
||||
let e = decode(data).err().unwrap();
|
||||
assert!(!e.to_string().contains("DO NOT ECHO"));
|
||||
}
|
||||
assert!(
|
||||
decode(br#"{"access_token":"x","token_type":"Bearer","expires_in":18446744073709551615}"#)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! RFC vectors and public codecs for pure signing.
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
};
|
||||
use sausage::cred::sign::*;
|
||||
fn raw(key: &[u8], alg: &str, payload: &[u8]) -> Vec<u8> {
|
||||
sign(&STANDARD.encode(key), None, alg, payload, "raw", None, None)
|
||||
.unwrap()
|
||||
.bytes
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn ed25519_readers_and_wire() {
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey;
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]);
|
||||
let pem = key.to_pkcs8_pem(Default::default()).unwrap();
|
||||
let jwk = serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode(key.to_bytes()),"x":URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes())}).to_string();
|
||||
let ssh = ssh_key::PrivateKey::new(
|
||||
ssh_key::private::Ed25519Keypair::from(&key).into(),
|
||||
"synthetic",
|
||||
)
|
||||
.unwrap()
|
||||
.to_openssh(Default::default())
|
||||
.unwrap();
|
||||
let expected = raw(&key.to_bytes(), "ed25519", b"message");
|
||||
for value in [pem.as_str(), jwk.as_str(), ssh.as_str()] {
|
||||
let sig = sign(value, None, "ed25519", b"message", "ssh", None, None).unwrap();
|
||||
let wire = STANDARD.decode(sig.signature).unwrap();
|
||||
assert_eq!(&wire[..19], b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x40");
|
||||
assert_eq!(&wire[19..], expected);
|
||||
for output in ["pem", "jwk", "ssh", "hex", "base64"] {
|
||||
assert!(public_key(value, None, "ed25519", output).is_ok());
|
||||
}
|
||||
}
|
||||
assert!(public_key(&STANDARD.encode([7; 32]), None, "hmac-sha256", "hex").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8032_and_4231() {
|
||||
let seed =
|
||||
hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60").unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(raw(&seed, "ed25519", b"")),
|
||||
concat!(
|
||||
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155",
|
||||
"5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"
|
||||
)
|
||||
);
|
||||
for (alg, expected) in [
|
||||
(
|
||||
"hmac-sha256",
|
||||
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
|
||||
),
|
||||
(
|
||||
"hmac-sha384",
|
||||
"afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6",
|
||||
),
|
||||
(
|
||||
"hmac-sha512",
|
||||
"87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cde daa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
hex::encode(raw(&[0x0b; 20], alg, b"Hi There")),
|
||||
expected.replace(' ', "")
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ec_readers() {
|
||||
use p256::pkcs8::EncodePrivateKey;
|
||||
let secret = p256::SecretKey::from_slice(&[7; 32]).unwrap();
|
||||
let public = secret.public_key();
|
||||
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
let point = public.to_encoded_point(false);
|
||||
let jwk = serde_json::json!({"kty":"EC","crv":"P-256","d":URL_SAFE_NO_PAD.encode(secret.to_bytes()),"x":URL_SAFE_NO_PAD.encode(point.x().unwrap()),"y":URL_SAFE_NO_PAD.encode(point.y().unwrap())}).to_string();
|
||||
let pkcs8 = secret.to_pkcs8_pem(Default::default()).unwrap();
|
||||
let sec1 = secret.to_sec1_pem(Default::default()).unwrap();
|
||||
let expected = raw(&[7; 32], "es256", b"message");
|
||||
let ssh = ssh_key::PrivateKey::from(ssh_key::private::EcdsaKeypair::NistP256 {
|
||||
public: point,
|
||||
private: ssh_key::private::EcdsaPrivateKey::from(secret.clone()),
|
||||
})
|
||||
.to_openssh(Default::default())
|
||||
.unwrap();
|
||||
for value in [jwk.as_str(), pkcs8.as_str(), sec1.as_str(), ssh.as_str()] {
|
||||
assert_eq!(
|
||||
sign(value, None, "es256", b"message", "raw", None, None)
|
||||
.unwrap()
|
||||
.bytes
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
let public = ssh_key::PublicKey::from_bytes(
|
||||
&STANDARD
|
||||
.decode(public_key(value, None, "es256", "ssh").unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let wire = STANDARD
|
||||
.decode(
|
||||
sign(value, None, "es256", b"message", "ssh", None, None)
|
||||
.unwrap()
|
||||
.signature,
|
||||
)
|
||||
.unwrap();
|
||||
let signature = ssh_key::Signature::try_from(wire.as_slice()).unwrap();
|
||||
ed25519_dalek::Verifier::verify(&public, b"message", &signature).unwrap();
|
||||
}
|
||||
let mut bad: serde_json::Value = serde_json::from_str(&jwk).unwrap();
|
||||
bad["x"] = serde_json::json!(URL_SAFE_NO_PAD.encode([0; 32]));
|
||||
assert!(
|
||||
sign(
|
||||
&bad.to_string(),
|
||||
None,
|
||||
"es256",
|
||||
b"message",
|
||||
"raw",
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc6979_and_formats() {
|
||||
let key =
|
||||
hex::decode("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721").unwrap();
|
||||
let expected = concat!(
|
||||
"efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716",
|
||||
"f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8"
|
||||
);
|
||||
assert_eq!(hex::encode(raw(&key, "es256", b"sample")), expected);
|
||||
let key = STANDARD.encode(key);
|
||||
for output in ["hex", "base64", "jws", "ssh"] {
|
||||
let result = sign(&key, None, "es256", b"sample", output, None, None).unwrap();
|
||||
assert!(!result.signature.is_empty());
|
||||
}
|
||||
for output in ["hex", "base64", "pem", "jwk", "ssh"] {
|
||||
assert!(!public_key(&key, None, "es256", output).unwrap().is_empty());
|
||||
}
|
||||
let pem = generate_es256().unwrap();
|
||||
assert!(public_key(&pem, None, "es256", "jwk").is_ok());
|
||||
for bad in [vec![], vec![1; 31], vec![0; 32], vec![255; 32]] {
|
||||
assert!(sign(&STANDARD.encode(bad), None, "es256", b"", "raw", None, None).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rsa_readers_and_ssh_wire() {
|
||||
use rsa::{
|
||||
pkcs1::EncodeRsaPrivateKey,
|
||||
pkcs8::EncodePrivateKey,
|
||||
traits::{PrivateKeyParts, PublicKeyParts},
|
||||
};
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
let key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 1024).unwrap();
|
||||
let pem = key.to_pkcs8_pem(Default::default()).unwrap();
|
||||
let pkcs1 = key.to_pkcs1_pem(Default::default()).unwrap();
|
||||
let ssh = ssh_key::PrivateKey::new(
|
||||
ssh_key::private::RsaKeypair::try_from(&key).unwrap().into(),
|
||||
"synthetic",
|
||||
)
|
||||
.unwrap();
|
||||
let openssh = ssh.to_openssh(Default::default()).unwrap();
|
||||
let encrypted = ssh
|
||||
.encrypt(&mut rsa::rand_core::OsRng, "synthetic")
|
||||
.unwrap()
|
||||
.to_openssh(Default::default())
|
||||
.unwrap();
|
||||
let jwk = serde_json::json!({"kty":"RSA", "n":URL_SAFE_NO_PAD.encode(key.n().to_bytes_be()), "e":URL_SAFE_NO_PAD.encode(key.e().to_bytes_be()), "d":URL_SAFE_NO_PAD.encode(key.d().to_bytes_be()), "p":URL_SAFE_NO_PAD.encode(key.primes()[0].to_bytes_be()), "q":URL_SAFE_NO_PAD.encode(key.primes()[1].to_bytes_be())}).to_string();
|
||||
for (value, pass) in [
|
||||
(pem.as_str(), None),
|
||||
(pkcs1.as_str(), None),
|
||||
(openssh.as_str(), None),
|
||||
(encrypted.as_str(), Some("synthetic")),
|
||||
(jwk.as_str(), None),
|
||||
] {
|
||||
for (alg, hash) in [
|
||||
("rs256", "sha256"),
|
||||
("rsa-sha2-256", "sha256"),
|
||||
("rsa-sha2-512", "sha512"),
|
||||
] {
|
||||
let result = sign(
|
||||
value,
|
||||
pass,
|
||||
alg,
|
||||
b"message",
|
||||
"raw",
|
||||
Some("pkcs1v15"),
|
||||
Some(hash),
|
||||
)
|
||||
.unwrap();
|
||||
let sig = result.bytes.unwrap();
|
||||
if hash == "sha512" {
|
||||
key.to_public_key()
|
||||
.verify(
|
||||
rsa::Pkcs1v15Sign::new::<Sha512>(),
|
||||
&Sha512::digest(b"message"),
|
||||
&sig,
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
key.to_public_key()
|
||||
.verify(
|
||||
rsa::Pkcs1v15Sign::new::<Sha256>(),
|
||||
&Sha256::digest(b"message"),
|
||||
&sig,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let wire = STANDARD
|
||||
.decode(
|
||||
sign(
|
||||
value,
|
||||
pass,
|
||||
alg,
|
||||
b"message",
|
||||
"ssh",
|
||||
Some("pkcs1v15"),
|
||||
Some(hash),
|
||||
)
|
||||
.unwrap()
|
||||
.signature,
|
||||
)
|
||||
.unwrap();
|
||||
let len = u32::from_be_bytes(wire[..4].try_into().unwrap()) as usize;
|
||||
assert_eq!(
|
||||
&wire[4..4 + len],
|
||||
if hash == "sha512" {
|
||||
b"rsa-sha2-512"
|
||||
} else {
|
||||
b"rsa-sha2-256"
|
||||
}
|
||||
);
|
||||
assert_eq!(&wire[8 + len..], sig);
|
||||
}
|
||||
for output in ["pem", "jwk", "ssh", "base64", "hex"] {
|
||||
assert!(public_key(value, pass, "rs256", output).is_ok());
|
||||
}
|
||||
}
|
||||
assert!(sign(&pem, None, "rs256", b"", "raw", None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_mismatched_keys() {
|
||||
for value in [
|
||||
"not a key",
|
||||
"{broken",
|
||||
"{\"kty\":\"unknown\"}",
|
||||
"-----BEGIN UNKNOWN PRIVATE KEY-----\nAAAA\n-----END UNKNOWN PRIVATE KEY-----",
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ninvalid",
|
||||
"AAAA",
|
||||
] {
|
||||
assert!(sign(value, None, "ed25519", b"", "raw", None, None).is_err());
|
||||
}
|
||||
let bad=serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode([1;32]),"x":URL_SAFE_NO_PAD.encode([2;32])}).to_string();
|
||||
assert!(sign(&bad, None, "ed25519", b"", "raw", None, None).is_err());
|
||||
let oct = serde_json::json!({"kty":"oct","k":URL_SAFE_NO_PAD.encode([11;20])}).to_string();
|
||||
assert!(sign(&oct, None, "hmac-sha384", b"Hi There", "raw", None, None).is_ok());
|
||||
assert!(sign(&oct, None, "ed25519", b"", "raw", None, None).is_err());
|
||||
assert!(sign(&oct, None, "hmac-sha384", b"", "ssh", None, None).is_err());
|
||||
assert!(sign(&oct, None, "hmac-sha384", b"", "unknown", None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8292() {
|
||||
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
let public=URL_SAFE_NO_PAD.decode("BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs").unwrap();
|
||||
let signature=URL_SAFE_NO_PAD.decode("i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA").unwrap();
|
||||
let input = concat!(
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.",
|
||||
"eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0"
|
||||
);
|
||||
VerifyingKey::from_sec1_bytes(&public)
|
||||
.unwrap()
|
||||
.verify(
|
||||
input.as_bytes(),
|
||||
&Signature::from_slice(&signature).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Behavioral contract for the credential-only isolate.
|
||||
use sausage::cred::{
|
||||
script::{ErrorKind, Limits, Script},
|
||||
v8::V8,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
fn run(s: &str) -> Result<String, sausage::cred::script::Error> {
|
||||
V8.evaluate(s, "null", None, Limits::default())
|
||||
}
|
||||
#[test]
|
||||
fn modules_promises_and_json() {
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"JSON.parse=()=>false; export default x=>x",
|
||||
"42",
|
||||
None,
|
||||
Limits::default()
|
||||
)
|
||||
.unwrap(),
|
||||
"42"
|
||||
);
|
||||
let error = run("export default ()=>{throw Error('DO NOT ECHO')}").unwrap_err();
|
||||
assert!(!error.message.contains("DO NOT ECHO"));
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"export default x=>({value:x}); export const other=x=>x+1",
|
||||
"42",
|
||||
None,
|
||||
Limits::default()
|
||||
)
|
||||
.unwrap(),
|
||||
"{\"value\":42}"
|
||||
);
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"export const other=x=>x+1",
|
||||
"42",
|
||||
Some("other"),
|
||||
Limits::default()
|
||||
)
|
||||
.unwrap(),
|
||||
"43"
|
||||
);
|
||||
assert_eq!(
|
||||
run("await Promise.resolve(); export default async x=>{await Promise.resolve();return 42}")
|
||||
.unwrap(),
|
||||
"42"
|
||||
);
|
||||
for (s, k) in [
|
||||
("export default (", ErrorKind::Syntax),
|
||||
("export default ()=>{throw Error('oops')}", ErrorKind::Throw),
|
||||
("export default ()=>Promise.reject('no')", ErrorKind::Throw),
|
||||
("export default ()=>undefined", ErrorKind::NonJson),
|
||||
("export default ()=>Symbol()", ErrorKind::NonJson),
|
||||
("export default ()=>()=>1", ErrorKind::NonJson),
|
||||
(
|
||||
"export default ()=>{let a={};a.a=a;return a}",
|
||||
ErrorKind::NonJson,
|
||||
),
|
||||
("export const x=1", ErrorKind::Engine),
|
||||
("export default 1", ErrorKind::Engine),
|
||||
("export default ()=>new Promise(()=>{})", ErrorKind::Engine),
|
||||
] {
|
||||
assert_eq!(run(s).unwrap_err().kind, k, "{s}");
|
||||
}
|
||||
assert_eq!(
|
||||
V8.evaluate("export default x=>x", "invalid", None, Limits::default())
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Syntax
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sandbox_and_isolation() {
|
||||
for name in [
|
||||
"Date",
|
||||
"Math.random",
|
||||
"fetch",
|
||||
"setTimeout",
|
||||
"setInterval",
|
||||
"WebSocket",
|
||||
"XMLHttpRequest",
|
||||
"std",
|
||||
"os",
|
||||
"fs",
|
||||
"Deno",
|
||||
"__bootstrap",
|
||||
"console",
|
||||
"performance",
|
||||
"require",
|
||||
"process",
|
||||
"TextEncoder",
|
||||
"Buffer",
|
||||
"Blob",
|
||||
"File",
|
||||
"crypto",
|
||||
] {
|
||||
assert_eq!(
|
||||
run(&format!("export default ()=>typeof {name}")).unwrap(),
|
||||
"\"undefined\"",
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
assert!(run("import x from 'fs'; export default ()=>x").is_err());
|
||||
assert!(run("import {core} from 'ext:core/mod.js'; export default ()=>typeof core").is_err());
|
||||
assert_eq!(
|
||||
run("export default ()=>import('fs')").unwrap_err().kind,
|
||||
ErrorKind::Throw
|
||||
);
|
||||
run("export default ()=>{globalThis.leak=1;btoa.leak=1;return true}").unwrap();
|
||||
assert_eq!(
|
||||
run("export default ()=>[typeof leak,typeof btoa.leak]").unwrap(),
|
||||
"[\"undefined\",\"undefined\"]"
|
||||
);
|
||||
for (expr, expected) in [
|
||||
("btoa('user:pass')", "\"dXNlcjpwYXNz\""),
|
||||
("atob('dXNlcjpwYXNz')", "\"user:pass\""),
|
||||
("btoa('')", "\"\""),
|
||||
("atob('AP+A') === '\\x00\\xff\\x80'", "true"),
|
||||
] {
|
||||
assert_eq!(
|
||||
run(&format!("export default ()=>{expr}")).unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
for expr in ["atob('!')", "btoa('雪')"] {
|
||||
assert_eq!(
|
||||
run(&format!("export default ()=>{expr}")).unwrap_err().kind,
|
||||
ErrorKind::Throw
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn independent_deadlines() {
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let other = barrier.clone();
|
||||
let runaway = std::thread::spawn(move || {
|
||||
other.wait();
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"export default ()=>{while(true){}}",
|
||||
"null",
|
||||
None,
|
||||
Limits {
|
||||
max_time_ms: Some(1000),
|
||||
..Limits::default()
|
||||
}
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Limit
|
||||
);
|
||||
});
|
||||
barrier.wait();
|
||||
let start = Instant::now();
|
||||
assert_eq!(run("export default ()=>true").unwrap(), "true");
|
||||
assert!(start.elapsed() < Duration::from_millis(500));
|
||||
runaway.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_limits() {
|
||||
for limits in [
|
||||
Limits {
|
||||
max_time_ms: Some(0),
|
||||
max_memory_bytes: Some(0),
|
||||
},
|
||||
Limits {
|
||||
max_time_ms: Some(u64::MAX),
|
||||
max_memory_bytes: Some(usize::MAX),
|
||||
},
|
||||
] {
|
||||
match V8.evaluate("export default ()=>true", "null", None, limits) {
|
||||
Ok(value) => assert_eq!(value, "true"),
|
||||
Err(error) => assert_eq!(error.kind, ErrorKind::Limit),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}",
|
||||
"null",
|
||||
None,
|
||||
Limits {
|
||||
max_time_ms: Some(1000),
|
||||
max_memory_bytes: Some(1024 * 1024)
|
||||
}
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Limit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_and_parallel_calls() {
|
||||
let mut workers = Vec::new();
|
||||
for s in [
|
||||
"export default ()=>{while(true){}}",
|
||||
"while(true){};export default x=>x",
|
||||
"export default ()=>({toJSON(){while(true){}}})",
|
||||
"export default ()=>{throw {get message(){while(true){}}}}",
|
||||
"export default ()=>{function f(){return f()+1}return f()}",
|
||||
"export default ()=>new Promise(()=>{function f(){Promise.resolve().then(f)}f()})",
|
||||
"export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}",
|
||||
] {
|
||||
workers.push(std::thread::spawn(move || {
|
||||
let start = Instant::now();
|
||||
assert_eq!(run(s).unwrap_err().kind, ErrorKind::Limit);
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
}));
|
||||
}
|
||||
let normal = std::thread::spawn(|| assert_eq!(run("export default ()=>true").unwrap(), "true"));
|
||||
for worker in workers {
|
||||
worker.join().unwrap();
|
||||
}
|
||||
normal.join().unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user