Skip to content

Commit 5e706ec

Browse files
committed
Hyphen-separated phrases of BIP-39 words
These are much easier to deal with, as they can be selected with a double-click, and don't require quoting.
1 parent 142490c commit 5e706ec

7 files changed

Lines changed: 352 additions & 92 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ bytesize = "2"
99
chrono = "0.4"
1010
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
1111
crypto-bigint = "0.5"
12-
diceware_wordlists = "1"
1312
dropshot = "0.16"
1413
ed25519-dalek = { version = "2", features = ["rand_core"] }
1514
humantime = "2"

common/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ publish = false
99
bytesize.workspace = true
1010
chrono.workspace = true
1111
crypto-bigint.workspace = true
12-
diceware_wordlists.workspace = true
1312
ed25519-dalek.workspace = true
1413
p256.workspace = true
1514
pem-rfc7468.workspace = true

common/src/certs.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use x509_cert::spki::{AlgorithmIdentifierOwned, SubjectPublicKeyInfo};
2929
use x509_cert::time::Validity;
3030
use x509_cert::{Certificate, TbsCertificate, Version};
3131

32-
use crate::codephrases::{InvalidCodephrase, codephrase, decode_phrase};
32+
use crate::codephrases::{InvalidCodephrase, WORD_SEPARATOR, codephrase, decode_phrase, id_phrase};
3333

3434
/// What went wrong handling a key, signature, or certificate.
3535
#[derive(Debug, Error)]
@@ -96,8 +96,8 @@ impl TryFrom<&Name> for KeyId {
9696

9797
fn try_from(name: &Name) -> Result<KeyId, Self::Error> {
9898
let hash = Sha256::digest(&name.to_der()?);
99-
let words = codephrase(U256::from_be_slice(hash.as_slice()));
100-
Ok(KeyId(words[..6].join(" ")))
99+
let phrase = id_phrase(U256::from_be_slice(hash.as_slice()));
100+
Ok(KeyId(phrase.join(WORD_SEPARATOR)))
101101
}
102102
}
103103

@@ -193,14 +193,15 @@ impl Signature {
193193
}
194194

195195
pub fn encode(&self) -> EncodedSignature {
196+
let codephrase = |x: U256| codephrase(x).join(WORD_SEPARATOR);
196197
match self {
197198
Self::EcdsaSha256(signature) => EncodedSignature {
198-
r: codephrase(U256::from_be_byte_array(signature.r().to_bytes())).join(" "),
199-
s: codephrase(U256::from_be_byte_array(signature.s().to_bytes())).join(" "),
199+
r: codephrase(U256::from_be_byte_array(signature.r().to_bytes())),
200+
s: codephrase(U256::from_be_byte_array(signature.s().to_bytes())),
200201
},
201202
Self::Ed25519(signature) => EncodedSignature {
202-
r: codephrase(U256::from_be_slice(signature.r_bytes())).join(" "),
203-
s: codephrase(U256::from_be_slice(signature.s_bytes())).join(" "),
203+
r: codephrase(U256::from_be_slice(signature.r_bytes())),
204+
s: codephrase(U256::from_be_slice(signature.s_bytes())),
204205
},
205206
}
206207
}

common/src/codephrases.rs

Lines changed: 101 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,39 @@
1-
//! Random code phrases like `cozy bountiful dullness quarry icing sixfold`.
1+
//! Random code phrases like `abstract misery favorite ordinary moon talk`.
22
//!
33
//! These are intended to serve not as secrets, but as representations
44
//! of up to 256 bits of entropy (e.g., identifiers, signature scalars)
5-
//! that must be readily transmissible over low bandwidth channels, e.g.,
6-
//! email, voice, printed or handwritten notes, etc.
5+
//! that must be readily transmissible over low bandwidth channels (e.g.,
6+
//! email, voice, printed or handwritten notes, etc.).
77
8-
use crypto_bigint::{Limb, NonZero, Random as _, U256, Wrapping};
9-
use diceware_wordlists::EFF_LONG_WORDLIST;
8+
use crypto_bigint::{Limb, Random as _, Reciprocal, U256, Wrapping};
109
use rand_core::OsRng;
1110
use thiserror::Error;
1211

13-
/// 6<sup>5</sup> = 7,776 = five rolls of a six-sided die.
14-
pub const WORDLIST_LEN: usize = 7_776;
12+
use crate::wordlist::{WORDLIST, WORDLIST_LEN};
1513

