Skip to content
Open
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
113 changes: 71 additions & 42 deletions contracts/did_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
// * The registry stores the controller `Address`, an off-chain GitHub
// handle binding, the Ed25519 *verification key* used to sign contributor
// proofs, and the set of verified contributor claims.
// * String-form entry points (`register_string`, `resolve_string`) accept
// only canonical `did:stellar:<64 hex>` DIDs and reject malformed strings
// with `DIDError::MalformedDID`; `validate_did` is a non-panicking view.
//
// Cryptographic *verification* of Ed25519 signatures over contributor proofs
// is performed by the off-chain resolver (see backend `didResolver`), which
Expand All @@ -17,49 +20,20 @@

#![no_std]
use soroban_sdk::{
contract, contractimpl, contracttype, Address, Bytes, BytesN, Env, Map, String, Symbol, Vec,
contract, contracterror, contractimpl, Address, Bytes, BytesN, Env, Map, String, Symbol, Vec,
};

/// A single signed contributor proof claim (PR or issue milestone).
#[derive(Clone)]
#[contracttype]
pub struct ContributorProof {
/// Kind of contribution: "pr" or "issue".
pub claim_type: String,
/// GitHub repository, e.g. "StellarDevHub/Web3-Student-Lab".
pub repo: String,
/// Identifier of the PR/issue (number or node id).
pub item_id: String,
/// GitHub handle of the contributor (must match the bound handle).
pub github_handle: String,
/// Unix timestamp (seconds) the claim was issued.
pub issued_at: u64,
/// Raw Ed25519 signature bytes over the canonical claim payload.
pub signature: Bytes,
}
pub mod types;
pub mod verification;

#[derive(Clone)]
#[contracttype]
pub struct DIDDocument {
pub owner: Address,
/// Off-chain GitHub handle bound to this DID.
pub github_handle: Option<String>,
/// Social handles keyed by provider symbol (github or discord).
pub social_identities: Map<Symbol, String>,
/// Ed25519 verification key (32 bytes) used to sign contributor proofs.
pub verification_key: Option<BytesN<32>>,
pub attributes: Map<Symbol, Bytes>,
pub controllers: Vec<Address>,
pub revoked: bool,
/// Stored contributor proofs (mirror of on-chain claims).
pub proofs: Vec<ContributorProof>,
}
pub use types::*;

