0
2
Core resolves identities, verifies atproto keys and calls XRPC
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain/src/crypto.rsnew+359−0
| @@ -0,0 +1,359 @@ | ||
//! The keys atproto uses: secp256k1 and P-256, as multikeys and `did:key`s, | ||
//! and the JWTs they sign. | ||
| ||
use anyhow::{Context, Result, bail}; | ||
use base64::Engine; | ||
use base64::engine::general_purpose::URL_SAFE_NO_PAD; | ||
use serde::Serialize; | ||
use serde::de::DeserializeOwned; | ||
use serde_json::Value; | ||
| ||
const K256_CODEC: [u8; 2] = [0xe7, 0x01]; | ||
const P256_CODEC: [u8; 2] = [0x80, 0x24]; | ||
| ||
/// A public key a DID document or `did:key` names. | ||
#[derive(Clone, Debug, PartialEq, Eq)] | ||
pub enum PublicKey { | ||
K256(k256::ecdsa::VerifyingKey), | ||
P256(p256::ecdsa::VerifyingKey), | ||
} | ||
| ||
impl PublicKey { | ||
/// Parses a multibase (`z`, base58btc) multicodec key. | ||
pub fn from_multibase(text: &str) -> Result<PublicKey> { | ||
let Some(b58) = text.strip_prefix('z') else { | ||
bail!("a multikey is base58btc, starting with z"); | ||
}; | ||
let bytes = bs58::decode(b58) | ||
.into_vec() | ||
.context("bad base58 in a multikey")?; | ||
let (codec, key) = bytes.split_at(2.min(bytes.len())); | ||
match <[u8; 2]>::try_from(codec) { | ||
Ok(K256_CODEC) => Ok(PublicKey::K256( | ||
k256::ecdsa::VerifyingKey::from_sec1_bytes(key).context("bad secp256k1 key")?, | ||
)), | ||
Ok(P256_CODEC) => Ok(PublicKey::P256( | ||
p256::ecdsa::VerifyingKey::from_sec1_bytes(key).context("bad P-256 key")?, | ||
)), | ||
_ => bail!("unsupported key type in a multikey"), | ||
} | ||
} | ||
| ||
/// Parses a DID document verification method: a `Multikey`, or one of | ||
/// the older types whose multibase holds the bare key with no codec. | ||
pub fn from_method(kind: &str, multibase: &str) -> Result<PublicKey> { | ||
let raw = || -> Result<Vec<u8>> { | ||
let Some(b58) = multibase.strip_prefix('z') else { | ||
bail!("a multikey is base58btc, starting with z"); | ||
}; | ||
bs58::decode(b58).into_vec().context("bad base58 in a key") | ||
}; | ||
match kind { | ||
"EcdsaSecp256k1VerificationKey2019" => Ok(PublicKey::K256( | ||
k256::ecdsa::VerifyingKey::from_sec1_bytes(&raw()?).context("bad secp256k1 key")?, | ||
)), | ||
"EcdsaSecp256r1VerificationKey2019" => Ok(PublicKey::P256( | ||
p256::ecdsa::VerifyingKey::from_sec1_bytes(&raw()?).context("bad P-256 key")?, | ||
)), | ||
_ => PublicKey::from_multibase(multibase), | ||
} | ||
} | ||
| ||
pub fn from_did_key(did: &str) -> Result<PublicKey> { | ||
match did.strip_prefix("did:key:") { | ||
Some(multibase) => PublicKey::from_multibase(multibase), | ||
None => bail!("{did} is not a did:key"), | ||
} | ||
} | ||
| ||
pub fn to_multibase(&self) -> String { | ||
let codec = match self { | ||
PublicKey::K256(_) => K256_CODEC, | ||
PublicKey::P256(_) => P256_CODEC, | ||
}; | ||
format!( | ||
"z{}", | ||
bs58::encode([&codec[..], &self.sec1()].concat()).into_string() | ||
) | ||
} | ||
| ||
/// The key as SEC1 bytes, compressed. | ||
pub fn sec1(&self) -> Vec<u8> { | ||
match self { | ||
PublicKey::K256(k) => k.to_encoded_point(true).as_bytes().to_vec(), | ||
PublicKey::P256(k) => k.to_encoded_point(true).as_bytes().to_vec(), | ||
} | ||
} | ||
| ||
pub fn to_did_key(&self) -> String { | ||
format!("did:key:{}", self.to_multibase()) | ||
} | ||
| ||
/// The JWT `alg` this key signs with. | ||
pub fn alg(&self) -> &'static str { | ||
match self { | ||
PublicKey::K256(_) => "ES256K", | ||
PublicKey::P256(_) => "ES256", | ||
} | ||
} | ||
| ||
/// Checks a 64-byte `r||s` signature over SHA-256 of `message`. High-S | ||
/// signatures are refused, as atproto requires. | ||
pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<()> { | ||
use k256::ecdsa::signature::Verifier; | ||
let ok = match self { | ||
PublicKey::K256(key) => k256::ecdsa::Signature::from_slice(signature) | ||
.ok() | ||
.filter(|s| s.normalize_s().is_none()) | ||
.is_some_and(|s| key.verify(message, &s).is_ok()), | ||
PublicKey::P256(key) => p256::ecdsa::Signature::from_slice(signature) | ||
.ok() | ||
.filter(|s| s.normalize_s().is_none()) | ||
.is_some_and(|s| key.verify(message, &s).is_ok()), | ||
}; | ||
if ok { Ok(()) } else { bail!("bad signature") } | ||
} | ||
} | ||
| ||
/// A P-256 key grain signs with: a DPoP key that OAuth tokens are bound | ||
/// to, or a confidential client's key that proves who the client is. | ||
pub struct P256Key(p256::ecdsa::SigningKey); | ||
| ||
impl P256Key { | ||
pub fn generate() -> P256Key { | ||
P256Key(p256::ecdsa::SigningKey::random(&mut rand_core::OsRng)) | ||
} | ||
| ||
/// The 32-byte secret scalar, for a session that keeps the key with | ||
/// the tokens bound to it. | ||
pub fn to_bytes(&self) -> Vec<u8> { | ||
self.0.to_bytes().to_vec() | ||
} | ||
| ||
pub fn from_bytes(bytes: &[u8]) -> Result<P256Key> { | ||
Ok(P256Key( | ||
p256::ecdsa::SigningKey::from_slice(bytes).context("bad P-256 secret key")?, | ||
)) | ||
} | ||
| ||
pub fn public(&self) -> PublicKey { | ||
PublicKey::P256(*self.0.verifying_key()) | ||
} | ||
| ||
/// The public key as a JWK, as a DPoP proof carries it. | ||
pub fn jwk(&self) -> Value { | ||
let point = self.0.verifying_key().to_encoded_point(false); | ||
let coordinate = |c: Option<&p256::FieldBytes>| { | ||
URL_SAFE_NO_PAD.encode(c.expect("an uncompressed point")) | ||
}; | ||
serde_json::json!({ | ||
"kty": "EC", | ||
"crv": "P-256", | ||
"x": coordinate(point.x()), | ||
"y": coordinate(point.y()), | ||
}) | ||
} | ||
| ||
/// A 64-byte, low-S `r||s` signature over SHA-256 of `message`. | ||
pub fn sign(&self, message: &[u8]) -> Vec<u8> { | ||
use p256::ecdsa::signature::Signer; | ||
let sig: p256::ecdsa::Signature = self.0.sign(message); | ||
sig.normalize_s().unwrap_or(sig).to_bytes().to_vec() | ||
} | ||
| ||
/// A compact JWT signed with this key, `alg` ES256. | ||
pub fn sign_jwt(&self, header: &Value, claims: &impl Serialize) -> String { | ||
let mut header = header.clone(); | ||
header["alg"] = "ES256".into(); | ||
let body = format!( | ||
"{}.{}", | ||
URL_SAFE_NO_PAD.encode(header.to_string()), | ||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("claims serialize")) | ||
); | ||
let sig = URL_SAFE_NO_PAD.encode(self.sign(body.as_bytes())); | ||
format!("{body}.{sig}") | ||
} | ||
} | ||
| ||
/// A JWT's header and claims, unverified. | ||
pub fn jwt_parts(token: &str) -> Result<(Value, Value)> { | ||
let mut parts = token.split('.'); | ||
let mut decode = |what: &str| -> Result<Value> { | ||
let part = parts | ||
.next() | ||
.with_context(|| format!("a JWT without a {what}"))?; | ||
let bytes = URL_SAFE_NO_PAD | ||
.decode(part) | ||
.with_context(|| format!("bad base64 in a JWT {what}"))?; | ||
serde_json::from_slice(&bytes).with_context(|| format!("bad JSON in a JWT {what}")) | ||
}; | ||
Ok((decode("header")?, decode("payload")?)) | ||
} | ||
| ||
/// Verifies `token` against `key` and returns its claims. Checks the | ||
/// signature, that `alg` matches the key, and `exp` against the clock. | ||
pub fn verify_jwt<T: DeserializeOwned>(token: &str, key: &PublicKey) -> Result<T> { | ||
let (header, claims) = jwt_parts(token)?; | ||
if header["alg"].as_str() != Some(key.alg()) { | ||
bail!( | ||
"a JWT signed with {} where {} was expected", | ||
header["alg"], | ||
key.alg() | ||
); | ||
} | ||
let (signed, sig) = token | ||
.rsplit_once('.') | ||
.context("a JWT without a signature")?; | ||
let sig = URL_SAFE_NO_PAD | ||
.decode(sig) | ||
.context("bad base64 in a JWT signature")?; | ||
key.verify(signed.as_bytes(), &sig)?; | ||
let now = crate::keys::now_secs(); | ||
match claims["exp"].as_u64() { | ||
Some(exp) if exp > now => {} | ||
_ => bail!("an expired JWT"), | ||
} | ||
Ok(serde_json::from_value(claims)?) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use serde_json::json; | ||
| ||
#[test] | ||
fn multikeys_round_trip() { | ||
let p = P256Key::generate().public(); | ||
assert_eq!(PublicKey::from_did_key(&p.to_did_key()).unwrap(), p); | ||
assert!(p.to_multibase().starts_with("zDn")); | ||
let k = PublicKey::K256( | ||
*k256::ecdsa::SigningKey::random(&mut rand_core::OsRng).verifying_key(), | ||
); | ||
assert_eq!(PublicKey::from_multibase(&k.to_multibase()).unwrap(), k); | ||
assert!(k.to_multibase().starts_with("zQ3s")); | ||
assert_eq!((k.alg(), p.alg()), ("ES256K", "ES256")); | ||
for bad in [ | ||
"x", | ||
"z0OIl", | ||
"z", | ||
"z2", | ||
&format!("z{}", bs58::encode([0xe7, 0x01, 1, 2]).into_string()), | ||
&format!("z{}", bs58::encode([0x80, 0x24, 1, 2]).into_string()), | ||
] { | ||
assert!(PublicKey::from_multibase(bad).is_err(), "{bad}"); | ||
} | ||
assert!(PublicKey::from_did_key("did:web:x").is_err()); | ||
| ||
// The older verification method types hold the bare key. | ||
let bare = |k: &PublicKey| match k { | ||
PublicKey::K256(k) => format!( | ||
"z{}", | ||
bs58::encode(k.to_encoded_point(false).as_bytes()).into_string() | ||
), | ||
PublicKey::P256(k) => format!( | ||
"z{}", | ||
bs58::encode(k.to_encoded_point(true).as_bytes()).into_string() | ||
), | ||
}; | ||
assert_eq!( | ||
PublicKey::from_method("EcdsaSecp256k1VerificationKey2019", &bare(&k)).unwrap(), | ||
k | ||
); | ||
assert_eq!( | ||
PublicKey::from_method("EcdsaSecp256r1VerificationKey2019", &bare(&p)).unwrap(), | ||
p | ||
); | ||
assert_eq!( | ||
PublicKey::from_method("Multikey", &p.to_multibase()).unwrap(), | ||
p | ||
); | ||
assert!(PublicKey::from_method("EcdsaSecp256k1VerificationKey2019", "x").is_err()); | ||
assert!(PublicKey::from_method("EcdsaSecp256k1VerificationKey2019", "z0").is_err()); | ||
assert!(PublicKey::from_method("EcdsaSecp256k1VerificationKey2019", "z2").is_err()); | ||
assert!(PublicKey::from_method("EcdsaSecp256r1VerificationKey2019", "z2").is_err()); | ||
} | ||
| ||
#[test] | ||
fn signatures() { | ||
use k256::ecdsa::signature::Signer; | ||
let key = P256Key::generate(); | ||
let again = P256Key::from_bytes(&key.to_bytes()).unwrap(); | ||
assert_eq!(again.public(), key.public()); | ||
assert!(P256Key::from_bytes(&[0; 32]).is_err()); | ||
let sig = key.sign(b"hello"); | ||
assert_eq!(sig.len(), 64); | ||
key.public().verify(b"hello", &sig).unwrap(); | ||
assert!(key.public().verify(b"other", &sig).is_err()); | ||
assert!(key.public().verify(b"hello", b"short").is_err()); | ||
| ||
let k = k256::ecdsa::SigningKey::random(&mut rand_core::OsRng); | ||
let s: k256::ecdsa::Signature = k.sign(b"m"); | ||
let low = s.normalize_s().unwrap_or(s); | ||
let public = PublicKey::K256(*k.verifying_key()); | ||
public.verify(b"m", &low.to_bytes()).unwrap(); | ||
// The same signature with s flipped high verifies in ECDSA, and is refused. | ||
let (r, s_low) = (low.r(), low.s()); | ||
let high = k256::ecdsa::Signature::from_scalars(r, -*s_low).unwrap(); | ||
assert!(public.verify(b"m", &high.to_bytes()).is_err()); | ||
} | ||
| ||
#[test] | ||
fn jwts() { | ||
let key = P256Key::generate(); | ||
let exp = crate::keys::now_secs() + 60; | ||
let token = key.sign_jwt(&json!({"typ": "JWT"}), &json!({"iss": "me", "exp": exp})); | ||
let claims: Value = verify_jwt(&token, &key.public()).unwrap(); | ||
assert_eq!(claims["iss"], "me"); | ||
let (header, _) = jwt_parts(&token).unwrap(); | ||
assert_eq!(header["alg"], "ES256"); | ||
| ||
let other = P256Key::generate(); | ||
assert!(verify_jwt::<Value>(&token, &other.public()).is_err()); | ||
let k = PublicKey::K256( | ||
*k256::ecdsa::SigningKey::random(&mut rand_core::OsRng).verifying_key(), | ||
); | ||
assert!(verify_jwt::<Value>(&token, &k).is_err()); | ||
let expired = key.sign_jwt(&json!({}), &json!({"exp": 1})); | ||
assert!(verify_jwt::<Value>(&expired, &key.public()).is_err()); | ||
let (signed, _) = token.rsplit_once('.').unwrap(); | ||
assert!(verify_jwt::<Value>(&format!("{signed}.!!"), &key.public()).is_err()); | ||
assert!(verify_jwt::<Value>(signed, &key.public()).is_err()); | ||
for bad in ["", "a", "e30", "!!.x", "e30.!!", "bm90IGpzb24.e30"] { | ||
assert!(jwt_parts(bad).is_err(), "{bad}"); | ||
} | ||
} | ||
| ||
/// A JWT signed with secp256k1, as most PDSes sign service tokens: | ||
/// grain verifies them, though it signs none. | ||
#[test] | ||
fn k256_jwts() { | ||
use k256::ecdsa::signature::Signer; | ||
let k = k256::ecdsa::SigningKey::random(&mut rand_core::OsRng); | ||
let public = PublicKey::K256(*k.verifying_key()); | ||
let sign = |header: Value, claims: Value| { | ||
let body = format!( | ||
"{}.{}", | ||
URL_SAFE_NO_PAD.encode(header.to_string()), | ||
URL_SAFE_NO_PAD.encode(claims.to_string()) | ||
); | ||
let sig: k256::ecdsa::Signature = k.sign(body.as_bytes()); | ||
let sig = sig.normalize_s().unwrap_or(sig); | ||
format!("{body}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes())) | ||
}; | ||
let exp = crate::keys::now_secs() + 60; | ||
let token = sign( | ||
json!({"alg": "ES256K"}), | ||
json!({"iss": "did:plc:a", "exp": exp}), | ||
); | ||
let claims: Value = verify_jwt(&token, &public).unwrap(); | ||
assert_eq!(claims["iss"], "did:plc:a"); | ||
// Its key named as a did:key verifies it too. | ||
let named = PublicKey::from_did_key(&public.to_did_key()).unwrap(); | ||
verify_jwt::<Value>(&token, &named).unwrap(); | ||
// Signed by the key, but claiming ES256: the alg is the key's. | ||
let mislabelled = sign(json!({"alg": "ES256"}), json!({"exp": exp})); | ||
assert!(verify_jwt::<Value>(&mislabelled, &public).is_err()); | ||
let no_exp = sign(json!({"alg": "ES256K"}), json!({"iss": "did:plc:a"})); | ||
assert!(verify_jwt::<Value>(&no_exp, &public).is_err()); | ||
} | ||
} | ||
crates/grain/src/env.rsnew+218−0
| @@ -0,0 +1,218 @@ | ||
//! Process configuration, read once from the environment. | ||
| ||
use std::path::PathBuf; | ||
use std::time::Duration; | ||
| ||
/// How a handle becomes a DID, tried in order. | ||
#[derive(Clone, Debug, PartialEq, Eq)] | ||
pub enum HandleResolver { | ||
/// `<scheme>://<handle>/.well-known/atproto-did`. | ||
WellKnown { scheme: String }, | ||
/// `com.atproto.identity.resolveHandle` on a service. | ||
Xrpc { service: String }, | ||
} | ||
| ||
#[derive(Clone, Debug)] | ||
pub struct Env { | ||
pub cache_dir: PathBuf, | ||
pub plc_url: String, | ||
pub handle_resolvers: Vec<HandleResolver>, | ||
/// Scheme for did:web documents; tests serve them over plain http. | ||
pub did_web_scheme: String, | ||
/// Certificates to trust beside the usual roots, PEM: a local network | ||
/// behind a proxy with a CA of its own. | ||
pub ca_certs: Option<PathBuf>, | ||
/// Skips DID resolution: every identity is served by this PDS. | ||
pub service: Option<String>, | ||
pub identity_ttl: Duration, | ||
pub timeout: Duration, | ||
} | ||
| ||
impl Env { | ||
pub fn from_process() -> Env { | ||
Env::from_vars(|name| std::env::var(name).ok()) | ||
} | ||
| ||
pub fn from_vars(get: impl Fn(&str) -> Option<String>) -> Env { | ||
let home = get("HOME").unwrap_or_else(|| ".".into()); | ||
let dir = |own: &str, xdg: &str, fallback: &str| { | ||
get(own).map(PathBuf::from).unwrap_or_else(|| { | ||
get(xdg) | ||
.map(PathBuf::from) | ||
.unwrap_or_else(|| PathBuf::from(&home).join(fallback)) | ||
.join("grain") | ||
}) | ||
}; | ||
let num = |name: &str, default: u64| { | ||
get(name) | ||
.and_then(|v| v.parse::<u64>().ok()) | ||
.unwrap_or(default) | ||
}; | ||
let handle_resolvers = match get("GRAIN_HANDLE_RESOLVER") { | ||
Some(list) => list | ||
.split(',') | ||
.filter(|s| !s.is_empty()) | ||
.map(|s| match s.strip_prefix("well-known:") { | ||
Some(scheme) => HandleResolver::WellKnown { | ||
scheme: scheme.into(), | ||
}, | ||
None => HandleResolver::Xrpc { service: s.into() }, | ||
}) | ||
.collect(), | ||
None => vec![ | ||
HandleResolver::WellKnown { | ||
scheme: "https".into(), | ||
}, | ||
HandleResolver::Xrpc { | ||
service: "https://public.api.bsky.app".into(), | ||
}, | ||
], | ||
}; | ||
Env { | ||
cache_dir: dir("GRAIN_CACHE_DIR", "XDG_CACHE_HOME", ".cache"), | ||
plc_url: get("GRAIN_PLC_URL").unwrap_or_else(|| "https://plc.directory".into()), | ||
handle_resolvers, | ||
did_web_scheme: get("GRAIN_DID_WEB_SCHEME").unwrap_or_else(|| "https".into()), | ||
ca_certs: get("GRAIN_CA_CERTS") | ||
.filter(|v| !v.is_empty()) | ||
.map(PathBuf::from), | ||
service: get("GRAIN_SERVICE").filter(|v| !v.is_empty()), | ||
identity_ttl: Duration::from_secs(num("GRAIN_IDENTITY_TTL_SECS", 3600)), | ||
timeout: Duration::from_secs(num("GRAIN_TIMEOUT_SECS", 60)), | ||
} | ||
} | ||
} | ||
| ||
/// Configuration plus the one HTTP client a process shares, so every request | ||
/// to a PDS reuses its connection. | ||
#[derive(Clone)] | ||
pub struct Ctx { | ||
pub env: Env, | ||
pub http: reqwest::Client, | ||
} | ||
| ||
impl Ctx { | ||
pub fn new(env: Env) -> Ctx { | ||
let mut http = reqwest::Client::builder(); | ||
for cert in env.ca_certs.iter().flat_map(|path| extra_roots(path)) { | ||
http = http.add_root_certificate(cert); | ||
} | ||
let http = http | ||
.user_agent(concat!("grain/", env!("CARGO_PKG_VERSION"))) | ||
.connect_timeout(Duration::from_secs(10)) | ||
.timeout(env.timeout) | ||
// Under the 5s Node servers keep an idle connection by default, | ||
// as a PLC directory does: past it, one can close under the | ||
// request reusing it. A PDS keeps one 90s, which ties with any | ||
// longer wait here. | ||
.pool_idle_timeout(Duration::from_secs(4)) | ||
.tcp_nodelay(true) | ||
.build() | ||
.expect("the HTTP client configuration is static"); | ||
Ctx { env, http } | ||
} | ||
} | ||
| ||
/// The certificates in the PEM file at `path`; none, said once, if it | ||
/// can't be read, so a mistyped path fails as an untrusted server would. | ||
fn extra_roots(path: &std::path::Path) -> Vec<reqwest::Certificate> { | ||
let roots = std::fs::read(path) | ||
.map_err(anyhow::Error::from) | ||
.and_then(|pem| Ok(reqwest::Certificate::from_pem_bundle(&pem)?)); | ||
roots.unwrap_or_else(|err| { | ||
eprintln!("grain: GRAIN_CA_CERTS {}: {err}", path.display()); | ||
Vec::new() | ||
}) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use std::collections::HashMap; | ||
| ||
fn env(vars: &[(&str, &str)]) -> Env { | ||
let map: HashMap<String, String> = vars | ||
.iter() | ||
.map(|(k, v)| (k.to_string(), v.to_string())) | ||
.collect(); | ||
Env::from_vars(|k| map.get(k).cloned()) | ||
} | ||
| ||
#[test] | ||
fn defaults() { | ||
let e = env(&[("HOME", "/home/a")]); | ||
assert_eq!(e.cache_dir, PathBuf::from("/home/a/.cache/grain")); | ||
assert_eq!(e.plc_url, "https://plc.directory"); | ||
assert_eq!(e.did_web_scheme, "https"); | ||
assert_eq!( | ||
e.handle_resolvers, | ||
vec![ | ||
HandleResolver::WellKnown { | ||
scheme: "https".into() | ||
}, | ||
HandleResolver::Xrpc { | ||
service: "https://public.api.bsky.app".into() | ||
} | ||
] | ||
); | ||
assert_eq!(e.identity_ttl, Duration::from_secs(3600)); | ||
assert_eq!(e.timeout, Duration::from_secs(60)); | ||
assert!(e.service.is_none()); | ||
assert!(e.ca_certs.is_none()); | ||
let bare = env(&[]); | ||
assert_eq!(bare.cache_dir, PathBuf::from("./.cache/grain")); | ||
} | ||
| ||
#[test] | ||
fn overrides() { | ||
let e = env(&[ | ||
("XDG_CACHE_HOME", "/x"), | ||
("GRAIN_HANDLE_RESOLVER", "well-known:http,http://pds,"), | ||
("GRAIN_PLC_URL", "http://plc"), | ||
("GRAIN_DID_WEB_SCHEME", "http"), | ||
("GRAIN_SERVICE", "http://pds"), | ||
("GRAIN_IDENTITY_TTL_SECS", "5"), | ||
("GRAIN_TIMEOUT_SECS", "soon"), | ||
]); | ||
assert_eq!(e.cache_dir, PathBuf::from("/x/grain")); | ||
assert_eq!(e.plc_url, "http://plc"); | ||
assert_eq!(e.did_web_scheme, "http"); | ||
assert_eq!(e.service.as_deref(), Some("http://pds")); | ||
assert_eq!(e.identity_ttl, Duration::from_secs(5)); | ||
assert_eq!(e.timeout, Duration::from_secs(60)); | ||
assert_eq!( | ||
e.handle_resolvers, | ||
vec![ | ||
HandleResolver::WellKnown { | ||
scheme: "http".into() | ||
}, | ||
HandleResolver::Xrpc { | ||
service: "http://pds".into() | ||
} | ||
] | ||
); | ||
assert_eq!( | ||
env(&[("GRAIN_CACHE_DIR", "/c")]).cache_dir, | ||
PathBuf::from("/c") | ||
); | ||
assert!(env(&[("GRAIN_SERVICE", "")]).service.is_none()); | ||
let _ = Ctx::new(e); | ||
} | ||
| ||
/// A throwaway self-signed CA, only for reading back. | ||
const TEST_CA: &str = "-----BEGIN CERTIFICATE-----\nMIIBjTCCATOgAwIBAgIUW5Z0OJcr5A6Uzy+D+Jc6OM3cDt8wCgYIKoZIzj0EAwIw\nGzEZMBcGA1UEAwwQa25vdGxlc3MgdGVzdCBDQTAgFw0yNjEwMjQwMDIzMTRaGA8y\nMTI2MDkzMDAwMjMxNFowGzEZMBcGA1UEAwwQa25vdGxlc3MgdGVzdCBDQTBZMBMG\nByqGSM49AgEGCCqGSM49AwEHA0IABJyz7he0x9D3F54sUTSlH1cmYOJFqVKhOxg/\nhms5rbXMyiARmltNCbVSSq//HJd/YNouFdBOXNnOuot4qukk3iajUzBRMB0GA1Ud\nDgQWBBQGfpQL8IXKrgaOIycZIqo3E3pdDzAfBgNVHSMEGDAWgBQGfpQL8IXKrgaO\nIycZIqo3E3pdDzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIBeZ\nk3lRccBXvvSJxmcoAKKnalqoI/600ObPgeVxRkSyAiEAsBLjRbTAWH5dOVShu82A\n++dP/h0UHb5o4K49o69ITM8=\n-----END CERTIFICATE-----\n"; | ||
| ||
#[test] | ||
fn extra_roots_are_read_from_a_pem_file() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let path = dir.path().join("ca.pem"); | ||
std::fs::write(&path, TEST_CA).unwrap(); | ||
assert_eq!(extra_roots(&path).len(), 1); | ||
// A mistyped path trusts nothing more. | ||
assert!(extra_roots(&dir.path().join("missing.pem")).is_empty()); | ||
let e = env(&[("GRAIN_CA_CERTS", path.to_str().unwrap())]); | ||
assert_eq!(e.ca_certs.as_deref(), Some(path.as_path())); | ||
Ctx::new(e); | ||
assert!(env(&[("GRAIN_CA_CERTS", "")]).ca_certs.is_none()); | ||
} | ||
} | ||
crates/grain/src/identity.rsnew+636−0
636 lines changed: not loaded until asked for.
crates/grain/src/keys.rsnew+60−0
| @@ -0,0 +1,60 @@ | ||
//! The clock, and record keys made from it: TIDs, the timestamp keys | ||
//! atproto names records with. | ||
| ||
use crate::base32; | ||
use std::sync::atomic::{AtomicU64, Ordering}; | ||
use std::time::{SystemTime, UNIX_EPOCH}; | ||
| ||
static LAST_MICROS: AtomicU64 = AtomicU64::new(0); | ||
| ||
pub fn now_micros() -> u64 { | ||
SystemTime::now() | ||
.duration_since(UNIX_EPOCH) | ||
.expect("the clock is after 1970") | ||
.as_micros() as u64 | ||
} | ||
| ||
/// Seconds since the epoch, as a JWT's `iat` and `exp` count them. | ||
pub fn now_secs() -> u64 { | ||
now_micros() / 1_000_000 | ||
} | ||
| ||
/// A TID that sorts after every TID this process made before it. | ||
pub fn tid() -> String { | ||
let now = now_micros(); | ||
let micros = LAST_MICROS | ||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |last| { | ||
Some(now.max(last + 1)) | ||
}) | ||
.map(|last| now.max(last + 1)) | ||
.expect("the update closure always returns Some"); | ||
tid_from(micros, u64::from(std::process::id())) | ||
} | ||
| ||
pub fn tid_from(micros: u64, clock: u64) -> String { | ||
let micros = micros & ((1 << 53) - 1); | ||
base32::encode_u64(base32::SORTABLE, (micros << 10) | (clock & 1023)) | ||
} | ||
| ||
/// The timestamp in a TID, or `None` if `tid` is not one. | ||
pub fn tid_micros(tid: &str) -> Option<u64> { | ||
base32::decode_u64(base32::SORTABLE, tid).map(|v| v >> 10) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn tids_increase_and_decode() { | ||
let a = tid(); | ||
let b = tid(); | ||
assert!(b > a); | ||
assert_eq!(a.len(), 13); | ||
let micros = tid_micros(&a).unwrap(); | ||
assert!(micros.abs_diff(now_micros()) < 10_000_000); | ||
assert_eq!(tid_micros(&tid_from(42, 7)), Some(42)); | ||
assert_eq!(tid_micros("nope"), None); | ||
assert!(now_secs().abs_diff(now_micros() / 1_000_000) <= 1); | ||
} | ||
} | ||
crates/grain/src/lib.rs+7−0
| @@ -5,4 +5,11 @@ | ||
//! over as grain needs them rather than all at once. | ||
| ||
pub mod base32; | ||
pub mod crypto; | ||
pub mod drisl; | ||
pub mod env; | ||
pub mod identity; | ||
pub mod keys; | ||
pub mod store; | ||
pub mod telemetry; | ||
pub mod xrpc; | ||
crates/grain/src/store.rsnew+75−0
| @@ -0,0 +1,75 @@ | ||
//! Small JSON files under the config and cache directories. | ||
| ||
use anyhow::{Context, Result}; | ||
use serde::Serialize; | ||
use serde::de::DeserializeOwned; | ||
use std::path::Path; | ||
| ||
/// The file's contents, or `None` when it is missing or unreadable: every | ||
/// file here is a cache or can be recreated by logging in again. | ||
pub fn read<T: DeserializeOwned>(path: &Path) -> Option<T> { | ||
let bytes = std::fs::read(path).ok()?; | ||
serde_json::from_slice(&bytes).ok() | ||
} | ||
| ||
/// Writes through a temporary file and a rename, so a reader never sees half | ||
/// a file. `private` restricts it to the owner. | ||
pub fn write<T: Serialize>(path: &Path, value: &T, private: bool) -> Result<()> { | ||
write_bytes(path, &serde_json::to_vec_pretty(value)?, private) | ||
} | ||
| ||
pub fn write_bytes(path: &Path, bytes: &[u8], private: bool) -> Result<()> { | ||
let dir = path.parent().context("a store path has a parent")?; | ||
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; | ||
let tmp = dir.join(format!( | ||
".{}.{}.tmp", | ||
path.file_name().and_then(|n| n.to_str()).unwrap_or("store"), | ||
crate::keys::tid() | ||
)); | ||
std::fs::write(&tmp, bytes).with_context(|| format!("writing {}", tmp.display()))?; | ||
#[cfg(unix)] | ||
if private { | ||
use std::os::unix::fs::PermissionsExt; | ||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; | ||
} | ||
#[cfg(not(unix))] | ||
let _ = private; | ||
std::fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))?; | ||
Ok(()) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use std::collections::BTreeMap; | ||
| ||
#[test] | ||
fn round_trip_and_failures() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let path = dir.path().join("a/b.json"); | ||
assert_eq!(read::<BTreeMap<String, u8>>(&path), None); | ||
let mut m = BTreeMap::new(); | ||
m.insert("x".to_string(), 1u8); | ||
write(&path, &m, true).unwrap(); | ||
#[cfg(unix)] | ||
{ | ||
use std::os::unix::fs::PermissionsExt; | ||
let mode = std::fs::metadata(&path).unwrap().permissions().mode(); | ||
assert_eq!(mode & 0o777, 0o600); | ||
} | ||
write(&path, &m, false).unwrap(); | ||
assert_eq!(read::<BTreeMap<String, u8>>(&path), Some(m.clone())); | ||
std::fs::write(&path, b"{").unwrap(); | ||
assert_eq!(read::<BTreeMap<String, u8>>(&path), None); | ||
assert!(write(Path::new("/"), &m, false).is_err()); | ||
let blocked = dir.path().join("file"); | ||
std::fs::write(&blocked, b"").unwrap(); | ||
assert!(write(&blocked.join("x.json"), &m, false).is_err()); | ||
// A directory where the file should go: the rename fails. | ||
let occupied = dir.path().join("occupied"); | ||
std::fs::create_dir_all(occupied.join("inside")).unwrap(); | ||
assert!(write(&occupied, &m, false).is_err()); | ||
// A name too long for the file system: writing the copy fails. | ||
assert!(write(&dir.path().join("n".repeat(300)), &m, false).is_err()); | ||
} | ||
} | ||
crates/grain/src/telemetry.rsnew+322−0
| @@ -0,0 +1,322 @@ | ||
//! The calls grain makes to other servers, each logged as it is answered, | ||
//! and the trace a request being served carries into them. | ||
//! | ||
//! Field names follow OpenTelemetry's semantic conventions | ||
//! (`server.address`, `http.response.status_code`), so the lines can be | ||
//! shipped to a backend as they are. A call made while a request is served | ||
//! carries its `trace_id`, handed on as a W3C `traceparent`. | ||
//! | ||
//! Nothing here logs a URL's query, a header, or a body. | ||
| ||
use rand_core::RngCore; | ||
use reqwest::header::{HeaderName, HeaderValue}; | ||
use std::fmt::Write as _; | ||
use std::time::{Duration, Instant}; | ||
use tracing::Level; | ||
| ||
/// Milliseconds, as a log line gives a duration. | ||
pub fn millis(d: Duration) -> u64 { | ||
u64::try_from(d.as_millis()).unwrap_or(u64::MAX) | ||
} | ||
| ||
/// The slowest a call is before it is logged at warn. | ||
pub const SLOW: Duration = Duration::from_secs(1); | ||
| ||
tokio::task_local! { | ||
static TRACE: String; | ||
} | ||
| ||
/// The trace the task is in: the request being served. | ||
pub fn trace_id() -> Option<String> { | ||
TRACE.try_with(String::clone).ok() | ||
} | ||
| ||
/// Runs `f` in the trace `id`. | ||
pub async fn traced<F: Future>(id: String, f: F) -> F::Output { | ||
TRACE.scope(id, f).await | ||
} | ||
| ||
/// The trace a `traceparent` header names, if it is a valid one; else a | ||
/// new trace. | ||
pub fn trace_from(traceparent: Option<&str>) -> String { | ||
let given = traceparent.and_then(trace_of); | ||
given.unwrap_or_else(|| hex(16)) | ||
} | ||
| ||
fn trace_of(traceparent: &str) -> Option<String> { | ||
let id = traceparent.split('-').nth(1)?; | ||
let hex = id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()); | ||
(hex && id.bytes().any(|b| b != b'0')).then(|| id.to_ascii_lowercase()) | ||
} | ||
| ||
fn hex(bytes: usize) -> String { | ||
let mut buf = vec![0u8; bytes]; | ||
rand_core::OsRng.fill_bytes(&mut buf); | ||
buf.iter().fold(String::new(), |mut s, b| { | ||
let _ = write!(s, "{b:02x}"); | ||
s | ||
}) | ||
} | ||
| ||
/// A path as a log line gives it: no query, and not too long. | ||
pub fn short(path: &str) -> String { | ||
path.chars().take(120).collect() | ||
} | ||
| ||
/// Sends `req`, the `resend`th time it is sent again, and logs it: at | ||
/// debug, or warn if it failed or was slow, by host and path, never its | ||
/// query or headers. In a trace, it hands the trace on as `traceparent`. | ||
pub async fn send(req: reqwest::RequestBuilder, resend: u32) -> reqwest::Result<reqwest::Response> { | ||
let (client, req) = req.build_split(); | ||
let mut req = req?; | ||
if let Some(id) = trace_id() { | ||
let parent = format!("00-{id}-{}-01", hex(8)); | ||
let parent = HeaderValue::try_from(parent).expect("hex is a header value"); | ||
let name = HeaderName::from_static("traceparent"); | ||
req.headers_mut().insert(name, parent); | ||
} | ||
let call = Call { | ||
host: req.url().host_str().unwrap_or_default().to_string(), | ||
method: req.method().to_string(), | ||
path: short(req.url().path()), | ||
resend, | ||
}; | ||
let started = Instant::now(); | ||
let res = client.execute(req).await; | ||
let status = res.as_ref().ok().map(|r| r.status().as_u16()); | ||
let error = res.as_ref().err().map(error_type); | ||
call.done(status, error.as_deref(), started.elapsed()); | ||
res | ||
} | ||
| ||
struct Call { | ||
host: String, | ||
method: String, | ||
path: String, | ||
resend: u32, | ||
} | ||
| ||
impl Call { | ||
fn done(&self, status: Option<u16>, error: Option<&str>, took: Duration) { | ||
let ms = millis(took); | ||
let server_error = status.filter(|s| *s >= 500).map(|s| s.to_string()); | ||
let error = error.or(server_error.as_deref()); | ||
let slow = took >= SLOW; | ||
let rpc = self.path.strip_prefix("/xrpc/"); | ||
let resent = (self.resend > 0).then_some(self.resend); | ||
let trace = trace_id(); | ||
macro_rules! line { | ||
($level:expr) => { | ||
tracing::event!( | ||
target: "grain::outgoing", | ||
$level, | ||
server.address = self.host.as_str(), | ||
http.request.method = self.method.as_str(), | ||
url.path = self.path.as_str(), | ||
rpc.method = rpc, | ||
http.response.status_code = status, | ||
error.type = error, | ||
http.request.resend_count = resent, | ||
duration_ms = ms, | ||
trace_id = trace.as_deref(), | ||
"outgoing" | ||
) | ||
}; | ||
} | ||
match error.is_some() || slow { | ||
true => line!(Level::WARN), | ||
false => line!(Level::DEBUG), | ||
} | ||
} | ||
} | ||
| ||
/// What kind of failure `err` is, and why, without the URL it names. | ||
fn error_type(err: &reqwest::Error) -> String { | ||
let kind = match () { | ||
_ if err.is_timeout() => "timeout", | ||
_ if err.is_connect() => "connect", | ||
_ if err.is_body() || err.is_decode() => "body", | ||
_ => "request", | ||
}; | ||
let mut said = kind.to_string(); | ||
let mut source = std::error::Error::source(err); | ||
while let Some(cause) = source { | ||
let _ = write!(said, ": {cause}"); | ||
source = cause.source(); | ||
} | ||
said | ||
} | ||
| ||
#[cfg(test)] | ||
pub(crate) mod tests { | ||
use super::*; | ||
use std::sync::{Arc, Mutex}; | ||
use tracing_subscriber::layer::SubscriberExt; | ||
| ||
/// What is logged while `f` runs, at every level, as JSON lines. | ||
pub(crate) fn logged(f: impl FnOnce()) -> Vec<serde_json::Value> { | ||
let lines = Arc::new(Mutex::new(Vec::new())); | ||
let kept = lines.clone(); | ||
let writer = move || Kept(kept.clone()); | ||
let layer = tracing_subscriber::fmt::layer() | ||
.json() | ||
.flatten_event(true) | ||
.with_writer(writer); | ||
let subscriber = tracing_subscriber::registry() | ||
.with(tracing_subscriber::EnvFilter::new("trace")) | ||
.with(layer); | ||
tracing::subscriber::with_default(subscriber, f); | ||
let text = String::from_utf8(lines.lock().unwrap().clone()).unwrap(); | ||
text.lines() | ||
.map(|l| serde_json::from_str(l).unwrap()) | ||
.collect() | ||
} | ||
| ||
struct Kept(Arc<Mutex<Vec<u8>>>); | ||
| ||
impl std::io::Write for Kept { | ||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | ||
self.0.lock().unwrap().extend_from_slice(buf); | ||
Ok(buf.len()) | ||
} | ||
fn flush(&mut self) -> std::io::Result<()> { | ||
Ok(()) | ||
} | ||
} | ||
| ||
#[test] | ||
fn settings() { | ||
assert_eq!(millis(Duration::from_secs(2)), 2000); | ||
assert_eq!(short(&"a".repeat(500)).len(), 120); | ||
std::io::Write::flush(&mut Kept(Arc::default())).unwrap(); | ||
} | ||
| ||
#[test] | ||
fn traces() { | ||
let id = "4bf92f3577b34da6a3ce929d0e0e4736"; | ||
let parent = format!("00-{id}-00f067aa0ba902b7-01"); | ||
assert_eq!(trace_from(Some(&parent)), id); | ||
assert_eq!(trace_from(Some(&parent.to_uppercase())), id); | ||
for bad in ["junk", "00-xyz-00f067aa0ba902b7-01"] { | ||
assert_ne!(trace_from(Some(bad)), id); | ||
} | ||
let zeros = format!("00-{}-00f067aa0ba902b7-01", "0".repeat(32)); | ||
assert_eq!(trace_from(Some(&zeros)).len(), 32); | ||
assert_eq!(trace_from(None).len(), 32); | ||
assert_eq!(trace_id(), None); | ||
let rt = tokio::runtime::Builder::new_current_thread() | ||
.build() | ||
.unwrap(); | ||
let within = rt.block_on(traced(id.into(), async { trace_id() })); | ||
assert_eq!(within.as_deref(), Some(id)); | ||
} | ||
| ||
/// A server that answers each request with `status`, and the headers | ||
/// of the last it was sent. | ||
pub(crate) fn answering(status: &'static str) -> (String, Arc<Mutex<String>>) { | ||
use std::io::{Read, Write}; | ||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); | ||
let url = format!("http://{}", listener.local_addr().unwrap()); | ||
let asked = Arc::new(Mutex::new(String::new())); | ||
let kept = asked.clone(); | ||
std::thread::spawn(move || { | ||
for stream in listener.incoming() { | ||
let mut stream = stream.unwrap(); | ||
let mut buf = [0u8; 4096]; | ||
let n = stream.read(&mut buf).unwrap(); | ||
*kept.lock().unwrap() = String::from_utf8_lossy(&buf[..n]).into_owned(); | ||
let answer = | ||
format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"); | ||
let _ = stream.write_all(answer.as_bytes()); | ||
} | ||
}); | ||
(url, asked) | ||
} | ||
| ||
#[test] | ||
fn outgoing_calls() { | ||
let (ok, asked) = answering("200 OK"); | ||
let (broken, _) = answering("503 Service Unavailable"); | ||
let rt = tokio::runtime::Builder::new_current_thread() | ||
.enable_all() | ||
.build() | ||
.unwrap(); | ||
let http = reqwest::Client::new(); | ||
let lines = logged(|| { | ||
rt.block_on(async { | ||
let xrpc = format!("{ok}/xrpc/com.atproto.sync.getRepo?did=did:plc:x&secret=1"); | ||
let id = "4bf92f3577b34da6a3ce929d0e0e4736".to_string(); | ||
let sent = traced(id, send(http.get(&xrpc), 0)).await; | ||
assert_eq!(sent.unwrap().status(), 200); | ||
let sent = send(http.get(&broken), 1).await; | ||
assert_eq!(sent.unwrap().status(), 503); | ||
let refused = send(http.get("http://127.0.0.1:9/x"), 0).await; | ||
assert!(refused.is_err()); | ||
let invalid = send(http.get("not a url"), 0).await; | ||
assert!(invalid.is_err()); | ||
}) | ||
}); | ||
assert!( | ||
asked | ||
.lock() | ||
.unwrap() | ||
.contains("traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-") | ||
); | ||
let calls: Vec<&serde_json::Value> = lines | ||
.iter() | ||
.filter(|l| l["message"] == "outgoing") | ||
.collect(); | ||
assert_eq!(calls.len(), 3); | ||
assert_eq!(calls[0]["level"], "DEBUG"); | ||
assert_eq!(calls[0]["rpc.method"], "com.atproto.sync.getRepo"); | ||
assert_eq!(calls[0]["url.path"], "/xrpc/com.atproto.sync.getRepo"); | ||
assert_eq!(calls[0]["server.address"], "127.0.0.1"); | ||
assert_eq!(calls[0]["trace_id"], "4bf92f3577b34da6a3ce929d0e0e4736"); | ||
assert!(!lines.iter().any(|l| l.to_string().contains("secret"))); | ||
assert_eq!(calls[1]["level"], "WARN"); | ||
assert_eq!(calls[1]["error.type"], "503"); | ||
assert_eq!(calls[1]["http.request.resend_count"], 1); | ||
assert!( | ||
calls[2]["error.type"] | ||
.as_str() | ||
.unwrap() | ||
.starts_with("connect") | ||
); | ||
} | ||
| ||
#[test] | ||
fn slow_calls_warn() { | ||
let call = Call { | ||
host: "pds.test".into(), | ||
method: "GET".into(), | ||
path: "/x".into(), | ||
resend: 0, | ||
}; | ||
let lines = logged(|| call.done(Some(200), None, SLOW)); | ||
assert_eq!(lines[0]["level"], "WARN"); | ||
assert_eq!(lines[0]["duration_ms"], 1000); | ||
assert!(lines[0].get("rpc.method").is_none()); | ||
} | ||
| ||
#[test] | ||
fn error_types() { | ||
let rt = tokio::runtime::Builder::new_current_thread() | ||
.enable_all() | ||
.build() | ||
.unwrap(); | ||
let (url, _) = answering("200 OK"); | ||
let http = reqwest::Client::new(); | ||
// Asked of a listener that never answers, so the timeout always | ||
// comes first: a local 200 could beat even a zero one. | ||
let silent = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); | ||
let never = format!("http://{}/", silent.local_addr().unwrap()); | ||
let timeout = rt.block_on(async { http.get(&never).timeout(Duration::ZERO).send().await }); | ||
let timeout = timeout.unwrap_err(); | ||
assert!(error_type(&timeout).starts_with("timeout")); | ||
let built = http.get(&url).header("bad\nname", "x").build().unwrap_err(); | ||
assert!(error_type(&built).starts_with("request")); | ||
let res = rt.block_on(http.get(&url).send()).unwrap(); | ||
let body = rt.block_on(res.json::<serde_json::Value>()).unwrap_err(); | ||
assert!(error_type(&body).starts_with("body")); | ||
} | ||
} | ||
crates/grain/src/xrpc.rsnew+450−0
450 lines changed: not loaded until asked for.
crates/grain/Cargo.toml+15−0
| @@ -7,6 +7,21 @@ license.workspace = true | ||
rust-version.workspace = true | ||
| ||
[dependencies] | ||
anyhow.workspace = true | ||
base64.workspace = true | ||
bs58.workspace = true | ||
bytes.workspace = true | ||
ciborium.workspace = true | ||
futures.workspace = true | ||
k256.workspace = true | ||
p256.workspace = true | ||
rand_core.workspace = true | ||
reqwest.workspace = true | ||
serde.workspace = true | ||
serde_json.workspace = true | ||
tokio.workspace = true | ||
tracing.workspace = true | ||
| ||
[dev-dependencies] | ||
tempfile.workspace = true | ||
tracing-subscriber.workspace = true | ||
Cargo.lock+400−5
405 lines changed: not loaded until asked for.
Cargo.toml+7−0
| @@ -13,7 +13,13 @@ grain = { path = "crates/grain" } | ||
anyhow = "1" | ||
axum = "0.8" | ||
base64 = "0.22" | ||
bs58 = "0.5" | ||
bytes = "1" | ||
ciborium = "0.2" | ||
futures = "0.3" | ||
k256 = { version = "0.13", features = ["ecdsa"] } | ||
p256 = { version = "0.13", features = ["ecdsa"] } | ||
rand_core = { version = "0.6", features = ["getrandom"] } | ||
reqwest = { version = "0.12", default-features = false, features = [ | ||
"json", | ||
"rustls-tls", | ||
| @@ -22,6 +28,7 @@ reqwest = { version = "0.12", default-features = false, features = [ | ||
] } | ||
serde = { version = "1", features = ["derive"] } | ||
serde_json = "1" | ||
tempfile = "3" | ||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "signal", "time"] } | ||
tracing = "0.1" | ||
tracing-subscriber = { version = "0.3", default-features = false, features = [ | ||