16-
/// The constituents of code phrases. We use the EFF Diceware list.
17-
pub const WORDLIST: &[&str; WORDLIST_LEN] = &EFF_LONG_WORDLIST;
14+
/// Entropy is treated as an integer whose base is to be changed
15+
/// to 2048, which gives us indexes into the BIP-39 word list.
16+
/// Since 2048<sup>23</sup> < 2<sup>256</sup> < 2048<sup>24</sup>,
17+
/// 24 words suffice to represent 256 bits with no redundancy.
18+
pub const PHRASE_WORDS_256: usize = 24;
19+
20+
/// With 2048 words, an 8 word code phrase has ~88 bits of entropy,
21+
/// making it suitable for use as a unique, hard-to-guess identifier,
22+
/// but not a secret.
23+
///
24+
/// Note that the security of the Support Shell protocol (RFD 620) does
25+
/// *not* rely on such identifiers being unguessable; it relies only on
26+
/// the strength of the signatures produced over these phrases.
27+
pub const PHRASE_WORDS_ID: usize = 8;
28+
29+
/// The BIP-39 word list contains no punctuation of any kind, so we are
30+
/// free to use ASCII hyphen (`-`) as the default word separator. Decoding
31+
/// will also accept arbitrary ASCII whitespace as word separators.
32+
pub const WORD_SEPARATOR: &str = "-";
1833

1934
/// Decoding a phrase failed.
2035
#[derive(Debug, Error)]
21-
#[error("invalid code phrase, must be ≤ 20 space-separated words from the EFF Diceware list")]
36+
#[error("invalid code phrase, must be ≤ {PHRASE_WORDS_256} words from the BIP-39 list")]
2237
pub struct InvalidCodephrase;
2338

2439
/// Look up a word in the word list.
@@ -33,51 +48,51 @@ fn index(word: &str) -> Result<usize, InvalidCodephrase> {
3348
WORDLIST.binary_search(&word).map_err(|_| InvalidCodephrase)
3449
}
3550

36-
/// Turn 256 bits of entropy into a code phrase.
51+
/// Turn 256 bits of entropy into an un-padded big-endian code phrase.
3752
///
38-
/// We treat the entropy as a single 256 bit integer and change its base to
39-
/// 6<sup>5</sup>, which gives us indexes into the EFF Diceware word list.
40-
/// Since 6<sup>5<sup>19</sup></sup> < 2<sup>256</sup> < 6<sup>5<sup>20</sup></sup>,
41-
/// 20 words suffice to represent 256 bits with no redundancy. Emits big endian
42-
/// phrases with no padding.
53+
/// The choice of whether to pad or not is somewhat tricky, and either
54+
/// path leads to bugs. On the one hand, it's nice to assume that every
55+
/// codephrase is the same length, and that the en/decoding operations
56+
/// are roughly constant time (though these shouldn't be used for secrets,
57+
/// so that shouldn't really matter). On the other hand, padding means
58+
/// we introduce a bias towards zero into the first few words, which
59+
/// is undesirable both for humans consuming those phrases (they look
60+
/// "non-random" if you assemble even a handful of them together),
61+
/// and for truncating them into ID phrases (because there's a bias,
62+
/// so we get fewer bits of entropy if we use the first few words).
63+
/// On the whole, not padding seems preferable, but not a clear win.
4364
pub fn codephrase(value: U256) -> Vec<&'static str> {
44-
let b = NonZero::new(Limb(WORDLIST_LEN as u64)).expect("should have some words");
65+
let b = Reciprocal::new(Limb(WORDLIST_LEN as u64)).expect("should have some words");
4566
let mut n = value;
4667
let mut r;
47-
let mut words = Vec::new();
48-
while n >= (*b).into() {
49-
(n, r) = n.div_rem_limb(b);
50-
words.push(word(r.0 as usize)); // accumulate little endian
51-
}
52-
53-
// limbs are little endian
54-
if let [Limb(l), Limb(0), Limb(0), Limb(0)] = n.to_limbs() {
55-
words.push(word(l as usize));
56-
} else {
57-
unreachable!("final remainder should fit in one limb");
68+
let mut words = Vec::with_capacity(PHRASE_WORDS_256);
69+
while n > U256::ZERO {
70+
(n, r) = n.ct_div_rem_limb_with_reciprocal(&b);
71+
words.push(word(r.0 as usize)); // accumulate little-endian
5872
}
59-
60-
assert!(words.len() <= 20, "codephrase should be at most 20 words");
61-
words.reverse(); // emit big endian
73+
words.reverse(); // emit big-endian
6274
words
6375
}
6476

