Skip to content

Commit 36bcb1d

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 36bcb1d

7 files changed

Lines changed: 304 additions & 66 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: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ 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::{
33+
ID_PHRASE_WORDS, InvalidCodephrase, WORD_SEPARATOR, codephrase, decode_phrase,
34+
};
3335

3436
/// What went wrong handling a key, signature, or certificate.
3537
#[derive(Debug, Error)]
@@ -97,7 +99,7 @@ impl TryFrom<&Name> for KeyId {
9799
fn try_from(name: &Name) -> Result<KeyId, Self::Error> {
98100
let hash = Sha256::digest(&name.to_der()?);
99101
let words = codephrase(U256::from_be_slice(hash.as_slice()));
100-
Ok(KeyId(words[..6].join(" ")))
102+
Ok(KeyId(words[..ID_PHRASE_WORDS].join(WORD_SEPARATOR)))
101103
}
102104
}
103105

@@ -193,14 +195,15 @@ impl Signature {
193195
}
194196

195197
pub fn encode(&self) -> EncodedSignature {
198+
let codephrase = |x: U256| codephrase(x).join(WORD_SEPARATOR);
196199
match self {
197200
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(" "),
201+
r: codephrase(U256::from_be_byte_array(signature.r().to_bytes())),
202+
s: codephrase(U256::from_be_byte_array(signature.s().to_bytes())),
200203
},
201204
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(" "),
205+
r: codephrase(U256::from_be_slice(signature.r_bytes())),
206+
s: codephrase(U256::from_be_slice(signature.s_bytes())),
204207
},
205208
}
206209
}

common/src/codephrases.rs

Lines changed: 52 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
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)
55
//! that must be readily transmissible over low bandwidth channels, e.g.,
66
//! email, voice, printed or handwritten notes, etc.
77
88
use crypto_bigint::{Limb, NonZero, Random as _, U256, Wrapping};
9-
use diceware_wordlists::EFF_LONG_WORDLIST;
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 MAX_PHRASE_WORDS: usize = 24;
19+
20+
/// With 2048 words, an 8 word code phrase has ~88 bits of entropy,
21+
/// making it suitable for use as an identifier, but not a secret.
22+
pub const ID_PHRASE_WORDS: usize = 8;
23+
24+
/// The BIP-39 word list contains no punctuation of any kind, so we are
25+
/// free to use ASCII hyphen (`-`) as the default word separator. We will
26+
/// also accept ASCII whitespace as word separators during decoding.
27+
pub const WORD_SEPARATOR: &str = "-";
1828

1929
/// Decoding a phrase failed.
2030
#[derive(Debug, Error)]
21-
#[error("invalid code phrase, must be ≤ 20 space-separated words from the EFF Diceware list")]
31+
#[error("invalid code phrase, must be ≤ {MAX_PHRASE_WORDS} words from the BIP-39 list")]
2232
pub struct InvalidCodephrase;
2333

2434
/// Look up a word in the word list.
@@ -35,11 +45,9 @@ fn index(word: &str) -> Result<usize, InvalidCodephrase> {
3545

3646
/// Turn 256 bits of entropy into a code phrase.
3747
///
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.
48+
/// We emit big endian phrases with no padding. This implementation makes
49+
/// no assumptions about the value of [`WORDLIST_LEN`]; in particular, it
50+
/// does not assume that it is a power of 2.
4351
pub fn codephrase(value: U256) -> Vec<&'static str> {
4452
let b = NonZero::new(Limb(WORDLIST_LEN as u64)).expect("should have some words");
4553
let mut n = value;
@@ -57,27 +65,30 @@ pub fn codephrase(value: U256) -> Vec<&'static str> {
5765
unreachable!("final remainder should fit in one limb");
5866
}
5967

60-
assert!(words.len() <= 20, "codephrase should be at most 20 words");
68+
assert!(words.len() <= MAX_PHRASE_WORDS, "codephrase is too long");
6169
words.reverse(); // emit big endian
6270
words
6371
}
6472

