Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions tinycloud-auth/src/identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
use crate::cacaos::siwe::encode_eip55;
use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PkhDid {
pub chain_id: u64,
pub address: String,
}

#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum IdentityError {
#[error("invalid EIP-155 address prefix")]
InvalidAddressPrefix,
#[error("invalid EIP-155 address length: expected 40, got {0}")]
InvalidAddressLength(usize),
#[error("invalid EIP-155 address character at {index}: {character}")]
InvalidAddressChar { index: usize, character: char },
#[error("invalid EIP-155 chain ID")]
InvalidChainId,
#[error("invalid did:pkh:eip155 DID")]
InvalidPkhDid,
#[error("invalid DID")]
InvalidDid,
}

fn hex_nibble(index: usize, c: char) -> Result<u8, IdentityError> {
match c {
'0'..='9' => Ok(c as u8 - b'0'),
'a'..='f' => Ok(c as u8 - b'a' + 10),
'A'..='F' => Ok(c as u8 - b'A' + 10),
_ => Err(IdentityError::InvalidAddressChar {
index,
character: c,
}),
}
}

pub fn canonicalize_eip155_address(address: &str) -> Result<String, IdentityError> {
let raw = address
.strip_prefix("0x")
.ok_or(IdentityError::InvalidAddressPrefix)?;
if raw.len() != 40 {
return Err(IdentityError::InvalidAddressLength(raw.len()));
}

let mut bytes = [0u8; 20];
let mut chars = raw.chars().enumerate();
for byte in &mut bytes {
let (hi_index, hi) = chars.next().ok_or(IdentityError::InvalidPkhDid)?;
let (lo_index, lo) = chars.next().ok_or(IdentityError::InvalidPkhDid)?;
*byte = (hex_nibble(hi_index, hi)? << 4) | hex_nibble(lo_index, lo)?;
}

Ok(format!("0x{}", encode_eip55(&bytes)))
}

pub fn parse_pkh_did(did: &str) -> Result<Option<PkhDid>, IdentityError> {
let Some(rest) = did.strip_prefix("did:pkh:eip155:") else {
return Ok(None);
};
let (chain_id, address) = rest.split_once(':').ok_or(IdentityError::InvalidPkhDid)?;
if address.contains(':') {
return Err(IdentityError::InvalidPkhDid);
}
let chain_id = chain_id
.parse::<u64>()
.ok()
.filter(|id| *id > 0)
.ok_or(IdentityError::InvalidChainId)?;

Ok(Some(PkhDid {
chain_id,
address: canonicalize_eip155_address(address)?,
}))
}

pub fn canonicalize_did(did: &str) -> Result<String, IdentityError> {
if let Some(pkh) = parse_pkh_did(did)? {
return Ok(format!("did:pkh:eip155:{}:{}", pkh.chain_id, pkh.address));
}
if did
.strip_prefix("did:")
.and_then(|rest| rest.split_once(':'))
.is_some()
{
return Ok(did.to_string());
}
Err(IdentityError::InvalidDid)
}

pub fn canonicalize_did_url(did_url: &str) -> Result<String, IdentityError> {
match did_url.split_once('#') {
Some((did, fragment)) => Ok(format!("{}#{}", canonicalize_did(did)?, fragment)),
None => canonicalize_did(did_url),
}
}

pub fn principal_did(did_url: &str) -> Result<String, IdentityError> {
let did = did_url.split_once('#').map_or(did_url, |(did, _)| did);
canonicalize_did(did)
}

pub fn canonicalize_principal(principal: &str) -> Result<String, IdentityError> {
if principal.starts_with("did:") {
canonicalize_did(principal)
} else {
Ok(principal.to_string())
}
}

pub fn did_principal_matches(actual: &str, expected: &str) -> bool {
match (principal_did(actual), principal_did(expected)) {
(Ok(a), Ok(b)) => a == b,
_ => actual == expected,
}
}

