~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

knotless takes its keys, JWTs, base32 and TIDs from the atproto crate

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:atproto/keys
.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(),
};
crates/knotless/src/space.rs+4−5
crates/knotless/src/space_sync.rs+7−4
crates/knotless/Cargo.toml+1−0
crates/knotless-mcp/src/http.rs+1−1
crates/knotless-mcp/tests/mcp.rs+1−1
crates/knotless-run/src/check.rs+1−1
crates/knotless-run/tests/run.rs+1−1
crates/knotless-serve/src/archive.rs+2−2
crates/knotless-serve/tests/gateway/orgs.rs+1−1
crates/knotless-serve/tests/forge.rs+2−2
crates/knotless-serve/tests/gateway.rs+1−1
AGENTS.md+8−0
Cargo.lock+26−0
Cargo.toml+2−0