chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
knotless takes its keys, JWTs, base32 and TIDs from the atproto crate
.cargo/config.tomlnew+4−0
| @@ -0,0 +1,4 @@ | ||
# The registry the atproto crate is published to, from its own repository | ||
# (knotless.dev/chadtmiller.com/atproto). Reading it needs no login. | ||
[registries.atproto] | ||
index = "sparse+https://knotless.dev/cargo/did:plc:bcgltzqazw5tb6k2g3ttenbj/3mzr5vldibtui/" | ||
crates/knotless/src/base32.rsdeleted+0−106
| @@ -1,106 +0,0 @@ | ||
//! Unpadded base32 over an arbitrary 32-symbol alphabet. | ||
| ||
/// The sortable alphabet atproto uses for TIDs. | ||
pub const SORTABLE: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz"; | ||
/// RFC 4648 base32, lowercased. | ||
pub const LOWER: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; | ||
| ||
/// Encodes `bytes` most significant bit first, without padding. | ||
pub fn encode(alphabet: &[u8; 32], bytes: &[u8]) -> String { | ||
let mut out = String::with_capacity(bytes.len() * 8 / 5 + 1); | ||
let mut buffer: u32 = 0; | ||
let mut bits = 0; | ||
for &byte in bytes { | ||
buffer = (buffer << 8) | u32::from(byte); | ||
bits += 8; | ||
while bits >= 5 { | ||
bits -= 5; | ||
out.push(alphabet[((buffer >> bits) & 31) as usize] as char); | ||
} | ||
} | ||
if bits > 0 { | ||
out.push(alphabet[((buffer << (5 - bits)) & 31) as usize] as char); | ||
} | ||
out | ||
} | ||
| ||
/// Inverts [`encode`]. Returns `None` for a symbol not in `alphabet`, or | ||
/// bits left over that are not zero or make a whole byte. | ||
pub fn decode(alphabet: &[u8; 32], text: &str) -> Option<Vec<u8>> { | ||
let mut out = Vec::with_capacity(text.len() * 5 / 8); | ||
let (mut buffer, mut bits) = (0u32, 0); | ||
for c in text.bytes() { | ||
let symbol = alphabet.iter().position(|&a| a == c)? as u32; | ||
buffer = (buffer << 5) | symbol; | ||
bits += 5; | ||
if bits >= 8 { | ||
bits -= 8; | ||
out.push((buffer >> bits) as u8); | ||
} | ||
} | ||
let clean = bits < 5 && buffer & ((1 << bits) - 1) == 0; | ||
clean.then_some(out) | ||
} | ||
| ||
/// Encodes a u64 as 13 symbols, so encodings sort the way the integers do. | ||
pub fn encode_u64(alphabet: &[u8; 32], value: u64) -> String { | ||
(0..13) | ||
.map(|i: u32| alphabet[((value >> (60 - 5 * i)) & 31) as usize] as char) | ||
.collect() | ||
} | ||
| ||
/// Inverts [`encode_u64`]. Returns `None` for anything that is not 13 symbols | ||
/// of `alphabet` or does not fit a u64. | ||
pub fn decode_u64(alphabet: &[u8; 32], text: &str) -> Option<u64> { | ||
if text.len() != 13 { | ||
return None; | ||
} | ||
let mut value: u64 = 0; | ||
for (i, c) in text.bytes().enumerate() { | ||
let symbol = alphabet.iter().position(|&a| a == c)? as u64; | ||
if i == 0 && symbol > 15 { | ||
return None; | ||
} | ||
value = (value << 5) | symbol; | ||
} | ||
Some(value) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn rfc4648_vectors() { | ||
assert_eq!(encode(LOWER, b""), ""); | ||
assert_eq!(encode(LOWER, b"f"), "my"); | ||
assert_eq!(encode(LOWER, b"fo"), "mzxq"); | ||
assert_eq!(encode(LOWER, b"foo"), "mzxw6"); | ||
assert_eq!(encode(LOWER, b"foobar"), "mzxw6ytboi"); | ||
for bytes in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar"] { | ||
assert_eq!(decode(LOWER, &encode(LOWER, bytes)).as_deref(), Some(bytes)); | ||
} | ||
// Not of the alphabet, bits left over set, or a byte's worth over. | ||
assert_eq!(decode(LOWER, "m1"), None); | ||
assert_eq!(decode(LOWER, "mz"), None); | ||
assert_eq!(decode(LOWER, "mzx"), None); | ||
} | ||
| ||
#[test] | ||
fn u64_round_trips_and_sorts() { | ||
for v in [0u64, 1, 31, 32, 1 << 40, u64::MAX >> 1, u64::MAX] { | ||
let s = encode_u64(SORTABLE, v); | ||
assert_eq!(s.len(), 13); | ||
assert_eq!(decode_u64(SORTABLE, &s), Some(v)); | ||
} | ||
assert!(encode_u64(SORTABLE, 5) < encode_u64(SORTABLE, 6)); | ||
assert!(encode_u64(SORTABLE, 1 << 50) > encode_u64(SORTABLE, (1 << 50) - 1)); | ||
} | ||
| ||
#[test] | ||
fn decode_rejects_garbage() { | ||
assert_eq!(decode_u64(SORTABLE, "short"), None); | ||
assert_eq!(decode_u64(SORTABLE, "1111111111111"), None); | ||
assert_eq!(decode_u64(SORTABLE, "z222222222222"), None); | ||
} | ||
} | ||
crates/knotless/src/crypto.rs+42−233
| @@ -1,126 +1,24 @@ | ||
//! 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; | ||
//! The keys atproto uses, from the `atproto` crate: secp256k1 and P-256, as | ||
//! multikeys and `did:key`s, and the JWTs they sign. What is here is | ||
//! knotless's own use of them: the P-256 key it holds for a session or a | ||
//! space credential, and a JWT held to low-S signatures and its `exp`. | ||
| ||
use anyhow::{Context, Result, bail, ensure}; | ||
use atproto::jose; | ||
use atproto::keys::{Curve, Keypair}; | ||
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") } | ||
} | ||
} | ||
pub use atproto::keys::PublicKey; | ||
| ||
/// An ephemeral P-256 key, for proving possession of a space credential. | ||
pub struct P256Key(p256::ecdsa::SigningKey); | ||
/// A P-256 key knotless holds: a DPoP key, a session's, or one proving | ||
/// possession of a space credential. | ||
pub struct P256Key(Keypair); | ||
| ||
impl P256Key { | ||
pub fn generate() -> P256Key { | ||
P256Key(p256::ecdsa::SigningKey::random(&mut rand_core::OsRng)) | ||
P256Key(Keypair::generate(Curve::P256)) | ||
} | ||
| ||
/// The 32-byte secret scalar, for a cache that keeps the key with what | ||
| @@ -130,83 +28,51 @@ impl P256Key { | ||
} | ||
| ||
pub fn from_bytes(bytes: &[u8]) -> Result<P256Key> { | ||
Ok(P256Key( | ||
p256::ecdsa::SigningKey::from_slice(bytes).context("bad P-256 secret key")?, | ||
)) | ||
let key = Keypair::from_bytes(Curve::P256, bytes).context("bad P-256 secret key")?; | ||
Ok(P256Key(key)) | ||
} | ||
| ||
pub fn public(&self) -> PublicKey { | ||
PublicKey::P256(*self.0.verifying_key()) | ||
self.0.public() | ||
} | ||
| ||
/// 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()), | ||
}) | ||
jose::jwk(&self.public()) | ||
} | ||
| ||
/// 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() | ||
self.0.sign(message).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}") | ||
let claims = serde_json::to_value(claims).expect("claims serialize"); | ||
jose::sign(&self.0, header, &claims) | ||
} | ||
} | ||
| ||
/// 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")?)) | ||
Ok(jose::parts(token)?) | ||
} | ||
| ||
/// Verifies `token` against `key` and returns its claims. Checks the | ||
/// signature, that `alg` matches the key, and `exp` against the clock. | ||
/// signature, low-S as atproto signs, 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()) { | ||
let alg = key.curve().alg(); | ||
if header["alg"].as_str() != Some(alg) { | ||
bail!( | ||
"a JWT signed with {} where {} was expected", | ||
header["alg"], | ||
key.alg() | ||
"a JWT signed with {} where {alg} was expected", | ||
header["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 (signed, sig) = token.rsplit_once('.').unwrap_or_default(); | ||
let sig = jose::unb64(sig).context("bad base64 in a JWT signature")?; | ||
ensure!(key.verify(signed.as_bytes(), &sig), "bad signature"); | ||
let now = crate::keys::now_micros() / 1_000_000; | ||
match claims["exp"].as_u64() { | ||
Some(exp) if exp > now => {} | ||
| @@ -220,80 +86,17 @@ 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()); | ||
assert!(key.public().verify(b"hello", &sig)); | ||
assert!(!key.public().verify(b"other", &sig)); | ||
assert_eq!(key.jwk()["crv"], "P-256"); | ||
} | ||
| ||
#[test] | ||
| @@ -308,9 +111,7 @@ mod tests { | ||
| ||
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(), | ||
); | ||
let k = Keypair::generate(Curve::K256).public(); | ||
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()); | ||
| @@ -320,5 +121,13 @@ mod tests { | ||
for bad in ["", "a", "e30", "!!.x", "e30.!!", "bm90IGpzb24.e30"] { | ||
assert!(jwt_parts(bad).is_err(), "{bad}"); | ||
} | ||
| ||
// The same signature with s flipped high verifies in ECDSA, and is | ||
// refused, as atproto signs low-S. | ||
let (signed, sig) = token.rsplit_once('.').unwrap(); | ||
let sig = p256::ecdsa::Signature::from_slice(&jose::unb64(sig).unwrap()).unwrap(); | ||
let high = p256::ecdsa::Signature::from_scalars(sig.r(), -*sig.s()).unwrap(); | ||
let forged = format!("{signed}.{}", jose::b64(high.to_bytes())); | ||
assert!(verify_jwt::<Value>(&forged, &key.public()).is_err()); | ||
} | ||
} | ||
crates/knotless/src/drisl.rs+2−2
| @@ -28,7 +28,7 @@ fn json(value: &ciborium::Value) -> Option<serde_json::Value> { | ||
json!({"$bytes": base64::engine::general_purpose::STANDARD_NO_PAD.encode(b)}) | ||
} | ||
V::Tag(42, _) => { | ||
let cid = crate::base32::encode(crate::base32::LOWER, link(value)?); | ||
let cid = atproto::base32::encode(atproto::base32::LOWER, link(value)?); | ||
json!({ "$link": format!("b{cid}") }) | ||
} | ||
V::Array(items) => J::Array(items.iter().map(json).collect::<Option<_>>()?), | ||
| @@ -63,7 +63,7 @@ fn cbor(value: &serde_json::Value) -> Option<ciborium::Value> { | ||
J::Array(items) => V::Array(items.iter().map(cbor).collect::<Option<_>>()?), | ||
J::Object(map) => match (map.len(), map.get("$link"), map.get("$bytes")) { | ||
(1, Some(J::String(link)), _) => { | ||
let cid = crate::base32::decode(crate::base32::LOWER, link.strip_prefix('b')?)?; | ||
let cid = atproto::base32::decode(atproto::base32::LOWER, link.strip_prefix('b')?)?; | ||
V::Tag(42, Box::new(V::Bytes([&[0][..], &cid].concat()))) | ||
} | ||
(1, _, Some(J::String(bytes))) => { | ||
crates/knotless/src/identity.rs+1−1
| @@ -393,7 +393,7 @@ fn method_key(doc: &Value, id: &str) -> Result<PublicKey> { | ||
.get("type") | ||
.and_then(Value::as_str) | ||
.unwrap_or("Multikey"); | ||
PublicKey::from_method(kind, multibase) | ||
Ok(PublicKey::from_method(kind, multibase)?) | ||
} | ||
| ||
#[cfg(test)] | ||
crates/knotless/src/keys.rs+3−6
| @@ -7,7 +7,7 @@ | ||
//! just after `.`) walks one repository's records, newest suffix first, and | ||
//! stops at the first key outside it. | ||
| ||
use crate::base32; | ||
use atproto::base32; | ||
use sha2::{Digest, Sha256}; | ||
use std::sync::atomic::{AtomicU64, Ordering}; | ||
use std::time::{SystemTime, UNIX_EPOCH}; | ||
| @@ -33,14 +33,11 @@ pub fn tid() -> String { | ||
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)) | ||
} | ||
pub use atproto::tid::tid_from; | ||
| ||
/// 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) | ||
atproto::tid::micros(tid) | ||
} | ||
| ||
/// 26 symbols of SHA-256: a ref name of any spelling, as a key fragment. | ||
crates/knotless/src/lib.rs+0−1
| @@ -15,7 +15,6 @@ pub mod approvals; | ||
pub mod aturi; | ||
pub mod auth; | ||
pub mod auto_merge; | ||
pub mod base32; | ||
pub mod branch_update; | ||
pub mod checks; | ||
pub mod cli; | ||
crates/knotless/src/oauth.rs+2−3
| @@ -959,9 +959,8 @@ mod tests { | ||
assert_eq!(claims["ath"], sha256("tok")); | ||
assert!(claims.get("nonce").is_none()); | ||
let (signed, sig) = token.rsplit_once('.').unwrap(); | ||
key.public() | ||
.verify(signed.as_bytes(), &URL_SAFE_NO_PAD.decode(sig).unwrap()) | ||
.unwrap(); | ||
let sig = URL_SAFE_NO_PAD.decode(sig).unwrap(); | ||
assert!(key.public().verify(signed.as_bytes(), &sig)); | ||
// Not a URL: proved as it is. | ||
let (_, claims) = crypto::jwt_parts(&proof(&key, "POST", "nowhere", None)).unwrap(); | ||
assert_eq!(claims["htu"], "nowhere"); | ||
crates/knotless/src/sealed.rs+4−4
| @@ -127,7 +127,7 @@ impl RunnerKeys { | ||
| ||
/// What was sealed for `bound` to the key whose `did:key` is `to`. | ||
pub fn open(&self, to: &str, bound: &Bound, sealed: &[u8]) -> Result<Vec<u8>> { | ||
let key = self.0.iter().find(|k| k.public().to_did_key() == to); | ||
let key = self.0.iter().find(|k| k.public().did_key() == to); | ||
let key = | ||
key.with_context(|| format!("sealed to {to}, a key this runner does not hold"))?; | ||
ensure!(sealed.len() > ENCAPPED, "too short to be sealed"); | ||
| @@ -173,7 +173,7 @@ mod tests { | ||
let again = RunnerKeys::open_or_make(&path).unwrap(); | ||
assert_eq!(keys.public(), again.public()); | ||
let to = keys.public(); | ||
let did = to.to_did_key(); | ||
let did = to.did_key(); | ||
let main = vec!["refs/heads/main".to_string()]; | ||
| ||
let sealed = seal(&to, &bound("TOKEN", &main), b"s3cret").unwrap(); | ||
| @@ -192,7 +192,7 @@ mod tests { | ||
// Nor does another key's, a short one, or one to a key not held. | ||
let theirs = P256Key::generate().public(); | ||
let sealed = seal(&theirs, &bound("TOKEN", &[]), b"x").unwrap(); | ||
let err = keys.open(&theirs.to_did_key(), &bound("TOKEN", &[]), &sealed); | ||
let err = keys.open(&theirs.did_key(), &bound("TOKEN", &[]), &sealed); | ||
assert!(err.unwrap_err().to_string().contains("does not hold")); | ||
let err = keys.open(&did, &bound("TOKEN", &[]), b"short"); | ||
assert!(err.unwrap_err().to_string().contains("too short")); | ||
| @@ -216,7 +216,7 @@ mod tests { | ||
std::fs::write(&path, format!(r#"{{"keys": ["{secret}"]}}"#)).unwrap(); | ||
let keys = RunnerKeys::open_or_make(&path).unwrap(); | ||
let did = "did:key:zDnaepwQjCS7ayRrLp2j3oFd8kfJ97mehqYTGFBNA3QqSsvNV"; | ||
assert_eq!(keys.public().to_did_key(), did); | ||
assert_eq!(keys.public().did_key(), did); | ||
let sealed = STANDARD.decode("BBqVduvJVRULW06YuBX8WAdCgA3QuLZBfNZ69ddRYQzHvrKsUA9K4R/MrILiMvOVzRJB3bFZfdB4whHnZ5SrkFmIhOUYR+DC0gjT5oCbjiBRRAXmXjWTg8QL/c8rPfGifFt3zL0=").unwrap(); | ||
let refs = ["refs/heads/main".to_string(), "refs/tags/v*".to_string()]; | ||
let opened = keys.open(did, &bound("TOKEN", &refs), &sealed).unwrap(); | ||
crates/knotless/src/secrets.rs+1−1
| @@ -247,7 +247,7 @@ pub async fn set( | ||
name: name.into(), | ||
runner: runner.clone(), | ||
refs: refs.to_vec(), | ||
key: key.to_did_key(), | ||
key: key.did_key(), | ||
sealed: Some(sealed::seal(&key, &bound, value)?), | ||
updated_at: String::new(), | ||
}; | ||