diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 1632cff2ab..5e9f6034ff 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -6,7 +6,7 @@ on: - main - dev pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [ opened, synchronize, reopened, ready_for_review ] branches: - main - dev @@ -63,7 +63,7 @@ jobs: build-and-test: runs-on: ${{ matrix.os }} - needs: [check-for-run-condition, check-for-modification] + needs: [ check-for-run-condition, check-for-modification ] if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' && needs.check-for-modification.outputs.core-modified == 'true' }} strategy: fail-fast: false @@ -123,6 +123,55 @@ jobs: with: os: ${{matrix.os}} + build-and-test-stardust: + needs: [ check-for-run-condition, check-for-modification ] + if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' && needs.check-for-modification.outputs.core-modified == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + include: + - os: ubuntu-latest + sccache-path: /home/runner/.cache/sccache + env: + SCCACHE_DIR: ${{ matrix.sccache-path }} + RUSTC_WRAPPER: sccache + + steps: + - uses: actions/checkout@v2 + + - name: Setup Rust and cache + uses: './.github/actions/rust/rust-setup' + with: + os: ${{ runner.os }} + job: ${{ github.job }} + target-cache-enabled: false + sccache-enabled: true + sccache-path: ${{ matrix.sccache-path }} + + - name: Setup sccache + uses: './.github/actions/rust/sccache/setup-sccache' + with: + os: ${{matrix.os}} + + - name: Build Stardust + uses: actions-rs/cargo@v1 + with: + command: build + args: --manifest-path ./identity_stardust/Cargo.toml --workspace --tests --examples --release + + - name: Run Stardust tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --manifest-path ./identity_stardust/Cargo.toml --all --all-features --release + + - name: Stop sccache + uses: './.github/actions/rust/sccache/stop-sccache' + with: + os: ${{matrix.os}} + build-and-test-libjose: needs: check-for-run-condition if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 4f0d1e2e3a..ce08221e1a 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -43,7 +43,14 @@ jobs: args: --all-targets --all-features -- -D warnings name: core - - name: wasm clippy check + - name: Stardust clippy check + uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --manifest-path ./identity_stardust/Cargo.toml --all-targets --all-features -- -D warnings + name: stardust + + - name: Wasm clippy check uses: actions-rs/clippy-check@v1 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 9e8894b595..ae910b33a3 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -41,6 +41,12 @@ jobs: command: fmt args: --all -- --check + - name: Stardust fmt check + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --manifest-path ./identity_stardust/Cargo.toml --all -- --check + - name: wasm fmt check uses: actions-rs/cargo@v1 with: diff --git a/.github/workflows/test-docs-build.yml b/.github/workflows/test-docs-build.yml index 17a969ab94..a93ae0d687 100644 --- a/.github/workflows/test-docs-build.yml +++ b/.github/workflows/test-docs-build.yml @@ -43,3 +43,11 @@ jobs: toolchain: nightly args: --all-features --no-deps --workspace + - name: Test Stardust Rust Documentation + uses: actions-rs/cargo@v1 + env: + RUSTDOCFLAGS: "-D warnings --cfg docsrs" + with: + command: doc + toolchain: nightly + args: --manifest-path ./identity_stardust/Cargo.toml --all-features --no-deps --workspace diff --git a/identity_stardust/Cargo.toml b/identity_stardust/Cargo.toml new file mode 100644 index 0000000000..212548aa76 --- /dev/null +++ b/identity_stardust/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "identity_stardust" +version = "0.6.0" +authors = ["IOTA Stiftung"] +edition = "2021" +homepage = "https://www.iota.org" +keywords = ["iota", "tangle", "stardust", "identity"] +license = "Apache-2.0" +readme = "../README.md" +repository = "https://github.com/iotaledger/identity.rs" +description = "An IOTA Ledger integration for the identity.rs library." + +[workspace] + +[dependencies] +identity_core = { version = "=0.6.0", path = "../identity_core", default-features = false } +identity_credential = { version = "=0.6.0", path = "../identity_credential", default-features = false } +identity_did = { version = "=0.6.0", path = "../identity_did", default-features = false } +lazy_static = { version = "1.4", default-features = false } +serde = { version = "1.0", default-features = false, features = ["std", "derive"] } +strum = { version = "0.21", features = ["derive"] } +thiserror = { version = "1.0", default-features = false } + +[dependencies.iota-client] +git = "https://github.com/iotaledger/iota.rs" +rev = "4e7db070a05321c4bd7579acdcc74436865235c0" # develop branch, 2022-07-11 +features = ["tls"] +default-features = false + +[dev-dependencies] +anyhow = { version = "1.0.57" } +iota-crypto = { version = "0.11.0", default-features = false, features = ["bip39", "bip39-en"] } +proptest = { version = "1.0.0", default-features = false, features = ["std"] } +tokio = { version = "1.17.0", default-features = false, features = ["macros"] } + +[package.metadata.docs.rs] +# To build locally: +# RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --all-features --no-deps --workspace --open +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/identity_stardust/README.md b/identity_stardust/README.md new file mode 100644 index 0000000000..6363517c21 --- /dev/null +++ b/identity_stardust/README.md @@ -0,0 +1,5 @@ +# IOTA Stardust Identity Library + +This is a work-in-progress intended to replace the `did:iota` DID Method. + +`cargo run --example create_did` diff --git a/identity_stardust/examples/create_did.rs b/identity_stardust/examples/create_did.rs new file mode 100644 index 0000000000..4c4b978d16 --- /dev/null +++ b/identity_stardust/examples/create_did.rs @@ -0,0 +1,177 @@ +use identity_core::convert::ToJson; +use iota_client::bee_block::output::feature::IssuerFeature; +use iota_client::bee_block::output::feature::MetadataFeature; +use iota_client::bee_block::output::feature::SenderFeature; +use iota_client::bee_block::output::unlock_condition::GovernorAddressUnlockCondition; +use iota_client::bee_block::output::unlock_condition::StateControllerAddressUnlockCondition; +use iota_client::bee_block::output::unlock_condition::UnlockCondition; +use iota_client::bee_block::output::AliasId; +use iota_client::bee_block::output::AliasOutputBuilder; +use iota_client::bee_block::output::ByteCostConfig; +use iota_client::bee_block::output::Feature; +use iota_client::bee_block::output::Output; +use iota_client::constants::SHIMMER_TESTNET_BECH32_HRP; +use iota_client::secret::mnemonic::MnemonicSecretManager; +use iota_client::secret::SecretManager; +use iota_client::Client; + +use identity_stardust::StardustDocument; + +// PROBLEMS SO FAR: +// 1) Alias Id is inferred from the block, so we have to use a placeholder DID for creation. +// 2) Cannot get an Output Id back from an Alias Id (hash of Output Id), need to use Indexer API. +// 3) The Output response from the Indexer is an Output, not a Block, so cannot infer Alias ID from it (fine since we +// use the ID to retrieve the Output in the first place). The OutputDto conversion is annoying too. +// 4) The pieces needed to publish an update are fragmented (Output ID for input, amount, document), bit annoying to +// reconstruct. Use a holder struct like Holder { AliasOutput, StardustDocument } with convenience functions? +// 5) Inferred fields such as the controller and governor need to reflect in the (JSON) Document but excluded from the +// StardustDocument serialization when published. Handle with a separate `pack` function like before? + +/// Demonstrate how to embed a DID Document in an Alias Output. +/// +/// iota.rs alias example: +/// https://github.com/iotaledger/iota.rs/blob/f945ccf326829a418334942ae9cf53b8fab3dbe5/examples/outputs/alias.rs +/// +/// iota.js mint-nft example: +/// https://github.com/iotaledger/iota.js/blob/79a71d3a2ad03be5bd6148689d083947f3b98476/packages/iota/examples/mint-nft/src/index.ts +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // let endpoint = "http://localhost:14265"; + let endpoint = "https://api.alphanet.iotaledger.net"; + let faucet_manual = "https://faucet.alphanet.iotaledger.net"; + + // =========================================================================== + // Step 1: Create or load your wallet. + // =========================================================================== + + // let keypair = identity_core::crypto::KeyPair::new(identity_core::crypto::KeyType::Ed25519).unwrap(); + // println!("PrivateKey: {}", keypair.private().to_string()); + // let mnemonic = + // iota_client::crypto::keys::bip39::wordlist::encode(keypair.private().as_ref(),&bip39::wordlist::ENGLISH).unwrap(); + + // NOTE: this is just a randomly generated mnemonic, REMOVE THIS, never actually commit your seed or mnemonic. + let mnemonic = "veteran provide abstract express quick another fee dragon trend extend cotton tail dog truly angle napkin lunch dinosaur shrimp odor gain bag media mountain"; + println!("Mnemonic: {}", mnemonic); + let secret_manager = SecretManager::Mnemonic(MnemonicSecretManager::try_from_mnemonic(mnemonic)?); + + // Create a client instance. + let client = Client::builder() + .with_node(endpoint)? + .with_node_sync_disabled() + .finish() + .await?; + + let address = client.get_addresses(&secret_manager).with_range(0..1).get_raw().await?[0]; + let address_bech32 = address.to_bech32(SHIMMER_TESTNET_BECH32_HRP); + println!("Wallet address: {address_bech32}"); + + println!("INTERACTION REQUIRED: request faucet funds to the above wallet from {faucet_manual}"); + // let faucet_auto = format!("{endpoint}/api/plugins/faucet/v1/enqueue"); + // iota_client::request_funds_from_faucet(&faucet_auto, &address_bech32).await?; + // tokio::time::sleep(std::time::Duration::from_secs(15)).await; + + // =========================================================================== + // Step 2: Create and publish a DID Document in an Alias Output. + // =========================================================================== + + // Create an empty DID Document. + // All new Stardust DID Documents initially use a placeholder DID, + // "did:stardust:0x00000000000000000000000000000000". + let document: StardustDocument = StardustDocument::new(); + println!("DID Document {:#}", document); + + // Create a new Alias Output with the DID Document as state metadata. + let byte_cost_config: ByteCostConfig = client.get_byte_cost_config().await?; + let alias_output: Output = AliasOutputBuilder::new_with_minimum_storage_deposit(byte_cost_config, AliasId::null())? + .with_state_index(0) + .with_foundry_counter(0) + .with_state_metadata(document.to_json_vec()?) + .add_feature(Feature::Sender(SenderFeature::new(address))) + .add_feature(Feature::Metadata(MetadataFeature::new(vec![1, 2, 3])?)) + .add_immutable_feature(Feature::Issuer(IssuerFeature::new(address))) + .add_unlock_condition(UnlockCondition::StateControllerAddress( + StateControllerAddressUnlockCondition::new(address), + )) + .add_unlock_condition(UnlockCondition::GovernorAddress(GovernorAddressUnlockCondition::new( + address, + ))) + .finish_output()?; + println!("Deposit amount: {}", alias_output.amount()); + + // Publish to the Tangle ledger. + let block = client + .block() + .with_secret_manager(&secret_manager) + .with_outputs(vec![alias_output])? + .finish() + .await?; + println!( + "Transaction with new alias output sent: {endpoint}/api/v2/blocks/{}", + block.id() + ); + let _ = client.retry_until_included(&block.id(), None, None).await?; + + // Infer DID from Alias Output block. + let did = StardustDocument::did_from_block(&block)?; + println!("DID: {did}"); + + // =========================================================================== + // Step 3: Resolve a DID Document. + // =========================================================================== + // iota.rs indexer example: + // https://github.com/iotaledger/iota.rs/blob/f945ccf326829a418334942ae9cf53b8fab3dbe5/examples/indexer.rs + + // Extract Alias ID from DID. + let alias_id: AliasId = StardustDocument::did_to_alias_id(&did)?; + println!("Alias ID: {alias_id}"); + + // Query Indexer INX Plugin for the Output of the Alias ID. + let output_id = client.alias_output_id(alias_id).await?; + println!("Output ID: {output_id}"); + let response = client.get_output(&output_id).await?; + let output = Output::try_from(&response.output)?; + println!("Output: {output:?}"); + + // The resolved DID Document replaces the placeholder DID with the correct one. + let resolved_document = StardustDocument::deserialize_from_output(&alias_id, &output)?; + println!("Resolved Document: {resolved_document:#}"); + + let alias_output = match output { + Output::Alias(output) => Ok(output), + _ => Err(anyhow::anyhow!("not an alias output")), + }?; + + // =========================================================================== + // Step 4: Publish an updated Alias ID. (optional) + // =========================================================================== + // TODO: we could always publish twice on creation to populate the DID (could fail), + // or just infer the DID during resolution (safer). + + // Update the Alias Output to contain an explicit ID and DID. + let updated_alias_output = AliasOutputBuilder::from(&alias_output) // Not adding any content, previous amount will cover the deposit. + // Set the explicit Alias ID. + .with_alias_id(alias_id) + // Update the DID Document content to replace the placeholder DID. + .with_state_metadata(resolved_document.to_json_vec()?) + // State controller updates increment the state index. + .with_state_index(alias_output.state_index() + 1) + .finish_output()?; + + println!("Updated output: {updated_alias_output:?}"); + + let block = client + .block() + .with_secret_manager(&secret_manager) + .with_input(output_id.into())? + .with_outputs(vec![updated_alias_output])? + .finish() + .await?; + + println!( + "Transaction with alias id set sent: {endpoint}/api/v2/blocks/{}", + block.id() + ); + let _ = client.retry_until_included(&block.id(), None, None).await?; + + Ok(()) +} diff --git a/identity_stardust/src/error.rs b/identity_stardust/src/error.rs new file mode 100644 index 0000000000..0561931837 --- /dev/null +++ b/identity_stardust/src/error.rs @@ -0,0 +1,21 @@ +// Copyright 2020-2022 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +pub type Result = core::result::Result; + +// TODO: replace all variants with specific errors? +#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] +pub enum Error { + #[error("{0}")] + CoreError(#[from] identity_core::Error), + #[error("{0}")] + CredError(#[from] identity_credential::Error), + #[error("{0}")] + InvalidDID(#[from] identity_did::did::DIDError), + #[error("{0}")] + InvalidDoc(#[from] identity_did::Error), + #[error("{0}")] + ClientError(#[from] iota_client::error::Error), + #[error("{0}")] + BeeError(#[from] iota_client::bee_block::Error), +} diff --git a/identity_stardust/src/lib.rs b/identity_stardust/src/lib.rs new file mode 100644 index 0000000000..50a7bc1c99 --- /dev/null +++ b/identity_stardust/src/lib.rs @@ -0,0 +1,13 @@ +// Copyright 2020-2022 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![forbid(unsafe_code)] +#![allow(clippy::upper_case_acronyms)] + +pub use self::error::Error; +pub use self::error::Result; + +pub use stardust_document::StardustDocument; + +mod error; +mod stardust_document; diff --git a/identity_stardust/src/stardust_document.rs b/identity_stardust/src/stardust_document.rs new file mode 100644 index 0000000000..35ff25dc6e --- /dev/null +++ b/identity_stardust/src/stardust_document.rs @@ -0,0 +1,172 @@ +// Copyright 2020-2022 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use core::fmt::Debug; +use core::fmt::Display; +use core::fmt::Formatter; +use core::fmt::Result as FmtResult; +use std::str::FromStr; + +use identity_core::common::Object; +use identity_core::convert::FmtJson; +use identity_core::convert::FromJson; +use identity_core::utils::Base; +use identity_core::utils::BaseEncoding; +use identity_did::did::CoreDID; +use identity_did::did::DID; +use identity_did::document::CoreDocument; +use iota_client::bee_block::output::AliasId; +use iota_client::bee_block::output::Output; +use iota_client::bee_block::output::OutputId; +use iota_client::bee_block::payload::transaction::TransactionEssence; +use iota_client::bee_block::payload::Payload; +use iota_client::bee_block::Block; +use lazy_static::lazy_static; +use serde::Deserialize; +use serde::Serialize; + +use crate::error::Result; + +/// An IOTA DID document resolved from the Tangle. Represents an integration chain message possibly +/// merged with one or more diff messages. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct StardustDocument(CoreDocument); + +// Tag is 64-bytes long, matching the hex-encoding of the Alias ID (without 0x prefix). +// TODO: should we just keep the 0x prefix in the tag? Other DID methods like did:ethr do... +lazy_static! { + static ref PLACEHOLDER_DID: CoreDID = + CoreDID::parse("did:stardust:0000000000000000000000000000000000000000000000000000000000000000").unwrap(); +} + +impl StardustDocument { + /// Constructs an empty DID Document with a [`StardustDocument::placeholder_did`] identifier. + pub fn new() -> StardustDocument { + Self( + // PANIC: constructing an empty DID Document is infallible, caught by tests otherwise. + CoreDocument::builder(Object::default()) + .id(Self::placeholder_did().clone()) + .build() + .expect("empty StardustDocument constructor failed"), + ) + } + + /// Returns the placeholder DID of newly constructed DID Documents, + /// `"did:stardust:0000000000000000000000000000000000000000000000000000000000000000"`. + // TODO: generalise to take network name? + pub fn placeholder_did() -> &'static CoreDID { + &PLACEHOLDER_DID + } + + /// Constructs a DID from an Alias ID. + /// + /// Uses the hex-encoding of the Alias ID as the DID tag. + pub fn alias_id_to_did(id: &AliasId) -> Result { + // Manually encode to hex to avoid 0x prefix. + let hex: String = BaseEncoding::encode(id.as_slice(), Base::Base16Lower); + CoreDID::parse(format!("did:stardust:{hex}")).map_err(Into::into) + } + + pub fn did_to_alias_id(did: &CoreDID) -> Result { + // TODO: just use 0x in the tag as well? + // Prepend 0x manually. + AliasId::from_str(&format!("0x{}", did.method_id())).map_err(Into::into) + } + + // TODO: can hopefully remove if the publishing logic is wrapped. + pub fn did_from_block(block: &Block) -> Result { + let id: AliasId = AliasId::from(get_alias_output_id_from_payload(block.payload().unwrap())); + Self::alias_id_to_did(&id) + } + + fn parse_block(block: &Block) -> (AliasId, &[u8], bool) { + match block.payload().unwrap() { + Payload::Transaction(tx_payload) => { + let TransactionEssence::Regular(regular) = tx_payload.essence(); + for (index, output) in regular.outputs().iter().enumerate() { + if let Output::Alias(alias_output) = output { + let alias_id = alias_output + .alias_id() + .or_from_output_id(OutputId::new(tx_payload.id(), index.try_into().unwrap()).unwrap()); + let document = alias_output.state_metadata(); + let first = alias_output.state_index() == 0; + return (alias_id, document, first); + } + } + panic!("No alias output in transaction essence") + } + _ => panic!("No tx payload"), + }; + } + + /// Deserializes a JSON-encoded `StardustDocument` from an Alias Output block. + /// + /// NOTE: [`AliasId`] is required since it cannot be inferred from the [`Output`] alone + /// for the first time an Alias Output is published, the transaction payload is required. + pub fn deserialize_from_output(alias_id: &AliasId, output: &Output) -> Result { + let (document, first): (&[u8], bool) = match output { + Output::Alias(alias_output) => (alias_output.state_metadata(), alias_output.alias_id().is_null()), + _ => panic!("not an alias output"), + }; + Self::deserialize_inner(alias_id, document, first) + } + + /// Deserializes a JSON-encoded `StardustDocument` from an Alias Output block. + pub fn deserialize_from_block(block: &Block) -> Result { + let (alias_id, document, first) = Self::parse_block(block); + Self::deserialize_inner(&alias_id, document, first) + } + + pub fn deserialize_inner(alias_id: &AliasId, document: &[u8], first: bool) -> Result { + let did: CoreDID = Self::alias_id_to_did(alias_id)?; + + // Replace the placeholder DID in the Document content for the first Alias Output block. + // TODO: maybe _always_ do this replacement in case developers forget to replace it? + if first { + let json = String::from_utf8(document.to_vec()).unwrap(); + let replaced = json.replace(Self::placeholder_did().as_str(), did.as_str()); + StardustDocument::from_json(&replaced).map_err(Into::into) + } else { + StardustDocument::from_json_slice(document).map_err(Into::into) + } + } +} + +impl Default for StardustDocument { + fn default() -> Self { + Self::new() + } +} + +impl Display for StardustDocument { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + self.fmt_json(f) + } +} + +// helper function to get the output id for the first alias output +fn get_alias_output_id_from_payload(payload: &Payload) -> OutputId { + match payload { + Payload::Transaction(tx_payload) => { + let TransactionEssence::Regular(regular) = tx_payload.essence(); + for (index, output) in regular.outputs().iter().enumerate() { + if let Output::Alias(_alias_output) = output { + return OutputId::new(tx_payload.id(), index.try_into().unwrap()).unwrap(); + } + } + panic!("No alias output in transaction essence") + } + _ => panic!("No tx payload"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let document: StardustDocument = StardustDocument::new(); + assert_eq!(document.0.id(), StardustDocument::placeholder_did()); + } +}