6573
/// Generate a code phrase with 256 bits of entropy.
6674
pub fn generate_codephrase() -> String {
67-
codephrase(U256::random(&mut OsRng)).join(" ")
75+
codephrase(U256::random(&mut OsRng)).join(WORD_SEPARATOR)
6876
}
6977

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

7683
/// Decode a big endian code phrase into 256 bits of entropy.
7784
pub fn decode_phrase(phrase: &str) -> Result<U256, InvalidCodephrase> {
7885
let b = Wrapping(U256::from_u64(WORDLIST_LEN as u64));
7986
let mut n = Wrapping(U256::ZERO);
80-
for word in phrase.split_ascii_whitespace().take(20) {
87+
for word in phrase
88+
.replace(WORD_SEPARATOR, " ")
89+
.split_ascii_whitespace()
90+
.take(MAX_PHRASE_WORDS)
91+
{
8192
let i = index(word)?;
8293
let r = Wrapping(U256::from_u64(i as u64));
8394
n *= b;
@@ -95,7 +106,7 @@ mod test {
95106
use super::*;
96107

97108
fn round_trip(entropy: U256) -> String {
98-
let codephrase = codephrase(entropy).join(" ");
109+
let codephrase = codephrase(entropy).join(WORD_SEPARATOR);
99110
let decoded = decode_phrase(&codephrase).unwrap();
100111
assert_eq!(entropy, decoded);
101112
codephrase
@@ -104,51 +115,51 @@ mod test {
104115
#[test]
105116
fn trivial_codephrases() {
106117
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");
118+
assert_eq!(round_trip(U256::ZERO), "abandon");
119+
assert_eq!(round_trip(U256::ONE), "ability");
120+
assert_eq!(round_trip(U256::from_u64(b - 1)), "zoo");
121+
assert_eq!(round_trip(U256::from_u64(b)), "ability-abandon");
122+
assert_eq!(round_trip(U256::from_u64(b + 1)), "ability-ability");
112123
assert_eq!(
113124
round_trip(U256::from_u64(b.pow(2))),
114-
"abdomen abacus abacus"
125+
"ability-abandon-abandon"
115126
);
116127
assert_eq!(
117128
round_trip(U256::from_u64(b.pow(3))),
118-
"abdomen abacus abacus abacus"
129+
"ability-abandon-abandon-abandon"
119130
);
120131
assert_eq!(
121132
round_trip(U256::from_u64(b.pow(3) + 1)),
122-
"abdomen abacus abacus abdomen"
133+
"ability-abandon-abandon-ability"
123134
);
124135
assert_eq!(
125136
round_trip(U256::from_u64(b.pow(3) + b - 1)),
126-
"abdomen abacus abacus zoom"
137+
"ability-abandon-abandon-zoo"
127138
);
128139
}
129140

130141
#[test]
131142
fn constant_codephrases() {
132143
assert_eq!(
133144
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"
145+
"abstract-summer-orange-gown-urge-model-\
146+
exact-gorilla-outside-common-this-pepper-\
147+
pear-dust-minimum-black-double-recipe-\
148+
castle-crystal-clog-logic-delay-hamster"
138149
);
139150
assert_eq!(
140151
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"
152+
"abstract-misery-favorite-ordinary-moon-talk-\
153+
write-coffee-digital-slogan-spray-angry-\
154+
once-jazz-random-income-garage-regret-accident-\
155+
file-release-deny-reward-drastic"
145156
);
146157
assert_eq!(
147158
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"
159+
"able-dismiss-cost-scheme-amazing-slogan-\
160+
service-current-protect-feed-length-text-\
161+
cruise-wisdom-beauty-angle-regret-truck-\
162+
prosper-album-decline-wheel-pause-legend"
152163
);
153164
}
154165

@@ -158,18 +169,8 @@ mod test {
158169
for _ in 0..1000 {
159170
let nonce = U256::random(&mut OsRng);
160171
let codephrase = round_trip(nonce);
161-
assert!([19, 20].contains(&codephrase.split_ascii_whitespace().count()));
172+
assert!([23, 24].contains(&codephrase.split(WORD_SEPARATOR).count()));
162173
assert!(seen.insert(codephrase));
163174
}
164175
}
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-
}
175176
}

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)