diff --git a/core/Cargo.lock b/core/Cargo.lock index 5d0d5406..2c7ee95b 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -171,7 +171,6 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", - "zbase32", "zeroize", "zip", ] @@ -6819,12 +6818,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbase32" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9079049688da5871a7558ddacb7f04958862c703e68258594cb7a862b5e33f" - [[package]] name = "zerocopy" version = "0.8.33" diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index e65b76d8..549d1eb7 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -109,8 +109,10 @@ data-encoding = "2.6" zeroize = { version = "1.8.2", features = ["derive"] } # Mainline DHT (did:dht — BitTorrent DHT for decentralized identity) +# z-base-32 is implemented in-tree at src/network/zbase32.rs: the `zbase32` +# crate is LGPL-3.0+, the only hard copyleft dep in the graph and a blocker for +# the MIT release (docs/LICENSE-COMPLIANCE-AUDIT.md §2). mainline = "2" -zbase32 = "0.1" bytes = "1" # Mesh networking (Meshcore serial protocol over USB LoRa radios) diff --git a/core/archipelago/src/network/did_dht.rs b/core/archipelago/src/network/did_dht.rs index 5eebf1f0..54b5a3dc 100644 --- a/core/archipelago/src/network/did_dht.rs +++ b/core/archipelago/src/network/did_dht.rs @@ -5,6 +5,7 @@ //! //! The did:dht identifier is the z-base-32 encoding of the Ed25519 public key. +use crate::network::zbase32; use anyhow::{Context, Result}; use ed25519_dalek::{SigningKey, VerifyingKey}; use std::collections::HashMap; @@ -41,13 +42,21 @@ pub fn did_from_pubkey(pubkey: &VerifyingKey) -> String { format!("did:dht:{}", encoded) } +/// Round-trip guard: a `did:dht` identifier IS the z-base-32 encoding of the +/// key, so a change in that encoding silently rotates every node's DID and +/// orphans its published DHT records. Pinned here as well as in the encoder's +/// own tests because this is the call site that gives the string its meaning. +#[cfg(test)] +const DID_FOR_TEST_MNEMONIC_NODE_KEY: &str = + "did:dht:1o96jdeaigue33hrds3ph6shicehgssx6q6c88yo638curyx7i4y"; + /// Extract the Ed25519 public key bytes from a did:dht identifier. pub fn pubkey_from_did(did: &str) -> Result<[u8; 32]> { let id = did .strip_prefix("did:dht:") .ok_or_else(|| anyhow::anyhow!("Not a did:dht identifier: {}", did))?; - let bytes = zbase32::decode_full_bytes_str(id) - .map_err(|e| anyhow::anyhow!("Invalid z-base-32: {:?}", e))?; + let bytes = + zbase32::decode_full_bytes_str(id).map_err(|e| anyhow::anyhow!("Invalid z-base-32: {e}"))?; if bytes.len() != 32 { anyhow::bail!("Expected 32-byte pubkey, got {} bytes", bytes.len()); } @@ -188,6 +197,30 @@ mod tests { assert!(pubkey_from_did("did:key:z123").is_err()); } + /// The identifier for a known key must not move. Guards the LGPL `zbase32` + /// → in-tree encoder swap, and any future change to it. + #[test] + fn did_for_a_known_key_is_stable() { + let key_bytes: [u8; 32] = + hex::decode("943fe48d18a9a68ce7841db2de7adcab11c35acff3bcc39c10f64ec9900fed74") + .unwrap() + .try_into() + .unwrap(); + let pubkey = VerifyingKey::from_bytes(&key_bytes).unwrap(); + + assert_eq!(did_from_pubkey(&pubkey), DID_FOR_TEST_MNEMONIC_NODE_KEY); + assert_eq!( + pubkey_from_did(DID_FOR_TEST_MNEMONIC_NODE_KEY).unwrap(), + key_bytes + ); + } + + #[test] + fn rejects_a_did_whose_body_is_not_zbase32() { + // `l` and `v` are not in the z-base-32 alphabet. + assert!(pubkey_from_did("did:dht:lllvvv").is_err()); + } + #[test] fn test_build_did_document() { let key = SigningKey::generate(&mut rand::rngs::OsRng); diff --git a/core/archipelago/src/network/mod.rs b/core/archipelago/src/network/mod.rs index e5d0c2cb..2c68f146 100644 --- a/core/archipelago/src/network/mod.rs +++ b/core/archipelago/src/network/mod.rs @@ -3,3 +3,4 @@ pub mod dns; pub mod dwn_store; pub mod dwn_sync; pub mod router; +pub mod zbase32; diff --git a/core/archipelago/src/network/zbase32.rs b/core/archipelago/src/network/zbase32.rs new file mode 100644 index 00000000..332b978d --- /dev/null +++ b/core/archipelago/src/network/zbase32.rs @@ -0,0 +1,227 @@ +//! z-base-32 encoding, as used by `did:dht` identifiers. +//! +//! [z-base-32](https://philzimmermann.com/docs/human-oriented-base-32-encoding.txt) +//! is Zooko's human-oriented base-32 alphabet: same 5-bits-per-character idea as +//! RFC 4648 base32, but with the characters permuted so the ones people confuse +//! (`0`/`O`, `1`/`l`/`I`, `2`/`Z`, `v`/`u`) are either absent or arranged to +//! minimise transcription errors, and with no `=` padding. +//! +//! # Why this exists rather than a crate +//! +//! This replaces the `zbase32` crate, which is **LGPL-3.0+** — the only hard +//! copyleft dependency in the Rust graph and a blocker for the MIT release +//! (`docs/LICENSE-COMPLIANCE-AUDIT.md` §2). Statically linking LGPL code into a +//! Rust binary obliges us to ship relinkable objects, which is impractical for +//! a node image. The encoding itself is an alphabet substitution over a bit +//! stream, so an original implementation is a few dozen lines and adds no +//! dependency at all. +//! +//! # Compatibility +//! +//! Output is **byte-identical** to `zbase32 0.1.2`'s `encode_full_bytes` / +//! `decode_full_bytes_str`, which is what the previous implementation called. +//! That matters because a `did:dht` identifier *is* this encoding of an Ed25519 +//! public key: a different output would silently change every node's DID and +//! break already-published DHT records. The tests below pin the crate's own +//! documented vectors, the canonical vectors from Zimmermann's spec, and +//! several 32-byte keys. +//! +//! # Bit layout +//! +//! Bits are taken most-significant-first from the byte stream and grouped into +//! 5-bit chunks. When the bit count is not a multiple of 5 the final chunk is +//! padded on the right (low side) with zero bits. A 32-byte key is 256 bits → +//! 52 characters (260 bits), so the last character carries 4 padding bits. + +/// The z-base-32 alphabet. Index = 5-bit value. +const ALPHABET: &[u8; 32] = b"ybndrfg8ejkmcpqxot1uwisza345h769"; + +/// Reverse of [`ALPHABET`]: ASCII byte → 5-bit value, `None` if not a digit. +/// Built at compile time so decoding is a table lookup and stays in sync with +/// the alphabet by construction. +const DECODE_TABLE: [Option; 256] = { + let mut table = [None; 256]; + let mut i = 0; + while i < 32 { + table[ALPHABET[i] as usize] = Some(i as u8); + i += 1; + } + table +}; + +/// Encode every bit of `data` as z-base-32. +/// +/// Equivalent to the `zbase32` crate's `encode_full_bytes`. +pub fn encode_full_bytes(data: &[u8]) -> String { + let bits = data.len() * 8; + // ceil(bits / 5) + let out_len = bits.div_ceil(5); + let mut out = String::with_capacity(out_len); + + // `acc` holds the not-yet-emitted low `acc_bits` bits, MSB-first. + let mut acc: u32 = 0; + let mut acc_bits: u32 = 0; + for &byte in data { + acc = (acc << 8) | u32::from(byte); + acc_bits += 8; + while acc_bits >= 5 { + acc_bits -= 5; + let idx = (acc >> acc_bits) & 0x1f; + out.push(ALPHABET[idx as usize] as char); + } + } + // Trailing bits: left-align them in a 5-bit group (pad right with zeros). + if acc_bits > 0 { + let idx = (acc << (5 - acc_bits)) & 0x1f; + out.push(ALPHABET[idx as usize] as char); + } + + debug_assert_eq!(out.len(), out_len); + out +} + +/// Decode a z-base-32 string, keeping only whole bytes. +/// +/// Equivalent to the `zbase32` crate's `decode_full_bytes_str`: the input +/// carries `len * 5` bits, and everything below the next lower byte boundary is +/// discarded. So 52 characters (260 bits) yield 32 bytes and the final 4 bits +/// are ignored — which is exactly why a 32-byte key round-trips. +/// +/// Returns `Err` with the offending character if the input is not z-base-32. +pub fn decode_full_bytes_str(s: &str) -> Result, String> { + let total_bits = s.len() * 5; + let keep_bits = total_bits / 8 * 8; + let mut out = Vec::with_capacity(keep_bits / 8); + + let mut acc: u32 = 0; + let mut acc_bits: u32 = 0; + let mut emitted_bits = 0usize; + for ch in s.chars() { + // Non-ASCII can't be a digit; `as usize` on a multi-byte char would + // index the table wrongly, so reject before the lookup. + let value = u8::try_from(ch as u32) + .ok() + .and_then(|b| DECODE_TABLE[b as usize]) + .ok_or_else(|| format!("not a z-base-32 digit: {ch:?}"))?; + acc = (acc << 5) | u32::from(value); + acc_bits += 5; + while acc_bits >= 8 && emitted_bits < keep_bits { + acc_bits -= 8; + out.push(((acc >> acc_bits) & 0xff) as u8); + emitted_bits += 8; + } + } + + debug_assert_eq!(out.len(), keep_bits / 8); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The three doc-test vectors from `zbase32 0.1.2` itself. If these hold, + /// this module is a drop-in for the calls the crate used to serve. + #[test] + fn matches_the_replaced_crates_own_doctests() { + assert_eq!( + encode_full_bytes("Just an arbitrary sentence.".as_bytes()), + "jj4zg7bycfznyam1cjwzehubqjh1yh5fp34gk5udcwzy" + ); + assert_eq!(decode_full_bytes_str("qb1ze3m1").unwrap(), b"peter"); + // `encode(b"testdata", 64)` — 64 bits is exactly 8 whole bytes, so + // encode_full_bytes agrees with the crate's bit-precision form here. + assert_eq!(encode_full_bytes(b"testdata"), "qt1zg7drcf4gn"); + } + + /// Canonical vectors from Zimmermann's z-base-32 spec (the whole-byte + /// subset — the spec's sub-byte cases exercise an API we deliberately + /// don't expose). + #[test] + fn matches_the_spec_vectors() { + assert_eq!(encode_full_bytes(&[0xf0, 0xbf, 0xc7]), "6n9hq"); + assert_eq!(encode_full_bytes(&[0xd4, 0x7a, 0x04]), "4t7ye"); + } + + /// A `did:dht` identifier is this encoding of a 32-byte Ed25519 key, so + /// these pin the exact strings that must not drift. Computed independently + /// and cross-checked against the spec vectors above. + #[test] + fn known_answers_for_32_byte_keys() { + let seq: Vec = (0u8..32).collect(); + assert_eq!( + encode_full_bytes(&seq), + "yyyoryarywdyqnyjbefoadeqbhebnrounoktcfaadrpbs8y7daxo" + ); + assert_eq!( + encode_full_bytes(&[0xff; 32]), + "999999999999999999999999999999999999999999999999999o" + ); + assert_eq!( + encode_full_bytes(&[0x00; 32]), + "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + ); + // The node Ed25519 public key derived from seed.rs's TEST_MNEMONIC. + let node_key = + hex::decode("943fe48d18a9a68ce7841db2de7adcab11c35acff3bcc39c10f64ec9900fed74") + .unwrap(); + assert_eq!( + encode_full_bytes(&node_key), + "1o96jdeaigue33hrds3ph6shicehgssx6q6c88yo638curyx7i4y" + ); + } + + #[test] + fn a_32_byte_key_is_52_chars_and_round_trips() { + for seed in 0u8..64 { + let key: Vec = (0u8..32).map(|i| i.wrapping_mul(7).wrapping_add(seed)).collect(); + let encoded = encode_full_bytes(&key); + assert_eq!(encoded.len(), 52, "256 bits must encode to 52 characters"); + assert_eq!(decode_full_bytes_str(&encoded).unwrap(), key); + } + } + + #[test] + fn round_trips_every_length_up_to_a_block() { + for len in 0..40usize { + let data: Vec = (0..len).map(|i| (i as u8).wrapping_mul(31) ^ 0x5a).collect(); + let encoded = encode_full_bytes(&data); + // decode_full_bytes only recovers whole bytes, and encoding N bytes + // produces ceil(8N/5) chars which always carry at least 8N bits. + assert_eq!(decode_full_bytes_str(&encoded).unwrap(), data, "len {len}"); + } + } + + #[test] + fn empty_input() { + assert_eq!(encode_full_bytes(&[]), ""); + assert_eq!(decode_full_bytes_str("").unwrap(), Vec::::new()); + } + + #[test] + fn rejects_non_alphabet_characters() { + // `l`, `v`, `2`, `0` are deliberately absent from the z-base-32 + // alphabet — they're the characters it exists to avoid. + for bad in ["l", "v", "2", "0", "A", "yyy!", "yyyé"] { + assert!( + decode_full_bytes_str(bad).is_err(), + "{bad:?} must not decode" + ); + } + } + + /// The alphabet must stay a permutation of 32 distinct ASCII characters, or + /// the compile-time decode table silently loses entries. + #[test] + fn alphabet_is_32_distinct_ascii_characters() { + let mut seen = std::collections::HashSet::new(); + for &c in ALPHABET.iter() { + assert!(c.is_ascii(), "non-ASCII in alphabet"); + assert!(seen.insert(c), "duplicate character in alphabet: {c}"); + } + assert_eq!(seen.len(), 32); + for (i, &c) in ALPHABET.iter().enumerate() { + assert_eq!(DECODE_TABLE[c as usize], Some(i as u8)); + } + } +} diff --git a/docs/LICENSE-COMPLIANCE-AUDIT.md b/docs/LICENSE-COMPLIANCE-AUDIT.md index e2457ef6..fabac229 100644 --- a/docs/LICENSE-COMPLIANCE-AUDIT.md +++ b/docs/LICENSE-COMPLIANCE-AUDIT.md @@ -7,7 +7,8 @@ Audit date: 2026-07-22. Scope: entire repo (core Rust workspace, neode-ui, apps/ > **Updated 2026-08-08.** §1 (no license) and §3 (non-redistributable committed > files) are now **closed** — root `LICENSE` (MIT) + `NOTICE` are in the tree, and > the proprietary fonts and unused packages have actually been deleted. **§2 -> (`zbase32`, LGPL-3.0+) is still open** and is the last hard blocker. +> (`zbase32`, LGPL-3.0+) is now closed too** — replaced by an in-tree +> implementation. No copyleft dependency remains in the Rust graph. --- @@ -44,7 +45,7 @@ Audit date: 2026-07-22. Scope: entire repo (core Rust workspace, neode-ui, apps/ - License inventories generated: `core/THIRD-PARTY-LICENSES.md` (649 crates) and `neode-ui/THIRD-PARTY-LICENSES.md` (runtime deps + fonts + vendored). **REMAINING (code changes, awaiting review — see sections below for detail):** -1. Replace `zbase32` (LGPL-3.0+) with `z32` or original impl — §2. +1. ~~Replace `zbase32` (LGPL-3.0+) with `z32` or original impl~~ — **DONE 2026-08-08**, original impl (§2). 2. Swap `redis:7.4.8` → Valkey in `scripts/image-versions.sh` and deploys — §3. 3. Delete dead StartOS-derived crates `core/{js-engine,container-init,models,helpers}` — §4. 4. Attribution build integration: cargo-about in CI → ship full license texts in ISO; vite/rollup license plugin (or UI licenses page) for the web bundle; Android OSS-licenses screen — §5. @@ -52,7 +53,7 @@ Audit date: 2026-07-22. Scope: entire repo (core Rust workspace, neode-ui, apps/ 6. ~~Before repo goes public: purge deleted fonts/APKs from git history (`git filter-repo`)~~ — **superseded**: the launch plan is a fresh-history publish, so there is no history to rewrite. What still applies is verifying the game-icons author credit, and actually deleting the files (see the correction above — they were never removed). **Re-verified 2026-08-08:** -- `zbase32 0.1.2` (LGPL-3.0+) is **still a direct dependency** (`core/archipelago/Cargo.toml:113`), still used at `network/did_dht.rs:40,49`. Item 1 remains open and is the only hard copyleft blocker. +- ~~`zbase32 0.1.2` (LGPL-3.0+) is still a direct dependency.~~ **Removed 2026-08-08** — see §2. - `LICENSE` (MIT) and `NOTICE` are present ✅. `core/THIRD-PARTY-LICENSES.md` and `neode-ui/THIRD-PARTY-LICENSES.md` are present ✅. - The four StartOS-derived crates in item 3 (`core/{js-engine,container-init,models,helpers}`) **still exist** — note KEY-05 legitimately cites `core/models`, so that one needs a look before deletion rather than a blind `rm`. @@ -70,9 +71,22 @@ There is no `LICENSE`/`COPYING` file anywhere in the repo. No crate in `core/` d - [ ] Add `license = "MIT"` to all five workspace member `Cargo.toml`s (archipelago, container, openwrt, performance, security) and `Android/rust/archy-fips-core` (declares MIT but ships no license file — add one). - [ ] Add `"license": "MIT"` to `neode-ui/package.json` and `apps/{morphos-server,router,did-wallet}/package.json`. -## 2. BLOCKER — copyleft dependency that must be replaced +## 2. BLOCKER — copyleft dependency that must be replaced ✅ CLOSED 2026-08-08 -- [ ] **`zbase32 0.1.2` — LGPL-3.0+** — the only hard copyleft blocker in all 649 resolved Rust crates. Direct dep of `archipelago`, used in `core/archipelago/src/network/did_dht.rs` for did:dht z-base-32 encoding. LGPL statically linked into a Rust binary requires shipping relinkable objects/source — impractical. **Replace with the MIT `z32` crate** or a ~30-line original alphabet-substitution implementation. +- [x] **`zbase32 0.1.2` — LGPL-3.0+** — was the only hard copyleft blocker in all 649 resolved Rust crates. Direct dep of `archipelago`, used in `core/archipelago/src/network/did_dht.rs` for did:dht z-base-32 encoding. LGPL statically linked into a Rust binary requires shipping relinkable objects/source — impractical. + + **DONE 2026-08-08.** Replaced with an original in-tree implementation at + `core/archipelago/src/network/zbase32.rs` (~60 lines incl. docs) rather than + the `z32` crate — the encoding is an alphabet substitution over a bit stream, + so this removes the blocker without adding any dependency or new supply-chain + surface. Dropped from `Cargo.toml` and `Cargo.lock`. + + Byte-compatibility was the hard requirement: a `did:dht` identifier *is* this + encoding of an Ed25519 public key, so any drift would silently rotate every + node's DID and orphan its published DHT records. The replacement is pinned + against the removed crate's own three doc-test vectors, the canonical vectors + from Zimmermann's z-base-32 spec, and four known 32-byte keys — plus a + `did_for_a_known_key_is_stable` test at the `did_dht.rs` call site. No GPL, AGPL, SSPL, or unlicensed crates exist anywhere else in the Rust graph. (`r-efi` and `self_cell` list LGPL/GPL only as options in OR-expressions — elect MIT/Apache, no action.) @@ -132,7 +146,7 @@ The ISO redistributes a full Debian (trixie) system plus ~29 container image tar ## Quick reference: what's already clean -- All 649 Rust crates except `zbase32`: permissive or dual-licensed. +- All Rust crates: permissive or dual-licensed (`zbase32` was the sole exception and is gone as of 2026-08-08). - All 833 npm packages in neode-ui: no GPL/AGPL anywhere; only dev-tool LGPL (sharp's libvips, never distributed). - Android Gradle deps: 100 % Apache-2.0, all pinned, no Play Services/telemetry. - FIPS mesh: MIT (© 2026 Johnathan Corgan) — keep notice.