├── 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 |
18 | Choose File
19 |
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 |
23 | Developers
24 |
29 |
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 |
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 |
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 |
--------------------------------------------------------------------------------