use pubky_common::{
auth::{
grant::GrantClaims,
jws::{GRANT_JWS_TYP, sign_jws},
},
capabilities::Capability,
crypto::Keypair,
encryption_keys::ScopedEncryptionKeyBundle,
};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
pub(crate) const APPROVAL_JWS_TYP: &str = "pubky-grant-approval";
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GrantApprovalEnvelope {
version: ApprovalVersion,
pub(crate) grant: String,
pub(crate) encryption_keys: ScopedEncryptionKeyBundle,
}
impl GrantApprovalEnvelope {
pub(crate) fn sign(keypair: &Keypair, claims: &GrantClaims) -> Zeroizing<String> {
let identity_secret = Zeroizing::new(keypair.secret());
let envelope = Self {
version: ApprovalVersion::V1,
grant: sign_jws(keypair, GRANT_JWS_TYP, claims),
encryption_keys: ScopedEncryptionKeyBundle::from_identity_secret(
&identity_secret,
claims
.caps
.iter()
.filter(|cap| cap.grants_encryption_keys())
.map(Capability::scope),
),
};
Zeroizing::new(sign_jws(keypair, APPROVAL_JWS_TYP, &envelope))
}
}
#[derive(Debug, Serialize, Deserialize)]
enum ApprovalVersion {
#[serde(rename = "v1")]
V1,
}
#[cfg(test)]
mod tests {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use pubky_common::{
StoragePath,
auth::jws::{ClientId, GrantId, decode_jws_payload},
capabilities::Capabilities,
crypto::{PublicKey, Signature},
};
use super::*;
fn claims(user: &Keypair) -> GrantClaims {
GrantClaims {
iss: user.public_key(),
client_id: ClientId::new("test.app").unwrap(),
caps: Capabilities::builder()
.read("/pub/read/")
.unwrap()
.encryption_keys("/pub/read/")
.unwrap()
.write("/priv/write/")
.unwrap()
.encryption_keys("/priv/write/")
.unwrap()
.read_write("/pub/both/file")
.unwrap()
.encryption_keys("/pub/both/file")
.unwrap()
.finish()
.to_vec(),
cnf: Keypair::random().public_key(),
jti: GrantId::generate(),
iat: 1,
exp: 2,
}
}
fn verify_signature(jws: &str, public_key: &PublicKey, typ: &str) -> bool {
let parts = jws.split('.').collect::<Vec<_>>();
let header: serde_json::Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap();
assert_eq!(header["alg"], "EdDSA");
assert_eq!(header["typ"], typ);
let signature: [u8; 64] = URL_SAFE_NO_PAD
.decode(parts[2])
.unwrap()
.try_into()
.unwrap();
public_key
.verify(
format!("{}.{}", parts[0], parts[1]).as_bytes(),
&Signature::from_bytes(&signature),
)
.is_ok()
}
#[test]
fn approval_binds_the_exact_grant_and_keys_to_the_signer() {
let user = Keypair::from_secret(&[7; 32]);
let claims = claims(&user);
let signed = GrantApprovalEnvelope::sign(&user, &claims);
assert!(verify_signature(
&signed,
&user.public_key(),
APPROVAL_JWS_TYP
));
assert!(!verify_signature(
&signed,
&Keypair::random().public_key(),
APPROVAL_JWS_TYP
));
let envelope: GrantApprovalEnvelope = decode_jws_payload(&signed).unwrap();
assert!(verify_signature(
&envelope.grant,
&user.public_key(),
GRANT_JWS_TYP
));
assert_eq!(GrantClaims::decode(&envelope.grant).unwrap(), claims);
let scopes = claims
.caps
.iter()
.map(Capability::scope)
.collect::<Vec<_>>();
assert_eq!(
envelope.encryption_keys.scopes().collect::<Vec<_>>(),
scopes
);
for target in ["/pub/read/file", "/priv/write/file", "/pub/both/file"] {
let path = StoragePath::new(target).unwrap();
let expected = ScopedEncryptionKeyBundle::from_identity_secret(
&user.secret(),
std::slice::from_ref(&path),
);
assert_eq!(
*envelope.encryption_keys.derive_for_path(&path).unwrap(),
*expected.derive_for_path(&path).unwrap(),
);
}
envelope
.encryption_keys
.derive_for_path(&StoragePath::root())
.unwrap_err();
let grant: serde_json::Value = decode_jws_payload(&envelope.grant).unwrap();
assert!(grant.get("encryption_keys").is_none());
assert!(format!("{envelope:?}").contains("<redacted>"));
}
#[test]
fn modifying_the_grant_keys_scope_or_version_invalidates_the_outer_signature() {
let user = Keypair::from_secret(&[7; 32]);
let signed = GrantApprovalEnvelope::sign(&user, &claims(&user));
let parts = signed.split('.').collect::<Vec<_>>();
let payload: serde_json::Value = decode_jws_payload(&signed).unwrap();
for field in ["grant", "secret", "scope", "version"] {
let mut changed = payload.clone();
match field {
"grant" => changed["grant"] = serde_json::json!("different-grant"),
"secret" => {
let secret = changed["encryption_keys"]["keys"][0]["secret"]
.as_str()
.unwrap();
let mut bytes = URL_SAFE_NO_PAD.decode(secret).unwrap();
bytes[0] ^= 1;
changed["encryption_keys"]["keys"][0]["secret"] =
serde_json::json!(URL_SAFE_NO_PAD.encode(bytes));
}
"scope" => changed["encryption_keys"]["keys"][0]["scope"] = serde_json::json!("/"),
"version" => changed["version"] = serde_json::json!("v2"),
_ => unreachable!(),
}
let changed = format!(
"{}.{}.{}",
parts[0],
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&changed).unwrap()),
parts[2]
);
assert!(!verify_signature(
&changed,
&user.public_key(),
APPROVAL_JWS_TYP
));
}
}
#[test]
fn decoding_rejects_unknown_versions_and_fields() {
let user = Keypair::random();
let signed = GrantApprovalEnvelope::sign(&user, &claims(&user));
let payload: serde_json::Value = decode_jws_payload(&signed).unwrap();
for version in ["v0", "v2", ""] {
let mut changed = payload.clone();
changed["version"] = serde_json::json!(version);
serde_json::from_value::<GrantApprovalEnvelope>(changed).unwrap_err();
}
let mut changed = payload;
changed["extra"] = serde_json::json!(true);
serde_json::from_value::<GrantApprovalEnvelope>(changed).unwrap_err();
}
#[test]
fn empty_capabilities_deliver_no_keys() {
let user = Keypair::random();
let mut claims = claims(&user);
claims.caps.clear();
let signed = GrantApprovalEnvelope::sign(&user, &claims);
let envelope: GrantApprovalEnvelope = decode_jws_payload(&signed).unwrap();
assert_eq!(envelope.encryption_keys.scopes().len(), 0);
}
#[test]
fn ordinary_multi_scope_approvals_fit_the_default_relay_limit() {
use pubky_common::crypto::encrypt;
let user = Keypair::from_secret(&[7; 32]);
for count in [4, 5] {
let mut capabilities = Capabilities::builder();
for index in 0..count {
capabilities = capabilities
.read_write(format!("/pub/app{index}.example/"))
.unwrap()
.encryption_keys(format!("/pub/app{index}.example/"))
.unwrap();
}
let mut claims = claims(&user);
claims.caps = capabilities.finish().to_vec();
claims.iat = 1_700_000_000;
claims.exp = 1_731_536_000;
let signed = GrantApprovalEnvelope::sign(&user, &claims);
let wire = encrypt(signed.as_bytes(), &[77; 32]);
assert!(
wire.len() <= 2048,
"{count} scopes need {} bytes",
wire.len()
);
let (_, public_key) =
super::super::approval_encryption::ApprovalRecipientSecret::generate();
let hpke_wire =
super::super::approval_encryption::seal(&public_key, signed.as_bytes()).unwrap();
assert!(
hpke_wire.len() <= 2048,
"{count} HPKE scopes need {} bytes",
hpke_wire.len()
);
}
}
}