65-
/// Generate a code phrase with 256 bits of entropy.
66-
pub fn generate_codephrase() -> String {
67-
codephrase(U256::random(&mut OsRng)).join(" ")
77+
/// Turn 256 bits of entropy into a reasonably unique code phrase.
78+
pub fn id_phrase(value: U256) -> [&'static str; PHRASE_WORDS_ID] {
79+
codephrase(value)[..PHRASE_WORDS_ID].try_into().unwrap()
6880
}
6981

70-
/// Generate a 6 word code phrase suitable for use as an identifier,
71-
/// but not a secret (~77.5 bits of entropy).
82+
/// Generate a code phrase for use as an identifier.
7283
pub fn generate_id() -> String {
73-
codephrase(U256::random(&mut OsRng))[..6].join(" ")
84+
id_phrase(U256::random(&mut OsRng)).join(WORD_SEPARATOR)
7485
}
7586

7687
/// Decode a big endian code phrase into 256 bits of entropy.
7788
pub fn decode_phrase(phrase: &str) -> Result<U256, InvalidCodephrase> {
7889
let b = Wrapping(U256::from_u64(WORDLIST_LEN as u64));
7990
let mut n = Wrapping(U256::ZERO);
80-
for word in phrase.split_ascii_whitespace().take(20) {
91+
for word in phrase
92+
.replace(WORD_SEPARATOR, " ")
93+
.split_ascii_whitespace()
94+
.take(PHRASE_WORDS_256)
95+
{
8196
let i = index(word)?;
8297
let r = Wrapping(U256::from_u64(i as u64));
8398
n *= b;
@@ -88,14 +103,12 @@ pub fn decode_phrase(phrase: &str) -> Result<U256, InvalidCodephrase> {
88103

89104
#[cfg(test)]
90105
mod test {
91-
use std::collections::HashSet;
92-
93-
use sha2::{Digest as _, Sha256};
94-
95106
use super::*;
107+
use sha2::{Digest as _, Sha256};
108+
use std::collections::HashSet;
96109

97110
fn round_trip(entropy: U256) -> String {
98-
let codephrase = codephrase(entropy).join(" ");
111+
let codephrase = codephrase(entropy).join(WORD_SEPARATOR);
99112
let decoded = decode_phrase(&codephrase).unwrap();
100113
assert_eq!(entropy, decoded);
101114
codephrase
@@ -104,72 +117,84 @@ mod test {
104117
#[test]
105118
fn trivial_codephrases() {
106119
let b = WORDLIST_LEN as u64;
107-
assert_eq!(round_trip(U256::ZERO), "abacus");
108-
assert_eq!(round_trip(U256::ONE), "abdomen");
109-
assert_eq!(round_trip(U256::from_u64(b - 1)), "zoom");
110-
assert_eq!(round_trip(U256::from_u64(b)), "abdomen abacus");
111-
assert_eq!(round_trip(U256::from_u64(b + 1)), "abdomen abdomen");
120+
assert_eq!(round_trip(U256::ZERO), "");
121+
assert_eq!(round_trip(U256::ONE), "ability");
122+
assert_eq!(round_trip(U256::from_u64(b - 1)), "zoo");
123+
assert_eq!(round_trip(U256::from_u64(b)), "ability-abandon");
124+
assert_eq!(round_trip(U256::from_u64(b + 1)), "ability-ability");
112125
assert_eq!(
113126
round_trip(U256::from_u64(b.pow(2))),
114-
"abdomen abacus abacus"
127+
"ability-abandon-abandon"
115128
);
116129
assert_eq!(
117130
round_trip(U256::from_u64(b.pow(3))),
118-
"abdomen abacus abacus abacus"
131+
"ability-abandon-abandon-abandon"
119132
);
120133
assert_eq!(
121134
round_trip(U256::from_u64(b.pow(3) + 1)),
122-
"abdomen abacus abacus abdomen"
135+
"ability-abandon-abandon-ability"
123136
);
124137
assert_eq!(
125138
round_trip(U256::from_u64(b.pow(3) + b - 1)),
126-
"abdomen abacus abacus zoom"
139+
"ability-abandon-abandon-zoo"
127140
);
128141
}
129142

130143
#[test]
131144
fn constant_codephrases() {
132145
assert_eq!(
133146
round_trip(U256::from_be_slice(&Sha256::digest("test phrase one"))),
134-
"cozy bountiful dullness quarry icing \
135-
sixfold plank armband childish gumminess \
136-
ibuprofen unvaried recliner subheader muzzle \
137-
map retention excitable dried unclamped"
147+
"abstract-summer-orange-gown-urge-model-\
148+
exact-gorilla-outside-common-this-pepper-\
149+
pear-dust-minimum-black-double-recipe-\
150+
castle-crystal-clog-logic-delay-hamster"
138151
);
139152
assert_eq!(
140153
round_trip(U256::from_be_slice(&Sha256::digest("another test phrase"))),
141-
"cork drained obsolete wish service \
142-
grout graph caution feel refurnish \
143-
cobalt scrap relax identity estranged \
144-
repacking underpass guiding tartly impurity"
154+
"abstract-misery-favorite-ordinary-moon-talk-\
155+
write-coffee-digital-slogan-spray-angry-\
156+
once-jazz-random-income-garage-regret-accident-\
157+
file-release-deny-reward-drastic"
145158
);
146159
assert_eq!(
147160
round_trip(U256::from_be_slice(&Sha256::digest("one more for luck!"))),
148-
"avenge chemicals gusty ascend unending \
149-
gaffe ranking suffix doorknob overpower \
150-
pamperer clarify walk unbeaten overcast \
151-
pants respect elsewhere angrily distinct"
161+
"able-dismiss-cost-scheme-amazing-slogan-\
162+
service-current-protect-feed-length-text-\
163+
cruise-wisdom-beauty-angle-regret-truck-\
164+
prosper-album-decline-wheel-pause-legend"
152165
);
153166
}
154167

155168
#[test]
156169
fn random_codephrases() {
157170
let mut seen = HashSet::new();
171+
172+
// Although this test is probabilistic, you should be able to set
173+
// the iteration count arbitrarily high and never see a failure
174+
// (though you may run out of memory or time). The current value
175+
// was chosen to run in < .1 seconds on a modest development system.
158176
for _ in 0..1000 {
159177
let nonce = U256::random(&mut OsRng);
160178
let codephrase = round_trip(nonce);
161-
assert!([19, 20].contains(&codephrase.split_ascii_whitespace().count()));
162-
assert!(seen.insert(codephrase));
179+
let n = codephrase.split(WORD_SEPARATOR).count();
180+
181+
// Because we're using un-padded phrases, we have to guess at a
182+
// lower bound for the number of words here. 12 should be safe;
183+
// if you've hit that, congratulations! You've found 256 bits
184+
// of "entropy" whose top half is all 0! Realistically, phrases
185+
// with 22 words are easy to find, 21 much harder. Below that,
186+
// you should be mining for ₿ instead of running this test.
187+
assert!(n > 12 && n <= 24, "{codephrase} has {n} words");
188+
189+
// If this assertion fails, congratulations are again in order:
190+
// you've found two distinct sets of 256 bits of "entropy" that
191+
// are exactly the same! Either something is deeply wrong with
192+
// the universe or our understanding of it, this code is broken,
193+
// or your OS RNG is not so R after all.
194+
assert!(
195+
seen.insert(codephrase.clone()),
196+
"duplicate codephrase {codephrase}"
197+
);
163198
}
164199
}
165-
166-
#[test]
167-
fn verify_wordlist() {
168-
assert_eq!(WORDLIST_LEN, 6_usize.pow(5));
169-
assert_eq!(WORDLIST_LEN, WORDLIST.len());
170-
assert_eq!(HashSet::from(*WORDLIST).len(), WORDLIST.len());
171-
assert!(WORDLIST.iter().all(|word| {
172-
word.len() >= 3 && word.chars().all(|c| c.is_ascii_lowercase() || c == '-')
173-
}));
174-
}
175200
}

common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod certs;
22
pub mod codephrases;
33
pub mod jobs;
4+
pub mod wordlist;
45

56
/// The file name of the OpenAPI document generated by
67
/// [Dropshot](https://crates.io/crates/dropshot) and used by

0 commit comments

Comments
 (0)