fix(license): replace the LGPL zbase32 crate with an in-tree implementation

`zbase32 0.1.2` is LGPL-3.0+ — the only hard copyleft dependency in the whole
Rust graph and the last remaining 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 audit offered two routes: the MIT `z32` crate, or an original
implementation. Took the latter — z-base-32 is an alphabet substitution over a
bit stream, so ~60 lines removes the blocker while adding *zero* new
dependencies rather than trading one supply-chain entry for another.

**Byte-compatibility was the requirement, not a nice-to-have.** 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 already-published DHT records.
So the semantics were not guessed: I read the vendored zbase32-0.1.2 source to
extract exactly what `encode_full_bytes` and `decode_full_bytes_str` do —
including that decode truncates to the next lower byte boundary, which is why a
52-character string round-trips to 32 bytes while discarding 4 padding bits.

A model implementation was then validated against three independent sources
before any Rust was written, all five vectors agreeing:

    encode(b"testdata", 64)       -> qt1zg7drcf4gn   (crate doctest)
    encode_full_bytes("Just an…") -> jj4zg7bycfzn…   (crate doctest)
    decode_full_bytes("qb1ze3m1") -> b"peter"        (crate doctest)
    encode([f0,bf,c7])            -> 6n9hq           (Zimmermann spec)
    encode([d4,7a,04])            -> 4t7ye           (Zimmermann spec)

The module pins all of those plus four known 32-byte keys, a 0..40-byte
round-trip sweep, a 52-char/round-trip check over 64 keys, rejection of the
characters z-base-32 deliberately omits (`l`, `v`, `2`, `0`) and of non-ASCII,
and an alphabet/decode-table consistency check so the compile-time reverse table
can't drift from the alphabet.

`did_dht.rs` gains `did_for_a_known_key_is_stable`, which pins the full
identifier string for a known key — the regression that would actually hurt,
asserted at the call site that gives the string its meaning.

Dropped from Cargo.toml and Cargo.lock (7 lines); no other user in the tree.
Verified: 28/28 network tests pass, zero copyleft crates remain in the lockfile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 04:55:51 -04:00
co-authored by Claude Opus 5
parent 6d33fea157
commit fe46c898d1
6 changed files with 286 additions and 16 deletions
-7
View File
@@ -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"
+3 -1
View File
@@ -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)
+35 -2
View File
@@ -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);
+1
View File
@@ -3,3 +3,4 @@ pub mod dns;
pub mod dwn_store;
pub mod dwn_sync;
pub mod router;
pub mod zbase32;
+227
View File
@@ -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<u8>; 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<Vec<u8>, 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<u8> = (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<u8> = (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<u8> = (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::<u8>::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));
}
}
}
+20 -6
View File
@@ -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.