#[cfg(test)]
mod tests {
use super::*;

const LOWER: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
const CHECKSUM: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";

#[test]
fn canonicalizes_eip155_address() {
assert_eq!(canonicalize_eip155_address(LOWER).unwrap(), CHECKSUM);
assert_eq!(
canonicalize_eip155_address("0xF39fd6e51aad88f6f4ce6ab8827279cfffb92266").unwrap(),
CHECKSUM
);
}

#[test]
fn canonicalizes_pkh_did() {
assert_eq!(
canonicalize_did(&format!("did:pkh:eip155:1:{LOWER}")).unwrap(),
format!("did:pkh:eip155:1:{CHECKSUM}")
);
assert_eq!(
parse_pkh_did(&format!("did:pkh:eip155:1:{LOWER}"))
.unwrap()
.unwrap(),
PkhDid {
chain_id: 1,
address: CHECKSUM.to_string(),
}
);
}

#[test]
fn preserves_other_did_methods() {
assert_eq!(
canonicalize_did("did:key:z6MkExampleAbcd").unwrap(),
"did:key:z6MkExampleAbcd"
);
assert!(!did_principal_matches(
"did:key:z6MkExampleAbcd",
"did:key:z6MkExampleabcd"
));
}

#[test]
fn canonicalizes_did_urls_and_principals() {
assert_eq!(
canonicalize_did_url(&format!("did:pkh:eip155:1:{LOWER}#controller")).unwrap(),
format!("did:pkh:eip155:1:{CHECKSUM}#controller")
);
assert!(did_principal_matches(
&format!("did:pkh:eip155:1:{LOWER}#controller"),
&format!("did:pkh:eip155:1:{CHECKSUM}#other")
));
}

#[test]
fn rejects_invalid_supported_pkh_dids() {
assert_eq!(
canonicalize_did("did:pkh:eip155:1:0x1234").unwrap_err(),
IdentityError::InvalidAddressLength(4)
);
}
}
1 change: 1 addition & 0 deletions tinycloud-auth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod authorization;
pub mod identity;
pub mod protocol;
pub mod resolver;
pub mod resource;
Expand Down
47 changes: 36 additions & 11 deletions tinycloud-auth/src/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use serde::Serialize;
use serde_with::{DeserializeFromStr, SerializeDisplay};
use ssi::dids::{DIDBuf, DID};

use crate::identity::canonicalize_did;
use std::{convert::TryFrom, fmt, str::FromStr};
use thiserror::Error;

