├── website ├── specs │ ├── specs.md │ ├── archives.md │ ├── sequences.md │ ├── explainer.md │ └── memos.md ├── static │ ├── demo.css │ ├── logo2.png │ ├── og-image.png │ ├── menu_24dp_000_FILL0_wght400_GRAD0_opsz24.svg │ ├── close_24dp_000_FILL0_wght400_GRAD0_opsz24.svg │ ├── link-external-small-ltr-progressive.svg │ ├── main.js │ ├── shared.js │ ├── demo.js │ └── favicon.svg ├── .gitignore ├── _data │ └── site.js ├── eleventy.config.ts ├── dev.md ├── guides │ └── quickstart.md ├── _includes │ ├── meta.liquid │ ├── sidebar.liquid │ └── index.liquid ├── package.json ├── demo │ └── index.html ├── index.md └── media │ └── cosmos-institute.svg ├── rust ├── szdt_cli │ ├── src │ │ ├── db.rs │ │ ├── lib.rs │ │ ├── rand.rs │ │ ├── config.rs │ │ ├── error.rs │ │ ├── szdt.rs │ │ ├── file.rs │ │ ├── key_storage.rs │ │ ├── db │ │ │ └── migrations.rs │ │ ├── migrations.rs │ │ └── bin │ │ │ └── szdt.rs │ ├── Cargo.toml │ └── README.md ├── szdt_core │ ├── src │ │ ├── time.rs │ │ ├── value.rs │ │ ├── lib.rs │ │ ├── link.rs │ │ ├── base58btc.rs │ │ ├── contact.rs │ │ ├── text.rs │ │ ├── content_type.rs │ │ ├── bytes.rs │ │ ├── nickname.rs │ │ ├── error.rs │ │ ├── hashseq.rs │ │ ├── mnemonic.rs │ │ ├── ed25519_key_material.rs │ │ ├── hash.rs │ │ ├── did.rs │ │ ├── cbor_seq.rs │ │ ├── ed25519.rs │ │ └── memo.rs │ ├── README.md │ └── Cargo.toml ├── szdt_wasm │ ├── Cargo.toml │ ├── src │ │ ├── lib.rs │ │ ├── hash.rs │ │ ├── did_key.rs │ │ ├── mnemonic.rs │ │ ├── ed25519_key_material.rs │ │ ├── memo.rs │ │ └── cbor_seq.rs │ └── README.md └── Cargo.toml ├── .gitignore ├── logo.afdesign ├── scripts └── setup.sh ├── LICENSE-MIT ├── .github └── workflows │ ├── rust.yml │ └── build-website.yml ├── justfile ├── README.md ├── log.md └── WHITEPAPER.md /website/specs/specs.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/demo.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/db.rs: -------------------------------------------------------------------------------- 1 | pub mod migrations; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .DS_Store 3 | node_modules/ 4 | docs/ 5 | -------------------------------------------------------------------------------- /logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gordonbrander/szdt/HEAD/logo.afdesign -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | cargo install wasm-pack 4 | cargo install just 5 | -------------------------------------------------------------------------------- /website/static/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gordonbrander/szdt/HEAD/website/static/logo2.png -------------------------------------------------------------------------------- /website/static/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gordonbrander/szdt/HEAD/website/static/og-image.png -------------------------------------------------------------------------------- /rust/szdt_cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod db; 3 | pub mod error; 4 | pub mod file; 5 | pub mod key_storage; 6 | pub mod rand; 7 | pub mod szdt; 8 | -------------------------------------------------------------------------------- /website/_data/site.js: -------------------------------------------------------------------------------- 1 | // Settings can be toggled for prod via 2 | // const isProduction = process.env.NODE_ENV === "production"; 3 | 4 | export default { 5 | url: "/", 6 | }; 7 | -------------------------------------------------------------------------------- /website/static/menu_24dp_000_FILL0_wght400_GRAD0_opsz24.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /website/static/close_24dp_000_FILL0_wght400_GRAD0_opsz24.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /rust/szdt_core/src/time.rs: -------------------------------------------------------------------------------- 1 | use std::time::{SystemTime, UNIX_EPOCH}; 2 | 3 | /// Get the current epoch time in seconds 4 | pub fn now() -> u64 { 5 | SystemTime::now() 6 | .duration_since(UNIX_EPOCH) 7 | .expect("Expected now to be greater than epoch") 8 | .as_secs() 9 | } 10 | -------------------------------------------------------------------------------- /website/eleventy.config.ts: -------------------------------------------------------------------------------- 1 | import { type EleventyConfig } from "@11ty/eleventy"; 2 | 3 | export default (config: EleventyConfig): void => { 4 | config.setOutputDirectory("../docs"); 5 | config.addPassthroughCopy("static"); 6 | config.addPassthroughCopy("media"); 7 | config.addPassthroughCopy("vendor"); 8 | }; 9 | -------------------------------------------------------------------------------- /rust/szdt_core/src/value.rs: -------------------------------------------------------------------------------- 1 | use crate::memo::Memo; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | /// Represents a value in the SZDT format. 5 | /// Either Memo or a CBOR Value. 6 | #[derive(Serialize, Deserialize)] 7 | #[serde(untagged)] 8 | pub enum Value { 9 | Memo(Box), 10 | Value(cbor4ii::core::Value), 11 | } 12 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/rand.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use rand::{TryRngCore, rngs::OsRng}; 3 | 4 | pub fn generate_entropy() -> Result<[u8; 32], Error> { 5 | let mut rng = OsRng; 6 | let mut bytes = [0u8; 32]; 7 | rng.try_fill_bytes(&mut bytes) 8 | .map_err(|e| Error::Rand(e.to_string()))?; 9 | Ok(bytes) 10 | } 11 | -------------------------------------------------------------------------------- /website/static/link-external-small-ltr-progressive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | external link 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /website/dev.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: index.liquid 3 | title: Development - SZDT 4 | --- 5 | 6 | # Development 7 | 8 | ## Installing binaries on your path with Cargo 9 | 10 | From the project directory: 11 | 12 | ```bash 13 | cargo install --path . 14 | ``` 15 | 16 | This will install the binaries to `~/.cargo/bin`, which is usually added to your path by the Rust installer. 17 | -------------------------------------------------------------------------------- /rust/szdt_core/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod base58btc; 2 | pub mod bytes; 3 | pub mod cbor_seq; 4 | pub mod contact; 5 | pub mod content_type; 6 | pub mod did; 7 | pub mod ed25519; 8 | pub mod ed25519_key_material; 9 | pub mod error; 10 | pub mod hash; 11 | pub mod hashseq; 12 | pub mod link; 13 | pub mod memo; 14 | pub mod mnemonic; 15 | pub mod nickname; 16 | pub mod text; 17 | pub mod time; 18 | pub mod value; 19 | -------------------------------------------------------------------------------- /rust/szdt_core/README.md: -------------------------------------------------------------------------------- 1 | # SZDT Core 2 | 3 | **S**igned **Z**ero-trust **D**a**T**a. Pronounced "Samizdat". 4 | 5 | TDLR: signed CBOR for censorship-resistant data. 6 | 7 | - [Whitepaper](./WHITEPAPER.md) 8 | - [Website](https://szdt.dev) 9 | 10 | This crate provides the core library for SZDT. You can use it to build libraries with SZDT in Rust. It is also used by the SZDT CLI and the WASM/web bindings. 11 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use std::path::PathBuf; 3 | 4 | /// Returns the path to the SZDT config directory. 5 | pub fn config_dir() -> Result { 6 | Ok(dirs::home_dir() 7 | .ok_or(Error::Fs("Unable to locate home directory".to_string()))? 8 | .join(".szdt")) 9 | } 10 | 11 | /// Returns the path to the keys directory. 12 | pub fn contacts_file() -> Result { 13 | Ok(config_dir()?.join("contacts.sqlite")) 14 | } 15 | -------------------------------------------------------------------------------- /rust/szdt_wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "szdt_wasm" 3 | version = "0.0.1" 4 | edition = "2024" 5 | description = "WebAssembly bindings for SZDT" 6 | 7 | [lib] 8 | crate-type = ["cdylib"] 9 | 10 | [dependencies] 11 | szdt_core = { path = "../szdt_core" } 12 | wasm-bindgen = "0.2" 13 | js-sys = "0.3" 14 | serde-wasm-bindgen = "0.6" 15 | wee_alloc = "0.4" 16 | serde_cbor_core = { workspace = true } 17 | cbor4ii = { workspace = true } 18 | 19 | [dependencies.web-sys] 20 | version = "0.3" 21 | features = ["console", "Window", "Crypto"] 22 | 23 | [package.metadata.wasm-pack.profile.release] 24 | wasm-opt = false 25 | -------------------------------------------------------------------------------- /website/static/main.js: -------------------------------------------------------------------------------- 1 | export const _ = (selector, root = document) => 2 | root.querySelector(`:scope ${selector}`); 3 | 4 | export const on = (element, event, callback, options) => { 5 | element.addEventListener(event, callback, options); 6 | return () => { 7 | element.removeEventListener(event, callback, options); 8 | }; 9 | }; 10 | 11 | on(_("#menu"), "pointerdown", (event) => { 12 | event.preventDefault(); 13 | _("#page").classList.toggle("sidebar-open"); 14 | }); 15 | 16 | on(_("#close"), "pointerdown", (event) => { 17 | event.preventDefault(); 18 | _("#page").classList.remove("sidebar-open"); 19 | }); 20 | -------------------------------------------------------------------------------- /rust/szdt_core/src/link.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::hash::Hash; 3 | use serde::Serialize; 4 | use serde_cbor_core; 5 | 6 | pub trait ToLink { 7 | fn to_link(&self) -> Result; 8 | } 9 | 10 | impl ToLink for T 11 | where 12 | T: Serialize, 13 | { 14 | /// Generate a content-addressed link from the given data. 15 | /// Serializes content to CBOR and generates a Blake3 hash for that CBOR data. 16 | fn to_link(&self) -> Result { 17 | let cbor_data = serde_cbor_core::to_vec(self)?; 18 | let hash = Hash::new(&cbor_data); 19 | Ok(hash) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /website/guides/quickstart.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: index.liquid 3 | title: Quickstart - SZDT 4 | --- 5 | 6 | # SZDT quickstart 7 | 8 | Install CLI: 9 | 10 | ```bash 11 | cargo install szdt_cli 12 | ``` 13 | 14 | Generate a keypair and give it a nickname ("alice"): 15 | 16 | ```bash 17 | szdt key create alice 18 | ``` 19 | 20 | Create a data archive from a directory, signing it with your key: 21 | 22 | ```bash 23 | szdt archive data/ --sign alice 24 | ``` 25 | 26 | Unarchive data: 27 | 28 | ```bash 29 | szdt unarchive data.szdt 30 | ``` 31 | 32 | Signatures are verified during unpacking. 33 | 34 | Check out `szdt --help` for more information. 35 | -------------------------------------------------------------------------------- /website/static/shared.js: -------------------------------------------------------------------------------- 1 | export const createCancelGroup = () => { 2 | const cancels = new Set(); 3 | 4 | const cancel = () => { 5 | for (const cancel of cancels) { 6 | cancel(); 7 | } 8 | cancels.clear(); 9 | }; 10 | 11 | const add = (cancel) => { 12 | cancels.add(cancel); 13 | }; 14 | 15 | return { cancel, add }; 16 | }; 17 | 18 | export const on = ( 19 | element, 20 | event, 21 | callback, 22 | ) => { 23 | element.addEventListener(event, callback); 24 | return () => { 25 | element.removeEventListener(event, callback); 26 | }; 27 | }; 28 | 29 | export const $ = (selector, parent = document) => { 30 | return parent.querySelector(selector) ?? undefined; 31 | }; 32 | -------------------------------------------------------------------------------- /rust/szdt_core/src/base58btc.rs: -------------------------------------------------------------------------------- 1 | use bs58; 2 | 3 | /// Encode bytes using Base58BTC encoding. 4 | pub fn encode(bytes: I) -> String 5 | where 6 | I: AsRef<[u8]>, 7 | { 8 | bs58::encode(bytes).into_string() 9 | } 10 | 11 | pub type DecodeError = bs58::decode::Error; 12 | 13 | /// Decode bytes from Base58BTC encoding. 14 | pub fn decode(s: &str) -> Result, DecodeError> { 15 | let bytes = bs58::decode(s).into_vec()?; 16 | Ok(bytes) 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use super::*; 22 | 23 | #[test] 24 | fn test_roundtrip() { 25 | let test = b"hello world".to_vec(); 26 | let encoded = encode(&test); 27 | let decoded = decode(&encoded).unwrap(); 28 | assert_eq!(test, decoded); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /rust/szdt_core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "szdt_core" 3 | version = "0.0.2" 4 | authors = ["Gordon Brander"] 5 | edition = "2024" 6 | description = "SZDT core functionality for encoding, decoding, signing, and hashing" 7 | license = "MIT" 8 | keywords = ["szdt", "decentralization", "CBOR"] 9 | repository = "https://github.com/gordonbrander/szdt" 10 | 11 | [dependencies] 12 | bip39 = { workspace = true } 13 | blake3 = { workspace = true } 14 | bs58 = { workspace = true } 15 | cbor4ii = { workspace = true } 16 | data-encoding = { workspace = true } 17 | ed25519-dalek = { workspace = true } 18 | mime_guess2 = { workspace = true } 19 | serde = { workspace = true } 20 | serde_cbor_core = { workspace = true } 21 | thiserror = { workspace = true } 22 | 23 | [dev-dependencies] 24 | tempfile = { workspace = true } 25 | -------------------------------------------------------------------------------- /website/_includes/meta.liquid: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /rust/szdt_cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "szdt_cli" 3 | version = "0.0.2" 4 | edition = "2024" 5 | description = "SZDT CLI for signing and verifying data" 6 | authors = ["Gordon Brander"] 7 | license = "MIT" 8 | keywords = ["szdt", "decentralization", "CBOR"] 9 | repository = "https://github.com/gordonbrander/szdt" 10 | 11 | [[bin]] 12 | name = "szdt" 13 | path = "src/bin/szdt.rs" 14 | 15 | [dependencies] 16 | clap = { workspace = true } 17 | console = { workspace = true } 18 | dialoguer = { workspace = true } 19 | dirs = { workspace = true } 20 | mime_guess2 = { workspace = true } 21 | rusqlite = { workspace = true } 22 | serde = { workspace = true } 23 | thiserror = { workspace = true } 24 | rand = { workspace = true } 25 | szdt_core = { version = "0.0.2", path = "../szdt_core" } 26 | 27 | [dev-dependencies] 28 | tempfile = { workspace = true } 29 | -------------------------------------------------------------------------------- /rust/szdt_cli/README.md: -------------------------------------------------------------------------------- 1 | # SZDT CLI 2 | 3 | **S**igned **Z**ero-trust **D**a**T**a. Pronounced "Samizdat". 4 | 5 | TDLR: signed CBOR for censorship-resistant data. 6 | 7 | - [Whitepaper](./WHITEPAPER.md) 8 | - [Website](https://szdt.dev) 9 | 10 | The SZDT CLI provides command-line tools for signing and verifying data with SZDT. 11 | 12 | ## Quickstart 13 | 14 | Install: 15 | 16 | ```bash 17 | cargo install szdt_cli 18 | ``` 19 | 20 | Generate a keypair and give it a nickname ("alice"): 21 | 22 | ```bash 23 | szdt key create alice 24 | ``` 25 | 26 | Create a data archive from a directory, signing it with your key: 27 | 28 | ```bash 29 | szdt archive data/ --sign alice 30 | ``` 31 | 32 | Unarchive data: 33 | 34 | ```bash 35 | szdt unarchive data.szdt 36 | ``` 37 | 38 | Signatures are verified during unpacking. 39 | 40 | Check out `szdt --help` for more information. 41 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "szdt-website", 3 | "version": "0.0.1", 4 | "description": "Website for SZDT", 5 | "homepage": "https://github.com/gordonbrander/szdt#readme", 6 | "bugs": { 7 | "url": "https://github.com/gordonbrander/szdt/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/gordonbrander/szdt.git" 12 | }, 13 | "license": "MIT", 14 | "author": "Gordon Brander", 15 | "type": "module", 16 | "main": "index.js", 17 | "scripts": { 18 | "build:prod": "NODE_ENV=production npx @11ty/eleventy --config=eleventy.config.ts", 19 | "build:dev": "npx @11ty/eleventy --config=eleventy.config.ts", 20 | "serve": "npx @11ty/eleventy --serve --config=eleventy.config.ts", 21 | "clean": "rm -rf ../docs" 22 | }, 23 | "dependencies": { 24 | "@11ty/eleventy": "^3.1.2" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^24.0.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /website/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SZDT Archive Explorer 7 | 8 | 11 | 12 | 13 | 14 | 20 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["szdt_core", "szdt_cli", "szdt_wasm"] 3 | resolver = "2" 4 | 5 | [workspace.dependencies] 6 | bip39 = { version = "2.2.0", default-features = false, features = [ 7 | "std", 8 | "alloc", 9 | ] } 10 | blake3 = { version = "1.8.2", features = ["serde"] } 11 | bs58 = "0.5.1" 12 | cbor4ii = { version = "1.0.0", features = ["serde", "serde1", "use_alloc"] } 13 | clap = { version = "4.5.31", features = ["derive"] } 14 | console = "0.16.0" 15 | data-encoding = "2.8.0" 16 | dialoguer = "0.11.0" 17 | dirs = "6.0.0" 18 | ed25519-dalek = { version = "2.2.0", default-features = false, features = [ 19 | "alloc", 20 | "digest", 21 | "serde", 22 | "signature", 23 | "std", 24 | ] } 25 | mime_guess2 = "2.3.1" 26 | rand = { version = "0.9.1" } 27 | rusqlite = "0.37.0" 28 | serde = { version = "1.0.219", features = ["derive"] } 29 | serde_cbor_core = "0.1.0" 30 | thiserror = "2.0.12" 31 | tempfile = "3.19.1" 32 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Gordon Brander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /website/static/demo.js: -------------------------------------------------------------------------------- 1 | import { $, on } from "./shared.js"; 2 | import initSzdtWasm from "../vendor/szdt_wasm.js"; 3 | 4 | /** 500 MB */ 5 | const MAX_FILE_SIZE = 500 * 1024 * 1024; 6 | 7 | const uploadFile = async (file) => { 8 | if (file.size > MAX_FILE_SIZE) { 9 | throw new Error(`File too large. Maximum size is ${MAX_FILE_SIZE}`); 10 | } 11 | 12 | // Read file as array buffer 13 | const arrayBuffer = await file.arrayBuffer(); 14 | const data = new Uint8Array(arrayBuffer); 15 | return data; 16 | }; 17 | 18 | const initializeFileInput = ( 19 | fileInput, 20 | ) => { 21 | on(fileInput, "change", async (e) => { 22 | const event = e; 23 | const target = event.target; 24 | const files = target.files; 25 | if (!files || files.length === 0) return; 26 | const file = files[0]; 27 | const bytes = await uploadFile(file); 28 | console.log(bytes); 29 | }); 30 | }; 31 | 32 | const main = async () => { 33 | console.log("Main initializing"); 34 | 35 | await initSzdtWasm(); 36 | 37 | const fileInput = $("#file-input"); 38 | initializeFileInput(fileInput); 39 | 40 | $("body")?.classList.add("ready"); 41 | }; 42 | 43 | main(); 44 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Check formatting 19 | working-directory: rust 20 | run: cargo fmt --all -- --check 21 | - name: Lint with Clippy 22 | working-directory: rust 23 | run: cargo clippy --all-targets --all-features -- -D warnings 24 | - name: Build workspace 25 | working-directory: rust 26 | run: cargo build --workspace --verbose 27 | - name: Test workspace 28 | working-directory: rust 29 | run: cargo test --workspace --verbose 30 | - name: Test szdt_core 31 | working-directory: rust 32 | run: cargo test --package szdt_core --verbose 33 | - name: Test szdt_cli 34 | working-directory: rust 35 | run: cargo test --package szdt_cli --verbose 36 | - name: Build CLI binary 37 | working-directory: rust 38 | run: cargo build --bin szdt --release 39 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::db::migrations::MigrationError; 2 | use szdt_core::error::Error as CoreError; 3 | use thiserror::Error; 4 | 5 | #[derive(Error, Debug)] 6 | pub enum Error { 7 | #[error("Core error: {0}")] 8 | Core(#[from] CoreError), 9 | #[error("SQLite error: {0}")] 10 | Sqlite(#[from] rusqlite::Error), 11 | #[error("Migration error: {0}")] 12 | MigrationError(#[from] MigrationError), 13 | #[error("IO error: {0}")] 14 | Io(#[from] std::io::Error), 15 | #[error("Unable generate randomness: {0}")] 16 | Rand(String), 17 | #[error("Error stripping path prefix")] 18 | StripPrefix(#[from] std::path::StripPrefixError), 19 | #[error("File system error: {0}")] 20 | Fs(String), 21 | #[error("Nickname already taken: {0}")] 22 | NicknameAlreadyTaken(String), 23 | } 24 | 25 | impl From for Error { 26 | fn from(err: szdt_core::nickname::NicknameError) -> Self { 27 | Error::Core(err.into()) 28 | } 29 | } 30 | 31 | impl From for Error { 32 | fn from(err: szdt_core::did::Error) -> Self { 33 | Error::Core(err.into()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /rust/szdt_wasm/src/lib.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::*; 2 | 3 | // Use `wee_alloc` as the global allocator. 4 | #[global_allocator] 5 | static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; 6 | 7 | // Import the `console.log` function from the browser 8 | #[wasm_bindgen] 9 | extern "C" { 10 | #[wasm_bindgen(js_namespace = console)] 11 | fn log(s: &str); 12 | } 13 | 14 | // Define a macro to provide `println!(..)`-style syntax for `console.log` logging. 15 | macro_rules! console_log { 16 | ( $( $t:tt )* ) => { 17 | log(&format!( $( $t )* )) 18 | } 19 | } 20 | 21 | // WASM wrapper modules 22 | pub mod cbor_seq; 23 | pub mod did_key; 24 | pub mod ed25519_key_material; 25 | pub mod hash; 26 | pub mod memo; 27 | pub mod mnemonic; 28 | 29 | // Re-export main types for easy use 30 | pub use cbor_seq::{CborSeqReader, CborSeqWriter}; 31 | pub use did_key::DidKey; 32 | pub use ed25519_key_material::Ed25519KeyMaterial; 33 | pub use hash::Hash; 34 | pub use memo::Memo; 35 | pub use mnemonic::Mnemonic; 36 | 37 | // Utility function to initialize the WASM module 38 | #[wasm_bindgen(start)] 39 | pub fn main() { 40 | console_log!("SZDT WASM module loaded"); 41 | } 42 | -------------------------------------------------------------------------------- /rust/szdt_core/src/contact.rs: -------------------------------------------------------------------------------- 1 | use crate::did::DidKey; 2 | use crate::ed25519_key_material::Ed25519KeyMaterial; 3 | use crate::error::Error; 4 | use crate::nickname::Nickname; 5 | 6 | #[derive(Debug)] 7 | pub struct Contact { 8 | pub nickname: Nickname, 9 | pub did: DidKey, 10 | pub private_key: Option>, 11 | } 12 | 13 | impl Contact { 14 | pub fn new(nickname: Nickname, did: DidKey, private_key: Option>) -> Self { 15 | Contact { 16 | nickname, 17 | did, 18 | private_key, 19 | } 20 | } 21 | } 22 | 23 | impl TryFrom<&Contact> for Ed25519KeyMaterial { 24 | type Error = Error; 25 | 26 | fn try_from(contact: &Contact) -> Result { 27 | if let Some(private_key) = &contact.private_key { 28 | let key_material = Ed25519KeyMaterial::try_from_private_key(private_key)?; 29 | return Ok(key_material); 30 | } 31 | let key_material = Ed25519KeyMaterial::try_from(&contact.did)?; 32 | Ok(key_material) 33 | } 34 | } 35 | 36 | impl From<&Contact> for DidKey { 37 | fn from(contact: &Contact) -> Self { 38 | contact.did.clone() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: 2 | just --list 3 | 4 | # Test workspace 5 | test: 6 | cd rust && cargo test --workspace 7 | 8 | # Lint 9 | clippy: 10 | cd rust && cargo clippy --all-targets --all-features -- -D warnings 11 | 12 | # Check code formatting 13 | check_fmt: 14 | cd rust && cargo fmt --all -- --check 15 | 16 | # Format code 17 | fmt: 18 | cd rust && cargo fmt --all 19 | 20 | # Build and install CLI 21 | install_cli: 22 | cargo install --path rust/szdt_cli 23 | 24 | # Build WASM bindings for web 25 | build_szdt_web: 26 | cd rust/szdt_wasm && wasm-pack build --target web --out-dir pkg/web 27 | 28 | # Build WASM bindings for web 29 | build_szdt_node: 30 | cd rust/szdt_wasm && wasm-pack build --target node --out-dir pkg/node 31 | 32 | # Vend WASM files to docs website 33 | vend_wasm: build_szdt_web 34 | mkdir -p "docs/static" 35 | cp -a rust/szdt_wasm/pkg/web/* "website/vendor" 36 | @echo "Copied szdt_wasm artifacts to static" 37 | 38 | # Build website dev 39 | build_website_dev: vend_wasm 40 | cd website && npm run build:dev 41 | 42 | # Build website prod 43 | build_website_prod: vend_wasm 44 | cd website && npm run build:prod 45 | 46 | serve_website: 47 | cd website && npm run serve 48 | 49 | clean: 50 | rm -rf "docs" 51 | -------------------------------------------------------------------------------- /website/_includes/sidebar.liquid: -------------------------------------------------------------------------------- 1 | 7 | 14 | 22 | 30 | -------------------------------------------------------------------------------- /.github/workflows/build-website.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Website 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "website/**" 8 | - ".github/workflows/build-website.yml" 9 | 10 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | # Allow only one concurrent deployment 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: "24.x" 33 | cache: "npm" 34 | cache-dependency-path: website/package-lock.json 35 | 36 | - name: Setup Pages 37 | uses: actions/configure-pages@v4 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | working-directory: ./website 42 | 43 | - name: Build website 44 | run: "npm run build:prod" 45 | working-directory: ./website 46 | 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v3 49 | with: 50 | path: "./docs" 51 | 52 | deploy: 53 | environment: 54 | name: github-pages 55 | url: ${{ steps.deployment.outputs.page_url }} 56 | runs-on: ubuntu-latest 57 | needs: build 58 | steps: 59 | - name: Deploy to GitHub Pages 60 | id: deployment 61 | uses: actions/deploy-pages@v4 62 | -------------------------------------------------------------------------------- /rust/szdt_core/src/text.rs: -------------------------------------------------------------------------------- 1 | pub static ELLIPSIS: &str = "…"; 2 | 3 | /// Truncate a string from the right side, adding provided ellipsis if necessary. 4 | pub fn truncate(s: &str, max_chars: usize, ellipsis: &str) -> String { 5 | let string_len = s.chars().count(); 6 | let ellipsis_len = ellipsis.chars().count(); 7 | if string_len <= max_chars { 8 | s.to_string() 9 | } else if max_chars > ellipsis_len { 10 | let truncated_string: String = s.chars().take(max_chars - ellipsis_len).collect(); 11 | format!("{truncated_string}{ellipsis}") 12 | } else { 13 | let truncated_string: String = s.chars().take(max_chars).collect(); 14 | truncated_string 15 | } 16 | } 17 | 18 | #[cfg(test)] 19 | mod tests { 20 | use super::*; 21 | 22 | #[test] 23 | fn test_truncate_no_truncation_needed() { 24 | let result = truncate("hello", 10, ELLIPSIS); 25 | assert_eq!(result, "hello"); 26 | } 27 | 28 | #[test] 29 | fn test_truncate_with_truncation() { 30 | let result = truncate("hello world", 8, ELLIPSIS); 31 | assert_eq!(result, "hello w…"); 32 | } 33 | 34 | #[test] 35 | fn test_truncate_exact_length() { 36 | let result = truncate("hello", 5, ELLIPSIS); 37 | assert_eq!(result, "hello"); 38 | } 39 | 40 | #[test] 41 | fn test_truncate_empty_string() { 42 | let result = truncate("", 5, ELLIPSIS); 43 | assert_eq!(result, ""); 44 | } 45 | 46 | #[test] 47 | fn test_truncate_very_long_ellipsis() { 48 | let result = truncate("h", 8, "123456789"); 49 | assert_eq!(result, "h"); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /website/static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /rust/szdt_core/src/content_type.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | // Guess the content type from a file extension. 4 | pub fn guess_from_ext(ext: &str) -> Option { 5 | mime_guess2::from_ext(ext) 6 | .first() 7 | .map(|mime| mime.to_string()) 8 | } 9 | 10 | // Guess the content type from a path. 11 | pub fn guess_from_path(path: &Path) -> Option { 12 | mime_guess2::from_path(path) 13 | .first() 14 | .map(|mime| mime.to_string()) 15 | } 16 | 17 | #[cfg(test)] 18 | mod tests { 19 | use super::*; 20 | use std::path::Path; 21 | 22 | #[test] 23 | fn test_guess_from_ext_html() { 24 | let result = guess_from_ext("html"); 25 | assert_eq!(result, Some("text/html".to_string())); 26 | } 27 | 28 | #[test] 29 | fn test_guess_from_ext_json() { 30 | let result = guess_from_ext("json"); 31 | assert_eq!(result, Some("application/json".to_string())); 32 | } 33 | 34 | #[test] 35 | fn test_guess_from_ext_png() { 36 | let result = guess_from_ext("png"); 37 | assert_eq!(result, Some("image/png".to_string())); 38 | } 39 | 40 | #[test] 41 | fn test_guess_from_ext_unknown() { 42 | let result = guess_from_ext("xyz123"); 43 | assert_eq!(result, None); 44 | } 45 | 46 | #[test] 47 | fn test_guess_from_ext_empty() { 48 | let result = guess_from_ext(""); 49 | assert_eq!(result, None); 50 | } 51 | 52 | #[test] 53 | fn test_guess_from_path_html() { 54 | let path = Path::new("index.html"); 55 | let result = guess_from_path(path); 56 | assert_eq!(result, Some("text/html".to_string())); 57 | } 58 | 59 | #[test] 60 | fn test_guess_from_path_with_directory() { 61 | let path = Path::new("/var/www/style.css"); 62 | let result = guess_from_path(path); 63 | assert_eq!(result, Some("text/css".to_string())); 64 | } 65 | 66 | #[test] 67 | fn test_guess_from_path_no_extension() { 68 | let path = Path::new("README"); 69 | let result = guess_from_path(path); 70 | assert_eq!(result, None); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /rust/szdt_wasm/src/hash.rs: -------------------------------------------------------------------------------- 1 | use szdt_core::hash::Hash as CoreHash; 2 | use wasm_bindgen::prelude::*; 3 | 4 | /// WASM wrapper for Blake3 hash operations 5 | #[wasm_bindgen] 6 | pub struct Hash { 7 | inner: CoreHash, 8 | } 9 | 10 | #[wasm_bindgen] 11 | impl Hash { 12 | /// Create a new hash from the provided data 13 | #[wasm_bindgen(constructor)] 14 | pub fn new(data: &[u8]) -> Hash { 15 | Self { 16 | inner: CoreHash::new(data), 17 | } 18 | } 19 | 20 | /// Create a hash from exactly 32 bytes 21 | #[wasm_bindgen] 22 | pub fn from_bytes(bytes: &[u8]) -> Result { 23 | if bytes.len() != 32 { 24 | return Err(JsError::new(&format!( 25 | "Hash must be exactly 32 bytes, got {}", 26 | bytes.len() 27 | ))); 28 | } 29 | 30 | let mut hash_bytes = [0u8; 32]; 31 | hash_bytes.copy_from_slice(bytes); 32 | 33 | Ok(Self { 34 | inner: CoreHash::from_bytes(hash_bytes), 35 | }) 36 | } 37 | 38 | /// Get the hash as a byte array 39 | #[wasm_bindgen] 40 | pub fn as_bytes(&self) -> Vec { 41 | self.inner.as_bytes().to_vec() 42 | } 43 | 44 | /// Get the hash as a base32 string representation 45 | // Allow inherent_to_string so we can expose `.toString()` via wasm_bindgen 46 | #[allow(clippy::inherent_to_string)] 47 | #[wasm_bindgen(js_name = toString)] 48 | pub fn to_string(&self) -> String { 49 | self.inner.to_string() 50 | } 51 | 52 | /// Check if two hashes are equal 53 | #[wasm_bindgen] 54 | pub fn equals(&self, other: &Hash) -> bool { 55 | self.inner == other.inner 56 | } 57 | 58 | /// Create hash from a string (UTF-8 bytes) 59 | #[wasm_bindgen] 60 | pub fn from_string(input: &str) -> Hash { 61 | Self { 62 | inner: CoreHash::new(input.as_bytes()), 63 | } 64 | } 65 | } 66 | 67 | // Internal conversion methods for use within the WASM crate 68 | impl Hash { 69 | pub fn from_core(core: CoreHash) -> Self { 70 | Self { inner: core } 71 | } 72 | 73 | pub fn into_core(self) -> CoreHash { 74 | self.inner 75 | } 76 | 77 | pub fn as_core(&self) -> &CoreHash { 78 | &self.inner 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /rust/szdt_core/src/bytes.rs: -------------------------------------------------------------------------------- 1 | use serde::de::{self, Visitor}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | /// Wrapper for byte vectors that will ensure they are serialized as byte strings 5 | /// and not arrays. 6 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 7 | pub struct Bytes(pub Vec); 8 | 9 | impl Bytes { 10 | pub fn into_inner(self) -> Vec { 11 | self.0 12 | } 13 | } 14 | 15 | impl Serialize for Bytes { 16 | fn serialize(&self, serializer: S) -> Result 17 | where 18 | S: serde::Serializer, 19 | { 20 | serializer.serialize_bytes(&self.0) 21 | } 22 | } 23 | 24 | impl<'de> Deserialize<'de> for Bytes { 25 | fn deserialize(deserializer: D) -> Result 26 | where 27 | D: serde::Deserializer<'de>, 28 | { 29 | deserializer.deserialize_bytes(BytesVisitor) 30 | } 31 | } 32 | 33 | struct BytesVisitor; 34 | 35 | impl Visitor<'_> for BytesVisitor { 36 | type Value = Bytes; 37 | 38 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 39 | formatter.write_str("a byte array") 40 | } 41 | 42 | fn visit_bytes(self, value: &[u8]) -> Result 43 | where 44 | E: de::Error, 45 | { 46 | Ok(Bytes(value.to_vec())) 47 | } 48 | 49 | fn visit_byte_buf(self, value: Vec) -> Result 50 | where 51 | E: de::Error, 52 | { 53 | Ok(Bytes(value)) 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | mod tests { 59 | use super::*; 60 | use serde_cbor_core; 61 | 62 | #[test] 63 | fn test_bytes_serialized_as_byte_string() { 64 | let bytes = Bytes(vec![1, 2, 3, 4, 5]); 65 | let serialized = serde_cbor_core::to_vec(&bytes).unwrap(); 66 | 67 | // Check that the first byte indicates a byte string (major type 2) 68 | // In CBOR, major type 2 is for byte strings, encoded as 0b010xxxxx 69 | assert_eq!( 70 | serialized[0] & 0xE0, 71 | 0x40, 72 | "Should be serialized as byte string (major type 2)" 73 | ); 74 | 75 | // Verify round-trip deserialization works 76 | let deserialized: Bytes = serde_cbor_core::from_slice(&serialized).unwrap(); 77 | assert_eq!(bytes, deserialized); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SZDT 2 | 3 | **S**igned **Z**ero-trust **D**a**T**a. Pronounced "Samizdat". 4 | 5 | Signed CBOR for censorship-resistant data. 6 | 7 | ## Intro 8 | 9 | **TLDR**: cryptographically-signed CBOR envelopes containing data, metadata, and everything needed to trustlessly verify it. 10 | 11 | - [Whitepaper](./WHITEPAPER.md) 12 | - [Website](https://szdt.dev) 13 | 14 | ## Features 15 | 16 | - **Zero-trust**: SZDT archives are verified using cryptographic hashing and public key cryptography. No centralized authorities are required. 17 | - **Censorship-resistant**: Because trust is decoupled from origin or transport, SZDT archives can be distributed via HTTP, Torrents, email, airdrop, sneakernet, or anything else that is available. 18 | - **Decentralizable**: SZDT decouples trust from origin, so data can be distributed to many redundant locations, including multiple HTTP servers, BitTorrent, hard drives, etc. [Lots Of Copies Keeps Stuff Safe](https://www.lockss.org/). 19 | - **Anonymous/pseudonymous**: SZDT uses [keys, not IDs](https://newsletter.squishy.computer/i/60168330/keys-not-ids-toward-personal-illegibility). No accounts are required. 20 | - **Streamable**: CBOR is inherently streamable, and Blake3 hashes enable streaming cryptographic verification. 21 | - **Any kind of data**: Memos can wrap API responses, file bytes, structured data, or anything else. They also provide a mechanism for adding self-certifying metadata (headers) to any data. 22 | 23 | ### Non-features 24 | 25 | - **P2P**: SZDT is transport-agnostic. It's just a file format. 26 | - **Efficiency**: SZDT prioritizes simplicity over efficiency. 27 | 28 | ## Development 29 | 30 | ### Prerequisites 31 | 32 | - [Node](https://nodejs.org/en/download) v24 or later 33 | - [Rust](https://www.rust-lang.org/) v1.88 or later 34 | 35 | ### Setting up dev environment 36 | 37 | - Clone the repository 38 | - Run `./scripts/setup.sh` to install development dependencies (`wasm-pack` and `just`) 39 | 40 | Run `just default` to see a list of build commands. 41 | 42 | ### Building WASM 43 | 44 | ```bash 45 | just build_szdt_web 46 | ``` 47 | 48 | ### Installing CLI from your path 49 | 50 | From the project directory: 51 | 52 | ```bash 53 | cargo install --path ./rust/szdt-cli 54 | ``` 55 | 56 | This will install the `szdt` binary to `~/.cargo/bin` (which should have been added to your path by the Rust installer). 57 | -------------------------------------------------------------------------------- /rust/szdt_wasm/src/did_key.rs: -------------------------------------------------------------------------------- 1 | use szdt_core::did::DidKey as CoreDidKey; 2 | use wasm_bindgen::prelude::*; 3 | 4 | /// WASM wrapper for DID key operations 5 | #[wasm_bindgen] 6 | pub struct DidKey { 7 | inner: CoreDidKey, 8 | } 9 | 10 | #[wasm_bindgen] 11 | impl DidKey { 12 | /// Create a new DidKey from public key bytes 13 | #[wasm_bindgen] 14 | pub fn new(public_key: &[u8]) -> Result { 15 | if public_key.len() != 32 { 16 | return Err(JsError::new(&format!( 17 | "Public key must be exactly 32 bytes, got {}", 18 | public_key.len() 19 | ))); 20 | } 21 | 22 | let inner = CoreDidKey::new(public_key)?; 23 | Ok(Self { inner }) 24 | } 25 | 26 | /// Parse a did:key URL string into a DidKey 27 | #[wasm_bindgen] 28 | pub fn parse(did_key_url: &str) -> Result { 29 | if did_key_url.trim().is_empty() { 30 | return Err(JsError::new("DID key URL cannot be empty")); 31 | } 32 | 33 | let inner = CoreDidKey::parse(did_key_url)?; 34 | Ok(Self { inner }) 35 | } 36 | 37 | /// Get the public key bytes 38 | #[wasm_bindgen] 39 | pub fn public_key(&self) -> Vec { 40 | self.inner.public_key().to_vec() 41 | } 42 | 43 | /// Get the DID key as a string 44 | // Allow inherent_to_string so we can expose `.toString()` via wasm_bindgen 45 | #[allow(clippy::inherent_to_string)] 46 | #[wasm_bindgen(js_name = toString)] 47 | pub fn to_string(&self) -> String { 48 | self.inner.to_string() 49 | } 50 | 51 | /// Check if two DID keys are equal 52 | #[wasm_bindgen] 53 | pub fn equals(&self, other: &DidKey) -> bool { 54 | self.inner == other.inner 55 | } 56 | 57 | /// Validate that this is a properly formatted DID key 58 | #[wasm_bindgen] 59 | pub fn is_valid(did_key_url: &str) -> bool { 60 | CoreDidKey::parse(did_key_url).is_ok() 61 | } 62 | } 63 | 64 | // Internal conversion methods for use within the WASM crate 65 | impl DidKey { 66 | pub fn from_core(core: CoreDidKey) -> Self { 67 | Self { inner: core } 68 | } 69 | 70 | pub fn into_core(self) -> CoreDidKey { 71 | self.inner 72 | } 73 | 74 | pub fn as_core(&self) -> &CoreDidKey { 75 | &self.inner 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /rust/szdt_core/src/nickname.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::text::truncate; 3 | use thiserror::Error; 4 | 5 | const NICKNAME_MAX_LENGTH: usize = 63; 6 | 7 | /// A nickname is a string that follows domain-name-compatible rules, less the TLD. 8 | /// 9 | /// A domain name: 10 | /// - Can only contain A-Z, 0-9 and hyphen (-), in addition to one punctuation (.). 11 | /// We omit the punctuation (tld). 12 | /// - Max length is 63 characters, excluding the extension like .com 13 | /// - Min length is 1 character 14 | /// - Hyphens cannot be the first or last character of the domain name. 15 | /// - Multiple hyphens are discouraged. 16 | /// 17 | /// We omit the TLD, so (.) is not allowed. 18 | #[derive(Debug, Clone, PartialEq, Eq)] 19 | pub struct Nickname(String); 20 | 21 | impl Nickname { 22 | /// Parses a string into a valid nickname. 23 | /// Note that this is a lossy process. 24 | pub fn parse(text: &str) -> Result { 25 | let mut nickname: String = text 26 | .chars() 27 | .filter(|c| c.is_alphanumeric() || *c == '-') 28 | .take(NICKNAME_MAX_LENGTH) 29 | .collect::() 30 | .to_lowercase(); 31 | 32 | if nickname.starts_with('-') { 33 | nickname.remove(0); 34 | } 35 | 36 | if nickname.ends_with('-') { 37 | nickname.remove(nickname.len() - 1); 38 | } 39 | 40 | if nickname.is_empty() { 41 | return Err(NicknameError::TooShort); 42 | } 43 | 44 | Ok(Nickname(nickname)) 45 | } 46 | 47 | pub fn with_suffix(text: &str, suffix: &str) -> Result { 48 | let suffix_len = suffix.chars().count(); 49 | let truncated = truncate(text, NICKNAME_MAX_LENGTH - suffix_len, ""); 50 | let text_with_suffix = format!("{truncated}{suffix}"); 51 | let nickname = Self::parse(&text_with_suffix)?; 52 | Ok(nickname) 53 | } 54 | 55 | /// Borrow nickname as a string slice. 56 | pub fn as_str(&self) -> &str { 57 | &self.0 58 | } 59 | } 60 | 61 | impl std::fmt::Display for Nickname { 62 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 63 | write!(f, "{}", self.0) 64 | } 65 | } 66 | 67 | impl TryFrom<&str> for Nickname { 68 | type Error = NicknameError; 69 | 70 | fn try_from(value: &str) -> Result { 71 | Self::parse(value) 72 | } 73 | } 74 | 75 | impl TryFrom for Nickname { 76 | type Error = NicknameError; 77 | 78 | fn try_from(value: String) -> Result { 79 | Self::parse(&value) 80 | } 81 | } 82 | 83 | #[derive(Debug, Error)] 84 | pub enum NicknameError { 85 | #[error("Nickname is too short. Must be at least 1 character.")] 86 | TooShort, 87 | } 88 | -------------------------------------------------------------------------------- /rust/szdt_core/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::did; 2 | use crate::ed25519; 3 | use crate::nickname; 4 | use std::{collections::TryReserveError, convert::Infallible}; 5 | use thiserror::Error; 6 | 7 | #[derive(Debug)] 8 | pub struct TimestampComparison { 9 | pub timestamp: Option, 10 | pub now: Option, 11 | } 12 | 13 | impl TimestampComparison { 14 | pub fn new(timestamp: Option, now: Option) -> Self { 15 | TimestampComparison { timestamp, now } 16 | } 17 | } 18 | 19 | impl std::fmt::Display for TimestampComparison { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | write!( 22 | f, 23 | "(timestamp: {}, now: {} )", 24 | self.timestamp 25 | .map(|ts| ts.to_string()) 26 | .unwrap_or("None".to_string()), 27 | self.now 28 | .map(|ts| ts.to_string()) 29 | .unwrap_or("None".to_string()) 30 | ) 31 | } 32 | } 33 | 34 | #[derive(Error, Debug)] 35 | pub enum Error { 36 | #[error("IO error: {0}")] 37 | Io(#[from] std::io::Error), 38 | #[error("CBOR decoding error: {0}")] 39 | CborDecode(String), 40 | #[error("CBOR encoding error: {0}")] 41 | CborEncode(String), 42 | #[error("ed25519 error: {0}")] 43 | Ed25519(#[from] ed25519::Error), 44 | #[error("DID error: {0}")] 45 | Did(#[from] did::Error), 46 | #[error("BIP39 error: {0}")] 47 | Bip39(#[from] bip39::Error), 48 | #[error("Private key missing: {0}")] 49 | PrivateKeyMissing(String), 50 | #[error("Data integrity error: {0}")] 51 | IntegrityError(String), 52 | #[error("Memo issuer DID is missing")] 53 | MemoIssMissing, 54 | #[error("Memo is unsigned")] 55 | MemoUnsigned, 56 | #[error("Memo is too early (nbf time didn't validate): {0}")] 57 | MemoNbfError(TimestampComparison), 58 | #[error("Memo has expired (exp time didn't validate): {0}")] 59 | MemoExpError(TimestampComparison), 60 | #[error("Nickname error: {0}")] 61 | NicknameError(#[from] nickname::NicknameError), 62 | #[error("EOF")] 63 | Eof, 64 | } 65 | 66 | impl From> for Error { 67 | fn from(err: serde_cbor_core::DecodeError) -> Self { 68 | Error::CborDecode(err.to_string()) 69 | } 70 | } 71 | 72 | impl From> for Error { 73 | fn from(err: serde_cbor_core::DecodeError) -> Self { 74 | Error::CborDecode(err.to_string()) 75 | } 76 | } 77 | 78 | impl From> for Error { 79 | fn from(err: serde_cbor_core::EncodeError) -> Self { 80 | Error::CborEncode(err.to_string()) 81 | } 82 | } 83 | 84 | impl From> for Error { 85 | fn from(err: serde_cbor_core::EncodeError) -> Self { 86 | Error::CborEncode(err.to_string()) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /website/_includes/index.liquid: -------------------------------------------------------------------------------- 1 | --- 2 | title: SZDT 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | {{title}} 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% render "meta" site_url: site.url %} 17 | 18 | 19 |
20 |
21 | 34 |
35 |
36 | {{ content }} 37 |
38 |
39 |
40 | 56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /rust/szdt_core/src/hashseq.rs: -------------------------------------------------------------------------------- 1 | use crate::hash::Hash; 2 | use thiserror::Error; 3 | 4 | pub struct HashSeq(Vec); 5 | 6 | impl HashSeq { 7 | /// Create an empty hashseq 8 | pub fn empty() -> Self { 9 | Self(Vec::new()) 10 | } 11 | 12 | /// Create a new sequence of hashes. 13 | pub fn new(bytes: Vec) -> Result { 14 | if bytes.len() % 32 == 0 { 15 | Ok(Self(bytes)) 16 | } else { 17 | Err(Error::InvalidBufferSize) 18 | } 19 | } 20 | 21 | pub fn append(&mut self, hash: Hash) { 22 | self.0.extend_from_slice(hash.as_bytes()); 23 | } 24 | 25 | /// Get the underlying byte representation 26 | pub fn as_bytes(&self) -> &[u8] { 27 | &self.0 28 | } 29 | 30 | /// Unwrap and return the underlying byte vec 31 | pub fn into_vec(self) -> Vec { 32 | self.0 33 | } 34 | 35 | /// Iterate over the hashes in this sequence. 36 | pub fn iter(&self) -> impl Iterator + '_ { 37 | self.0.chunks_exact(32).map(|chunk| { 38 | let hash: [u8; 32] = chunk.try_into().unwrap(); 39 | hash.into() 40 | }) 41 | } 42 | } 43 | 44 | impl serde::Serialize for HashSeq { 45 | fn serialize(&self, serializer: S) -> Result 46 | where 47 | S: serde::Serializer, 48 | { 49 | serializer.serialize_bytes(&self.0) 50 | } 51 | } 52 | 53 | impl<'de> serde::Deserialize<'de> for HashSeq { 54 | fn deserialize(deserializer: D) -> Result 55 | where 56 | D: serde::Deserializer<'de>, 57 | { 58 | deserializer.deserialize_bytes(HashSeqVisitor) 59 | } 60 | } 61 | 62 | impl From for HashSeq 63 | where 64 | I: Iterator, 65 | { 66 | /// Construct hashseq from iterator of hashes 67 | fn from(iter: I) -> Self { 68 | let mut hashseq = HashSeq::empty(); 69 | for hash in iter { 70 | hashseq.append(hash); 71 | } 72 | hashseq 73 | } 74 | } 75 | 76 | impl From for Hash { 77 | /// Generate Hash for HashSeq 78 | fn from(value: HashSeq) -> Self { 79 | Hash::new(value.as_bytes()) 80 | } 81 | } 82 | 83 | struct HashSeqVisitor; 84 | 85 | impl serde::de::Visitor<'_> for HashSeqVisitor { 86 | type Value = HashSeq; 87 | 88 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 89 | formatter.write_str("a byte slice representing a HashSeq") 90 | } 91 | 92 | fn visit_bytes(self, v: &[u8]) -> Result 93 | where 94 | E: serde::de::Error, 95 | { 96 | Ok(HashSeq(v.to_vec())) 97 | } 98 | 99 | fn visit_byte_buf(self, v: Vec) -> Result 100 | where 101 | E: serde::de::Error, 102 | { 103 | Ok(HashSeq(v)) 104 | } 105 | } 106 | 107 | #[derive(Debug, Error)] 108 | pub enum Error { 109 | #[error("Invalid hash sequence buffer size")] 110 | InvalidBufferSize, 111 | } 112 | -------------------------------------------------------------------------------- /website/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: index.liquid 3 | title: SZDT - Signed Zero-trust DaTa 4 | --- 5 | 6 | 7 | 8 | # SZDT 9 | 10 | **S**igned **Z**ero-trust **D**a**T**a. Pronounced "Samizdat". 11 | 12 | It's time to make the web censorship-resistant. SZDT decouples trust from servers so that data can spread like dandelion seeds. 13 | 14 |
15 | Whitepaper 16 | Quickstart 17 | GitHub 18 |
19 | 20 | SZDT is signed CBOR with everything needed to cryptographically verify authenticity and integrity. Data can be seeded across cheap commodity HTTP servers or over p2p protocols like [BitTorrent](https://transmissionbt.com/) and [Iroh](https://www.iroh.computer/). 21 | 22 | ## Supporters 23 | 24 |
25 | 26 | Cosmos Institute 27 | 28 |
29 | 30 | ## Specifications 31 | 32 | SZDT is a set of building blocks for publishing censorship-resistant data. 33 | 34 | - [SZDT Whitepaper](https://github.com/gordonbrander/szdt/blob/main/WHITEPAPER.md) 35 | - [SZDT Explainer]({{site.url}}specs/explainer/): Overview of SZDT concepts. Start here. 36 | - [SZDT Memos Specification]({{site.url}}specs/memos/): CBOR metadata envelopes that can be cryptographically signed to create self-certifying data. 37 | - [SZDT Sequence Specification]({{site.url}}specs/sequences/): Bundle multiple CBOR objects into a single content-addressed CBOR Sequence. 38 | - [SZDT Archives Specification]({{site.url}}specs/archives/): a file archiving format built on memos and sequences. Archives provide a censorship-resistant format for distributing collections of signed, verifiable files. 39 | 40 | ## Features 41 | 42 | - **Zero-trust**: SZDT archives are verified using cryptographic hashing and public key cryptography. No centralized authorities are required. 43 | - **Censorship-resistant**: Because trust is decoupled from origin or transport, SZDT archives can be distributed via HTTP, Torrents, email, airdrop, sneakernet, or anything else that is available. 44 | - **Decentralizable**: SZDT decouples trust from origin, so data can be distributed to many redundant locations, including multiple HTTP servers, BitTorrent, hard drives, etc. [Lots Of Copies Keeps Stuff Safe](https://www.lockss.org/). 45 | - **Anonymous/pseudonymous**: SZDT uses [keys, not IDs](https://newsletter.squishy.computer/i/60168330/keys-not-ids-toward-personal-illegibility). No accounts are required. 46 | - **Streamable**: CBOR is inherently streamable, and Blake3 hashes enable streaming cryptographic verification. 47 | - **Any kind of data**: Memos can wrap API responses, file bytes, structured data, or anything else. They also provide a mechanism for adding self-certifying metadata (headers) to any data. 48 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/szdt.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::file::walk_files; 3 | use std::fs::{self, File}; 4 | use std::io::BufRead; 5 | use std::path::Path; 6 | use szdt_core::bytes::Bytes; 7 | use szdt_core::cbor_seq::{CborSeqReader, CborSeqWriter}; 8 | use szdt_core::contact::Contact; 9 | use szdt_core::content_type; 10 | use szdt_core::ed25519_key_material::Ed25519KeyMaterial; 11 | use szdt_core::memo::Memo; 12 | 13 | #[derive(Debug, Clone)] 14 | pub struct ArchiveReceipt { 15 | pub manifest: Vec, 16 | } 17 | 18 | /// Write an archive file by reading files from a directory 19 | pub fn archive( 20 | dir: &Path, 21 | archive_file: &Path, 22 | contact: &Contact, 23 | ) -> Result { 24 | let key_material = Ed25519KeyMaterial::try_from(contact)?; 25 | let paths = walk_files(dir)?; 26 | 27 | let archive_file = File::create(archive_file)?; 28 | let mut archive_writer = CborSeqWriter::new(archive_file); 29 | let mut manifest: Vec = Vec::new(); 30 | 31 | for path in &paths { 32 | // Read file bytes 33 | let bytes = fs::read(path)?; 34 | let cbor_bytes = Bytes(bytes); 35 | let relative_path = path.strip_prefix(dir)?; 36 | // Create a memo for this file 37 | let mut memo = Memo::for_body(&cbor_bytes)?; 38 | // Set file path 39 | memo.protected.path = Some(relative_path.to_string_lossy().to_string()); 40 | // Set content type (if we can guess it) 41 | memo.protected.content_type = content_type::guess_from_path(path); 42 | memo.protected.iss_nickname = Some(contact.nickname.to_string()); 43 | // Sign memo 44 | memo.sign(&key_material)?; 45 | // Write memo 46 | archive_writer.write_block(&memo)?; 47 | // Write bytes 48 | archive_writer.write_block(&cbor_bytes)?; 49 | // Push memo into manifest 50 | manifest.push(memo); 51 | } 52 | 53 | archive_writer.flush()?; 54 | 55 | Ok(ArchiveReceipt { manifest }) 56 | } 57 | 58 | /// Read a pair of memo and bytes from an archive. 59 | /// This function assumes the streaming-friendly sequence layout of: 60 | /// `memo | bytes | memo | bytes | ...` 61 | fn read_archive_memo_pair( 62 | reader: &mut CborSeqReader, 63 | ) -> Result<(Memo, Bytes), Error> { 64 | let memo: Memo = reader.read_block()?; 65 | let bytes: Bytes = reader.read_block()?; 66 | Ok((memo, bytes)) 67 | } 68 | 69 | pub struct Unarchiver { 70 | reader: CborSeqReader, 71 | } 72 | 73 | impl Unarchiver { 74 | pub fn new(reader: R) -> Self { 75 | Self { 76 | reader: CborSeqReader::new(reader), 77 | } 78 | } 79 | } 80 | 81 | impl Iterator for Unarchiver { 82 | type Item = Result<(Memo, Bytes), Error>; 83 | 84 | /// Returns an unvalidated pair of `(Memo, Bytes)` 85 | fn next(&mut self) -> Option { 86 | match read_archive_memo_pair(&mut self.reader) { 87 | Ok((memo, bytes)) => Some(Ok((memo, bytes))), 88 | Err(Error::Core(szdt_core::error::Error::Eof)) => None, 89 | Err(err) => Some(Err(err)), 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /website/specs/archives.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: index.liquid 3 | title: SZDT Archives Specification 4 | --- 5 | 6 | # SZDT Archives Specification 7 | 8 | SZDT Archives provide a standardized format for creating censorship-resistant signed file archives. SZDT archives can be distributed across multiple channels and verified without trusted authorities. Archives are [SZDT Sequences]({{ "specs/sequences/" | prepend: site.url }}) of [memos]({{ "specs/memos/" | prepend: site.url }}) and file bytes. 9 | 10 | ## Motivation 11 | 12 | Traditional archives (ZIP, TAR, etc.) provide no built-in authenticity verification. 13 | 14 | SZDT Archives address these limitations by providing: 15 | 16 | - **Cryptographic verification**: Every archive is signed and can be verified without trusted authorities 17 | - **Decentralized distribution**: Archives can be shared via HTTP, BitTorrent, email, sneakernet, or any other transport 18 | - **Censorship resistance**: No single point of failure or control 19 | 20 | Archives may be particularly valuable for: 21 | - Scientific datasets that need long-term preservation 22 | - Documentation that may face censorship 23 | - Any collection of files that needs distributed, verifiable storage 24 | 25 | ## Archive Format 26 | 27 | SZDT Archives are [CBOR sequences](https://www.rfc-editor.org/rfc/rfc8742.html) conforming to the [SZDT sequence]({{ "specs/sequences/" | prepend: site.url }}) spec, and having the following high-level structure: 28 | 29 | ``` 30 | archive = memo1 | bytes1 | memo2 | bytes2 | ... 31 | ``` 32 | 33 | Where: 34 | - **memo**: A signed [memo]({{ "specs/memos/" | prepend: site.url }}) pointing to the manifest 35 | - **manifest**: A CBOR-encoded manifest listing all resources in the archive 36 | - **bytes**: CBOR byte strings, representing the raw bytes of each resource, in the order listed in the manifest 37 | 38 | ## Archive memos 39 | 40 | Archive entries are described by a standard [SZDT memo]({{ "specs/memos/" | prepend: site.url }}) that wraps the archive manifest. In addition to the required memo headers, archives add the following protected headers: 41 | 42 | - `path`: a hint indicating an appropriate file path when unpacking the archive. The client may choose to interpret this hint in whatever way is appropriate to the context. 43 | 44 | ### Path Requirements 45 | 46 | - Paths MUST start with "/" (absolute within archive) 47 | - Paths MUST use "/" as separator (Unix-style) 48 | - Paths MUST NOT contain ".." components 49 | - Paths MUST be unique within the archive 50 | 51 | As in web contexts, paths are keys, and do not entail the presence of intermediate directoriesm, or a file system. A resource with path `/music/jazz/coltrane.mp3` does not imply that `/music/` or `/music/jazz` exist as directories. However, clients unpacking archives and interpreting paths may choose to render resources to a file system with intermediate directories. 52 | 53 | ## Resource ordering 54 | 55 | SZDT archive should be encoded in depth-first, first seen order to enable efficient streaming. Since archive memos always point to bytes, this means that an archive is made up of pairs of a memo block followed by a byte block. 56 | 57 | The SZDT unarchiving CLI currently takes advantage of this structure to enable efficient streaming deserialization. Future versions may add logic to allow for streaming deserialization of arbitrary sequences. 58 | 59 | ## Appendix 60 | 61 | ### Reference Implementation 62 | 63 | A reference implementation is available at [github.com/gordonbrander/szdt](https://github.com/gordonbrander/szdt). 64 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/file.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::io; 3 | use std::path::{Path, PathBuf}; 4 | 5 | /// Recursively walks a directory and returns all file paths found. 6 | pub fn walk_files(dir: &Path) -> Result, io::Error> { 7 | let mut paths = Vec::new(); 8 | _walk_files(&mut paths, dir)?; 9 | Ok(paths) 10 | } 11 | 12 | fn _walk_files(paths: &mut Vec, path: &Path) -> Result<(), io::Error> { 13 | if path.is_dir() { 14 | // Iterate over directory entries 15 | for child in fs::read_dir(path)? { 16 | let child = child?; 17 | _walk_files(paths, &child.path())?; 18 | } 19 | } else { 20 | // Add the entry itself 21 | paths.push(path.to_path_buf()); 22 | } 23 | Ok(()) 24 | } 25 | 26 | /// List all files in a directory (non-recursive). 27 | pub fn list_files(path: &Path) -> Result, io::Error> { 28 | let mut paths = Vec::new(); 29 | for child in fs::read_dir(path)? { 30 | let child = child?; 31 | if child.file_type()?.is_file() { 32 | paths.push(child.path()); 33 | } 34 | } 35 | Ok(paths) 36 | } 37 | 38 | /// Write file to a path, creating parent directories if necessary. 39 | pub fn write_file_deep, C: AsRef<[u8]>>( 40 | path: P, 41 | content: C, 42 | ) -> Result<(), io::Error> { 43 | let path = path.as_ref(); 44 | let parent = path.parent().ok_or_else(|| io::Error::other("no parent"))?; 45 | fs::create_dir_all(parent)?; 46 | fs::write(path, content) 47 | } 48 | 49 | #[cfg(test)] 50 | mod tests { 51 | use super::*; 52 | use std::fs; 53 | use tempfile::tempdir; 54 | 55 | #[test] 56 | fn test_walk_dir() -> Result<(), io::Error> { 57 | // Create a temporary directory structure 58 | let temp_dir = tempdir()?; 59 | let temp_path = temp_dir.path(); 60 | 61 | // Create some nested directories and files 62 | let subdir1 = temp_path.join("subdir1"); 63 | let subdir2 = temp_path.join("subdir1/subdir2"); 64 | fs::create_dir(&subdir1)?; 65 | fs::create_dir(&subdir2)?; 66 | 67 | // Create some files 68 | fs::write(temp_path.join("file1.txt"), b"content1")?; 69 | fs::write(subdir1.join("file2.txt"), b"content2")?; 70 | fs::write(subdir2.join("file3.txt"), b"content3")?; 71 | 72 | // Test walk_dir 73 | let paths = walk_files(temp_path)?; 74 | 75 | // Check that we have the expected number of paths 76 | assert_eq!(paths.len(), 3); // root dir + 2 subdirs + 3 files 77 | 78 | // Check that specific paths exist in the result 79 | assert!(paths.contains(&temp_path.join("file1.txt"))); 80 | assert!(paths.contains(&subdir1.join("file2.txt"))); 81 | assert!(paths.contains(&subdir2.join("file3.txt"))); 82 | 83 | Ok(()) 84 | } 85 | 86 | #[test] 87 | fn test_write_file_deep() -> Result<(), io::Error> { 88 | // Create a temporary directory structure 89 | let temp_dir = tempdir()?; 90 | let temp_path = temp_dir.path(); 91 | 92 | // Write content to a new file in a new directory 93 | let new_file_path = temp_path.join("new_directory/new_file.txt"); 94 | let new_file_content = b"new file content"; 95 | 96 | write_file_deep(&new_file_path, new_file_content)?; 97 | 98 | // Check that file exists and content is as expected 99 | assert!(new_file_path.exists()); 100 | assert_eq!(fs::read_to_string(new_file_path)?, "new file content"); 101 | 102 | Ok(()) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /rust/szdt_core/src/mnemonic.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use bip39; 3 | 4 | pub struct Mnemonic(bip39::Mnemonic); 5 | 6 | impl Mnemonic { 7 | /// Create a new mnemonic from entropy 8 | pub fn from_entropy(entropy: &[u8]) -> Result { 9 | let mnemonic = bip39::Mnemonic::from_entropy(entropy)?; 10 | Ok(Self(mnemonic)) 11 | } 12 | 13 | /// Parse a BIP39 mnemonic seed phrase into a Mnemonic 14 | pub fn parse(mnemonic: &str) -> Result { 15 | let mnemonic = bip39::Mnemonic::parse_normalized(mnemonic)?; 16 | Ok(Self(mnemonic)) 17 | } 18 | 19 | pub fn to_entropy(&self) -> Vec { 20 | self.0.to_entropy() 21 | } 22 | } 23 | 24 | impl From for String { 25 | fn from(mnemonic: Mnemonic) -> Self { 26 | mnemonic.to_string() 27 | } 28 | } 29 | 30 | impl TryFrom<&str> for Mnemonic { 31 | type Error = Error; 32 | 33 | fn try_from(mnemonic: &str) -> Result { 34 | Self::parse(mnemonic) 35 | } 36 | } 37 | 38 | impl TryFrom for Mnemonic { 39 | type Error = Error; 40 | 41 | fn try_from(mnemonic: String) -> Result { 42 | Self::parse(&mnemonic) 43 | } 44 | } 45 | 46 | impl std::fmt::Display for Mnemonic { 47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 48 | self.0.fmt(f) 49 | } 50 | } 51 | 52 | impl std::fmt::Debug for Mnemonic { 53 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 54 | write!(f, "Mnemonic(...)") 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::*; 61 | 62 | #[test] 63 | fn test_mnemonic_round_trip() { 64 | // Test with various entropy sizes 65 | let entropy_sizes = [16, 20, 24, 28, 32]; // 128, 160, 192, 224, 256 bits 66 | 67 | for &size in &entropy_sizes { 68 | let entropy = vec![0x42u8; size]; // Use a consistent pattern 69 | 70 | // Create mnemonic from entropy 71 | let mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); 72 | 73 | // Convert to string and back 74 | let mnemonic_str = mnemonic.to_string(); 75 | let parsed_mnemonic = Mnemonic::parse(&mnemonic_str).unwrap(); 76 | 77 | // Check that entropy round-trips correctly 78 | let recovered_entropy = parsed_mnemonic.to_entropy(); 79 | assert_eq!( 80 | entropy, recovered_entropy, 81 | "Entropy round-trip failed for size {size}" 82 | ); 83 | 84 | // Check that string representation round-trips correctly 85 | assert_eq!( 86 | mnemonic_str, 87 | parsed_mnemonic.to_string(), 88 | "String round-trip failed for size {size}" 89 | ); 90 | } 91 | } 92 | 93 | #[test] 94 | fn test_mnemonic_try_from_conversions() { 95 | let entropy = vec![0x11u8; 16]; 96 | let original = Mnemonic::from_entropy(&entropy).unwrap(); 97 | let mnemonic_str = original.to_string(); 98 | 99 | // Test TryFrom<&str> 100 | let from_str_ref = Mnemonic::try_from(mnemonic_str.as_str()).unwrap(); 101 | assert_eq!(original.to_entropy(), from_str_ref.to_entropy()); 102 | 103 | // Test TryFrom 104 | let from_string = Mnemonic::try_from(mnemonic_str.clone()).unwrap(); 105 | assert_eq!(original.to_entropy(), from_string.to_entropy()); 106 | 107 | // Test From for String 108 | let string_from_mnemonic: String = original.into(); 109 | assert_eq!(mnemonic_str, string_from_mnemonic); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /rust/szdt_wasm/README.md: -------------------------------------------------------------------------------- 1 | # SZDT WASM 2 | 3 | WebAssembly bindings for SZDT core functionality, providing cryptographic operations, memo management, and data structures for JavaScript/TypeScript applications. 4 | 5 | ## Features 6 | 7 | - **Hash Operations**: Blake3 hashing with encoding support 8 | - **Ed25519 Key Management**: Key generation, signing, verification, and DID conversion 9 | - **Memo Operations**: Create, sign, validate, and serialize memos 10 | - **DID Key Support**: Parse and manipulate DID:key identifiers 11 | - **Mnemonic Support**: BIP39 mnemonic phrase handling 12 | - **CBOR Sequences**: Read and write CBOR sequence data 13 | 14 | ## Building 15 | 16 | ### Prerequisites 17 | 18 | Install `wasm-pack`: 19 | 20 | ```bash 21 | cargo install wasm-pack 22 | ``` 23 | 24 | ### Build for Web 25 | 26 | ```bash 27 | wasm-pack build --target web --out-dir pkg-web 28 | ``` 29 | 30 | ### Build for Node.js 31 | 32 | ```bash 33 | wasm-pack build --target nodejs --out-dir pkg-node 34 | ``` 35 | 36 | ### Build for Bundlers (Webpack, etc.) 37 | 38 | ```bash 39 | wasm-pack build --target bundler --out-dir pkg-bundler 40 | ``` 41 | 42 | ## Usage Example 43 | 44 | ```typescript 45 | import { Hash, Ed25519KeyMaterial, Memo } from './pkg-web/szdt_wasm.js'; 46 | 47 | // Create a hash 48 | const data = new Uint8Array([1, 2, 3, 4]); 49 | const hash = new Hash(data); 50 | console.log(hash.toString()); // Base32 representation 51 | 52 | // Generate keys 53 | const keyMaterial = Ed25519KeyMaterial.generate(); 54 | console.log(keyMaterial.did_string()); 55 | 56 | // Create and sign a memo 57 | const memo = new Memo(hash); 58 | memo.sign(keyMaterial); 59 | const isValid = memo.verify(); 60 | console.log('Memo is valid:', isValid); 61 | 62 | // Serialize to CBOR 63 | const cborData = memo.to_cbor(); 64 | ``` 65 | 66 | ## API Reference 67 | 68 | ### Hash 69 | - `new Hash(data: Uint8Array)` - Create hash from data 70 | - `Hash.from_bytes(bytes: Uint8Array)` - Create from hash bytes 71 | - `Hash.from_string(input: string)` - Create from string 72 | - `as_bytes(): Uint8Array` - Get hash bytes 73 | - `toString(): string` - Get base32 representation 74 | - `equals(other: Hash): boolean` - Compare hashes 75 | 76 | ### Ed25519KeyMaterial 77 | - `Ed25519KeyMaterial.generate()` - Generate new key pair 78 | - `Ed25519KeyMaterial.from_seed(seed: Uint8Array)` - From 32-byte seed 79 | - `Ed25519KeyMaterial.from_mnemonic(mnemonic: string)` - From BIP39 mnemonic 80 | - `Ed25519KeyMaterial.from_public_key(pubkey: Uint8Array)` - Public key only 81 | - `public_key(): Uint8Array` - Get public key bytes 82 | - `private_key(): Uint8Array | undefined` - Get private key bytes (if available) 83 | - `did(): DidKey` - Get DID key representation 84 | - `did_string(): string` - Get DID as string 85 | - `sign(data: Uint8Array): Uint8Array` - Sign data 86 | - `verify(data: Uint8Array, signature: Uint8Array): boolean` - Verify signature 87 | - `can_sign(): boolean` - Check if private key is available 88 | 89 | ### Memo 90 | - `new Memo(bodyHash: Hash)` - Create memo with body hash 91 | - `Memo.for_body(content: Uint8Array)` - Create memo for content 92 | - `Memo.for_string(content: string)` - Create memo for string 93 | - `Memo.empty()` - Create empty memo 94 | - `sign(keyMaterial: Ed25519KeyMaterial)` - Sign the memo 95 | - `verify(): boolean` - Verify signature 96 | - `validate(timestamp?: number): boolean` - Full validation 97 | - `to_cbor(): Uint8Array` - Serialize to CBOR 98 | - `Memo.from_cbor(data: Uint8Array)` - Deserialize from CBOR 99 | - Various getters/setters for metadata (timestamp, expiration, content type, etc.) 100 | 101 | ### DidKey 102 | - `new DidKey(publicKey: Uint8Array)` - Create from public key 103 | - `DidKey.parse(didKeyUrl: string)` - Parse DID:key URL 104 | - `public_key(): Uint8Array` - Get public key bytes 105 | - `toString(): string` - Get DID:key URL 106 | - `equals(other: DidKey): boolean` - Compare DIDs 107 | - `DidKey.is_valid(didKeyUrl: string): boolean` - Validate DID format 108 | 109 | ### Mnemonic 110 | - `Mnemonic.from_entropy(entropy: Uint8Array)` - Create from entropy 111 | - `Mnemonic.parse(mnemonic: string)` - Parse mnemonic phrase 112 | - `toString(): string` - Get mnemonic phrase 113 | - `to_entropy(): Uint8Array` - Get entropy bytes 114 | - `word_count(): number` - Get word count 115 | - `Mnemonic.generate_12_word()` - Generate 12-word mnemonic 116 | - `Mnemonic.generate_24_word()` - Generate 24-word mnemonic 117 | - (And 15, 18, 21-word variants) 118 | 119 | ### CBOR Sequences 120 | - `CborSeqReader` / `CborSeqWriter` - For reading/writing CBOR sequence files 121 | - Utility functions for CBOR parsing and serialization 122 | 123 | ## Target Compatibility 124 | 125 | - Web browsers (with ES modules) 126 | - Node.js 127 | - Bundlers (Webpack, Rollup, etc.) 128 | - TypeScript (with generated .d.ts files) -------------------------------------------------------------------------------- /website/specs/sequences.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: index.liquid 3 | title: SZDT Sequences Specification 4 | --- 5 | 6 | # SZDT Sequences Specification 7 | 8 | SZDT Sequences can be used to store or deliver a series of content-addressed CBOR objects. Sequences serve as the base layer for higher-level SZDT formats like [archives](./archives). 9 | 10 | ## Sequence Format 11 | 12 | SZDT Sequences are ordinary [CBOR sequences](https://www.rfc-editor.org/rfc/rfc8742.html)—streams of concatenated, definite-length CBOR values. 13 | 14 | ``` 15 | sequence = cbor_value | cbor_value | ... 16 | ``` 17 | 18 | Where each `cbor_value` is: 19 | - A definite-length CBOR value 20 | - Encoded using the [CBOR/c](#cbor-encoding) (CBOR Core) deterministic profile 21 | 22 | ## Content Addressing 23 | 24 | Values in a sequence may reference other top-level values by content address, that is, the Blake3 hash of the CBOR/c-encoded value. 25 | 26 | ``` 27 | content_address = Blake3(cborc_encode(value)) 28 | ``` 29 | 30 | - **Algorithm**: Blake3 with 256-bit (32-byte) output 31 | - **Input**: The complete CBOR/c-encoded bytes of the object 32 | 33 | ### Verifying content addresses 34 | 35 | When parsing sequences, implementors must verify each block by hashing the CBOR/c-encoded value with BLAKE3, and comparing the hash to the expected content address. 36 | 37 | ### Reference Format 38 | 39 | Values may reference other top-level values by their content address. Content addresses are encoded as CBOR byte strings containing the 32-byte Blake3 hash: 40 | 41 | ```cbor 42 | h'a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890' 43 | ``` 44 | 45 | ## Sequence ordering 46 | 47 | Objects in sequences should be ordered using **depth-first, first-seen order** of content address traversal to facilitate efficient streaming. 48 | 49 | ## Incomplete DAGs 50 | 51 | Implementors must not assume that all content-addressed objects will be included in the sequence. Authors may omit referenced objects from the sequence if desired. 52 | 53 | ## Duplication 54 | 55 | Implementors must not assume that objects in sequences will be de-duplicated. The same object may appear multiple times in the same sequence. 56 | 57 | ## Multiple DAGs 58 | 59 | A single sequence may contain multiple independent DAGs. 60 | 61 | When multiple DAGs are present, authors should order objects so that the objects of the DAG are contiguous, and ordered in **depth-first, first-seen order** of content address traversal. That is, DAGs should be sequenced one after the other, and not interleaved. 62 | 63 | ## CBOR Encoding 64 | 65 | All CBOR objects must use the deterministic [CBOR/c ("CBOR Core")](https://datatracker-ietf-org.lucaspardue.com/doc/draft-rundgren-cbor-core/) profile: 66 | 67 | Deterministic encoding ensures: 68 | 69 | - **Consistent hashing**: Identical logical objects produce identical hashes 70 | - **Interoperability**: Different implementations produce identical byte streams 71 | - **Verification**: Content can be independently verified 72 | 73 | ## Examples 74 | 75 | ### Simple Linear Chain 76 | 77 | ```cbor 78 | // Object A (root, references B) 79 | { 80 | "title": "Chain Example", 81 | "start": h'b_hash...' 82 | } 83 | 84 | // Object B (references C) 85 | { 86 | "message": "Processing data", 87 | "next": h'c_hash...' 88 | } 89 | 90 | // Object C (leaf) 91 | { 92 | "data": "Hello World", 93 | "value": 42 94 | } 95 | ``` 96 | 97 | ### Branching DAG 98 | 99 | ```cbor 100 | // Object A (root, references B and C) 101 | { 102 | "left": h'b_hash_32_bytes...', 103 | "right": h'c_hash_32_bytes...' 104 | } 105 | 106 | // Object B (references D) 107 | { 108 | "ref": h'd_hash_32_bytes...' 109 | } 110 | 111 | // Object D (shared leaf) 112 | { 113 | "value": "d" 114 | } 115 | 116 | // Object C (references D) 117 | { 118 | "branch": "right", 119 | "ref": h'd_hash_32_bytes...' 120 | } 121 | 122 | // Object D (shared leaf duplicate) 123 | { 124 | "value": "d" 125 | } 126 | ``` 127 | 128 | ### Multiple DAGs 129 | 130 | ```cbor 131 | // DAG 1: Simple chain 132 | // a1 -> a2 133 | { 134 | "id": "a1", 135 | "next": h'b1_hash...' 136 | } 137 | { 138 | "id": "a2", 139 | "data": "end" 140 | } 141 | 142 | // DAG 2: Single object 143 | { 144 | "id": "b1", 145 | } 146 | ``` 147 | 148 | ## Appendix 149 | 150 | ### MIME Type 151 | 152 | SZDT sequences should use the MIME type: 153 | 154 | ``` 155 | application/vnd.szdt.seq+cbor-seq 156 | ``` 157 | 158 | ### File Extension 159 | 160 | When used in file contexts, SZDT sequences SHOULD use the file extension: 161 | 162 | ``` 163 | .szdt 164 | ``` 165 | 166 | ### References 167 | 168 | - [CBOR Sequences (RFC 8742)](https://www.rfc-editor.org/rfc/rfc8742.html) 169 | - [CBOR Core Profile](https://datatracker-ietf-org.lucaspardue.com/doc/draft-rundgren-cbor-core/) 170 | - [Blake3 Specification](https://github.com/BLAKE3-team/BLAKE3-specs) 171 | -------------------------------------------------------------------------------- /rust/szdt_wasm/src/mnemonic.rs: -------------------------------------------------------------------------------- 1 | use szdt_core::mnemonic::Mnemonic as CoreMnemonic; 2 | use wasm_bindgen::prelude::*; 3 | 4 | /// WASM wrapper for BIP39 mnemonic operations 5 | #[wasm_bindgen] 6 | pub struct Mnemonic { 7 | inner: CoreMnemonic, 8 | } 9 | 10 | #[wasm_bindgen] 11 | impl Mnemonic { 12 | /// Create a mnemonic from entropy bytes 13 | #[wasm_bindgen] 14 | pub fn from_entropy(entropy: &[u8]) -> Result { 15 | // Validate entropy length (must be 16, 20, 24, 28, or 32 bytes for BIP39) 16 | if ![16, 20, 24, 28, 32].contains(&entropy.len()) { 17 | return Err(JsError::new(&format!( 18 | "Entropy must be 16, 20, 24, 28, or 32 bytes, got {}", 19 | entropy.len() 20 | ))); 21 | } 22 | 23 | let inner = CoreMnemonic::from_entropy(entropy)?; 24 | Ok(Self { inner }) 25 | } 26 | 27 | /// Parse a BIP39 mnemonic phrase 28 | #[wasm_bindgen] 29 | pub fn parse(mnemonic: &str) -> Result { 30 | if mnemonic.trim().is_empty() { 31 | return Err(JsError::new("Mnemonic phrase cannot be empty")); 32 | } 33 | 34 | // Basic validation - check word count 35 | let word_count = mnemonic.split_whitespace().count(); 36 | if ![12, 15, 18, 21, 24].contains(&word_count) { 37 | return Err(JsError::new(&format!( 38 | "Mnemonic must contain 12, 15, 18, 21, or 24 words, got {word_count}", 39 | ))); 40 | } 41 | 42 | let inner = CoreMnemonic::parse(mnemonic)?; 43 | Ok(Self { inner }) 44 | } 45 | 46 | /// Get the mnemonic as a string 47 | // Allow inherent_to_string so we can expose `.toString()` via wasm_bindgen 48 | #[allow(clippy::inherent_to_string)] 49 | #[wasm_bindgen(js_name = toString)] 50 | pub fn to_string(&self) -> String { 51 | self.inner.to_string() 52 | } 53 | 54 | /// Get the entropy bytes from the mnemonic 55 | #[wasm_bindgen] 56 | pub fn to_entropy(&self) -> Vec { 57 | self.inner.to_entropy() 58 | } 59 | 60 | /// Get the word count of the mnemonic 61 | #[wasm_bindgen] 62 | pub fn word_count(&self) -> usize { 63 | self.inner.to_string().split_whitespace().count() 64 | } 65 | 66 | /// Validate that a mnemonic phrase is valid BIP39 67 | #[wasm_bindgen] 68 | pub fn is_valid(mnemonic: &str) -> bool { 69 | CoreMnemonic::parse(mnemonic).is_ok() 70 | } 71 | 72 | /// Generate a random 12-word mnemonic (128 bits entropy) 73 | #[wasm_bindgen] 74 | pub fn generate_12_word() -> Result { 75 | let entropy = generate_random_entropy(16)?; 76 | Self::from_entropy(&entropy) 77 | } 78 | 79 | /// Generate a random 15-word mnemonic (160 bits entropy) 80 | #[wasm_bindgen] 81 | pub fn generate_15_word() -> Result { 82 | let entropy = generate_random_entropy(20)?; 83 | Self::from_entropy(&entropy) 84 | } 85 | 86 | /// Generate a random 18-word mnemonic (192 bits entropy) 87 | #[wasm_bindgen] 88 | pub fn generate_18_word() -> Result { 89 | let entropy = generate_random_entropy(24)?; 90 | Self::from_entropy(&entropy) 91 | } 92 | 93 | /// Generate a random 21-word mnemonic (224 bits entropy) 94 | #[wasm_bindgen] 95 | pub fn generate_21_word() -> Result { 96 | let entropy = generate_random_entropy(28)?; 97 | Self::from_entropy(&entropy) 98 | } 99 | 100 | /// Generate a random 24-word mnemonic (256 bits entropy) 101 | #[wasm_bindgen] 102 | pub fn generate_24_word() -> Result { 103 | let entropy = generate_random_entropy(32)?; 104 | Self::from_entropy(&entropy) 105 | } 106 | } 107 | 108 | // Helper function to generate random entropy 109 | fn generate_random_entropy(length: usize) -> Result, JsError> { 110 | let mut entropy = vec![0u8; length]; 111 | 112 | // Use Web Crypto API for random bytes in browser 113 | let crypto = web_sys::window() 114 | .ok_or_else(|| JsError::new("No window object available"))? 115 | .crypto() 116 | .map_err(|_| JsError::new("Web Crypto API not available"))?; 117 | 118 | crypto 119 | .get_random_values_with_u8_array(&mut entropy[..]) 120 | .map_err(|_| JsError::new("Failed to generate random bytes"))?; 121 | Ok(entropy) 122 | } 123 | 124 | // Internal conversion methods for use within the WASM crate 125 | impl Mnemonic { 126 | pub fn from_core(core: CoreMnemonic) -> Self { 127 | Self { inner: core } 128 | } 129 | 130 | pub fn into_core(self) -> CoreMnemonic { 131 | self.inner 132 | } 133 | 134 | pub fn as_core(&self) -> &CoreMnemonic { 135 | &self.inner 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/key_storage.rs: -------------------------------------------------------------------------------- 1 | use crate::db::migrations::migrate; 2 | use crate::error::Error; 3 | use rusqlite::params; 4 | use std::path::Path; 5 | use szdt_core::contact::Contact; 6 | use szdt_core::did::DidKey; 7 | use szdt_core::nickname::Nickname; 8 | 9 | fn migration1(tx: &rusqlite::Transaction) -> Result<(), rusqlite::Error> { 10 | tx.execute( 11 | "CREATE TABLE IF NOT EXISTS contact ( 12 | nickname TEXT PRIMARY KEY, 13 | did TEXT NOT NULL, 14 | private_key BLOB 15 | )", 16 | [], 17 | )?; 18 | Ok(()) 19 | } 20 | 21 | fn contact_from_table_row(row: &rusqlite::Row) -> Result { 22 | let nickname_string: String = row.get(0)?; 23 | let nickname = Nickname::try_from(nickname_string)?; 24 | let did_url_string: String = row.get(1)?; 25 | let did = DidKey::try_from(did_url_string)?; 26 | let private_key = row.get(2)?; 27 | Ok(Contact::new(nickname, did, private_key)) 28 | } 29 | 30 | pub struct InsecureKeyStorage { 31 | db: rusqlite::Connection, 32 | } 33 | 34 | impl InsecureKeyStorage { 35 | pub fn new(file_path: &Path) -> Result { 36 | let mut db = rusqlite::Connection::open(file_path)?; 37 | migrate(&mut db, &[migration1])?; 38 | Ok(InsecureKeyStorage { db }) 39 | } 40 | 41 | /// Read key with name, returning Ed25519KeyMaterial with private key 42 | pub fn contact(&self, nickname: &Nickname) -> Result, Error> { 43 | match self.db.query_row_and_then( 44 | "SELECT nickname, did, private_key FROM contact WHERE nickname = ?", 45 | params![nickname.to_string()], 46 | contact_from_table_row, 47 | ) { 48 | Ok(contact) => Ok(Some(contact)), 49 | Err(Error::Sqlite(rusqlite::Error::QueryReturnedNoRows)) => Ok(None), 50 | Err(e) => Err(e), 51 | } 52 | } 53 | 54 | pub fn contact_for_did(&self, did: &DidKey) -> Result, Error> { 55 | let did_string = did.to_string(); 56 | match self.db.query_row_and_then( 57 | "SELECT nickname, did, private_key FROM contact WHERE did = ?", 58 | [did_string], 59 | contact_from_table_row, 60 | ) { 61 | Ok(contact) => Ok(Some(contact)), 62 | Err(Error::Sqlite(rusqlite::Error::QueryReturnedNoRows)) => Ok(None), 63 | Err(e) => Err(e), 64 | } 65 | } 66 | 67 | /// Generate a unique nickname for a new contact. If nickname given has 68 | /// not been taken, will just return it. Otherwise, will attempt to make it 69 | /// unique by appending a random suffix. 70 | pub fn unique_nickname(&self, text: &str) -> Result { 71 | let default_nickname = Nickname::parse("anon")?; 72 | let nickname = Nickname::parse(text).unwrap_or(default_nickname); 73 | 74 | if self.contact(&nickname)?.is_none() { 75 | return Ok(nickname.clone()); 76 | } 77 | 78 | for i in 0..128 { 79 | // We start at 2 so we get "foo", "foo2", "foo3", etc. 80 | let suffix = (i + 2).to_string(); 81 | let draft_nickname = Nickname::with_suffix(nickname.to_string().as_str(), &suffix)?; 82 | if self.contact(&draft_nickname)?.is_none() { 83 | return Ok(draft_nickname); 84 | } 85 | } 86 | 87 | Err(Error::NicknameAlreadyTaken( 88 | "Nickname is already taken. Unable to make it unique.".to_string(), 89 | )) 90 | } 91 | 92 | /// Create a new public/private keypair, stored at nickname. 93 | /// Nickname must be unique. If a record with this nickname already exists, 94 | /// a Sqlite error will be returned. 95 | pub fn create_contact(&self, contact: &Contact) -> Result<(), Error> { 96 | self.db.execute( 97 | "INSERT INTO contact (nickname, did, private_key) VALUES (?, ?, ?)", 98 | params![ 99 | contact.nickname.to_string(), 100 | &contact.did.to_string(), 101 | &contact.private_key, 102 | ], 103 | )?; 104 | Ok(()) 105 | } 106 | 107 | pub fn delete_contact(&self, nickname: &str) -> Result<(), Error> { 108 | self.db 109 | .execute("DELETE FROM contact WHERE nickname = ?", params![nickname])?; 110 | Ok(()) 111 | } 112 | 113 | /// Get a HashMap of key nicknames to DID 114 | pub fn contacts(&self) -> Result, Error> { 115 | let mut stmt = self 116 | .db 117 | .prepare("SELECT nickname, did, private_key FROM contact ORDER BY nickname")?; 118 | let mut contacts: Vec = Vec::new(); 119 | for contact in stmt.query_and_then([], contact_from_table_row)? { 120 | contacts.push(contact?); 121 | } 122 | Ok(contacts) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /rust/szdt_wasm/src/ed25519_key_material.rs: -------------------------------------------------------------------------------- 1 | use crate::did_key::DidKey; 2 | use szdt_core::ed25519_key_material::Ed25519KeyMaterial as CoreKeyMaterial; 3 | use szdt_core::error::Error as CoreError; 4 | use wasm_bindgen::prelude::*; 5 | 6 | /// WASM wrapper for Ed25519 key material operations 7 | #[wasm_bindgen] 8 | pub struct Ed25519KeyMaterial { 9 | inner: CoreKeyMaterial, 10 | } 11 | 12 | // Helper function to convert core errors to JsError 13 | fn core_error_to_js_error(err: CoreError) -> JsError { 14 | JsError::new(&err.to_string()) 15 | } 16 | 17 | #[wasm_bindgen] 18 | impl Ed25519KeyMaterial { 19 | /// Generate a new random Ed25519 key pair 20 | #[wasm_bindgen] 21 | pub fn generate() -> Result { 22 | // Generate 32 bytes of entropy using Web Crypto API 23 | let mut seed = vec![0u8; 32]; 24 | 25 | let crypto = web_sys::window() 26 | .ok_or_else(|| JsError::new("No window object available"))? 27 | .crypto() 28 | .map_err(|_| JsError::new("Web Crypto API not available"))?; 29 | 30 | crypto 31 | .get_random_values_with_u8_array(&mut seed[..]) 32 | .map_err(|_| JsError::new("Failed to generate random bytes"))?; 33 | 34 | let inner = CoreKeyMaterial::try_from_private_key(&seed).map_err(core_error_to_js_error)?; 35 | Ok(Self { inner }) 36 | } 37 | 38 | /// Create key material from a 32-byte seed 39 | #[wasm_bindgen] 40 | pub fn from_seed(seed: &[u8]) -> Result { 41 | if seed.len() != 32 { 42 | return Err(JsError::new(&format!( 43 | "Seed must be exactly 32 bytes, got {}", 44 | seed.len() 45 | ))); 46 | } 47 | 48 | let inner = CoreKeyMaterial::try_from_private_key(seed).map_err(core_error_to_js_error)?; 49 | Ok(Self { inner }) 50 | } 51 | 52 | /// Create key material from a BIP39 mnemonic phrase 53 | #[wasm_bindgen] 54 | pub fn from_mnemonic(mnemonic: &str) -> Result { 55 | if mnemonic.trim().is_empty() { 56 | return Err(JsError::new("Mnemonic cannot be empty")); 57 | } 58 | 59 | let mnemonic_obj = 60 | szdt_core::mnemonic::Mnemonic::parse(mnemonic).map_err(core_error_to_js_error)?; 61 | let inner = CoreKeyMaterial::try_from(&mnemonic_obj).map_err(core_error_to_js_error)?; 62 | Ok(Self { inner }) 63 | } 64 | 65 | /// Create key material from public key only (cannot sign) 66 | #[wasm_bindgen] 67 | pub fn from_public_key(public_key: &[u8]) -> Result { 68 | if public_key.len() != 32 { 69 | return Err(JsError::new(&format!( 70 | "Public key must be exactly 32 bytes, got {}", 71 | public_key.len() 72 | ))); 73 | } 74 | 75 | let inner = 76 | CoreKeyMaterial::try_from_public_key(public_key).map_err(core_error_to_js_error)?; 77 | Ok(Self { inner }) 78 | } 79 | 80 | /// Get the public key as bytes 81 | #[wasm_bindgen] 82 | pub fn public_key(&self) -> Vec { 83 | self.inner.public_key() 84 | } 85 | 86 | /// Get the private key as bytes (if available) 87 | #[wasm_bindgen] 88 | pub fn private_key(&self) -> Option> { 89 | self.inner.private_key() 90 | } 91 | 92 | /// Get the DID key representation 93 | #[wasm_bindgen] 94 | pub fn did(&self) -> DidKey { 95 | DidKey::from_core(self.inner.did()) 96 | } 97 | 98 | /// Get the DID key as a string 99 | #[wasm_bindgen] 100 | pub fn did_string(&self) -> String { 101 | self.inner.did().to_string() 102 | } 103 | 104 | /// Sign data with the private key 105 | #[wasm_bindgen] 106 | pub fn sign(&self, data: &[u8]) -> Result, JsError> { 107 | if data.is_empty() { 108 | return Err(JsError::new("Cannot sign empty data")); 109 | } 110 | 111 | let signature = self.inner.sign(data).map_err(core_error_to_js_error)?; 112 | Ok(signature) 113 | } 114 | 115 | /// Verify a signature against the public key 116 | #[wasm_bindgen] 117 | pub fn verify(&self, data: &[u8], signature: &[u8]) -> Result { 118 | if signature.len() != 64 { 119 | return Err(JsError::new("Signature must be 64 bytes")); 120 | } 121 | 122 | match self.inner.verify(data, signature) { 123 | Ok(()) => Ok(true), 124 | Err(_) => Ok(false), 125 | } 126 | } 127 | 128 | /// Check if this key material can sign (has private key) 129 | #[wasm_bindgen] 130 | pub fn can_sign(&self) -> bool { 131 | self.inner.private_key().is_some() 132 | } 133 | } 134 | 135 | // Internal conversion methods for use within the WASM crate 136 | impl Ed25519KeyMaterial { 137 | pub fn from_core(core: CoreKeyMaterial) -> Self { 138 | Self { inner: core } 139 | } 140 | 141 | pub fn into_core(self) -> CoreKeyMaterial { 142 | self.inner 143 | } 144 | 145 | pub fn as_core(&self) -> &CoreKeyMaterial { 146 | &self.inner 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /rust/szdt_core/src/ed25519_key_material.rs: -------------------------------------------------------------------------------- 1 | use crate::did::DidKey; 2 | use crate::ed25519::{ 3 | PrivateKey, PublicKey, derive_public_key, generate_keypair_from_entropy, sign, to_private_key, 4 | to_public_key, verify, 5 | }; 6 | use crate::error::Error; 7 | use crate::mnemonic::Mnemonic; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | /// Wraps ed25519 key material, allowing you to 11 | /// - sign and verify 12 | /// - get a DID corresponding to the public key 13 | #[derive(Debug, Clone, Serialize, Deserialize)] 14 | pub struct Ed25519KeyMaterial { 15 | public_key: PublicKey, 16 | private_key: Option, 17 | } 18 | 19 | impl Ed25519KeyMaterial { 20 | pub fn generate_from_entropy(seed: &[u8]) -> Result { 21 | let (public_key, private_key) = generate_keypair_from_entropy(seed)?; 22 | Ok(Self { 23 | public_key, 24 | private_key: Some(private_key), 25 | }) 26 | } 27 | 28 | /// Initialize from private key bytes 29 | pub fn try_from_private_key(private_key: &[u8]) -> Result { 30 | let private_key = to_private_key(private_key)?; 31 | let public_key = derive_public_key(&private_key)?; 32 | Ok(Self { 33 | public_key, 34 | private_key: Some(private_key), 35 | }) 36 | } 37 | 38 | /// Construct key material from a publick key, without a private key 39 | pub fn try_from_public_key(pubkey: &[u8]) -> Result { 40 | let public_key = to_public_key(pubkey)?; 41 | Ok(Self { 42 | public_key, 43 | private_key: None, 44 | }) 45 | } 46 | 47 | /// Get the private key portion 48 | pub fn private_key(&self) -> Option> { 49 | self.private_key 50 | .map(|private_key| private_key.as_slice().to_vec()) 51 | } 52 | 53 | /// Get the public key portion 54 | pub fn public_key(&self) -> Vec { 55 | self.public_key.as_slice().to_vec() 56 | } 57 | 58 | pub fn did(&self) -> DidKey { 59 | let public_key = self.public_key(); 60 | DidKey::new(&public_key).expect("Should be valid public key") 61 | } 62 | 63 | /// Sign payload, returning signature bytes 64 | pub fn sign(&self, payload: &[u8]) -> Result, Error> { 65 | match &self.private_key { 66 | Some(private_key) => { 67 | let sig = sign(payload, private_key)?; 68 | Ok(sig) 69 | } 70 | None => Err(Error::PrivateKeyMissing( 71 | "Can't sign payload. No private key.".to_string(), 72 | )), 73 | } 74 | } 75 | 76 | /// Verify signature 77 | pub fn verify(&self, payload: &[u8], signature: &[u8]) -> Result<(), Error> { 78 | verify(payload, signature, &self.public_key)?; 79 | Ok(()) 80 | } 81 | } 82 | 83 | impl TryFrom<&Mnemonic> for Ed25519KeyMaterial { 84 | type Error = Error; 85 | 86 | fn try_from(value: &Mnemonic) -> Result { 87 | let entropy = value.to_entropy(); 88 | Self::try_from_private_key(&entropy) 89 | } 90 | } 91 | 92 | impl TryFrom<&Ed25519KeyMaterial> for Mnemonic { 93 | type Error = Error; 94 | 95 | fn try_from(key_material: &Ed25519KeyMaterial) -> Result { 96 | let Some(private_key) = key_material.private_key() else { 97 | return Err(Error::PrivateKeyMissing( 98 | "Cannot generate mnemonic".to_string(), 99 | )); 100 | }; 101 | let mnemonic = Mnemonic::from_entropy(private_key.as_slice())?; 102 | Ok(mnemonic) 103 | } 104 | } 105 | 106 | impl From<&Ed25519KeyMaterial> for DidKey { 107 | fn from(key_material: &Ed25519KeyMaterial) -> Self { 108 | key_material.did() 109 | } 110 | } 111 | 112 | impl TryFrom<&DidKey> for Ed25519KeyMaterial { 113 | type Error = Error; 114 | 115 | fn try_from(did_key: &DidKey) -> Result { 116 | let public_key = did_key.public_key(); 117 | let material = Self::try_from_public_key(public_key)?; 118 | Ok(material) 119 | } 120 | } 121 | 122 | #[cfg(test)] 123 | mod tests { 124 | use super::*; 125 | use crate::ed25519::generate_keypair_from_entropy; 126 | 127 | #[test] 128 | fn test_ed25519_key_material_roundtrip() { 129 | // Generate a signing key with test entropy 130 | let test_seed = [3u8; 32]; 131 | let (pubkey, privkey) = generate_keypair_from_entropy(&test_seed).unwrap(); 132 | 133 | // Create Ed25519KeyMaterial from the signing key 134 | let key_material = Ed25519KeyMaterial::try_from_private_key(&privkey).unwrap(); 135 | 136 | // Test signing and verification 137 | let message = b"test message for roundtrip verification"; 138 | let signature = key_material.sign(message).unwrap(); 139 | 140 | let key_material_2 = Ed25519KeyMaterial::try_from_public_key(&pubkey).unwrap(); 141 | 142 | // Verify using the same key material 143 | let result = key_material_2.verify(message, &signature); 144 | assert!(result.is_ok()); 145 | 146 | // Try to verify with a different message 147 | let wrong_message = b"wrong message".to_vec(); 148 | let result = key_material_2.verify(&wrong_message, &signature); 149 | assert!(result.is_err()); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/db/migrations.rs: -------------------------------------------------------------------------------- 1 | use rusqlite::{Connection, Error as SqlError, Transaction}; 2 | use thiserror::Error; 3 | 4 | type MigrationFn = fn(&Transaction) -> Result<(), SqlError>; 5 | 6 | /// Migrate the database to the latest version. 7 | /// Applies database migrations in order, starting from the current user_version. 8 | /// 9 | /// This function is idempotent - running it multiple times will only apply 10 | /// migrations that haven't been run yet. Each migration is executed in its own 11 | /// transaction and the database's user_version pragma is updated to track 12 | /// progress. 13 | /// 14 | /// Will roll back to last good version on error. 15 | pub fn migrate(conn: &mut Connection, migrations: &[MigrationFn]) -> Result { 16 | let current_version = get_user_version(conn)?; 17 | 18 | let mut last_successful_version = current_version; 19 | 20 | for (index, migration) in migrations.iter().enumerate() { 21 | let migration_version = index + 1; 22 | 23 | if migration_version > last_successful_version { 24 | let tx = conn.transaction()?; 25 | match migration(&tx) { 26 | Ok(()) => { 27 | set_user_version(&tx, migration_version)?; 28 | tx.commit()?; 29 | last_successful_version = migration_version; 30 | } 31 | Err(error) => { 32 | tx.rollback()?; 33 | return Err(MigrationError { 34 | version: last_successful_version, 35 | error, 36 | }); 37 | } 38 | } 39 | } 40 | } 41 | 42 | Ok(last_successful_version) 43 | } 44 | 45 | /// Represents an error that occurred during database migrations. 46 | #[derive(Debug, Error)] 47 | #[error("Error performing migration. Rolled back to version {version}. Error: {error}")] 48 | pub struct MigrationError { 49 | /// Last good version 50 | pub version: usize, 51 | /// Error that stopped completion of migrations 52 | pub error: SqlError, 53 | } 54 | 55 | impl From for MigrationError { 56 | fn from(error: SqlError) -> Self { 57 | MigrationError { version: 0, error } 58 | } 59 | } 60 | 61 | /// Returns the current user_version of the database. 62 | pub fn get_user_version(conn: &Connection) -> Result { 63 | let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; 64 | Ok(version as usize) 65 | } 66 | 67 | fn set_user_version(tx: &Transaction, version: usize) -> Result<(), SqlError> { 68 | tx.pragma_update(None, "user_version", version) 69 | } 70 | 71 | #[cfg(test)] 72 | mod tests { 73 | use super::*; 74 | 75 | fn create_test_db() -> Connection { 76 | Connection::open_in_memory().unwrap() 77 | } 78 | 79 | fn migration1(tx: &Transaction) -> Result<(), SqlError> { 80 | tx.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)", [])?; 81 | Ok(()) 82 | } 83 | 84 | fn migration2(tx: &Transaction) -> Result<(), SqlError> { 85 | tx.execute("ALTER TABLE test ADD COLUMN name TEXT", [])?; 86 | Ok(()) 87 | } 88 | 89 | fn failing_migration(_tx: &Transaction) -> Result<(), SqlError> { 90 | Err(SqlError::SqliteFailure( 91 | rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT), 92 | Some("Test error".to_string()), 93 | )) 94 | } 95 | 96 | #[test] 97 | fn test_empty_migrations() { 98 | let mut conn = create_test_db(); 99 | let migrations: &[MigrationFn] = &[]; 100 | 101 | let result = migrate(&mut conn, migrations).unwrap(); 102 | assert_eq!(result, 0); 103 | assert_eq!(get_user_version(&conn).unwrap(), 0); 104 | } 105 | 106 | #[test] 107 | fn test_single_migration() { 108 | let mut conn = create_test_db(); 109 | 110 | let migrations: &[MigrationFn] = &[migration1]; 111 | let result = migrate(&mut conn, migrations).unwrap(); 112 | 113 | assert_eq!(result, 1); 114 | assert_eq!(get_user_version(&conn).unwrap(), 1); 115 | } 116 | 117 | #[test] 118 | fn test_multiple_migrations() { 119 | let mut conn = create_test_db(); 120 | 121 | let migrations: &[MigrationFn] = &[migration1, migration2]; 122 | let result = migrate(&mut conn, migrations).unwrap(); 123 | 124 | assert_eq!(result, 2); 125 | assert_eq!(get_user_version(&conn).unwrap(), 2); 126 | } 127 | 128 | #[test] 129 | fn test_migration_failure_rollback() { 130 | let mut conn = create_test_db(); 131 | 132 | let migrations: &[MigrationFn] = &[migration1, failing_migration]; 133 | let error = 134 | migrate(&mut conn, migrations).expect_err("Migrate should have returned an error"); 135 | 136 | assert_eq!(error.version, 1); 137 | assert_eq!(get_user_version(&conn).unwrap(), 1); 138 | } 139 | 140 | #[test] 141 | fn test_idempotent_migrations() { 142 | let mut conn = create_test_db(); 143 | 144 | let migrations: &[MigrationFn] = &[migration1]; 145 | 146 | let result1 = migrate(&mut conn, migrations).unwrap(); 147 | assert_eq!(result1, 1); 148 | 149 | let result2 = migrate(&mut conn, migrations).unwrap(); 150 | assert_eq!(result2, 1); 151 | assert_eq!(get_user_version(&conn).unwrap(), 1); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/migrations.rs: -------------------------------------------------------------------------------- 1 | use rusqlite::{Connection, Error as SqlError, Transaction}; 2 | use thiserror::Error; 3 | 4 | /// Migrate the database to the latest version. 5 | /// Applies database migrations in order, starting from the current user_version. 6 | /// 7 | /// This function is idempotent - running it multiple times will only apply 8 | /// migrations that haven't been run yet. Each migration is executed in its own 9 | /// transaction and the database's user_version pragma is updated to track 10 | /// progress. 11 | /// 12 | /// Will roll back to last good version on error. 13 | pub fn migrate( 14 | conn: &mut Connection, 15 | migrations: &[fn(&Transaction) -> Result<(), SqlError>], 16 | ) -> Result { 17 | let current_version = get_user_version(conn)?; 18 | 19 | let mut last_successful_version = current_version; 20 | 21 | for (index, migration) in migrations.iter().enumerate() { 22 | let migration_version = index + 1; 23 | 24 | if migration_version > last_successful_version { 25 | let tx = conn.transaction()?; 26 | match migration(&tx) { 27 | Ok(()) => { 28 | set_user_version(&tx, migration_version)?; 29 | tx.commit()?; 30 | last_successful_version = migration_version; 31 | } 32 | Err(error) => { 33 | tx.rollback()?; 34 | return Err(MigrationError { 35 | version: last_successful_version, 36 | error: error, 37 | }); 38 | } 39 | } 40 | } 41 | } 42 | 43 | Ok(last_successful_version) 44 | } 45 | 46 | /// Represents an error that occurred during database migrations. 47 | #[derive(Debug, Error)] 48 | #[error("Error performing migration. Rolled back to version {version}. Error: {error}")] 49 | pub struct MigrationError { 50 | /// Last good version 51 | pub version: usize, 52 | /// Error that stopped completion of migrations 53 | pub error: SqlError, 54 | } 55 | 56 | impl From for MigrationError { 57 | fn from(error: SqlError) -> Self { 58 | MigrationError { version: 0, error } 59 | } 60 | } 61 | 62 | /// Returns the current user_version of the database. 63 | pub fn get_user_version(conn: &Connection) -> Result { 64 | let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; 65 | Ok(version as usize) 66 | } 67 | 68 | fn set_user_version(tx: &Transaction, version: usize) -> Result<(), SqlError> { 69 | tx.pragma_update(None, "user_version", version) 70 | } 71 | 72 | #[cfg(test)] 73 | mod tests { 74 | use super::*; 75 | 76 | fn create_test_db() -> Connection { 77 | Connection::open_in_memory().unwrap() 78 | } 79 | 80 | fn migration1(tx: &Transaction) -> Result<(), SqlError> { 81 | tx.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)", [])?; 82 | Ok(()) 83 | } 84 | 85 | fn migration2(tx: &Transaction) -> Result<(), SqlError> { 86 | tx.execute("ALTER TABLE test ADD COLUMN name TEXT", [])?; 87 | Ok(()) 88 | } 89 | 90 | fn failing_migration(_tx: &Transaction) -> Result<(), SqlError> { 91 | Err(SqlError::SqliteFailure( 92 | rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT), 93 | Some("Test error".to_string()), 94 | )) 95 | } 96 | 97 | #[test] 98 | fn test_empty_migrations() { 99 | let mut conn = create_test_db(); 100 | let migrations: &[fn(&Transaction) -> Result<(), SqlError>] = &[]; 101 | 102 | let result = migrate(&mut conn, migrations).unwrap(); 103 | assert_eq!(result, 0); 104 | assert_eq!(get_user_version(&conn).unwrap(), 0); 105 | } 106 | 107 | #[test] 108 | fn test_single_migration() { 109 | let mut conn = create_test_db(); 110 | 111 | let migrations: &[fn(&Transaction) -> Result<(), SqlError>] = &[migration1]; 112 | let result = migrate(&mut conn, migrations).unwrap(); 113 | 114 | assert_eq!(result, 1); 115 | assert_eq!(get_user_version(&conn).unwrap(), 1); 116 | } 117 | 118 | #[test] 119 | fn test_multiple_migrations() { 120 | let mut conn = create_test_db(); 121 | 122 | let migrations: &[fn(&Transaction) -> Result<(), SqlError>] = &[migration1, migration2]; 123 | let result = migrate(&mut conn, migrations).unwrap(); 124 | 125 | assert_eq!(result, 2); 126 | assert_eq!(get_user_version(&conn).unwrap(), 2); 127 | } 128 | 129 | #[test] 130 | fn test_migration_failure_rollback() { 131 | let mut conn = create_test_db(); 132 | 133 | let migrations: &[fn(&Transaction) -> Result<(), SqlError>] = 134 | &[migration1, failing_migration]; 135 | let error = 136 | migrate(&mut conn, migrations).expect_err("Migrate should have returned an error"); 137 | 138 | assert_eq!(error.version, 1); 139 | assert_eq!(get_user_version(&conn).unwrap(), 1); 140 | } 141 | 142 | #[test] 143 | fn test_idempotent_migrations() { 144 | let mut conn = create_test_db(); 145 | 146 | let migrations: &[fn(&Transaction) -> Result<(), SqlError>] = &[migration1]; 147 | 148 | let result1 = migrate(&mut conn, migrations).unwrap(); 149 | assert_eq!(result1, 1); 150 | 151 | let result2 = migrate(&mut conn, migrations).unwrap(); 152 | assert_eq!(result2, 1); 153 | assert_eq!(get_user_version(&conn).unwrap(), 1); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /rust/szdt_core/src/hash.rs: -------------------------------------------------------------------------------- 1 | use data_encoding::BASE32_NOPAD; 2 | use serde::de::{self, Unexpected, Visitor}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::io::Read; 5 | 6 | /// Blake3 hash 7 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] 8 | pub struct Hash(blake3::Hash); 9 | 10 | impl Hash { 11 | /// Hash the provided bytes. 12 | pub fn new(buf: impl AsRef<[u8]>) -> Self { 13 | let val = blake3::hash(buf.as_ref()); 14 | Hash(val) 15 | } 16 | 17 | /// Streaming hash the bytes returned by a reader 18 | pub fn from_reader(reader: R) -> Self { 19 | let mut hasher = blake3::Hasher::new(); 20 | let mut buffer = [0; 1024]; 21 | let mut reader = std::io::BufReader::new(reader); 22 | while let Ok(n) = reader.read(&mut buffer) { 23 | if n == 0 { 24 | break; 25 | } 26 | hasher.update(&buffer[..n]); 27 | } 28 | Hash(hasher.finalize()) 29 | } 30 | 31 | /// Construct a hash from a byte array representing the hash. 32 | pub fn from_bytes(bytes: [u8; 32]) -> Self { 33 | Self(blake3::Hash::from_bytes(bytes)) 34 | } 35 | 36 | /// Bytes of the hash. 37 | pub fn as_bytes(&self) -> &[u8; 32] { 38 | self.0.as_bytes() 39 | } 40 | 41 | /// Construct a hash from a slice representing the hash bytes 42 | pub fn from_slice(bytes: &[u8]) -> Result { 43 | let byte_array: [u8; 32] = bytes.try_into()?; 44 | Ok(Self::from_bytes(byte_array)) 45 | } 46 | } 47 | 48 | impl PartialOrd for Hash { 49 | fn partial_cmp(&self, other: &Self) -> Option { 50 | Some(self.0.as_bytes().cmp(other.0.as_bytes())) 51 | } 52 | } 53 | 54 | impl Ord for Hash { 55 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 56 | self.0.as_bytes().cmp(other.0.as_bytes()) 57 | } 58 | } 59 | 60 | impl std::fmt::Display for Hash { 61 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 62 | let hash_base32_hex = BASE32_NOPAD.encode(self.0.as_bytes()); 63 | let hash_base32_hex_lowercase = hash_base32_hex.to_lowercase(); 64 | write!(f, "{hash_base32_hex_lowercase}") 65 | } 66 | } 67 | 68 | impl Serialize for Hash { 69 | fn serialize(&self, serializer: S) -> Result 70 | where 71 | S: serde::Serializer, 72 | { 73 | serializer.serialize_bytes(self.0.as_bytes()) 74 | } 75 | } 76 | 77 | impl<'de> Deserialize<'de> for Hash { 78 | fn deserialize(deserializer: D) -> Result 79 | where 80 | D: serde::Deserializer<'de>, 81 | { 82 | deserializer.deserialize_bytes(HashVisitor) 83 | } 84 | } 85 | 86 | struct HashVisitor; 87 | 88 | impl Visitor<'_> for HashVisitor { 89 | type Value = Hash; 90 | 91 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 92 | formatter.write_str("a byte string representing a hash") 93 | } 94 | 95 | fn visit_bytes(self, value: &[u8]) -> Result 96 | where 97 | E: de::Error, 98 | { 99 | let hash = Hash::from_slice(value).map_err(|_| { 100 | de::Error::invalid_value(Unexpected::Bytes(value), &"a byte array of length 32") 101 | })?; 102 | Ok(hash) 103 | } 104 | 105 | fn visit_byte_buf(self, value: Vec) -> Result 106 | where 107 | E: de::Error, 108 | { 109 | let hash = Hash::from_slice(&value).map_err(|_| { 110 | de::Error::invalid_value(Unexpected::Bytes(&value), &"a byte array of length 32") 111 | })?; 112 | Ok(hash) 113 | } 114 | } 115 | 116 | impl From<[u8; 32]> for Hash { 117 | /// Convert hash bytes to hash 118 | fn from(value: [u8; 32]) -> Self { 119 | Self::from_bytes(value) 120 | } 121 | } 122 | 123 | impl From for blake3::Hash { 124 | fn from(value: Hash) -> Self { 125 | value.0 126 | } 127 | } 128 | 129 | impl From for Hash { 130 | fn from(value: blake3::Hash) -> Self { 131 | Hash(value) 132 | } 133 | } 134 | 135 | #[cfg(test)] 136 | mod tests { 137 | use super::*; 138 | 139 | #[test] 140 | fn test_hash_serializes_as_cbor_byte_string() { 141 | let hash = Hash::new(b"test data"); 142 | let serialized = serde_cbor_core::to_vec(&hash).expect("Failed to serialize hash"); 143 | 144 | // CBOR byte strings with length 32 should start with 0x58 0x20 145 | // 0x58 = major type 2 (byte string) with additional info 24 (1-byte length follows) 146 | // 0x20 = 32 in decimal (the length of blake3 hash) 147 | assert_eq!( 148 | serialized[0], 0x58, 149 | "First byte should indicate CBOR byte string with 1-byte length" 150 | ); 151 | assert_eq!(serialized[1], 32, "Second byte should be the length (32)"); 152 | assert_eq!( 153 | serialized.len(), 154 | 34, 155 | "Total length should be 2 header bytes + 32 data bytes" 156 | ); 157 | 158 | // Verify the hash bytes are included 159 | assert_eq!(&serialized[2..], hash.as_bytes(), "Hash bytes should match"); 160 | } 161 | 162 | #[test] 163 | fn test_hash_serialize_roundtrip() { 164 | let original_hash = Hash::new(b"roundtrip test data"); 165 | 166 | // Serialize 167 | let serialized = serde_cbor_core::to_vec(&original_hash).expect("Failed to serialize"); 168 | 169 | // Deserialize 170 | let deserialized_hash: Hash = 171 | serde_cbor_core::from_slice(&serialized).expect("Failed to deserialize"); 172 | 173 | assert_eq!( 174 | original_hash, deserialized_hash, 175 | "Hash should roundtrip correctly" 176 | ); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /rust/szdt_core/src/did.rs: -------------------------------------------------------------------------------- 1 | use crate::base58btc; 2 | use crate::ed25519; 3 | use ed25519_dalek::PUBLIC_KEY_LENGTH; 4 | use serde::{Deserialize, Serialize}; 5 | use thiserror::Error; 6 | 7 | type PublicKey = [u8; PUBLIC_KEY_LENGTH]; 8 | 9 | /// The multicodec prefix for ed25519 public key is 0xed01. 10 | /// https://github.com/multiformats/multicodec/blob/master/table.csv 11 | const MULTICODEC_ED25519_PUB_PREFIX: &[u8] = &[0xed, 0x01]; 12 | 13 | /// The prefix for did:key using Base58BTC encoding. 14 | /// The multibase code for ed25519 public key is 'z'. 15 | const DID_KEY_BASE58BTC_PREFIX: &str = "did:key:z"; 16 | 17 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 18 | pub struct DidKey(PublicKey); 19 | 20 | impl DidKey { 21 | /// Create a new DidKey from a public key. 22 | pub fn new(pubkey_bytes: &[u8]) -> Result { 23 | let pubkey = ed25519::to_public_key(pubkey_bytes)?; 24 | Ok(DidKey(pubkey)) 25 | } 26 | 27 | /// Parse a did:key URL string into a DidKey. 28 | pub fn parse(did_key_url: &str) -> Result { 29 | // Parse the did:key 30 | let base58_key = did_key_url 31 | .strip_prefix(DID_KEY_BASE58BTC_PREFIX) 32 | .ok_or(Error::Base( 33 | "Unsupported base encoding. Only Base58BTC is supported.".to_string(), 34 | ))?; 35 | 36 | let decoded_bytes = base58btc::decode(base58_key)?; 37 | 38 | // Strip the ED25519_PUB_PREFIX, and return an error if the prefix is not present 39 | let Some(key_bytes) = decoded_bytes.strip_prefix(MULTICODEC_ED25519_PUB_PREFIX) else { 40 | return Err(Error::UnsupportedCodec( 41 | "Only Ed25519 public keys are supported.".to_string(), 42 | )); 43 | }; 44 | // Extract the public key 45 | DidKey::new(key_bytes) 46 | } 47 | 48 | pub fn public_key(&self) -> &ed25519::PublicKey { 49 | &self.0 50 | } 51 | } 52 | 53 | impl std::fmt::Display for DidKey { 54 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 55 | // Convert public key to multibase encoded string 56 | let mut multicodec_bytes = MULTICODEC_ED25519_PUB_PREFIX.to_vec(); 57 | multicodec_bytes.extend_from_slice(&self.0); 58 | 59 | // Encode with multibase (Base58BTC, prefix 'z') 60 | let multibase_encoded = base58btc::encode(multicodec_bytes); 61 | 62 | // Construct the did:key 63 | write!(f, "{DID_KEY_BASE58BTC_PREFIX}{multibase_encoded}") 64 | } 65 | } 66 | 67 | impl From<&DidKey> for String { 68 | fn from(did_key: &DidKey) -> Self { 69 | did_key.to_string() 70 | } 71 | } 72 | 73 | impl TryFrom<&str> for DidKey { 74 | type Error = Error; 75 | 76 | /// Parse a did:key str encoding an ed25519 public key into a DidKey. 77 | fn try_from(did_key_url: &str) -> Result { 78 | DidKey::parse(did_key_url) 79 | } 80 | } 81 | 82 | impl TryFrom for DidKey { 83 | type Error = Error; 84 | 85 | /// Parse a did:key str encoding an ed25519 public key into a DidKey. 86 | fn try_from(did_key_url: String) -> Result { 87 | DidKey::parse(&did_key_url) 88 | } 89 | } 90 | 91 | impl Serialize for DidKey { 92 | fn serialize(&self, serializer: S) -> Result 93 | where 94 | S: serde::Serializer, 95 | { 96 | serializer.serialize_str(&String::from(self)) 97 | } 98 | } 99 | 100 | impl<'de> Deserialize<'de> for DidKey { 101 | fn deserialize(deserializer: D) -> Result 102 | where 103 | D: serde::Deserializer<'de>, 104 | { 105 | let s = String::deserialize(deserializer)?; 106 | DidKey::parse(&s).map_err(|e| serde::de::Error::custom(e.to_string())) 107 | } 108 | } 109 | 110 | #[derive(Debug, Error)] 111 | pub enum Error { 112 | #[error("Public key error: {0}")] 113 | Key(#[from] ed25519::Error), 114 | #[error("Base encoding/decoding error: {0}")] 115 | Base(String), 116 | #[error("Unsupported codec: {0}")] 117 | UnsupportedCodec(String), 118 | } 119 | 120 | impl From for Error { 121 | fn from(err: bs58::decode::Error) -> Self { 122 | Error::Base(err.to_string()) 123 | } 124 | } 125 | 126 | #[cfg(test)] 127 | mod tests { 128 | use super::*; 129 | 130 | #[test] 131 | fn test_roundtrip_did_key() { 132 | // Test vector 133 | let pubkey: [u8; 32] = [ 134 | 215, 90, 152, 1, 130, 177, 10, 183, 213, 75, 254, 211, 201, 100, 7, 58, 14, 225, 114, 135 | 243, 218, 166, 35, 37, 175, 2, 26, 104, 247, 7, 81, 26, 136 | ]; 137 | 138 | let did = DidKey::new(&pubkey).unwrap(); 139 | let did_string = String::from(&did); 140 | let did2 = DidKey::try_from(did_string.as_str()).unwrap(); 141 | 142 | assert_eq!(did, did2); 143 | } 144 | 145 | #[test] 146 | fn test_roundtrip_did_url() { 147 | let did_url = "did:key:z6MkjxXr49JYNRDagDRVTNJKj17vTcmxwPb1KybzeVUM13qs"; 148 | let did = DidKey::parse(did_url).unwrap(); 149 | let did_url_2 = did.to_string(); 150 | assert_eq!(did_url, did_url_2); 151 | } 152 | 153 | #[test] 154 | fn test_did_key_string_magic_prefix() { 155 | let pubkey: [u8; 32] = [ 156 | 215, 90, 152, 1, 130, 177, 10, 183, 213, 75, 254, 211, 201, 100, 7, 58, 14, 225, 114, 157 | 243, 218, 166, 35, 37, 175, 2, 26, 104, 247, 7, 81, 26, 158 | ]; 159 | 160 | let did = DidKey::new(&pubkey).unwrap(); 161 | 162 | assert!(did.to_string().starts_with("did:key:z6Mk")); 163 | } 164 | 165 | #[test] 166 | fn test_decode_invalid_did_key() { 167 | // Invalid prefix 168 | assert!(DidKey::try_from("did:invalid:z123").is_err()); 169 | 170 | // Invalid encoding 171 | assert!(DidKey::try_from("did:key:INVALID").is_err()); 172 | 173 | // Empty string 174 | assert!(DidKey::try_from("").is_err()); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /rust/szdt_wasm/src/memo.rs: -------------------------------------------------------------------------------- 1 | use crate::ed25519_key_material::Ed25519KeyMaterial; 2 | use crate::hash::Hash; 3 | use szdt_core::error::Error as CoreError; 4 | use szdt_core::memo::Memo as CoreMemo; 5 | use wasm_bindgen::prelude::*; 6 | 7 | /// WASM wrapper for SZDT memo operations 8 | #[wasm_bindgen] 9 | pub struct Memo { 10 | inner: CoreMemo, 11 | } 12 | 13 | #[wasm_bindgen] 14 | impl Memo { 15 | /// Create a new memo with the given body hash 16 | #[wasm_bindgen(constructor)] 17 | pub fn new(body_hash: Hash) -> Memo { 18 | Self { 19 | inner: CoreMemo::new(body_hash.into_core()), 20 | } 21 | } 22 | 23 | /// Create a memo for the given body content 24 | /// Content will be serialized to CBOR and hashed 25 | #[wasm_bindgen] 26 | pub fn for_body(content: &[u8]) -> Result { 27 | let inner = CoreMemo::for_body(content).map_err(|e| JsError::new(&e.to_string()))?; 28 | Ok(Self { inner }) 29 | } 30 | 31 | /// Create a memo for a string body content 32 | #[wasm_bindgen] 33 | pub fn for_string(content: &str) -> Result { 34 | let inner = CoreMemo::for_body(content).map_err(|e| JsError::new(&e.to_string()))?; 35 | Ok(Self { inner }) 36 | } 37 | 38 | /// Create an empty memo (no body content) 39 | #[wasm_bindgen] 40 | pub fn empty() -> Memo { 41 | Self { 42 | inner: CoreMemo::empty(), 43 | } 44 | } 45 | 46 | /// Sign the memo with the given key material 47 | #[wasm_bindgen] 48 | pub fn sign(&mut self, key_material: &Ed25519KeyMaterial) -> Result<(), JsError> { 49 | self.inner 50 | .sign(key_material.as_core()) 51 | .map_err(|e| JsError::new(&e.to_string()))?; 52 | Ok(()) 53 | } 54 | 55 | /// Verify the memo signature 56 | #[wasm_bindgen] 57 | pub fn verify(&self) -> Result { 58 | match self.inner.verify() { 59 | Ok(()) => Ok(true), 60 | Err(CoreError::MemoUnsigned) => Ok(false), 61 | Err(CoreError::MemoIssMissing) => Ok(false), 62 | Err(_) => Ok(false), 63 | } 64 | } 65 | 66 | /// Validate the memo (verify signature and check time bounds) 67 | #[wasm_bindgen] 68 | pub fn validate(&self, timestamp: Option) -> Result { 69 | match self.inner.validate(timestamp) { 70 | Ok(()) => Ok(true), 71 | Err(_) => Ok(false), 72 | } 73 | } 74 | 75 | /// Check if the memo is expired 76 | #[wasm_bindgen] 77 | pub fn is_expired(&self, timestamp: Option) -> bool { 78 | self.inner.is_expired(timestamp) 79 | } 80 | 81 | /// Check if the memo is too early (before nbf time) 82 | #[wasm_bindgen] 83 | pub fn is_too_early(&self, timestamp: Option) -> bool { 84 | self.inner.is_too_early(timestamp) 85 | } 86 | 87 | /// Serialize the memo to CBOR bytes 88 | #[wasm_bindgen] 89 | pub fn to_cbor(&self) -> Result, JsError> { 90 | let bytes = 91 | serde_cbor_core::to_vec(&self.inner).map_err(|e| JsError::new(&e.to_string()))?; 92 | Ok(bytes) 93 | } 94 | 95 | /// Deserialize a memo from CBOR bytes 96 | #[wasm_bindgen] 97 | pub fn from_cbor(data: &[u8]) -> Result { 98 | let inner: CoreMemo = 99 | serde_cbor_core::from_slice(data).map_err(|e| JsError::new(&e.to_string()))?; 100 | Ok(Self { inner }) 101 | } 102 | 103 | /// Check the memo's body hash against a provided hash 104 | #[wasm_bindgen] 105 | pub fn checksum(&self, body_hash: &Hash) -> Result { 106 | match self.inner.checksum(body_hash.as_core()) { 107 | Ok(()) => Ok(true), 108 | Err(_) => Ok(false), 109 | } 110 | } 111 | 112 | /// Get the body hash 113 | #[wasm_bindgen] 114 | pub fn body_hash(&self) -> Hash { 115 | Hash::from_core(self.inner.protected.src) 116 | } 117 | 118 | /// Get the timestamp when the memo was issued 119 | #[wasm_bindgen] 120 | pub fn issued_at(&self) -> u64 { 121 | self.inner.protected.iat 122 | } 123 | 124 | /// Get the expiration timestamp (if any) 125 | #[wasm_bindgen] 126 | pub fn expires_at(&self) -> Option { 127 | self.inner.protected.exp 128 | } 129 | 130 | /// Get the not-before timestamp (if any) 131 | #[wasm_bindgen] 132 | pub fn not_before(&self) -> Option { 133 | self.inner.protected.nbf 134 | } 135 | 136 | /// Get the issuer DID (if signed) 137 | #[wasm_bindgen] 138 | pub fn issuer_did(&self) -> Option { 139 | self.inner.protected.iss.as_ref().map(|did| did.to_string()) 140 | } 141 | 142 | /// Get the content type (if any) 143 | #[wasm_bindgen] 144 | pub fn content_type(&self) -> Option { 145 | self.inner.protected.content_type.clone() 146 | } 147 | 148 | /// Set the content type 149 | #[wasm_bindgen] 150 | pub fn set_content_type(&mut self, content_type: Option) { 151 | self.inner.protected.content_type = content_type; 152 | } 153 | 154 | /// Get the file path (if any) 155 | #[wasm_bindgen] 156 | pub fn path(&self) -> Option { 157 | self.inner.protected.path.clone() 158 | } 159 | 160 | /// Set the file path 161 | #[wasm_bindgen] 162 | pub fn set_path(&mut self, path: Option) { 163 | self.inner.protected.path = path; 164 | } 165 | 166 | /// Set the expiration time 167 | #[wasm_bindgen] 168 | pub fn set_expires_at(&mut self, timestamp: Option) { 169 | self.inner.protected.exp = timestamp; 170 | } 171 | 172 | /// Set the not-before time 173 | #[wasm_bindgen] 174 | pub fn set_not_before(&mut self, timestamp: Option) { 175 | self.inner.protected.nbf = timestamp; 176 | } 177 | } 178 | 179 | // Internal conversion methods for use within the WASM crate 180 | impl Memo { 181 | pub fn from_core(core: CoreMemo) -> Self { 182 | Self { inner: core } 183 | } 184 | 185 | pub fn into_core(self) -> CoreMemo { 186 | self.inner 187 | } 188 | 189 | pub fn as_core(&self) -> &CoreMemo { 190 | &self.inner 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /rust/szdt_core/src/cbor_seq.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use serde::{de::DeserializeOwned, ser::Serialize}; 3 | use std::io::{BufRead, Write}; 4 | 5 | /// A specialized reader for deserializes SZDT archives. 6 | /// SZDT archives are CBOR sequences with a particular shape. 7 | pub struct CborSeqReader { 8 | reader: R, 9 | } 10 | 11 | impl CborSeqReader { 12 | pub fn new(reader: R) -> Self { 13 | Self { reader } 14 | } 15 | 16 | /// Deserialize next block 17 | pub fn read_block(&mut self) -> Result { 18 | let result: T = match serde_cbor_core::de::from_reader_once(&mut self.reader) { 19 | Ok(value) => value, 20 | Err(serde_cbor_core::DecodeError::Eof) => return Err(Error::Eof), 21 | Err(err) => return Err(Error::CborDecode(err.to_string())), 22 | }; 23 | Ok(result) 24 | } 25 | 26 | /// Unwrap inner reader 27 | pub fn into_inner(self) -> R { 28 | self.reader 29 | } 30 | } 31 | 32 | /// Represents the metadata portion of an SZDT archive 33 | pub struct CborSeqWriter { 34 | writer: W, 35 | } 36 | 37 | impl CborSeqWriter { 38 | pub fn new(writer: W) -> Self { 39 | Self { writer } 40 | } 41 | 42 | /// Serialize next block 43 | pub fn write_block(&mut self, block: &T) -> Result<(), Error> { 44 | serde_cbor_core::ser::to_writer(&mut self.writer, block)?; 45 | Ok(()) 46 | } 47 | 48 | /// Unwrap inner writer 49 | pub fn into_inner(self) -> W { 50 | self.writer 51 | } 52 | 53 | pub fn flush(&mut self) -> Result<(), Error> { 54 | self.writer.flush()?; 55 | Ok(()) 56 | } 57 | } 58 | 59 | impl Write for CborSeqWriter { 60 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 61 | self.writer.write(buf) 62 | } 63 | 64 | fn flush(&mut self) -> std::io::Result<()> { 65 | self.writer.flush() 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | use serde::{Deserialize, Serialize}; 73 | use std::io::Cursor; 74 | 75 | #[derive(Debug, Serialize, Deserialize, PartialEq)] 76 | struct TestData { 77 | id: u32, 78 | name: String, 79 | active: bool, 80 | } 81 | 82 | #[test] 83 | fn test_write_and_read_single_block() { 84 | let test_data = TestData { 85 | id: 42, 86 | name: "test".to_string(), 87 | active: true, 88 | }; 89 | 90 | // Write data 91 | let mut buffer = Vec::new(); 92 | let mut writer = CborSeqWriter::new(&mut buffer); 93 | writer.write_block(&test_data).unwrap(); 94 | writer.flush().unwrap(); 95 | 96 | // Read data back 97 | let cursor = Cursor::new(buffer); 98 | let mut reader = CborSeqReader::new(cursor); 99 | let result: TestData = reader.read_block().unwrap(); 100 | 101 | assert_eq!(test_data, result); 102 | } 103 | 104 | #[test] 105 | fn test_write_and_read_multiple_blocks() { 106 | let test_data1 = TestData { 107 | id: 1, 108 | name: "first".to_string(), 109 | active: true, 110 | }; 111 | let test_data2 = TestData { 112 | id: 2, 113 | name: "second".to_string(), 114 | active: false, 115 | }; 116 | 117 | // Write multiple blocks 118 | let mut buffer = Vec::new(); 119 | let mut writer = CborSeqWriter::new(&mut buffer); 120 | writer.write_block(&test_data1).unwrap(); 121 | writer.write_block(&test_data2).unwrap(); 122 | writer.flush().unwrap(); 123 | 124 | // Read blocks back 125 | let cursor = Cursor::new(buffer); 126 | let mut reader = CborSeqReader::new(cursor); 127 | 128 | let result1: TestData = reader.read_block().unwrap(); 129 | let result2: TestData = reader.read_block().unwrap(); 130 | 131 | assert_eq!(test_data1, result1); 132 | assert_eq!(test_data2, result2); 133 | } 134 | 135 | #[test] 136 | fn test_read_eof_error() { 137 | let buffer = Vec::new(); 138 | let cursor = Cursor::new(buffer); 139 | let mut reader = CborSeqReader::new(cursor); 140 | 141 | let result: Result = reader.read_block(); 142 | assert!(matches!(result, Err(Error::Eof))); 143 | } 144 | 145 | #[test] 146 | fn test_read_eof_after_data() { 147 | let test_data = TestData { 148 | id: 123, 149 | name: "last".to_string(), 150 | active: false, 151 | }; 152 | 153 | // Write one block 154 | let mut buffer = Vec::new(); 155 | let mut writer = CborSeqWriter::new(&mut buffer); 156 | writer.write_block(&test_data).unwrap(); 157 | writer.flush().unwrap(); 158 | 159 | // Read the block and then try to read another 160 | let cursor = Cursor::new(buffer); 161 | let mut reader = CborSeqReader::new(cursor); 162 | 163 | let result1: TestData = reader.read_block().unwrap(); 164 | assert_eq!(test_data, result1); 165 | 166 | let result2: Result = reader.read_block(); 167 | assert!(matches!(result2, Err(Error::Eof))); 168 | } 169 | 170 | #[test] 171 | fn test_writer_into_inner() { 172 | let buffer = Vec::new(); 173 | let writer = CborSeqWriter::new(buffer); 174 | let inner = writer.into_inner(); 175 | assert_eq!(inner.len(), 0); 176 | } 177 | 178 | #[test] 179 | fn test_reader_into_inner() { 180 | let buffer = vec![1, 2, 3, 4]; 181 | let cursor = Cursor::new(buffer.clone()); 182 | let reader = CborSeqReader::new(cursor); 183 | let inner = reader.into_inner(); 184 | assert_eq!(inner.into_inner(), buffer); 185 | } 186 | 187 | #[test] 188 | fn test_writer_write_trait() { 189 | let mut buffer = Vec::new(); 190 | let mut writer = CborSeqWriter::new(&mut buffer); 191 | 192 | let data = b"hello world"; 193 | let bytes_written = writer.write(data).unwrap(); 194 | writer.flush().unwrap(); 195 | 196 | assert_eq!(bytes_written, data.len()); 197 | assert_eq!(buffer, data); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /rust/szdt_core/src/ed25519.rs: -------------------------------------------------------------------------------- 1 | use ed25519_dalek::{ 2 | self, PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SecretKey, Signature, Signer, SigningKey, Verifier, 3 | VerifyingKey, 4 | }; 5 | use thiserror::Error; 6 | 7 | pub type PublicKey = [u8; PUBLIC_KEY_LENGTH]; 8 | pub type SignatureBytes = [u8; 64]; 9 | pub type PrivateKey = [u8; SECRET_KEY_LENGTH]; 10 | 11 | /// Generate a signing keypair from 32 bytes of entropy. 12 | /// Returns a tuple of `(pubkey, privkey)`. 13 | pub fn generate_keypair_from_entropy(seed: &[u8]) -> Result<(PublicKey, PrivateKey), Error> { 14 | if seed.len() != SECRET_KEY_LENGTH { 15 | return Err(Error::InvalidKey(format!( 16 | "Seed must be {} bytes, got {}", 17 | SECRET_KEY_LENGTH, 18 | seed.len() 19 | ))); 20 | } 21 | 22 | let mut secret_key = [0u8; SECRET_KEY_LENGTH]; 23 | secret_key.copy_from_slice(seed); 24 | 25 | let signing_key = SigningKey::from_bytes(&secret_key); 26 | Ok(( 27 | signing_key.verifying_key().to_bytes(), 28 | signing_key.to_bytes(), 29 | )) 30 | } 31 | 32 | /// Get the public key from a private key. 33 | pub fn derive_public_key(private_key: &[u8]) -> Result { 34 | let private_key = to_private_key(private_key)?; 35 | let signing_key = SigningKey::from_bytes(&private_key); 36 | let public_key = to_public_key(&signing_key.verifying_key().to_bytes())?; 37 | Ok(public_key) 38 | } 39 | 40 | /// Sign a payload with a private key. 41 | /// Returns the signature as a Vec. 42 | pub fn sign(payload: &[u8], private_key: &[u8]) -> Result, Error> { 43 | let private_key = to_private_key(private_key)?; 44 | let signing_key = SigningKey::from_bytes(&private_key); 45 | let signature = signing_key.sign(payload); 46 | Ok(signature.to_bytes().to_vec()) 47 | } 48 | 49 | /// Verify a signature with a public key. 50 | /// Returns an error if the signature is invalid. 51 | pub fn verify(payload: &[u8], signature: &[u8], public_key: &[u8]) -> Result<(), Error> { 52 | let public_key = to_public_key(public_key)?; 53 | let signature = Signature::from_slice(signature)?; 54 | let verifying_key = VerifyingKey::from_bytes(&public_key)?; 55 | verifying_key.verify(payload, &signature)?; 56 | Ok(()) 57 | } 58 | 59 | /// Convert a Vec to PublicKey. 60 | /// Returns an error if the input is not exactly 32 bytes. 61 | pub fn to_public_key(bytes: &[u8]) -> Result { 62 | if bytes.len() != PUBLIC_KEY_LENGTH { 63 | return Err(Error::InvalidKey(format!( 64 | "Public key must be {} bytes, got {}", 65 | PUBLIC_KEY_LENGTH, 66 | bytes.len() 67 | ))); 68 | } 69 | 70 | let mut public_key = [0u8; PUBLIC_KEY_LENGTH]; 71 | public_key.copy_from_slice(bytes); 72 | Ok(public_key) 73 | } 74 | 75 | /// Convert a Vec to PrivateKey. 76 | /// Returns an error if the input is not exactly 32 bytes. 77 | pub fn to_private_key(bytes: &[u8]) -> Result { 78 | if bytes.len() != SECRET_KEY_LENGTH { 79 | return Err(Error::InvalidKey(format!( 80 | "Private key must be {} bytes, got {}", 81 | SECRET_KEY_LENGTH, 82 | bytes.len() 83 | ))); 84 | } 85 | 86 | let mut private_key = [0u8; SECRET_KEY_LENGTH]; 87 | private_key.copy_from_slice(bytes); 88 | Ok(private_key) 89 | } 90 | 91 | #[derive(Debug, Error)] 92 | pub enum Error { 93 | #[error("Ed25519 error: {0}")] 94 | Signature(#[from] ed25519_dalek::SignatureError), 95 | #[error("Invalid key length: {0}")] 96 | InvalidKey(String), 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::*; 102 | 103 | #[test] 104 | fn test_vec_to_public_key() { 105 | // Valid case 106 | let valid_bytes = vec![0u8; PUBLIC_KEY_LENGTH]; 107 | let result = to_public_key(&valid_bytes); 108 | assert!(result.is_ok()); 109 | 110 | // Invalid case - wrong length 111 | let invalid_bytes = vec![0u8; PUBLIC_KEY_LENGTH - 1]; 112 | let result = to_public_key(&invalid_bytes); 113 | assert!(result.is_err()); 114 | } 115 | 116 | #[test] 117 | fn test_vec_to_private_key() { 118 | // Valid case 119 | let valid_bytes = vec![0u8; SECRET_KEY_LENGTH]; 120 | let result = to_private_key(&valid_bytes); 121 | assert!(result.is_ok()); 122 | 123 | // Invalid case - wrong length 124 | let invalid_bytes = vec![0u8; SECRET_KEY_LENGTH - 1]; 125 | let result = to_private_key(&invalid_bytes); 126 | assert!(result.is_err()); 127 | } 128 | 129 | #[test] 130 | fn test_derive_public_key_derives_the_public_key() { 131 | // Generate a keypair to test with using test entropy 132 | let test_seed = [1u8; SECRET_KEY_LENGTH]; 133 | let (expected_pubkey, privkey) = generate_keypair_from_entropy(&test_seed).unwrap(); 134 | 135 | // Derive public key from private key 136 | let derived_pubkey = derive_public_key(&privkey).unwrap(); 137 | 138 | // Should match the original public key 139 | assert_eq!(expected_pubkey, derived_pubkey); 140 | } 141 | 142 | #[test] 143 | fn test_derive_public_key_returns_err_for_invalid_key() { 144 | // Test with invalid private key length 145 | let invalid_privkey = vec![0u8; SECRET_KEY_LENGTH - 1]; 146 | let result = derive_public_key(&invalid_privkey); 147 | assert!(result.is_err()); 148 | } 149 | 150 | #[test] 151 | fn test_sign_verify_roundtrip() { 152 | let test_seed = [2u8; SECRET_KEY_LENGTH]; 153 | let (pubkey, privkey) = generate_keypair_from_entropy(&test_seed).unwrap(); 154 | let payload = b"test message"; 155 | 156 | // Valid signing 157 | let signature = sign(payload, &privkey).unwrap(); 158 | 159 | // Valid verification 160 | let result = verify(payload, &signature, &pubkey); 161 | assert!(result.is_ok()); 162 | } 163 | 164 | #[test] 165 | fn test_sign_returns_err_for_invalid_key() { 166 | // Test with invalid private key length 167 | let invalid_privkey = vec![0u8; SECRET_KEY_LENGTH - 1]; 168 | let result = sign(b"test message", &invalid_privkey); 169 | assert!(result.is_err()); 170 | } 171 | 172 | #[test] 173 | fn test_generate_keypair_from_seed_valid() { 174 | let test_seed = [42u8; SECRET_KEY_LENGTH]; 175 | let result = generate_keypair_from_entropy(&test_seed); 176 | assert!(result.is_ok()); 177 | } 178 | 179 | #[test] 180 | fn test_generate_keypair_from_seed_invalid_length() { 181 | let invalid_seed = vec![0u8; SECRET_KEY_LENGTH - 1]; 182 | let result = generate_keypair_from_entropy(&invalid_seed); 183 | assert!(result.is_err()); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /rust/szdt_wasm/src/cbor_seq.rs: -------------------------------------------------------------------------------- 1 | use crate::memo::Memo; 2 | use cbor4ii::core::Value; 3 | use std::io::Cursor; 4 | use szdt_core::cbor_seq::{CborSeqReader as CoreCborSeqReader, CborSeqWriter as CoreCborSeqWriter}; 5 | use szdt_core::error::Error as CoreError; 6 | use wasm_bindgen::prelude::*; 7 | 8 | /// WASM wrapper for reading CBOR sequences 9 | #[wasm_bindgen] 10 | pub struct CborSeqReader { 11 | // Store the data and current position for JavaScript compatibility 12 | data: Vec, 13 | position: usize, 14 | } 15 | 16 | #[wasm_bindgen] 17 | impl CborSeqReader { 18 | /// Create a new CBOR sequence reader from data 19 | #[wasm_bindgen(constructor)] 20 | pub fn new(data: &[u8]) -> CborSeqReader { 21 | Self { 22 | data: data.to_vec(), 23 | position: 0, 24 | } 25 | } 26 | 27 | /// Read the next memo from the sequence 28 | #[wasm_bindgen] 29 | pub fn read_memo(&mut self) -> Result { 30 | if self.position >= self.data.len() { 31 | return Err(JsError::new("End of sequence reached")); 32 | } 33 | 34 | let remaining_data = &self.data[self.position..]; 35 | let cursor = Cursor::new(remaining_data); 36 | let mut reader = CoreCborSeqReader::new(cursor); 37 | 38 | let core_memo: szdt_core::memo::Memo = reader.read_block().map_err(|e| match e { 39 | CoreError::Eof => JsError::new("End of sequence reached"), 40 | _ => JsError::new(&e.to_string()), 41 | })?; 42 | 43 | // Update position - we need to track how many bytes were consumed 44 | let consumed = remaining_data.len() - reader.into_inner().into_inner().len(); 45 | self.position += consumed; 46 | 47 | Ok(Memo::from_core(core_memo)) 48 | } 49 | 50 | /// Read raw CBOR data as bytes 51 | #[wasm_bindgen] 52 | pub fn read_raw(&mut self) -> Result, JsError> { 53 | if self.position >= self.data.len() { 54 | return Err(JsError::new("End of sequence reached")); 55 | } 56 | 57 | let remaining_data = &self.data[self.position..]; 58 | let cursor = Cursor::new(remaining_data); 59 | let mut reader = CoreCborSeqReader::new(cursor); 60 | 61 | // Read as raw CBOR value and serialize back to bytes 62 | let value: Value = reader.read_block().map_err(|e| match e { 63 | CoreError::Eof => JsError::new("End of sequence reached"), 64 | _ => JsError::new(&e.to_string()), 65 | })?; 66 | 67 | // Update position 68 | let consumed = remaining_data.len() - reader.into_inner().into_inner().len(); 69 | self.position += consumed; 70 | 71 | // Serialize the value back to CBOR 72 | let cbor_bytes = 73 | serde_cbor_core::to_vec(&value).map_err(|e| JsError::new(&e.to_string()))?; 74 | Ok(cbor_bytes) 75 | } 76 | 77 | /// Check if there's more data to read 78 | #[wasm_bindgen] 79 | pub fn has_more(&self) -> bool { 80 | self.position < self.data.len() 81 | } 82 | 83 | /// Reset the reader to the beginning 84 | #[wasm_bindgen] 85 | pub fn reset(&mut self) { 86 | self.position = 0; 87 | } 88 | 89 | /// Get the current position in the sequence 90 | #[wasm_bindgen] 91 | pub fn position(&self) -> usize { 92 | self.position 93 | } 94 | 95 | /// Get the total length of the data 96 | #[wasm_bindgen] 97 | pub fn length(&self) -> usize { 98 | self.data.len() 99 | } 100 | } 101 | 102 | /// WASM wrapper for writing CBOR sequences 103 | #[derive(Debug, Default)] 104 | #[wasm_bindgen] 105 | pub struct CborSeqWriter { 106 | data: Vec, 107 | } 108 | 109 | #[wasm_bindgen] 110 | impl CborSeqWriter { 111 | /// Create a new CBOR sequence writer 112 | #[wasm_bindgen(constructor)] 113 | pub fn new() -> CborSeqWriter { 114 | Self { data: Vec::new() } 115 | } 116 | 117 | /// Write a memo to the sequence 118 | #[wasm_bindgen] 119 | pub fn write_memo(&mut self, memo: &Memo) -> Result<(), JsError> { 120 | let mut writer = CoreCborSeqWriter::new(&mut self.data); 121 | writer.write_block(memo.as_core())?; 122 | writer.flush()?; 123 | Ok(()) 124 | } 125 | 126 | /// Write raw CBOR data to the sequence 127 | #[wasm_bindgen] 128 | pub fn write_raw(&mut self, cbor_data: &[u8]) -> Result<(), JsError> { 129 | // Parse the CBOR data to validate it 130 | let _value: Value = 131 | serde_cbor_core::from_slice(cbor_data).map_err(|e| JsError::new(&e.to_string()))?; 132 | 133 | // Write directly to our buffer 134 | self.data.extend_from_slice(cbor_data); 135 | Ok(()) 136 | } 137 | 138 | /// Write a JavaScript object as CBOR (must be serializable) 139 | #[wasm_bindgen] 140 | pub fn write_object(&mut self, js_value: &JsValue) -> Result<(), JsError> { 141 | // Convert JsValue to a Rust value that can be serialized 142 | let value: Value = serde_wasm_bindgen::from_value(js_value.clone()) 143 | .map_err(|e| JsError::new(&e.to_string()))?; 144 | 145 | let mut writer = CoreCborSeqWriter::new(&mut self.data); 146 | writer.write_block(&value)?; 147 | writer.flush()?; 148 | Ok(()) 149 | } 150 | 151 | /// Get the written data as bytes 152 | #[wasm_bindgen] 153 | pub fn to_bytes(&self) -> Vec { 154 | self.data.clone() 155 | } 156 | 157 | /// Clear the writer buffer 158 | #[wasm_bindgen] 159 | pub fn clear(&mut self) { 160 | self.data.clear(); 161 | } 162 | 163 | /// Get the current size of the buffer 164 | #[wasm_bindgen] 165 | pub fn size(&self) -> usize { 166 | self.data.len() 167 | } 168 | 169 | /// Check if the buffer is empty 170 | #[wasm_bindgen] 171 | pub fn is_empty(&self) -> bool { 172 | self.data.is_empty() 173 | } 174 | } 175 | 176 | // Utility functions for working with CBOR data 177 | 178 | /// Parse CBOR data and return it as a JavaScript value 179 | #[wasm_bindgen] 180 | pub fn parse_cbor(data: &[u8]) -> Result { 181 | let value: Value = 182 | serde_cbor_core::from_slice(data).map_err(|e| JsError::new(&e.to_string()))?; 183 | let js_value = 184 | serde_wasm_bindgen::to_value(&value).map_err(|e| JsError::new(&e.to_string()))?; 185 | Ok(js_value) 186 | } 187 | 188 | /// Serialize a JavaScript value to CBOR data 189 | #[wasm_bindgen] 190 | pub fn serialize_cbor(js_value: &JsValue) -> Result, JsError> { 191 | let value: Value = serde_wasm_bindgen::from_value(js_value.clone()) 192 | .map_err(|e| JsError::new(&e.to_string()))?; 193 | let cbor_bytes = serde_cbor_core::to_vec(&value).map_err(|e| JsError::new(&e.to_string()))?; 194 | Ok(cbor_bytes) 195 | } 196 | 197 | /// Validate that data is valid CBOR 198 | #[wasm_bindgen] 199 | pub fn is_valid_cbor(data: &[u8]) -> bool { 200 | serde_cbor_core::from_slice::(data).is_ok() 201 | } 202 | -------------------------------------------------------------------------------- /website/specs/explainer.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: index.liquid 3 | title: SZDT Explainer 4 | --- 5 | 6 | # SZDT Explainer 7 | 8 | **TLDR**: signed CBOR for censorship-resistant data. 9 | 10 | SZDT is a simple format for decentralizing data. SZDT data is self-certifying, so it can be distributed across any protocol, and cryptographically verified without relying on any centralized authority. It is built on broadly available technology (CBOR, Ed25519, Blake3). 11 | 12 | ## Problem: websites are single points of failure 13 | 14 | On the web, trust is centralized around the server. Web resources are accessed by URLs (Uniform Resource Locators), meaning they belong to a single location. This makes web content centralized, vulnerable to lock-in, [link rot](https://en.wikipedia.org/wiki/Link_rot), and censorship. Websites are single points of failure, and single points of failure fail eventually. The only question is when. 15 | 16 | The quick fix would be to distribute data to multiple locations, or even across multiple transports (HTTP, BitTorrent, etc...). This would make data resistant to lock-in, link rot, and censorship. Unfortunately, the web's trust model makes this impossible. Since trust is rooted in the centralized server, we can't trust data we get from other sources. There's no way to know if it has been tampered with. 17 | 18 | ## Solution: self-certifying data 19 | 20 | SZDT solves this by combining two big ideas: 21 | 22 | - **Public key cryptography**: use cryptographic signatures to trustlessly prove who created the archive. 23 | - **Content addressing**: use cryptographic hashes to address data and trustlessly prove data integrity. 24 | 25 | Together, these two technologies give us everything we need to create self-certifying data. We no longer need to trust the server. We can verify with cryptography. 26 | 27 | Because SZDT data is self-certifying, it can be decentralized across multiple untrusted servers, and distributed via any protocol—HTTP, BitTorrent, even sneakernet. 28 | 29 | ## Content Addressing 30 | 31 | In traditional systems, we refer to files by their location: 32 | 33 | ``` 34 | https://example.com/data.zip 35 | /home/user/documents/report.pdf 36 | ``` 37 | 38 | In SZDT, data is referred to by its **[BLAKE3 hash](https://en.wikipedia.org/wiki/BLAKE_(hash_function))**, a unique fingerprint computed from the actual bytes: 39 | 40 | ``` 41 | c8d5e6f7a9b8c7d6e5f4g3h2i1j0k9l8m7n6o5p4q3r2s1t0u9v8w7x6y5z4a3b2c1 42 | ``` 43 | 44 | When we request content by hash, we can verify we have the correct data by recomputing the hash. If the content produces the same hash, we know we got the bytes we asked for. 45 | 46 | This means we no longer need to trust the server to serve the right bytes, we can verify the data integrity with cryptography. Now we can decentralize data across multiple untrusted sources. The hash proves we got what we came for. 47 | 48 | ## Cryptographic Signatures 49 | 50 | Public-key cryptography lets us "sign" data and prove that the 51 | 52 | SZDT data is signed with a user-controlled key, and published with all of the cryptographic information needed to verify its authenticity and integrity. 53 | 54 | ## Memos: Signed Metadata Envelopes 55 | 56 | SZDT is built around **memos**, CBOR metadata envelopes signed with an Ed25519 cryptographic key. 57 | 58 | Memos are conceptually made up of two parts, **headers** and a **body** (the data). This memo format will be familiar if you've worked with HTTP or other internet protocols. 59 | 60 | ```cbor 61 | { 62 | "protected": { 63 | "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 64 | "iat": 1640995200, 65 | "src": h'c8d5e6f7a9b8c7d6e5f4g3h2i1j0k9l8m7n6o5p4q3r2s1t0u9v8w7x6y5z4a3b2c1', 66 | "content-type": "text/plain" 67 | }, 68 | "unprotected": { 69 | "sig": h'5d2f8a0f3b4c7e8f9a1b2c3d4e5f6789abcdef01234567890abcdef0123456789abcdef01234567890abcdef0123456789abcdef01234567890abcdef' 70 | } 71 | } 72 | ``` 73 | 74 | The memo contains: 75 | 76 | - a **cryptographic signature**, signing over the **protected** data 77 | - a **DID** resolving to the public key used to sign the message 78 | - a **src** containing the Blake3 hash of the "body part" of the memo 79 | 80 | This gives us everything we need to verify the authenticity and integrity of the data. 81 | 82 | ### Protected Headers 83 | 84 | Protected headers are protected by the cryptographic signature when the memo is signed. 85 | 86 | Memos can contain open-ended headers, but there are a few headers that have defined semantics, such as: 87 | 88 | - `iss`: The issuer's (memo author's) DID (Decentralized Identifier). Essentially their public key. 89 | - `iat`: A timestamp indicating when the memo was created (Unix epoch in seconds) 90 | - `src`: The Blake3 hash of the body content for this memo 91 | - `content-type`: The MIME type for the body content 92 | - `prev`: The Blake3 hash of the previous version of this memo 93 | - ...plus arbitrary other headers 94 | 95 | For a full description of headers, see the [SZDT Memos specification]({{site.url}}/specs/memos/). 96 | 97 | Note that memos dont't contain the actual content. They point to the content via a content address in the `src` header. Since the signature covers protected headers, and the `src` hash can be used to prove the integrity of the body, the body is also protected by the signature. Signing over a content address in this way allows for streaming verification, as well as other optimizations — but we'll get to that in a bit. 98 | 99 | ### Unprotected Headers 100 | 101 | Unprotected headers can be modified without breaking the signature: 102 | 103 | - `sig`: The Ed25519 signature over the protected headers 104 | 105 | Currently the only defined unprotected header is `sig`. Most headers should be protected, but unprotected headers are a good place to put signature data. 106 | 107 | ## SZDT Sequences 108 | 109 | Content addressing gives us a lot of freedom over how content is distributed and retreived. Memos and body content can be distributed separately and retrieved via multiple methods, such as content addressed HTTP file servers, or p2p protocols like [Iroh](https://www.iroh.computer/). 110 | 111 | However, sometimes we want to distribute memos and bodies together, or even distribute multiple memos together. Enter **SZDT Sequences**. 112 | 113 | An SZDT sequence is a standard CBOR Sequence: just multiple CBOR objects concatenated one after the other: 114 | 115 | ``` 116 | memo1 | content1 | memo2 | content2 | memo3 | content3... 117 | ``` 118 | 119 | Sequences can be handy for distributing SZDT data in bulk, in a single response, or in the form of a file. 120 | 121 | That's pretty much all there is to it, but you can check out the [SZDT Sequences spec]({{site.url}}/specs/sequences/) for more details. 122 | 123 | ## Verified streaming, Blake3's superpower 124 | 125 | SZDT uses Blake3, a fast and secure cryptographic hash function that enables **verified streaming**. CBOR and CBOR sequences are also streaming-friendly, making SZDT a great way to move large amounts of data efficiently. 126 | 127 | Most cryptographic hashing functions require the content to be fully hashed before the hash can be verified. Blake3 uses a clever Merkel Tree structure that allows data to be verified as it is streamed in. If the data is invalid, we can exit early. 128 | 129 | Additionally, Blake3 allows us to hash arbitrary slices *within* bytes, and prove that the hash of the slice belongs to the parent content address. This gives us the ability to generate cryptographically verifiable indexes into SZDT data. 130 | 131 | For more about Blake3 verified streaming, see [Bao](https://github.com/oconnor663/bao). 132 | 133 | ## Summing it up 134 | 135 | SZDT provides building blocks for censorship-resistant data distribution: 136 | 137 | - Decentralizable data: data is self-certifying. It can be distributed across multiple servers and protocols, and requires no central authorities to verify. 138 | - Transport-independence: SZDT is just a data format, making them easy to distribute via various protocols. 139 | - Verifiable streaming: SZDT uses Blake3, allowing us to cryptographically prove integrity as we stream. 140 | - Developer Friendly: the format is built around widely available tech and standards: CBOR, Ed25519 keys, and Blake3. 141 | 142 | Information wants to be free. SZDT makes sure it stays that way. 143 | -------------------------------------------------------------------------------- /website/media/cosmos-institute.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /log.md: -------------------------------------------------------------------------------- 1 | # Log 2 | 3 | Reverse-chronological. 4 | 5 | --- 6 | 7 | Why not COSE_Sign1 for memos? If we use CBOR Core, then we can have tags and integer keys. The COSE_Sign1 array can be tagged, giving us a discriminator. 8 | 9 | However, COSE_Sign1 signs over the protected headers and payload, meaning we don't get that nice streaming property of signing over the hash unless we make the hash the payload.s 10 | 11 | Note: cbor4ii doesn't support custom CBOR tags: 12 | - https://github.com/quininer/cbor4ii/blob/99607373e66b02c488acb2a7cb7d7a75566d5e81/src/serde/de.rs#L76 13 | - https://github.com/quininer/cbor4ii/blob/master/src/core.rs#L170 14 | - https://github.com/quininer/cbor4ii/issues/2#issuecomment-1019399237 15 | - https://github.com/quininer/cbor4ii/issues/35#issuecomment-2461243496 16 | 17 | - cborc (CBOR/c, CBOR Core) https://datatracker-ietf-org.lucaspardue.com/doc/draft-rundgren-cbor-core/ 18 | - CBOR spec section 4.2.1. "Core Deterministic Encoding Requirements" https://datatracker.ietf.org/doc/html/rfc8949#core-det 19 | 20 | Conclusion: keep it simple. Memos can be custom. It's ok. 21 | 22 | --- 23 | 24 | Noosphere stored the private key in: 25 | 26 | ``` 27 | ~/.noosphere/keys/.public 28 | ~/.noosphere/keys/.private 29 | ``` 30 | 31 | With 32 | - private key being stored as a mnemonic 33 | - public key stored as `did:key` 34 | 35 | --- 36 | 37 | How are keys sorted in... 38 | 39 | - CBOR Core: bytewise lexicographic order of their deterministic encoding 40 | - cborc42: bytewise lexicographic order 41 | - dag-cbor bytewise lexicographic order (I think?) 42 | - serde_ipld_dagcbor 43 | 44 | --- 45 | 46 | https://dasl.ing/masl.html has two modes: 47 | 48 | - `src` points to a single resource 49 | - `resources` is a map of n resources by path 50 | - Each resource value is a map representing its own headers 51 | 52 | We could do something like this, where each entry has a header of `length` and `start` or `position`. The only difficulty is that we need to include the length of the manifest somehow if this is going to work for HTTP range requests. I think that means that the index HAS to come at the end, which presents its own challenges. 53 | 54 | --- 55 | 56 | Serialization options: 57 | 58 | - Detatched headers with flattened DAG 59 | - `{protected, unprotected} body` 60 | - Can immediately verify signature, then streaming verify body 61 | - Less convenient when reading back in 62 | - Memo, sign over headers, with body hash in headers 63 | - `[{protected, unprotected}, body]` 64 | - `[protected, unprotected, body]` 65 | - Has benefits of DAG approach 66 | - But not all CBOR parsers support streaming 67 | - Memo, sign over headers and (hashed) body 68 | - `{protected, unprotected} body` 69 | - Disadvantage: Can't streaming verify body because there is no hash to 70 | compare to. 71 | - Disadvantage: Can't verify signature until body is hashed 72 | - Memo, sign over headers and hashed body w footer 73 | - `[headers, body, footer]` 74 | - Hash headers 75 | - Hash body 76 | - Verify signature against hashseq of `[headers, body]` 77 | - Disadvantage: Can't streaming verify body because there is no hash to 78 | compare to. 79 | - Disadvantage: Can't verify signature until body is hashed 80 | 81 | Tradeoffs: 82 | 83 | - With footer approach, I can streaming hash, then verify 84 | - But I can't streaming verify, since there is no comparison hash 85 | - I have to wait until end to verify signature 86 | - With DAG approach, I can verify sig, then streaming verify hash 87 | 88 | Decisions: 89 | 90 | - I think DAG approach is overall better, if a little more complex. 91 | - Regardless, we should sign over the headers, and the header should contain the body hash. 92 | - This allows us to do streaming verification by comparing the body hash to the hash in the headers 93 | 94 | Signing Phase: 95 | 96 | - Compute the complete Blake3 hash tree over the entire data 97 | - Sign the root hash with your private key 98 | - Store/transmit: signature + Blake3 tree nodes needed for verification 99 | 100 | Streaming Verification Phase: 101 | 102 | - Verifier receives and validates the signature on the root hash immediately 103 | - As data chunks arrive, they come with their Blake3 proof path (sibling hashes up to the root) 104 | - Each chunk can be verified against the already-trusted root hash 105 | - Any tampering is detected immediately for that chunk 106 | 107 | --- 108 | 109 | Always sign over the hash of the (unmodified) bytes. This way the claim can be distributed separately from the bytes, and the bytes can be retreived over content-addressed storage. 110 | 111 | --- 112 | 113 | Alternatively, we could separate the memo from the bytes, and hold a hash of the body part in the memo under `src` or `body`. If we did this, we should flatten the graph into a CBOR sequence (like CAR). 114 | 115 | In what order? Depth-first, first-seen order. 116 | 117 | > A filecoin-deterministic car-file is currently implementation-defined as containing all DAG-forming blocks in first-seen order, as a result of a depth-first DAG traversal starting from a single root. 118 | 119 | Since we aren't doing deep graphs, this would mean: 120 | 121 | ``` 122 | headers | body | headers | body 123 | ``` 124 | 125 | --- 126 | 127 | Split headers into protected/unprotected (like COSE): 128 | 129 | ``` 130 | [ 131 | { 132 | sig: h`abc123` 133 | }, // unprotected 134 | { 135 | "content-type": "application/octet-stream", 136 | }, // protected 137 | h'xyz123' // body 138 | ] 139 | ``` 140 | 141 | Signatures happen over protected headers and body: 142 | 143 | ``` 144 | sign( 145 | [ 146 | { 147 | "content-type": "application/octet-stream", 148 | }, 149 | h'xyz123' 150 | ], 151 | key 152 | ) 153 | ``` 154 | 155 | If we split headers into protected and unprotected, then we can permissionlessly extend signing and encryption features in future, without resorting to spec'ing a signing procedure beyond the protected/unprotected mechanism. For example, we could add witnesses by signing over the protected fields, and adding a `witnesses` field to the unprotected headers. 156 | 157 | --- 158 | 159 | What should we index? 160 | 161 | In IPLD CARv2 format, the index provides an index of CIDs (Content Identifiers) to their byte offsets within the CAR file. Maybe that's all we need. 162 | 163 | For the case where you want to e.g. to serve a single memo (or a slice) from an archive, without unpacking the archive, what you need is a map of hashes to byte offsets. 164 | 165 | The thing we want to sign over for the collection is probably a hash sequence (concatenated 32-byte Blake3 hashes). 166 | 167 | --- 168 | 169 | New idea for archive format, leveraging Blake3 and Bao for streaming verification. 170 | 171 | - Archives are CBOR sequences of memos 172 | - Everything is a memo 173 | - Each memo is an array of `[headers, bytes]`, where headers are a CBOR map of metadata, and bytes are the body part. 174 | - Memos can be signed 175 | - Signature lives on `sig` field in the headers 176 | - Signature verification happens by reconstructing the unsigned memo 177 | - Copy memo, remove `sig` header, and verify 178 | - Or perhaps we should sign over headers only and include a hash of the body in the headers. 179 | - Signature in header allows for Blake3 streaming verification 180 | - Memos are encoded using cbor/c (CBOR core) profile. All CBOR items must be fixed length. 181 | - A manifest may be generated over archives 182 | - Manifest can be distributed alone, sidecar, or at the head of the CBOR sequence 183 | - The manifest corresponds to a particular CBOR sequence 184 | - Each manifest entry contains `{path, length, hash}` 185 | - `path` is a string representing a file path 186 | - `length` is an integer representing the length of the referenced memo in bytes 187 | - `hash` is a string representing the Blake3 hash of the referenced memo 188 | - Entries are in order of their appearance in the sequence 189 | - If you have the manifest and the sequence, you can efficiently access any memo by its path by seeking to the appropriate offset, calculated as the sum of the lengths of all previous memos, and then reading `length` bytes. 190 | 191 | What this enables: 192 | 193 | - Efficient streaming 194 | - Efficient random access (via optional manifest) 195 | - Streaming verification of memo integrity 196 | -------------------------------------------------------------------------------- /WHITEPAPER.md: -------------------------------------------------------------------------------- 1 | # SZDT: signed CBOR for censorship-resistant data 2 | 3 | ## Summary 4 | 5 | **TLDR**: signed CBOR for censorship-resistant data. 6 | 7 | SZDT is a simple format for decentralizing data. SZDT data is signed and self-certifying, so it can be distributed across any protocol and cryptographically verified without relying on any centralized authority. 8 | 9 | ## Problem: websites are single points of failure 10 | 11 | On the web, trust is centralized around the server. Web resources are accessed by URLs (Uniform Resource Locators), meaning they belong to a single location. This makes web content centralized, vulnerable to lock-in, link rot, and censorship. 12 | 13 | The quick fix would be to distribute data to multiple locations, or even across multiple transports (HTTP, BitTorrent, etc...). This would make data resistant to lock-in, link rot, and censorship. Unfortunately, the web's trust model makes this impossible. Since trust is rooted in the centralized server, we can't trust data we get from other sources. There's no way to know if it has been tampered with. 14 | 15 | ## Solution: self-certifying data 16 | 17 | SZDT solves this by combining two ideas: 18 | 19 | - **Public key cryptography**: use cryptographic signatures to trustlessly prove who created the data. 20 | - **Content addressing**: use cryptographic hashes to address data and trustlessly prove data integrity. 21 | 22 | Together, these things allow us to create **self-certifying data**, data that can be cryptographically verified without relying on any centralized authority. 23 | 24 | We no longer need to trust the server. We can verify with cryptography. Because trust is decoupled from the server, data can be decentralized. 25 | 26 | ## How It Works 27 | 28 | ### Memos 29 | 30 | SZDT is built around **memos**, CBOR metadata envelopes signed with an Ed25519 cryptographic key. 31 | 32 | Memos are conceptually made up of two parts, **headers** and a **body** (the data). This memo format will be familiar if you've worked with HTTP or other internet protocols. 33 | 34 | Headers are further broken down into **protected** (covered by signature) and **unprotected** (not covered by signature) headers. 35 | 36 | ```cbor 37 | { 38 | "type": "szdt/memo", 39 | "protected": { 40 | "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 41 | "iat": 1640995200, 42 | "src": h'c8d5e6f7a9b8c7d6e5f4g3h2i1j0k9l8m7n6o5p4q3r2s1t0u9v8w7x6y5z4a3b2c1', 43 | "content-type": "text/plain" 44 | }, 45 | "unprotected": { 46 | "sig": h'5d2f8a0f3b4c7e8f9a1b2c3d4e5f6789abcdef01234567890abcdef0123456789abcdef01234567890abcdef0123456789abcdef01234567890abcdef' 47 | } 48 | } 49 | ``` 50 | 51 | Each memo contains: 52 | 53 | - a **cryptographic signature**, signing over the **protected** data 54 | - a **DID** resolving to the public key used to sign the message 55 | - a **src** containing the **Blake3 hash** of the "body part" of the memo 56 | 57 | This gives us everything we need to verify the **authenticity** and **integrity** of the data. 58 | 59 | The body bytes are not present in the memo itself. Instead, we use the `src` field, a Blake3 hash of the serialized body bytes, as a **content address**. This enables memos to be distributed separately from content as as proofs. Alternatively, memos and bodies are commonly distributed together as [CBOR sequences](https://www.rfc-editor.org/rfc/rfc8742.html) by simply concatenating the memo, followed by the body part. 60 | 61 | Any CBOR value is a valid SZDT value, so bodies can be CBOR byte strings, or more complex CBOR structures. 62 | 63 | ### Serialization 64 | 65 | - SZDT always serializes data using the deterministic [CBOR/c ("CBOR Core")](https://datatracker-ietf-org.lucaspardue.com/doc/draft-rundgren-cbor-core/) profile. 66 | - The requirements for this serialization profile are the same as IETF RFC 8949, section 4.2.1. ["Core Deterministic Encoding Requirements"](https://datatracker.ietf.org/doc/html/rfc8949#core-det), but add a few additional clarifications to resolve ambiguities. 67 | - Ensures deterministic serialization for consistent hashing and signing. 68 | 69 | ### Signing scheme 70 | 71 | #### Signing 72 | 73 | 1. **Prepare protected headers**: Ensure all required protected headers are present 74 | 2. **Encode headers**: Serialize protected headers to CBOR using CBOR/c profile 75 | 3. **Hash headers**: Compute the Blake3 hash of the serialized protected headers 76 | 4. **Sign hash**: Generate Ed25519 signature over the Blake3 hash 77 | 5. **Add signature**: Place signature in `sig` field of unprotected headers. Signature must be serialized as a CBOR byte string. 78 | 79 | #### Verification 80 | 81 | 1. **Extract signature**: Get signature from `sig` field in unprotected headers 82 | 2. **Encode headers**: Serialize protected headers to CBOR using CBOR/c profile 83 | 3. **Hash headers**: Compute Blake3 hash of the serialized protected headers 84 | 4. **Verify signature**: Validate Ed25519 signature over the hash using issuer's public key 85 | 5. **Verify timestamps**: If `nbf` and `exp` are present, check that `nbf` is not in the future and `exp` is not in the past. A slush factor of 1000 milliseconds may be used to account for clock skew. 86 | 6. **Verify content integrity**: Verify content integrity using `src` hash, using the steps outlined below. 87 | 88 | To verify content integrity: 89 | 90 | 1. **Read content**: Obtain the content referenced by the memo 91 | 2. **Hash content**: Compute Blake3 hash of the content 92 | 3. **Compare hashes**: Verify computed hash matches `src` field in protected headers 93 | 94 | For big data, implementors may use Blake3's streaming capabilities or [Bao](https://github.com/oconnor663/bao) for incremental verification. 95 | 96 | ## Key Features 97 | 98 | - **Zero-trust**: SZDT archives are verified using cryptographic hashing and public key cryptography. No centralized authorities are required. 99 | - **Censorship-resistant**: Because trust is decoupled from origin or transport, SZDT archives can be distributed via HTTP, Torrents, email, airdrop, sneakernet, or anything else that is available. 100 | - **Decentralizable**: SZDT decouples trust from origin, so data can be distributed to many redundant locations, including multiple HTTP servers, BitTorrent, hard drives, etc. [Lots Of Copies Keeps Stuff Safe](https://www.lockss.org/). 101 | - **Anonymous/pseudonymous**: SZDT uses [keys, not IDs](https://newsletter.squishy.computer/i/60168330/keys-not-ids-toward-personal-illegibility). No accounts are required. 102 | - **Streamable**: CBOR is inherently streamable, and Blake3 hashes enable streaming cryptographic verification. 103 | - **Any kind of data**: Memos can wrap API responses, file bytes, structured data, or anything else. They also provide a mechanism for adding self-certifying metadata (headers) to any data. 104 | 105 | ### Non-features 106 | 107 | - **P2P**: SZDT is transport-agnostic. It's just a file format. 108 | - **Efficiency**: SZDT prioritizes simplicity over efficiency. 109 | 110 | ## Real-World Applications 111 | 112 | ### Decentralizable app data 113 | 114 | - Build [relay architectures](https://newsletter.squishy.computer/p/natures-many-attempts-to-evolve-a) 115 | - Make HTTP APIs into trustless endpoints 116 | - [Credible exit](https://newsletter.squishy.computer/p/credible-exit) via signed data exports 117 | 118 | ### Censorship-resistant data archives 119 | 120 | - Scientific data archives 121 | - Verifiable HTTP mirrors 122 | - Signed archive files (save SZDT sequences as `.szdt` files analogous to TAR files) 123 | - CBOR is a simple format and an IETF standard that will be around in 10 or 100 years. Good qualities for archival data. 124 | 125 | ## Technical Advantages 126 | 127 | ### Over plain HTTP 128 | 129 | - Trust is decoupled from origin so data becomes decentralizable. 130 | 131 | ### Over existing decentralized solutions 132 | 133 | - Single opinionated answer for signing AND content addressing to verify BOTH data's authenticity AND integrity. 134 | - Uses Blake3 throughout, making streaming verification possible at every level. 135 | - Decouples trust from transport, allowing use with any protocol, p2p, HTTP, email, sneakernet, whatever. 136 | - It's "just" CBOR, DIDs, Blake3, and Ed25519. Easy to implement, easy to integrate into existing stacks. 137 | 138 | ## Implementation Highlights 139 | 140 | - Rust library with ergonomic API 141 | - CLI for generating signed file archives 142 | - The beginnings of a [nickname (petname) system](https://newsletter.squishy.computer/p/nickname-petname-system) 143 | - **COMING SOON**: Web and Node bindings via WASM 144 | 145 | ## Future 146 | 147 | SZDT is designed to be permissionlessly extensible. We also plan to explore future expansions to the core format: 148 | 149 | - More DID methods (e.g. `did:eth`, `did:webvh`...) 150 | - OCAP inspired by [UCAN](https://github.com/ucan-wg/spec) 151 | - e2ee 152 | 153 | ## Conclusion 154 | 155 | SZDT achieves censorship-resistance without exotic protocols. By combining boring technology with zero-trust cryptographic primitives, we can make data censorship-resistant and decentralizable. If there are many copies, and many ways to find them, then data can survive the way dandelions do—by spreading seeds. 156 | 157 | All code and docs for the project are open source and released under the MIT license. 158 | -------------------------------------------------------------------------------- /rust/szdt_cli/src/bin/szdt.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | use console::style; 3 | use dialoguer::Confirm; 4 | use std::ffi::OsStr; 5 | use std::fs::File; 6 | use std::io::BufReader; 7 | use std::path::Path; 8 | use std::path::PathBuf; 9 | use szdt_cli::config; 10 | use szdt_cli::file::write_file_deep; 11 | use szdt_cli::key_storage::InsecureKeyStorage; 12 | use szdt_cli::rand::generate_entropy; 13 | use szdt_cli::szdt::{Unarchiver, archive}; 14 | use szdt_core::contact::Contact; 15 | use szdt_core::ed25519_key_material::Ed25519KeyMaterial; 16 | use szdt_core::link::ToLink; 17 | use szdt_core::mnemonic::Mnemonic; 18 | use szdt_core::nickname::Nickname; 19 | use szdt_core::text::{ELLIPSIS, truncate}; 20 | use szdt_core::time::now; 21 | 22 | /// Shared CLI configuration 23 | struct Config { 24 | pub key_storage: InsecureKeyStorage, 25 | } 26 | 27 | #[derive(Parser)] 28 | #[command(version = "0.0.1")] 29 | #[command(author = "szdt")] 30 | #[command(about = "Censorship-resistant publishing and archiving")] 31 | struct Cli { 32 | #[command(subcommand)] 33 | command: Commands, 34 | } 35 | 36 | #[derive(Subcommand)] 37 | enum Commands { 38 | #[command(about = "Unpack an .szdt archive")] 39 | Unarchive { 40 | #[arg(help = "Archive file")] 41 | #[arg(value_name = "FILE")] 42 | file: PathBuf, 43 | #[arg( 44 | value_name = "DIR", 45 | short, 46 | long, 47 | help = "Directory to unpack archive into. Defaults to archive file name." 48 | )] 49 | dir: Option, 50 | }, 51 | 52 | #[command(about = "Create an .szdt archive from a folder")] 53 | Archive { 54 | #[arg(help = "Folder to archive")] 55 | #[arg(value_name = "DIR")] 56 | dir: PathBuf, 57 | 58 | #[arg(help = "Key to sign archive with")] 59 | #[arg( 60 | long_help = "Nickname of the key to sign the archive with. You can generate a signing key with `szdt key create`." 61 | )] 62 | #[arg(short, long)] 63 | #[arg(value_name = "NICKNAME")] 64 | sign: String, 65 | }, 66 | 67 | #[command(about = "Create and manage signing keys")] 68 | Key { 69 | #[command(subcommand)] 70 | command: KeyCommands, 71 | }, 72 | } 73 | 74 | #[derive(Subcommand)] 75 | enum KeyCommands { 76 | #[command(about = "Create a new keypair")] 77 | Create { 78 | #[arg(help = "Nickname for key")] 79 | #[arg(value_name = "NICKNAME")] 80 | nickname: String, 81 | }, 82 | 83 | #[command(about = "List all signing keys")] 84 | List {}, 85 | 86 | #[command(about = "Delete a signing key")] 87 | Delete { 88 | #[arg(help = "Key nickname")] 89 | #[arg(value_name = "NAME")] 90 | nickname: String, 91 | }, 92 | } 93 | 94 | fn archive_cmd(config: &Config, dir: &Path, nickname: &str) { 95 | let default_file_name = OsStr::new("archive"); 96 | 97 | let file_name = 98 | PathBuf::from(dir.file_stem().unwrap_or(default_file_name)).with_extension("szdt"); 99 | 100 | let nickname = Nickname::parse(nickname).expect("Invalid nickname"); 101 | 102 | let contact = config 103 | .key_storage 104 | .contact(&nickname) 105 | .expect("Unable to access contacts") 106 | .expect("No contact with that nickname. Tip: create a key using `szdt key create`."); 107 | 108 | let archive_receipt = archive(dir, &file_name, &contact).expect("Unable to create archive"); 109 | 110 | println!("{:<12} {}", "Archive:", file_name.display()); 111 | println!( 112 | "{:<12} {} {}", 113 | "Issuer:", 114 | style(contact.nickname).bold().cyan(), 115 | style(format!("<{}>", contact.did)).cyan() 116 | ); 117 | println!(); 118 | println!("{:<32} | {:<52}", "File", "Hash"); 119 | for memo in &archive_receipt.manifest { 120 | let path = memo.protected.path.as_deref().unwrap_or("None"); 121 | println!( 122 | "{:<32} | {:<52}", 123 | truncate(path, 32, ELLIPSIS), 124 | style(memo.protected.src).green() 125 | ); 126 | } 127 | println!(); 128 | println!("Archived {} files", &archive_receipt.manifest.len()); 129 | } 130 | 131 | fn unarchive_cmd(config: &mut Config, dir: Option, file_path: PathBuf) { 132 | // Create a folder named after the file path 133 | let archive_dir = match dir { 134 | Some(dir) => dir, 135 | None => file_path 136 | .file_stem() 137 | .map(|p| p.into()) 138 | .unwrap_or("archive".into()), 139 | }; 140 | 141 | let file_bufreader = BufReader::new(File::open(&file_path).expect("Unable to open file")); 142 | 143 | let now_time = now(); 144 | 145 | let mut count = 0; 146 | for result in Unarchiver::new(file_bufreader) { 147 | let (memo, bytes) = result.expect("Unable to read archive blocks"); 148 | 149 | let Some(iss) = memo.protected.iss.as_ref() else { 150 | println!("Unsigned memo. Skipping"); 151 | continue; 152 | }; 153 | 154 | let contact: Contact = match config 155 | .key_storage 156 | .contact_for_did(iss) 157 | .expect("Unable to get key for did") 158 | { 159 | Some(contact) => contact, 160 | None => { 161 | let iss_nickname: &str = memo.protected.iss_nickname.as_deref().unwrap_or("anon"); 162 | let iss_key_material = 163 | Ed25519KeyMaterial::try_from(iss).expect("Unable to get public key from did"); 164 | 165 | let confirmation = Confirm::new() 166 | .with_prompt(format!( 167 | "Unknown issuer {} {}. Do you want to add to trusted contacts?", 168 | style(format!("~{iss_nickname}")).italic().bold().cyan(), 169 | style(format!("<{iss}>")).cyan() 170 | )) 171 | .default(true) 172 | .show_default(true) 173 | .interact() 174 | .expect("Could not interact with terminal"); 175 | 176 | if confirmation { 177 | let unique_nickname = config 178 | .key_storage 179 | .unique_nickname(iss_nickname) 180 | .expect("Nickname is not valid"); 181 | 182 | let contact = Contact::new( 183 | unique_nickname.clone(), 184 | iss_key_material.did(), 185 | iss_key_material.private_key(), 186 | ); 187 | 188 | config 189 | .key_storage 190 | .create_contact(&contact) 191 | .expect("Couldn't save key"); 192 | 193 | println!( 194 | "Saved to contacts as {}", 195 | style(unique_nickname).bold().cyan() 196 | ); 197 | println!(); 198 | 199 | contact 200 | } else { 201 | println!("Skipping..."); 202 | continue; 203 | } 204 | } 205 | }; 206 | 207 | // Check sig and expiries 208 | memo.validate(Some(now_time)) 209 | .expect("Invalid memo signature"); 210 | 211 | // Check checksum 212 | let hash = bytes.to_link().expect("Unable to hash body bytes"); 213 | memo.checksum(&hash) 214 | .expect("Body bytes don't match checksum"); 215 | 216 | // Use the path in the headers, or else the hash if no path given 217 | let file_path = memo.protected.path.clone().unwrap_or(hash.to_string()); 218 | let path = archive_dir.join(&file_path); 219 | let bytes = bytes.into_inner(); 220 | write_file_deep(&path, &bytes).expect("Unable to write file"); 221 | 222 | println!("Path: {}", style(&file_path).bold()); 223 | println!("Hash: {}", style(memo.protected.src.to_string()).green()); 224 | println!( 225 | "Issuer: {} {}", 226 | style(contact.nickname).bold().cyan(), 227 | style(format!("<{}>", contact.did)).cyan() 228 | ); 229 | println!(); 230 | count += 1; 231 | } 232 | 233 | println!("Unarchived {} files to {}", count, archive_dir.display()); 234 | } 235 | 236 | fn create_key_cmd(config: &mut Config, nickname: &str) { 237 | let unique_nickname = config 238 | .key_storage 239 | .unique_nickname(nickname) 240 | .expect("Unable to generate unique nickname"); 241 | 242 | if unique_nickname.as_str() != nickname { 243 | println!( 244 | "Nickname {} already exists, using {}", 245 | nickname, &unique_nickname 246 | ); 247 | println!(); 248 | } 249 | 250 | let entropy = generate_entropy().expect("Unable to generate private key entropy"); 251 | let key_material = Ed25519KeyMaterial::generate_from_entropy(&entropy) 252 | .expect("Unable to cryptographic key material"); 253 | 254 | let contact = Contact::new( 255 | unique_nickname.clone(), 256 | key_material.did(), 257 | key_material.private_key(), 258 | ); 259 | 260 | config 261 | .key_storage 262 | .create_contact(&contact) 263 | .expect("Unable to create key"); 264 | 265 | let mnemonic = Mnemonic::try_from(&key_material).expect("Unable to generate mnemonic"); 266 | 267 | println!("Key created:"); 268 | println!( 269 | "{} {}", 270 | style(&unique_nickname).bold().cyan(), 271 | style(format!("<{}>", key_material.did())).cyan() 272 | ); 273 | println!(); 274 | println!("Recovery phrase:"); 275 | println!("{mnemonic}"); 276 | } 277 | 278 | fn list_keys_cmd(config: &Config) { 279 | println!("{:<2} | {:<24} | {:<56}", "🔒", "Nickname", "DID"); 280 | 281 | for contact in config 282 | .key_storage 283 | .contacts() 284 | .expect("Unable to read contacts") 285 | { 286 | let has_private_key = if contact.private_key.is_some() { 287 | "🔑" 288 | } else { 289 | " " 290 | }; 291 | println!( 292 | "{:<2} | {:<24} | {:<56}", 293 | has_private_key, contact.nickname, contact.did 294 | ); 295 | } 296 | } 297 | 298 | fn delete_key_cmd(config: &mut Config, nickname: &str) { 299 | config 300 | .key_storage 301 | .delete_contact(nickname) 302 | .expect("Unable to delete key"); 303 | } 304 | 305 | fn main() { 306 | let contacts_file = config::contacts_file().expect("Unable to locate key storage directory"); 307 | let key_storage = 308 | InsecureKeyStorage::new(&contacts_file).expect("Unable to initialize key storage"); 309 | let mut config = Config { key_storage }; 310 | 311 | let cli = Cli::parse(); 312 | match cli.command { 313 | Commands::Archive { dir, sign } => archive_cmd(&config, &dir, &sign), 314 | Commands::Unarchive { file, dir } => unarchive_cmd(&mut config, dir, file), 315 | Commands::Key { command } => match command { 316 | KeyCommands::Create { nickname } => create_key_cmd(&mut config, &nickname), 317 | KeyCommands::List {} => list_keys_cmd(&config), 318 | KeyCommands::Delete { nickname } => delete_key_cmd(&mut config, &nickname), 319 | }, 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /rust/szdt_core/src/memo.rs: -------------------------------------------------------------------------------- 1 | use crate::bytes::Bytes; 2 | use crate::ed25519_key_material::Ed25519KeyMaterial; 3 | use crate::error::Error; 4 | use crate::hash::Hash; 5 | use crate::link::ToLink; 6 | use crate::time::now; 7 | use crate::{did::DidKey, error::TimestampComparison}; 8 | use cbor4ii::core::Value; 9 | use serde::{Deserialize, Serialize}; 10 | use std::collections::HashMap; 11 | 12 | /// Unprotected headers for a memo. 13 | /// Contains metadata that is not signed and can be freely modified. 14 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] 15 | pub struct UnprotectedHeaders { 16 | /// Ed25519 signature over protected memo fields 17 | #[serde(skip_serializing_if = "Option::is_none")] 18 | pub sig: Option, 19 | /// Additional fields 20 | #[serde(flatten)] 21 | pub extra: HashMap, 22 | } 23 | 24 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 25 | pub struct ProtectedHeaders { 26 | /// Issuer (DID) 27 | #[serde(skip_serializing_if = "Option::is_none")] 28 | pub iss: Option, 29 | /// Issuer's suggested nickname for their key. 30 | /// Note: Nicknames for keys (also called [petnames](https://files.spritely.institute/papers/petnames.html)) 31 | /// are ultimately chosen by the user, so this value may be used, modified, or 32 | /// ignored by the user. 33 | #[serde(rename = "iss-nickname")] 34 | pub iss_nickname: Option, 35 | /// Issued at (UNIX timestamp, seconds) 36 | pub iat: u64, 37 | /// Not valid before (UNIX timestamp, seconds) 38 | #[serde(skip_serializing_if = "Option::is_none")] 39 | pub nbf: Option, 40 | /// Expiration time (UNIX timestamp, seconds) 41 | #[serde(skip_serializing_if = "Option::is_none")] 42 | pub exp: Option, 43 | /// Blake3 hash of the previous version of the memo 44 | #[serde(skip_serializing_if = "Option::is_none")] 45 | pub prev: Option, 46 | /// Content type (MIME type) 47 | #[serde(skip_serializing_if = "Option::is_none")] 48 | #[serde(rename = "content-type")] 49 | pub content_type: Option, 50 | /// File path within archive 51 | #[serde(skip_serializing_if = "Option::is_none")] 52 | pub path: Option, 53 | /// Blake3 hash of the memo body 54 | pub src: Hash, 55 | /// Additional fields 56 | #[serde(flatten)] 57 | pub extra: HashMap, 58 | } 59 | 60 | impl ProtectedHeaders { 61 | /// Create new headers with the given issuer and body hash 62 | pub fn new(body: Hash) -> Self { 63 | Self { 64 | iss: None, 65 | iss_nickname: None, 66 | iat: now(), 67 | nbf: Some(now()), 68 | exp: None, 69 | prev: None, 70 | content_type: None, 71 | path: None, 72 | src: body, 73 | extra: HashMap::new(), 74 | } 75 | } 76 | } 77 | 78 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 79 | #[serde(tag = "type", rename = "szdt/memo")] 80 | pub struct Memo { 81 | /// Unsigned headers 82 | pub unprotected: UnprotectedHeaders, 83 | /// Headers protected by signature 84 | pub protected: ProtectedHeaders, 85 | } 86 | 87 | impl Memo { 88 | /// Create a new memo with the given hash for the body content. 89 | pub fn new(body: Hash) -> Self { 90 | Self { 91 | unprotected: UnprotectedHeaders::default(), 92 | protected: ProtectedHeaders::new(body), 93 | } 94 | } 95 | 96 | /// Create a memo that notionally wraps the given body content. 97 | /// Content will be serialized to CBOR/c and hashed. 98 | pub fn for_body(body: T) -> Result { 99 | Ok(Self::new(body.to_link()?)) 100 | } 101 | 102 | /// Create a memo wrapping empty body content 103 | pub fn empty() -> Self { 104 | Self::new(Hash::new([])) 105 | } 106 | 107 | /// Sign the headers with the given key material 108 | pub fn sign(&mut self, key_material: &Ed25519KeyMaterial) -> Result<(), Error> { 109 | // Set the issuer DID on the protected headers 110 | self.protected.iss = Some(key_material.did()); 111 | let protected_hash = &self.protected.to_link()?; 112 | 113 | // Sign 114 | let sig = key_material.sign(protected_hash.as_bytes())?; 115 | 116 | // Set the signature 117 | self.unprotected.sig = Some(Bytes(sig)); 118 | Ok(()) 119 | } 120 | 121 | /// Verify the memo signature, returning a result. 122 | /// In the case that memo is not signed, will return an error of `Error::MemoUnsigned`. 123 | pub fn verify(&self) -> Result<(), Error> { 124 | let Some(iss) = &self.protected.iss else { 125 | return Err(Error::MemoIssMissing); 126 | }; 127 | 128 | let Some(sig) = &self.unprotected.sig else { 129 | return Err(Error::MemoUnsigned); 130 | }; 131 | 132 | let key_material = Ed25519KeyMaterial::try_from(iss)?; 133 | 134 | // Construct the signing bytes 135 | let protected_hash = self.protected.to_link()?; 136 | // Verify the signature against the signing bytes. 137 | key_material.verify(protected_hash.as_bytes(), &sig.0)?; 138 | Ok(()) 139 | } 140 | 141 | /// Is expired? 142 | pub fn is_expired(&self, now_time: Option) -> bool { 143 | match self.protected.exp { 144 | Some(exp) => exp < now_time.unwrap_or_else(now), 145 | None => false, 146 | } 147 | } 148 | 149 | /// Is too early? 150 | pub fn is_too_early(&self, now_time: Option) -> bool { 151 | match self.protected.nbf { 152 | Some(nbf) => nbf > now_time.unwrap_or_else(now), 153 | None => false, 154 | } 155 | } 156 | 157 | /// Is memo valid? 158 | /// Checks if expired or too early, and verifies the signature. 159 | /// Unsigned memos are considered invalid (untrusted). 160 | pub fn validate(&self, now_time: Option) -> Result<(), Error> { 161 | if self.is_expired(now_time) { 162 | return Err(Error::MemoExpError(TimestampComparison::new( 163 | self.protected.exp, 164 | now_time, 165 | ))); 166 | } 167 | if self.is_too_early(now_time) { 168 | return Err(Error::MemoNbfError(TimestampComparison::new( 169 | self.protected.nbf, 170 | now_time, 171 | ))); 172 | } 173 | self.verify() 174 | } 175 | 176 | /// Check the hash of a serializable value against the `src` field of this memo. 177 | /// Value will be serialized to CBOR and hashed, and the hash compared to 178 | /// the `src` hash of the memo. 179 | pub fn checksum(&self, body_hash: &Hash) -> Result<(), Error> { 180 | if &self.protected.src != body_hash { 181 | return Err(Error::IntegrityError(format!( 182 | "Value hash does not match src. Expected {}. Got: {}", 183 | &self.protected.src, &body_hash 184 | ))); 185 | } 186 | Ok(()) 187 | } 188 | } 189 | 190 | #[cfg(test)] 191 | mod tests { 192 | use super::*; 193 | use crate::hash::Hash; 194 | 195 | fn create_test_key() -> Ed25519KeyMaterial { 196 | let seed = [0u8; 32]; 197 | Ed25519KeyMaterial::generate_from_entropy(&seed).unwrap() 198 | } 199 | 200 | fn create_test_body() -> Vec { 201 | b"Hello World".to_vec() 202 | } 203 | 204 | #[test] 205 | fn test_headers_new() { 206 | let body = create_test_body(); 207 | let body_hash = Hash::new(&body); 208 | 209 | let headers = ProtectedHeaders::new(body_hash); 210 | 211 | assert!(headers.iss.is_none()); 212 | assert_eq!(headers.src, body_hash); 213 | assert!(headers.nbf.is_some()); 214 | assert!(headers.exp.is_none()); 215 | assert!(headers.prev.is_none()); 216 | assert!(headers.content_type.is_none()); 217 | assert!(headers.path.is_none()); 218 | } 219 | 220 | #[test] 221 | fn test_memo_new() { 222 | let body_content = "Hello World"; 223 | let memo = Memo::for_body(body_content).unwrap(); 224 | 225 | let cbor_bytes = serde_cbor_core::to_vec(body_content).unwrap(); 226 | assert_eq!(memo.protected.src, Hash::new(&cbor_bytes)); 227 | } 228 | 229 | #[test] 230 | fn test_memo_is_expired() { 231 | let body = create_test_body(); 232 | 233 | let mut memo = Memo::for_body(&body).unwrap(); 234 | 235 | // Not expired when no expiration set 236 | assert!(!memo.is_expired(None)); 237 | 238 | // Set expiration in the past 239 | memo.protected.exp = Some(now() - 3600); 240 | assert!(memo.is_expired(None)); 241 | 242 | // Set expiration in the future 243 | memo.protected.exp = Some(now() + 3600); 244 | assert!(!memo.is_expired(None)); 245 | } 246 | 247 | #[test] 248 | fn test_headers_is_too_early() { 249 | let body = create_test_body(); 250 | 251 | let mut memo = Memo::for_body(&body).unwrap(); 252 | 253 | // Set nbf in the future 254 | memo.protected.nbf = Some(now() + 3600); 255 | assert!(memo.is_too_early(None)); 256 | 257 | // Set nbf in the past 258 | memo.protected.nbf = Some(now() - 3600); 259 | assert!(!memo.is_too_early(None)); 260 | 261 | // No nbf set 262 | memo.protected.nbf = None; 263 | assert!(!memo.is_too_early(None)); 264 | } 265 | 266 | #[test] 267 | fn test_memo_validate_unsigned() { 268 | let body_content = b"Hello World".to_vec(); 269 | let memo = Memo::for_body(body_content).unwrap(); 270 | 271 | // Unsigned memo should be invalid 272 | assert!(memo.validate(None).is_err()); 273 | } 274 | 275 | #[test] 276 | fn test_memo_sign_and_verify() { 277 | let key = create_test_key(); 278 | let body = create_test_body(); 279 | let mut memo = Memo::for_body(&body).unwrap(); 280 | 281 | memo.sign(&key).unwrap(); 282 | 283 | assert!(memo.unprotected.sig.is_some()); 284 | 285 | memo.verify().unwrap(); 286 | } 287 | 288 | #[test] 289 | fn test_signed_memo_validate() { 290 | let key = create_test_key(); 291 | let body_content = b"Hello World".to_vec(); 292 | let mut memo = Memo::for_body(&body_content).unwrap(); 293 | 294 | memo.sign(&key).unwrap(); 295 | memo.validate(None).unwrap(); 296 | } 297 | 298 | #[test] 299 | fn test_signed_memo_validate_expired() { 300 | let key = create_test_key(); 301 | let body_content = b"Hello World".to_vec(); 302 | let mut memo = Memo::for_body(&body_content).unwrap(); 303 | 304 | memo.protected.exp = Some(now() - 3600); // Expired 305 | memo.sign(&key).unwrap(); 306 | 307 | assert!(memo.validate(None).is_err()); 308 | } 309 | 310 | #[test] 311 | fn test_memo_checksum() { 312 | let body_content = b"Hello World".to_vec(); 313 | let memo = Memo::for_body(&body_content).unwrap(); 314 | 315 | // Checksum should pass for the same content 316 | memo.checksum(&body_content.to_link().unwrap()).unwrap(); 317 | 318 | // Checksum should fail for different content 319 | let different_content = b"Different content".to_vec(); 320 | assert!( 321 | memo.checksum(&different_content.to_link().unwrap()) 322 | .is_err() 323 | ); 324 | } 325 | 326 | #[test] 327 | fn test_memo_cbor_type_field() { 328 | let body_content = b"Hello World".to_vec(); 329 | let memo = Memo::for_body(body_content).unwrap(); 330 | 331 | // Serialize memo to CBOR 332 | let cbor_bytes = serde_cbor_core::to_vec(&memo).unwrap(); 333 | 334 | // Deserialize back to a generic Value to check the type field 335 | let value: cbor4ii::core::Value = serde_cbor_core::from_slice(&cbor_bytes).unwrap(); 336 | 337 | if let cbor4ii::core::Value::Map(entries) = value { 338 | let type_key = cbor4ii::core::Value::Text("type".to_string()); 339 | for (key, value) in entries { 340 | if key == type_key { 341 | assert_eq!(value, cbor4ii::core::Value::Text("szdt/memo".to_string())); 342 | return; 343 | } 344 | } 345 | } 346 | panic!("Serialized memo is not a map"); 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /website/specs/memos.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: index.liquid 3 | title: SZDT Memos Specification 4 | --- 5 | 6 | # SZDT Memos Specification 7 | 8 | Memos are general-purpose metadata envelopes for annotating and cryptographically signing arbitrary CBOR data. 9 | 10 | Memos take inspiration from [the flexible memo format used in HTTP and email](https://newsletter.squishy.computer/p/if-headers-did-not-exist-it-would), with headers encoding arbitrary key-value metadata, followed by a body. 11 | 12 | Memos provide a standardized way to: 13 | 14 | - **Annotate data** with open-ended key-value metadata. 15 | - **Cryptographically sign data** to create self-certifying records. 16 | - **Address data** using cryptographic hashes for integrity verification. 17 | - **Version data** using Git-like semantics. 18 | 19 | ## Memo Structure 20 | 21 | Memos are CBOR maps containing headers divided into two buckets: `protected` and `unprotected`. 22 | 23 | An example of a basic memo: 24 | 25 | ```cbor 26 | { 27 | "type": "szdt/memo", // always "szdt/memo" 28 | "protected": { 29 | "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 30 | "iat": 1640995200, 31 | "src": h'c8d5e6f7a9b8c7d6e5f4g3h2i1j0k9l8m7n6o5p4q3r2s1t0u9v8w7x6y5z4a3b2c1', 32 | "content-type": "text/plain" 33 | }, 34 | "unprotected": { 35 | "sig": h'5d2f8a0f3b4c7e8f9a1b2c3d4e5f6789abcdef01234567890abcdef0123456789abcdef01234567890abcdef0123456789abcdef01234567890abcdef' 36 | } 37 | } 38 | ``` 39 | 40 | - **protected**: A CBOR map of headers (key-value metadata). Protected headers are covered by cryptographic signatures when the memo is signed. Protected headers contain security-critical metadata like content hashes, timestamps, and issuer information. 41 | - **unprotected**: A CBOR map of headers (key-value metadata). Unprotected headers are not covered by cryptographic signatures and may be freely modified. Unprotected headers contain auxiliary metadata, such as the signatures themselves, caching hints, or routing information. 42 | 43 | This design allows intermediaries to add metadata (like caching information) without invalidating signatures, while critical metadata remains tamperproof. 44 | 45 | ## Content Addressing 46 | 47 | SZDT memos may point to other resources using _content addresses_ (referencing data by cryptographic hash). SZDT content addresses are always [Blake3 hashes](https://www.ietf.org/archive/id/draft-aumasson-blake3-00.html), serialized in CBOR as byte strings. 48 | 49 | Content addressing has a number of benefits: 50 | 51 | - **Decentralized**: Content addresses allow us to refer to data based on _what_ it is, not _where_ it lives. Data referenced by a content address may live in multiple locations, and be retreived over multiple transports (HTTP, [Iroh](https://www.iroh.computer/), etc). 52 | - **Zero-trust**: Content addressed data may be retreived from untrusted locations, since the hash can be used to guarantee that the data you asked for is the data you got. 53 | - **Efficient**: Content addresses allow for efficient storage and retrieval of data. Only the hash needs to be stored and transmitted, rather than the entire data payload. The same data can be referenced by multiple memos without duplicating the data itself. 54 | 55 | ## Memo body 56 | 57 | Like HTTP or email, memos are made up of a header part and a body part. Unlike HTTP and email, the body of a memo is not embedded directly into the memo structure itself. Instead, the body is referred to by a content address (Blake3 hash), stored in the `src` field of the protected headers. 58 | 59 | ```cbor 60 | { 61 | "type": "szdt/memo", 62 | "unprotected": ..., 63 | "protected": { 64 | src: h'abcd1234...', 65 | ... 66 | } 67 | } 68 | ``` 69 | 70 | This design enables: 71 | 72 | - **Streaming verification**: The signature over the headers may be verified immediately, while the body part is be streamed and verified incrementally via Blake3/[Bao](https://github.com/oconnor663/bao/blob/master/docs/spec.md). 73 | - **Flexible delivery**: Because the signature happens over the headers containing the hash of the body, the body part can be delivered in multiple ways. Memo and body can be bundled together into an [SZDT sequence](./sequence/), or distributed independently. 74 | 75 | ## Required and optional headers 76 | 77 | Both protected and unprotected headers are CBOR maps containing open-ended key-value metadata. 78 | 79 | Some header keys have predefined semantics. Some header keys are required, while others are optional. Optional headers must be omitted when not given a definite value. Authors must not serialize null values for unused headers. 80 | 81 | ## Protected Headers 82 | 83 | Required headers: 84 | 85 | | Field | Type | Description | 86 | |-------|------|-------------| 87 | | `iss` | String | Issuer DID (required for signed memos) | 88 | | `iat` | Integer | Issued at timestamp (Unix seconds) | 89 | | `src` | Bytes(32) | Blake3 hash of the referenced content | 90 | 91 | Optional headers: 92 | 93 | | Field | Type | Description | 94 | |-------|------|-------------| 95 | | `nbf` | Integer | Not valid before timestamp (Unix seconds) | 96 | | `exp` | Integer | Expiration timestamp (Unix seconds) | 97 | | `prev` | Bytes(32) | Blake3 hash of previous version of this memo | 98 | | `content-type` | String | MIME content type of referenced content | 99 | | `iss-nickname` | String | The issuer's suggested nickname | 100 | 101 | ## Unprotected Headers 102 | 103 | Optional: 104 | 105 | | Field | Type | Description | 106 | |-------|------|-------------| 107 | | `sig` | Bytes | Ed25519 cryptographic signature over protected headers | 108 | 109 | ## Custom Headers 110 | 111 | Applications may define additional headers for application-specific use cases. Custom headers: 112 | 113 | - Should use lowercase string keys 114 | - Should prefer protected headers, unless the header needs to be modifiable 115 | 116 | ### Compatibility with HTTP headers 117 | 118 | Headers defined in the HTTP suite of specifications should be considered to have the same semantics as their HTTP counterparts. Custom headers must not be defined that conflict with HTTP header semantics. 119 | 120 | ## Header Serialization Rules 121 | 122 | - Optional headers with undefined values MUST be omitted from serialization 123 | - Header maps MUST use deterministic CBOR encoding (CBOR/c profile) 124 | - Header keys MUST be strings 125 | - Header values may be any valid CBOR type 126 | 127 | ## CBOR Encoding 128 | 129 | All CBOR structures MUST use the deterministic [CBOR/c ("CBOR Core")](https://datatracker-ietf-org.lucaspardue.com/doc/draft-rundgren-cbor-core/) profile with definite-length encoding to ensure deterministic serialization for consistent hashing and signing. 130 | 131 | ## Signatures 132 | 133 | Memos support signing with an Ed25519 signatures to provide cryptographic proof of authenticity. Signatures are optional but recommended for zero-trust scenarios. 134 | 135 | ### Signing Process 136 | 137 | 1. **Prepare protected headers**: Ensure all required protected headers are present 138 | 2. **Encode headers**: Serialize protected headers to CBOR using CBOR/c profile 139 | 3. **Hash headers**: Compute the Blake3 hash of the serialized protected headers 140 | 4. **Sign hash**: Generate Ed25519 signature over the Blake3 hash 141 | 5. **Add signature**: Place signature in `sig` field of unprotected headers. Signature must be serialized as a CBOR byte string. 142 | 143 | ### Verification Process 144 | 145 | 1. **Extract signature**: Get signature from `sig` field in unprotected headers 146 | 2. **Encode headers**: Serialize protected headers to CBOR using CBOR/c profile 147 | 3. **Hash headers**: Compute Blake3 hash of the serialized protected headers 148 | 4. **Verify signature**: Validate Ed25519 signature over the hash using issuer's public key 149 | 5. **Verify timestamps**: If `nbf` and `exp` are present, check that `nbf` is not in the future and `exp` is not in the past. A slush factor of 1000 milliseconds may be used to account for clock skew. 150 | 6. **Verify content integrity**: Verify content integrity using `src` hash, using the steps outlined below. 151 | 152 | To verify content integrity: 153 | 154 | 1. **Read content**: Obtain the content referenced by the memo 155 | 2. **Hash content**: Compute Blake3 hash of the content 156 | 3. **Compare hashes**: Verify computed hash matches `src` field in protected headers 157 | 158 | For large content, implementors may use Blake3's streaming capabilities or [Bao](https://github.com/oconnor663/bao) for incremental verification. 159 | 160 | ## DIDs (Decentralized IDentifiers) 161 | 162 | Actors in SZDT are identified with [DIDs](https://github.com/w3c/did-wg/blob/main/did-explainer.md). DIDs provide a decentralized way to reference a public key. 163 | 164 | Memo issuers (authors) are identified using a [`did:key`](https://w3c-ccg.github.io/did-key-spec/) encoding the Ed25519 public key that may be used to verify the memo signature. The issuer DID is stored on the `iss` field of the protected headers. 165 | 166 | ``` 167 | { 168 | "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" 169 | } 170 | ``` 171 | 172 | DIDs are always serialized to their string representation. 173 | 174 | Future versions of SZDT may support additional DID methods, such as [`did:web`](https://w3c-ccg.github.io/did-method-web/). 175 | 176 | 177 | ## Versioning and Updates 178 | 179 | Memos can reference previous versions using the `prev` field in protected headers: 180 | 181 | ```cbor 182 | { 183 | "type": "szdt/memo", 184 | "unprotected": { 185 | "sig": h'9876fedc...' 186 | }, 187 | "protected": { 188 | "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 189 | "iat": 1640998800, 190 | "prev": h'abcd1234...', // Blake3 hash of previous memo 191 | "src": h'5678efab...', // Blake3 hash of current content 192 | "content-type": "text/plain" 193 | } 194 | } 195 | ``` 196 | 197 | This creates a hash-linked chain of versions enabling Git-like version history. 198 | 199 | Applications may choose to interpret this version history in a variety of ways, such as displaying a timeline of changes, or implementing branching workflows. 200 | 201 | For example, to determine the most recent version, an application might implement last-write wins semantics using the following scheme: 202 | 203 | - Choose a `iss` (issuer) to trust. Of the memos issued by that issuer... 204 | - Compare `iat` timestamps of versions. Newest wins. 205 | - If more than one memo has the newest `iat` timestamp, take the Blake3 hashes of the conflicting memos, and sort them in bytewise lexicographic order. The largest hash is the most recent version. 206 | 207 | Applications are also free to implement other versioning strategies, such as comparing the longest branch from a common ancestor, embedding CRDTs in the body, etc. 208 | 209 | ## Usage Examples 210 | 211 | ### Basic Signed Memo 212 | 213 | ```cbor 214 | { 215 | "type": "szdt/memo", 216 | "unprotected": { 217 | "sig": h'5d2f8a0f3b4c7e8f9a1b2c3d4e5f6789abcdef01234567890abcdef0123456789abcdef01234567890abcdef0123456789abcdef01234567890abcdef' 218 | }, 219 | "protected": { 220 | "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 221 | "iat": 1640995200, 222 | "src": h'c8d5e6f7a9b8c7d6e5f4g3h2i1j0k9l8m7n6o5p4q3r2s1t0u9v8w7x6y5z4a3b2c1', 223 | "content-type": "text/plain" 224 | } 225 | } 226 | ``` 227 | 228 | ### Versioned Content Chain 229 | 230 | ```cbor 231 | // Version 1 232 | { 233 | "type": "szdt/memo", 234 | "unprotected": { 235 | "sig": h'signature1...' 236 | }, 237 | "protected": { 238 | "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 239 | "iat": 1640995200, 240 | "src": h'content_hash_v1...', 241 | "content-type": "application/json" 242 | } 243 | } 244 | 245 | // Version 2 (references version 1) 246 | { 247 | "type": "szdt/memo", 248 | "unprotected": { 249 | "sig": h'signature2...' 250 | }, 251 | "protected": { 252 | "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 253 | "iat": 1640998800, 254 | "prev": h'memo_hash_v1...', 255 | "src": h'content_hash_v2...', 256 | "content-type": "application/json" 257 | } 258 | } 259 | ``` 260 | 261 | ## References 262 | 263 | - [CBOR Core Profile](https://datatracker-ietf-org.lucaspardue.com/doc/draft-rundgren-cbor-core/) 264 | - [Blake3 Specification](https://github.com/BLAKE3-team/BLAKE3-specs) 265 | - [Ed25519 Signature Scheme (RFC 8032)](https://datatracker.ietf.org/doc/html/rfc8032) 266 | - [DID Key Method](https://w3c-ccg.github.io/did-key-spec/) 267 | - [HTTP Semantics (RFC 9110)](https://datatracker.ietf.org/doc/html/rfc9110) 268 | 269 | ## Appendix 270 | 271 | ### MIME Type 272 | 273 | SZDT memos should use the MIME type: 274 | 275 | ``` 276 | application/vnd.szdt.memo+cbor 277 | ``` 278 | --------------------------------------------------------------------------------