#[contracttype]
pub enum DataKey {
DIDs,
/// Map<BytesN<32>, Vec<ContributorProof>> — claims per DID.
Proofs,
/// Contract-level errors.
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum DIDError {
/// `1` — input was not a canonical `did:stellar:<64 hex>` string.
MalformedDID = 1,
}

#[contract]
Expand Down Expand Up @@ -153,6 +127,23 @@ impl DIDRegistryContract {
env.storage().persistent().set(&DataKey::DIDs, &dids);
}

/// Revoke the delegated Ed25519 verification (signing) key. Only the
/// owner may revoke it; a new key can be set afterwards.
pub fn revoke_verification_key(env: Env, sender: Address, did: BytesN<32>) {
sender.require_auth();
let mut dids: Map<BytesN<32>, DIDDocument> =
env.storage().persistent().get(&DataKey::DIDs).unwrap();
let mut doc = dids.get(did.clone()).unwrap();
assert!(!doc.revoked, "DID revoked");
assert!(
doc.owner == sender,
"Only owner can revoke verification key"
);
doc.verification_key = None;
dids.set(did, doc);
env.storage().persistent().set(&DataKey::DIDs, &dids);
}

/// Append a signed contributor proof claim. Only the owner may add; the
/// resolver performs the cryptographic verification off-chain.
pub fn add_contributor_proof(
Expand All @@ -167,7 +158,7 @@ impl DIDRegistryContract {
let doc = dids.get(did.clone()).unwrap();
assert!(!doc.revoked, "DID revoked");
assert!(
doc.owner == sender || doc.controllers.contains(&sender),
verification::is_owner_or_controller(&doc, &sender),
"Not authorized"
);
if let Some(handle) = doc.github_handle.clone() {
Expand All @@ -194,7 +185,7 @@ impl DIDRegistryContract {
let mut doc = dids.get(did.clone()).unwrap();
assert!(!doc.revoked, "DID revoked");
assert!(
doc.owner == sender || doc.controllers.contains(&sender),
verification::is_owner_or_controller(&doc, &sender),
"Not authorized"
);
doc.attributes = attributes;
Expand Down Expand Up @@ -268,6 +259,41 @@ impl DIDRegistryContract {
Some(doc)
}

/// Pure structural check: is this a canonical `did:stellar:<64 hex>`
/// string? Never panics — safe to call on untrusted resolver input.
pub fn validate_did(_env: Env, did: String) -> bool {
verification::is_valid_did_string(&did)
}

/// Register a DID from its canonical string form. Malformed DID strings
/// are rejected with `DIDError::MalformedDID`.
pub fn register_string(env: Env, owner: Address, did: String, attributes: Map<Symbol, Bytes>) {
// Parsing runs first so malformed input is rejected before anything
// else; `register` performs the `owner.require_auth()` itself (a
// second `require_auth` on the same frame is an auth error).
let key = match verification::parse_did_string(&env, &did) {
Some(key) => key,
None => soroban_sdk::panic_with_error!(&env, DIDError::MalformedDID),
};
Self::register(env, owner, key, attributes);
}

/// Resolve a DID document from its canonical string form. Malformed DID
/// strings are rejected with `DIDError::MalformedDID`.
pub fn resolve_string(env: Env, did: String) -> Option<DIDDocument> {
let key = match verification::parse_did_string(&env, &did) {
Some(key) => key,
None => soroban_sdk::panic_with_error!(&env, DIDError::MalformedDID),
};
Self::resolve(env, key)
}

/// Canonical `did:stellar:<hex>` string for a raw DID key — exact inverse
/// of the parser backing the string entry points.
pub fn did_to_string(env: Env, did: BytesN<32>) -> String {
verification::did_to_string(&env, &did)
}

/// Returns the bound GitHub handle for a DID, if any.
pub fn get_github_handle(env: Env, did: BytesN<32>) -> Option<String> {
let dids: Map<BytesN<32>, DIDDocument> = env
Expand Down Expand Up @@ -311,7 +337,7 @@ impl DIDRegistryContract {
}

#[cfg(test)]
mod test {
mod tests {
use super::*;
use soroban_sdk::{symbol_short, testutils::Address as _, BytesN, Env};

Expand Down Expand Up @@ -382,3 +408,6 @@ mod test {
assert!(doc.revoked);
}
}

#[cfg(test)]
mod test;
159 changes: 150 additions & 9 deletions contracts/did_registry/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
//! - Key rotation
//! - Revocation: deactivated DIDs reject all subsequent mutations
//! - Duplicate DID registration rejected
//! - DID string form (SC-010): `did:stellar:<64 hex>` round-trip, malformed
//! string rejection via the typed `DIDError::MalformedDID` error
//! - Unauthorized key binding attempts rejected (SC-010)
//! - Delegated verification key revocation (SC-010)

use crate::{ContributorProof, DIDRegistryContract, DIDRegistryContractClient};
use soroban_sdk::{
symbol_short,
testutils::Address as _,
Address, Bytes, BytesN, Env, Map, String, Symbol,
symbol_short, testutils::Address as _, Address, Bytes, BytesN, Env, Map, String, Symbol,
};

// ── Helpers ───────────────────────────────────────────────────────────────────
Expand All @@ -31,6 +33,30 @@ fn make_did(env: &Env, seed: u8) -> BytesN<32> {
BytesN::from_array(env, &[seed; 32])
}

/// Canonical `did:stellar:` string for `make_did(env, seed)` — built with an
/// explicit hex table so it stays independent of `verification.rs`.
fn canonical_string(env: &Env, seed: u8) -> String {
let hex = b"0123456789abcdef";
let mut buf = [0u8; 76];
buf[..12].copy_from_slice(b"did:stellar:");
for i in 0..32 {
buf[12 + 2 * i] = hex[(seed >> 4) as usize];
buf[13 + 2 * i] = hex[(seed & 0x0f) as usize];
}
String::from_bytes(env, &buf)
}

/// Concatenate byte parts into a soroban String (no alloc needed).
fn cat(env: &Env, parts: &[&[u8]]) -> String {
let mut buf = [0u8; 256];
let mut n = 0usize;
for p in parts {
buf[n..n + p.len()].copy_from_slice(p);
n += p.len();
}
String::from_bytes(env, &buf[..n])
}

fn register_default(
env: &Env,
client: &DIDRegistryContractClient,
Expand Down Expand Up @@ -99,10 +125,7 @@ fn test_bind_github_handle() {

let doc = client.resolve(&did).unwrap();
assert_eq!(doc.github_handle, Some(handle.clone()));
assert_eq!(
client.get_github_handle(&did),
Some(handle)
);
assert_eq!(client.get_github_handle(&did), Some(handle));
}

// ── Social identity binding ───────────────────────────────────────────────────
Expand Down Expand Up @@ -281,7 +304,10 @@ fn test_old_owner_cannot_mutate_after_rotation() {
// Old owner tries to rotate again
let another = Address::generate(&env);
let result = client.try_rotate_key(&owner, &did, &another);
assert!(result.is_err(), "old owner must not be able to rotate after handoff");
assert!(
result.is_err(),
"old owner must not be able to rotate after handoff"
);
}

// ── Revocation ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -374,9 +400,124 @@ fn test_update_attributes() {
register_default(&env, &client, &owner, &did);

let mut new_attrs = Map::new(&env);
new_attrs.set(symbol_short!("endpoint"), Bytes::from_slice(&env, b"https://example.com"));
new_attrs.set(
symbol_short!("endpoint"),
Bytes::from_slice(&env, b"https://example.com"),
);
client.update(&owner, &did, &new_attrs);

let doc = client.resolve(&did).unwrap();
assert!(doc.attributes.contains_key(symbol_short!("endpoint")));
}

// ── DID string form (SC-010) ──────────────────────────────────────────────────

#[test]
fn test_register_and_resolve_via_did_string() {
let env = Env::default();
env.mock_all_auths();
let (_id, client) = deploy(&env);
let owner = Address::generate(&env);
let did = make_did(&env, 20);
let did_str = canonical_string(&env, 20);
let mut attrs = Map::new(&env);
attrs.set(symbol_short!("name"), Bytes::from_slice(&env, b"Alice"));

assert!(client.validate_did(&did_str));

client.register_string(&owner, &did_str, &attrs);
let doc = client.resolve_string(&did_str).unwrap();
assert_eq!(doc.owner, owner);
// The string form resolves to the same document as the raw 32-byte key.
assert_eq!(client.resolve(&did).unwrap().owner, owner);
// Contract-side formatter round-trips to the exact canonical string.
assert_eq!(client.did_to_string(&did), did_str);
assert!(client.validate_did(&client.did_to_string(&did)));
}

#[test]
fn test_malformed_did_strings_rejected() {
let env = Env::default();
env.mock_all_auths();
let (_id, client) = deploy(&env);
let owner = Address::generate(&env);

let hex64 = [b'a'; 64];
// Sanity: the happy-path construction is accepted by the view.
let valid = cat(&env, &[b"did:stellar:", &hex64]);
assert!(client.validate_did(&valid));

let cases = [
cat(&env, &[]), // empty
cat(&env, &[b"did:stellar:"]), // prefix only
cat(&env, &[b"did:stellar:", &hex64[..63]]), // 63 hex chars
cat(&env, &[b"did:stellar:", &hex64, b"a"]), // 65 hex chars
cat(&env, &[b"did:stellar:", &[b'z'; 64]]), // non-hex payload
cat(&env, &[b"did:ethr:", &hex64]), // wrong method
cat(&env, &[b"did:STELLAR:", &hex64]), // case-sensitive prefix
cat(&env, &[&hex64]), // missing prefix
cat(&env, &[b"did:stellar:", &hex64, b"x"]), // trailing suffix
];
for case in cases {
assert!(!client.validate_did(&case), "view must reject: {case:?}");
let reg = client.try_register_string(&owner, &case, &Map::new(&env));
assert!(reg.is_err(), "register_string must reject: {case:?}");
let res = client.try_resolve_string(&case);
assert!(res.is_err(), "resolve_string must reject: {case:?}");
}
}

// ── Unauthorized key binding (SC-010) ─────────────────────────────────────────

#[test]
fn test_unauthorized_key_binding_rejected() {
let env = Env::default();
env.mock_all_auths();
let (_id, client) = deploy(&env);
let owner = Address::generate(&env);
let stranger = Address::generate(&env);
let did = make_did(&env, 21);
register_default(&env, &client, &owner, &did);

// A non-owner must be rejected from every binding operation.
let handle = String::from_str(&env, "mallory");
assert!(client.try_bind_github(&stranger, &did, &handle).is_err());
assert!(client
.try_bind_social(&stranger, &did, &Symbol::new(&env, "discord"), &handle)
.is_err());
let key = BytesN::from_array(&env, &[9u8; 32]);
assert!(client
.try_set_verification_key(&stranger, &did, &key)
.is_err());
assert!(client.try_revoke_verification_key(&stranger, &did).is_err());
assert!(client
.try_add_contributor_proof(&stranger, &did, &make_proof(&env, "mallory"))
.is_err());

// None of the attempts mutated the document.
let doc = client.resolve(&did).unwrap();
assert!(doc.github_handle.is_none());
assert_eq!(client.get_verification_key(&did), None);
assert_eq!(client.get_proofs(&did).len(), 0);
}

#[test]
fn test_owner_revoke_verification_key() {
let env = Env::default();
env.mock_all_auths();
let (_id, client) = deploy(&env);
let owner = Address::generate(&env);
let did = make_did(&env, 22);
register_default(&env, &client, &owner, &did);

let key = BytesN::from_array(&env, &[7u8; 32]);
client.set_verification_key(&owner, &did, &key);
assert_eq!(client.get_verification_key(&did), Some(key.clone()));

client.revoke_verification_key(&owner, &did);
assert_eq!(client.get_verification_key(&did), None);

// A new key can be bound again after revocation.
client.set_verification_key(&owner, &did, &key);
assert_eq!(client.get_verification_key(&did), Some(key));
}
Loading
Loading