Expand Down Expand Up @@ -294,6 +295,12 @@ pub enum KRIParseError {
UriStringParse(#[from] iri_string::validate::Error),
#[error("Invalid DID string: {0}")]
DidParse(#[from] ssi::dids::InvalidDID<String>),
#[error(transparent)]
Identity(#[from] crate::identity::IdentityError),
}

fn did_from_suffix(suffix: &str) -> Result<DIDBuf, KRIParseError> {
Ok(canonicalize_did(&format!("did:{suffix}"))?.try_into()?)
}

impl TryFrom<&UriStr> for SpaceId {
Expand All @@ -315,10 +322,7 @@ impl TryFrom<&UriStr> for SpaceId {
Some((suf, name))
}
}) {
Ok(Self::new(
["did:", suf].concat().try_into()?,
Name(name.to_string()),
))
Ok(Self::new(did_from_suffix(suf)?, Name(name.to_string())))
} else {
Err(KRIParseError::IncorrectForm)
}
Expand Down Expand Up @@ -357,13 +361,12 @@ impl TryFrom<&UriStr> for ResourceId {
})
{
Ok(
SpaceId::new(["did:", suf].concat().try_into()?, Name(name.to_string()))
.to_resource(
Service(service.to_string()),
path.map(|p| Path(p.to_string())),
uri.query().map(|q| q.into()),
uri.fragment().map(|q| q.into()),
),
SpaceId::new(did_from_suffix(suf)?, Name(name.to_string())).to_resource(
Service(service.to_string()),
path.map(|p| Path(p.to_string())),
uri.query().map(|q| q.into()),
uri.fragment().map(|q| q.into()),
),
)
} else {
Err(KRIParseError::IncorrectForm)
Expand Down Expand Up @@ -446,6 +449,28 @@ mod tests {
.unwrap();
}

#[test]
fn canonicalizes_pkh_eip155_addresses() {
let space: SpaceId =
"tinycloud:pkh:eip155:1:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266:default"
.parse()
.unwrap();

assert_eq!(
space.to_string(),
"tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default"
);

let resource: ResourceId =
"tinycloud:pkh:eip155:1:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266:default/kv/path"
.parse()
.unwrap();
assert_eq!(
resource.to_string(),
"tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default/kv/path"
);
}

#[test]
fn roundtrip() {
let resource_uri: String = "tinycloud:ens:example.eth:ns0/kv/prefix#list".into();
Expand Down
23 changes: 15 additions & 8 deletions tinycloud-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use std::collections::{HashMap, HashSet};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use tinycloud_auth::{
authorization::{EncodingError, TinyCloudDelegation},
identity::{canonicalize_did, did_principal_matches},
resource::{Path, SpaceId},
};

Expand Down Expand Up @@ -1282,14 +1283,16 @@ async fn get_valid_delegations<C: ConnectionTrait, S: StorageSetup, K: Secrets>(
///
/// Returns the original DID if it's already a PKH DID or if no delegation chain is found.
async fn resolve_pkh_did<C: ConnectionTrait>(db: &C, did: &str) -> Result<String, DbErr> {
let canonical_did = canonicalize_did(did).unwrap_or_else(|_| did.to_string());

// If already a PKH DID, return it directly
if did.starts_with("did:pkh:") {
return Ok(did.to_string());
if canonical_did.starts_with("did:pkh:") {
return Ok(canonical_did);
}

// Look for a delegation where this DID is the delegatee
// The delegator would be the next step up in the chain
let mut current_did = did.to_string();
let mut current_did = canonical_did.clone();
let mut visited = std::collections::HashSet::new();

loop {
Expand All @@ -1308,10 +1311,10 @@ async fn resolve_pkh_did<C: ConnectionTrait>(db: &C, did: &str) -> Result<String
Some(del) => {
// Found a parent - check if it's a PKH DID
if del.delegator.starts_with("did:pkh:") {
return Ok(del.delegator);
return Ok(canonicalize_did(&del.delegator).unwrap_or(del.delegator));
}
// Continue up the chain
current_did = del.delegator;
current_did = canonicalize_did(&del.delegator).unwrap_or(del.delegator);
}
None => {
// No parent found - return what we have
Expand All @@ -1321,7 +1324,7 @@ async fn resolve_pkh_did<C: ConnectionTrait>(db: &C, did: &str) -> Result<String
}

// Return the original DID if we couldn't resolve to a PKH
Ok(did.to_string())
Ok(canonical_did)
}

/// Get delegations with optional filters applied.
Expand Down Expand Up @@ -1373,8 +1376,12 @@ async fn get_filtered_delegations<C: ConnectionTrait, S: StorageSetup, K: Secret

// Direction filter (using resolved PKH DID, not session key DID)
match direction {
Some("created") if del.delegator != pkh_did => return None,
Some("received") if del.delegatee != pkh_did => return None,
Some("created") if !did_principal_matches(&del.delegator, &pkh_did) => {
return None;
}
Some("received") if !did_principal_matches(&del.delegatee, &pkh_did) => {
return None;
}
_ => {}
}

Expand Down
21 changes: 21 additions & 0 deletions tinycloud-core/src/encryption_network/network_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
use thiserror::Error;
use tinycloud_auth::identity::canonicalize_principal;

const NETWORK_ID_PREFIX: &str = "urn:tinycloud:encryption:";

Expand All @@ -22,6 +23,8 @@ pub enum NetworkIdError {
MissingSeparator,
#[error("network name may not contain ':' or '/'")]
InvalidName,
#[error("invalid owner principal: {0}")]
InvalidPrincipal(String),
}

/// Owned, validated network id. Round-trips through [`Display`] and [`FromStr`].
Expand All @@ -48,6 +51,8 @@ impl NetworkId {
if name.contains(':') || name.contains('/') {
return Err(NetworkIdError::InvalidName);
}
let owner_did = canonicalize_principal(&owner_did)
.map_err(|err| NetworkIdError::InvalidPrincipal(err.to_string()))?;
Ok(Self { owner_did, name })
}

Expand Down Expand Up @@ -146,6 +151,22 @@ mod tests {
assert_eq!(err, NetworkIdError::InvalidName);
}

#[test]
fn canonicalizes_pkh_principal() {
let id: NetworkId =
"urn:tinycloud:encryption:did:pkh:eip155:1:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266:default"
.parse()
.unwrap();
assert_eq!(
id.owner_did(),
"did:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
);
assert_eq!(
id.to_string(),
"urn:tinycloud:encryption:did:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default"
);
}

#[test]
fn rejects_no_separator() {
let err: Result<NetworkId, _> = "urn:tinycloud:encryption:standalone".parse();
Expand Down
Loading
Loading