From 54e870d61c4f29d32e306d1099e4d08db3cd6c74 Mon Sep 17 00:00:00 2001 From: Esther Date: Sun, 18 Oct 2026 18:53:10 +0100 Subject: [PATCH] feat(SC-010): did:stellar string validation, key revocation, wire test suite (#1631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split spec key files: types.rs (ContributorProof, DIDDocument, DataKey) and verification.rs (pure did:stellar string parsing/formatting and the shared owner-or-controller authorization predicate) - AC: reject malformed DID strings — new string entry points register_string and resolve_string accept only canonical 'did:stellar:<64 hex>' and fail with typed DIDError::MalformedDID (1); validate_did is a non-panicking view for resolver input; did_to_string is the exact inverse formatter - AC: owner-only delegated key lifecycle — new revoke_verification_key (owner-only, clears and allows rebinding of the Ed25519 signing key) alongside the existing owner-only set_verification_key - AC: unauthorized key binding attempts rejected — new tests prove a non-owner cannot bind github/social handles, set or revoke the verification key, or add contributor proofs, with no state mutation - Wire the previously dead src/test.rs (19 tests) by renaming the inline lib.rs module to 'tests' and declaring 'mod test'; 4 new contract tests cover string round-trip, malformed rejection, unauthorized bindings, and owner revocation --- contracts/did_registry/src/lib.rs | 113 ++++++++----- contracts/did_registry/src/test.rs | 159 ++++++++++++++++- contracts/did_registry/src/types.rs | 48 ++++++ contracts/did_registry/src/verification.rs | 188 +++++++++++++++++++++ 4 files changed, 457 insertions(+), 51 deletions(-) create mode 100644 contracts/did_registry/src/types.rs create mode 100644 contracts/did_registry/src/verification.rs diff --git a/contracts/did_registry/src/lib.rs b/contracts/did_registry/src/lib.rs index 2d739b258..6cadaff75 100644 --- a/contracts/did_registry/src/lib.rs +++ b/contracts/did_registry/src/lib.rs @@ -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 @@ -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, - /// Social handles keyed by provider symbol (github or discord). - pub social_identities: Map, - /// Ed25519 verification key (32 bytes) used to sign contributor proofs. - pub verification_key: Option>, - pub attributes: Map, - pub controllers: Vec
, - pub revoked: bool, - /// Stored contributor proofs (mirror of on-chain claims). - pub proofs: Vec, -} +pub use types::*; -#[contracttype] -pub enum DataKey { - DIDs, - /// Map, Vec> — 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] @@ -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, 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( @@ -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() { @@ -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; @@ -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) { + // 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 { + 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:` 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 { let dids: Map, DIDDocument> = env @@ -311,7 +337,7 @@ impl DIDRegistryContract { } #[cfg(test)] -mod test { +mod tests { use super::*; use soroban_sdk::{symbol_short, testutils::Address as _, BytesN, Env}; @@ -382,3 +408,6 @@ mod test { assert!(doc.revoked); } } + +#[cfg(test)] +mod test; diff --git a/contracts/did_registry/src/test.rs b/contracts/did_registry/src/test.rs index 8fee07bbd..a4e3c6415 100644 --- a/contracts/did_registry/src/test.rs +++ b/contracts/did_registry/src/test.rs @@ -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 ─────────────────────────────────────────────────────────────────── @@ -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, @@ -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 ─────────────────────────────────────────────────── @@ -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 ──────────────────────────────────────────────────────────────── @@ -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)); +} diff --git a/contracts/did_registry/src/types.rs b/contracts/did_registry/src/types.rs new file mode 100644 index 000000000..fb65bd14a --- /dev/null +++ b/contracts/did_registry/src/types.rs @@ -0,0 +1,48 @@ +//! Core storage types for the `did:stellar` DID registry. +//! +//! Spec key files for SC-010 split these out of `lib.rs` alongside +//! `verification.rs`. + +use soroban_sdk::{contracttype, Address, Bytes, BytesN, 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, +} + +#[derive(Clone)] +#[contracttype] +pub struct DIDDocument { + pub owner: Address, + /// Off-chain GitHub handle bound to this DID. + pub github_handle: Option, + /// Social handles keyed by provider symbol (github or discord). + pub social_identities: Map, + /// Ed25519 verification key (32 bytes) used to sign contributor proofs. + pub verification_key: Option>, + pub attributes: Map, + pub controllers: Vec
, + pub revoked: bool, + /// Stored contributor proofs (mirror of on-chain claims). + pub proofs: Vec, +} + +#[contracttype] +pub enum DataKey { + DIDs, + /// Map, Vec> — claims per DID. + Proofs, +} diff --git a/contracts/did_registry/src/verification.rs b/contracts/did_registry/src/verification.rs new file mode 100644 index 000000000..82b60cda9 --- /dev/null +++ b/contracts/did_registry/src/verification.rs @@ -0,0 +1,188 @@ +//! DID string parsing and shared authorization helpers for `did:stellar:`. +//! +//! Spec key file for SC-010. The registry stores DIDs as raw 32-byte keys, +//! but the `did:stellar` method's external form is the canonical W3C string +//! `did:stellar:<64 hex chars>`. Everything in this module is pure and +//! side-effect free so it can be unit tested without deploying the contract. + +use crate::types::DIDDocument; +use soroban_sdk::{Address, BytesN, Env, String}; + +/// Canonical `did:stellar:` method prefix. +pub const DID_PREFIX: &[u8] = b"did:stellar:"; + +/// Canonical length: 12-byte prefix + 64 hex chars encoding 32 bytes. +pub const DID_STR_LEN: usize = 76; + +/// Lowercase hex digits used when formatting DIDs. +const HEX: &[u8; 16] = b"0123456789abcdef"; + +fn hex_nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// Structural validation of a `did:stellar:` string. Never panics. +/// +/// Accepts exactly `did:stellar:` followed by 64 hex chars (either case), +/// and rejects everything else: wrong prefix, wrong method, wrong length, +/// non-hex payload, or any extra suffix. +pub fn is_valid_did_string(did: &String) -> bool { + check(did).is_some() +} + +fn check(did: &String) -> Option<()> { + let b = did.to_bytes(); + if b.len() as usize != DID_STR_LEN { + return None; + } + for (i, expected) in DID_PREFIX.iter().enumerate() { + if b.get(i as u32)? != *expected { + return None; + } + } + for i in DID_PREFIX.len()..DID_STR_LEN { + hex_nibble(b.get(i as u32)?)?; + } + Some(()) +} + +/// Parse a canonical `did:stellar:` string into its raw 32-byte key. +/// Returns `None` when the string is malformed. +pub fn parse_did_string(env: &Env, did: &String) -> Option> { + check(did)?; + let b = did.to_bytes(); + let mut out = [0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + let hi = hex_nibble(b.get((DID_PREFIX.len() + 2 * i) as u32)?)?; + let lo = hex_nibble(b.get((DID_PREFIX.len() + 2 * i + 1) as u32)?)?; + *byte = (hi << 4) | lo; + } + Some(BytesN::from_array(env, &out)) +} + +/// Format a raw 32-byte DID key as its canonical lowercase +/// `did:stellar:` string. Exact inverse of [`parse_did_string`]. +pub fn did_to_string(env: &Env, did: &BytesN<32>) -> String { + let raw = did.to_array(); + let mut buf = [0u8; DID_STR_LEN]; + buf[..DID_PREFIX.len()].copy_from_slice(DID_PREFIX); + for (i, byte) in raw.iter().enumerate() { + buf[DID_PREFIX.len() + 2 * i] = HEX[(byte >> 4) as usize]; + buf[DID_PREFIX.len() + 2 * i + 1] = HEX[(byte & 0x0f) as usize]; + } + String::from_bytes(env, &buf) +} + +/// Shared authorization predicate: sender is the DID owner or a controller. +pub fn is_owner_or_controller(doc: &DIDDocument, sender: &Address) -> bool { + doc.owner == *sender || doc.controllers.contains(sender) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Env, Map, Vec}; + + /// Concatenate byte parts into a soroban String (no alloc needed). + fn pad(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]) + } + + /// Canonical `did:stellar:` + 64 hex chars of `[byte; 32]`, + /// built with hardcoded digits (independent of the nibble math above). + fn canonical(env: &Env, hi: u8, lo: u8) -> String { + let mut buf = [0u8; DID_STR_LEN]; + buf[..DID_PREFIX.len()].copy_from_slice(DID_PREFIX); + for i in 0..32 { + buf[DID_PREFIX.len() + 2 * i] = hi; + buf[DID_PREFIX.len() + 2 * i + 1] = lo; + } + String::from_bytes(env, &buf) + } + + #[test] + fn test_parse_accepts_canonical_string() { + let env = Env::default(); + let canonical = canonical(&env, b'a', b'b'); + assert!(is_valid_did_string(&canonical)); + let key = parse_did_string(&env, &canonical).unwrap(); + assert_eq!(key, BytesN::from_array(&env, &[0xabu8; 32])); + } + + #[test] + fn test_parse_accepts_uppercase_hex() { + let env = Env::default(); + let upper = canonical(&env, b'A', b'B'); + assert!(is_valid_did_string(&upper)); + let key = parse_did_string(&env, &upper).unwrap(); + assert_eq!(key, BytesN::from_array(&env, &[0xabu8; 32])); + } + + #[test] + fn test_format_roundtrips() { + let env = Env::default(); + let key = BytesN::from_array(&env, &[0xabu8; 32]); + let text = did_to_string(&env, &key); + assert_eq!(text, canonical(&env, b'a', b'b')); + assert_eq!(parse_did_string(&env, &text), Some(key)); + } + + #[test] + fn test_rejects_malformed_strings() { + let env = Env::default(); + let hex64 = [b'a'; 64]; + // Sanity: the constructed happy-path string is valid. + assert!(is_valid_did_string(&pad(&env, &[b"did:stellar:", &hex64]))); + let cases = [ + pad(&env, &[]), // empty + pad(&env, &[b"did:stellar:"]), // prefix only + pad(&env, &[b"did:stellar:", &hex64[..63]]), // 63 hex (75 chars) + pad(&env, &[b"did:stellar:", &hex64, b"a"]), // 65 hex (77 chars) + pad(&env, &[b"did:stellar:", &[b'z'; 64]]), // non-hex payload + pad(&env, &[b"did:ethr:", &hex64]), // wrong method + pad(&env, &[b"did:STELLAR:", &hex64]), // case-sensitive prefix + pad(&env, &[&hex64]), // no prefix + pad(&env, &[b"did:stellar:", &hex64, b"x"]), // trailing suffix + pad(&env, &[b"xdid:stellar:", &hex64]), // leading garbage + ]; + for case in cases { + assert!(!is_valid_did_string(&case), "must reject: {case:?}"); + assert!( + parse_did_string(&env, &case).is_none(), + "must reject: {case:?}" + ); + } + } + + #[test] + fn test_owner_or_controller_predicate() { + let env = Env::default(); + let owner = Address::generate(&env); + let stranger = Address::generate(&env); + let mut doc = DIDDocument { + owner: owner.clone(), + github_handle: None, + social_identities: Map::new(&env), + verification_key: None, + attributes: Map::new(&env), + controllers: Vec::new(&env), + revoked: false, + proofs: Vec::new(&env), + }; + assert!(is_owner_or_controller(&doc, &owner)); + assert!(!is_owner_or_controller(&doc, &stranger)); + doc.controllers.push_back(stranger.clone()); + assert!(is_owner_or_controller(&doc, &stranger)); + } +}