cred: add pure scripts, signing, and OAuth refresh; validation blocked

This commit is contained in:
2026-11-06 19:05:07 +00:00
parent 1dc09bc418
commit 9e7e5d7b2a
10 changed files with 1888 additions and 44 deletions
+100
View File
@@ -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()
);
}
+283
View File
@@ -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();
}
+217
View File
@@ -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();
}