0
0
mirror of https://github.com/signalapp/libsignal.git synced 2024-09-19 19:42:19 +02:00

Implement username generation, hashing, and proofs

This commit is contained in:
Max Moiseev 2023-01-25 18:06:39 -08:00 committed by moiseev-signal
parent 1d358c3432
commit 731964f468
8 changed files with 628 additions and 1 deletions

96
Cargo.lock generated
View File

@ -223,6 +223,21 @@ dependencies = [
"shlex",
]
[[package]]
name = "bit-set"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -645,6 +660,12 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "279fb028e20b3c4c320317955b77c5e0c9701f05a1d309905d6fc702cdc5053e"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.5.0"
@ -1382,6 +1403,26 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "proptest"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0d9cc07f18492d879586c92b485def06bc850da3118075cd45d50e9c95b0e5"
dependencies = [
"bit-set",
"bitflags",
"byteorder",
"lazy_static",
"num-traits",
"quick-error 2.0.1",
"rand 0.8.5",
"rand_chacha 0.3.1",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
]
[[package]]
name = "prost"
version = "0.9.0"
@ -1435,6 +1476,18 @@ dependencies = [
"prost",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.10"
@ -1515,6 +1568,15 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_xorshift"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f"
dependencies = [
"rand_core 0.6.3",
]
[[package]]
name = "rayon"
version = "1.5.1"
@ -1596,6 +1658,18 @@ dependencies = [
"semver 0.11.0",
]
[[package]]
name = "rusty-fork"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f"
dependencies = [
"fnv",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.9"
@ -1934,6 +2008,19 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99c0ec316ab08201476c032feb2f94a5c8ece5b209765c1fbc4430dd6e931ad6"
[[package]]
name = "usernames"
version = "0.1.0"
dependencies = [
"curve25519-dalek",
"lazy_static",
"poksho",
"proptest",
"rand 0.7.3",
"sha2",
"zkgroup",
]
[[package]]
name = "uuid"
version = "1.1.2"
@ -1956,6 +2043,15 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "wait-timeout"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6"
dependencies = [
"libc",
]
[[package]]
name = "walkdir"
version = "2.3.2"

View File

@ -5,6 +5,7 @@ members = [
"rust/device-transfer",
"rust/poksho",
"rust/protocol",
"rust/usernames",
"rust/zkgroup",
"rust/bridge/ffi",
"rust/bridge/jni",
@ -15,6 +16,7 @@ default-members = [
"rust/device-transfer",
"rust/poksho",
"rust/protocol",
"rust/usernames",
"rust/zkgroup",
]
resolver = "2" # so that our dev-dependency features don't leak into products

View File

@ -11,6 +11,7 @@ as a Java, Swift, or TypeScript library. The underlying implementations are writ
- attest: Functionality for remote attestation of [SGX enclaves][] and server-side [HSMs][].
- zkgroup: Functionality for [zero-knowledge groups][] and related features available in Signal.
- poksho: Utilities for implementing zero-knowledge proofs (such as those used by zkgroup); stands for "proof-of-knowledge, stateful-hash-object".
- usernames: Functionality for username generation, hashing, and proofs.
This repository is used by the Signal client apps ([Android][], [iOS][], and [Desktop][]) as well as
server-side. Use outside of Signal is unsupported. In particular, the products of this repository
@ -136,6 +137,6 @@ Administration Regulations, Section 740.13) for both object code and source code
## License
Copyright 2020-2022 Signal Messenger, LLC.
Copyright 2020-2023 Signal Messenger, LLC.
Licensed under the AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html

29
rust/usernames/Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
#
# Copyright (C) 2023 Signal Messenger, LLC.
# SPDX-License-Identifier: AGPL-3.0-only
#
[package]
name = "usernames"
authors = ["Signal Messenger LLC"]
version = "0.1.0"
edition = "2018"
description = "A zero-knowledge usernames library"
license = "AGPL-3.0-only"
[dependencies]
poksho = { path = "../poksho" }
sha2 = "0.9.0"
lazy_static = "1.4.0"
rand = "0.7.3"
[dependencies.curve25519-dalek]
features = ["serde"]
version = "3.0.0"
git = "https://github.com/signalapp/curve25519-dalek.git"
branch = "lizard2"
[dev-dependencies]
zkgroup = { path = "../zkgroup" }
proptest = "1.0"

View File

@ -0,0 +1,64 @@
//
// Copyright 2023 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
use std::ops::Range;
use curve25519_dalek::ristretto::{CompressedRistretto, RistrettoPoint};
use lazy_static::lazy_static;
lazy_static! {
pub(crate) static ref BASE_POINTS: [RistrettoPoint; 3] =
COMPRESSED_BASE_POINTS_RAW.map(|bytes| {
let compressed = CompressedRistretto::from_slice(&bytes);
compressed.decompress().unwrap()
});
}
const COMPRESSED_BASE_POINTS_RAW: [[u8; 32]; 3] = [
[
0x60, 0xb9, 0x93, 0x66, 0x3a, 0x3d, 0xae, 0xcc, 0x4c, 0x85, 0x2f, 0x53, 0x35, 0x47, 0xe3,
0x5, 0x38, 0x8c, 0x2a, 0x50, 0xa5, 0x83, 0x93, 0xea, 0x27, 0x7d, 0xe4, 0xab, 0xf3, 0xde,
0x54, 0x3a,
],
[
0xf2, 0xb6, 0xf1, 0xc8, 0x26, 0xfa, 0x36, 0x40, 0x20, 0x6f, 0x3b, 0x58, 0xb2, 0x28, 0x6b,
0xde, 0xfd, 0xfd, 0xa6, 0xa5, 0x4f, 0xf9, 0x2, 0xf2, 0x4, 0xa7, 0x2d, 0xe7, 0x37, 0xd2,
0x61, 0x57,
],
[
0x6, 0x6, 0xbd, 0x3a, 0xbf, 0xce, 0x4e, 0x96, 0x17, 0xd4, 0x48, 0xfb, 0x2c, 0xae, 0xb6,
0xcc, 0x2, 0x8e, 0xc9, 0xa2, 0xb6, 0x2b, 0x10, 0xb3, 0xd9, 0xeb, 0x29, 0x48, 0xda, 0x6f,
0x3f, 0x53,
],
];
// 37^48 will overflow the Scalar. See nickname_scalar implementation for details.
pub(crate) const MAX_NICKNAME_LENGTH: usize = 48;
pub(crate) const DISCRIMINATOR_RANGES: [Range<u32>; 8] = [
1..100,
100..1_000,
1_000..10_000,
10_000..100_000,
100_000..1_000_000,
1_000_000..10_000_000,
10_000_000..100_000_000,
100_000_000..1_000_000_000,
];
pub(crate) const CANDIDATES_PER_RANGE: [u8; 8] = [4, 3, 3, 2, 2, 2, 2, 2];
#[cfg(test)]
mod test {
use zkgroup::common::sho::Sho;
use super::*;
#[test]
fn generate_points() {
let mut sho = Sho::new(b"Signal_Username_20230130_Constant_Points_Generate", b"");
for p in BASE_POINTS.iter() {
assert_eq!(&sho.get_point(), p);
}
}
}

View File

@ -0,0 +1,14 @@
//
// Copyright 2023 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
#[derive(Debug)]
pub enum UsernameError {
BadUsernameFormat,
BadDiscriminator,
BadNicknameCharacter,
NicknameTooShort,
NicknameTooLong,
ProofVerificationFailure,
}

11
rust/usernames/src/lib.rs Normal file
View File

@ -0,0 +1,11 @@
//
// Copyright 2023 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
mod constants;
mod error;
mod username;
pub use error::UsernameError;
pub use username::*;

View File

@ -0,0 +1,410 @@
//
// Copyright 2023 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
use std::fmt::{Debug, Display, Formatter};
use std::ops::{Add, Range, RangeInclusive};
use std::str::FromStr;
use curve25519_dalek::ristretto::{CompressedRistretto, RistrettoPoint};
use curve25519_dalek::scalar::Scalar;
use lazy_static::lazy_static;
use rand::Rng;
use sha2::{Digest, Sha512};
use poksho::args::{PointArgs, ScalarArgs};
use poksho::{PokshoError, Statement};
use crate::constants::{
BASE_POINTS, CANDIDATES_PER_RANGE, DISCRIMINATOR_RANGES, MAX_NICKNAME_LENGTH,
};
use crate::error::UsernameError;
lazy_static! {
static ref PROOF_STATEMENT: Statement = {
let mut st = Statement::new();
st.add(
"username_hash",
&[
("username_sha_scalar", "G1"),
("nickname_scalar", "G2"),
("discriminator_scalar", "G3"),
],
);
st
};
}
pub struct Username {
nickname: String,
discriminator: u64,
scalars: Vec<Scalar>,
}
impl Display for Username {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}", self.nickname, self.discriminator)
}
}
impl Debug for Username {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Username")
.field("nickname", &self.nickname)
.field("discriminator", &self.discriminator)
.finish()
}
}
#[derive(Debug)]
pub struct NicknameLimits(RangeInclusive<usize>);
impl Default for NicknameLimits {
fn default() -> Self {
NicknameLimits::new(3, 32).unwrap()
}
}
impl NicknameLimits {
pub fn new(min_len: usize, max_len: usize) -> Result<Self, UsernameError> {
assert!(
max_len <= MAX_NICKNAME_LENGTH,
"Long nicknames are not supported. The maximum supported length is {}",
MAX_NICKNAME_LENGTH
);
assert!(
min_len < max_len,
"Invalid nickname size limits: {}..{}",
min_len,
max_len
);
Ok(NicknameLimits(min_len..=max_len))
}
pub fn validate(&self, n: usize) -> Result<(), UsernameError> {
if &n < self.0.start() {
return Err(UsernameError::NicknameTooShort);
}
if &n > self.0.end() {
return Err(UsernameError::NicknameTooLong);
}
Ok(())
}
}
impl Username {
pub fn new(s: &str) -> Result<Self, UsernameError> {
let (original_nickname, suffix) =
s.rsplit_once('.').ok_or(UsernameError::BadUsernameFormat)?;
let nickname = original_nickname.to_ascii_lowercase();
let discriminator = validate_discriminator(suffix)?;
let scalars = make_scalars(&nickname, discriminator)?;
Ok(Self {
nickname: original_nickname.to_string(),
discriminator,
scalars,
})
}
pub fn hash(&self) -> [u8; 32] {
*Self::hash_from_scalars(&self.scalars).compress().as_bytes()
}
pub fn proof(&self, randomness: &[u8]) -> Result<Vec<u8>, UsernameError> {
let hash = Self::hash_from_scalars(&self.scalars);
let scalar_args = Self::make_scalar_args(&self.scalars);
let point_args = Self::make_point_args(hash);
let message = *hash.compress().as_bytes();
PROOF_STATEMENT
.prove(&scalar_args, &point_args, &message, randomness)
.map_err(|e| panic!("Failed to create proof. Cause: PokshoError::{:?}", e))
}
pub fn verify_proof(proof: &[u8], hash: [u8; 32]) -> Result<(), UsernameError> {
let hash_point = CompressedRistretto(hash)
.decompress()
.ok_or(UsernameError::ProofVerificationFailure)?;
let point_args = Self::make_point_args(hash_point);
PROOF_STATEMENT
.verify_proof(proof, &point_args, &hash)
.map_err(|e| match e {
PokshoError::VerificationFailure => UsernameError::ProofVerificationFailure,
_ => panic!("Unexpected verification error PokshoError::{:?}", e),
})
}
pub fn candidates_from<R: Rng>(
rng: &mut R,
nickname: &str,
limits: NicknameLimits,
) -> Result<Vec<String>, UsernameError> {
validate_nickname(nickname, &limits)?;
let candidates = random_discriminators(rng, &CANDIDATES_PER_RANGE, &DISCRIMINATOR_RANGES)
.unwrap()
.iter()
.map(|d| format!("{}.{:0>2}", nickname, d))
.collect();
Ok(candidates)
}
fn hash_from_scalars(scalars: &[Scalar]) -> RistrettoPoint {
BASE_POINTS
.iter()
.zip(scalars)
.map(|(point, scalar)| point * scalar)
.reduce(RistrettoPoint::add)
.unwrap()
}
fn make_scalar_args(scalars: &[Scalar]) -> ScalarArgs {
let mut args = ScalarArgs::new();
for (scalar, name) in scalars.iter().zip([
"username_sha_scalar",
"nickname_scalar",
"discriminator_scalar",
]) {
args.add(name, *scalar);
}
args
}
fn make_point_args(lhs: RistrettoPoint) -> PointArgs {
let mut args = PointArgs::new();
for (idx, point) in BASE_POINTS.iter().enumerate() {
let name = format!("G{}", idx + 1);
args.add(&name, *point);
}
args.add("username_hash", lhs);
args
}
}
fn username_sha_scalar(nickname: &str, discriminator: u64) -> Result<Scalar, UsernameError> {
let mut hash = Sha512::new();
hash.update(nickname.as_bytes());
hash.update([0x00]);
hash.update(discriminator.to_be_bytes());
Ok(Scalar::from_hash(hash))
}
fn nickname_scalar(nickname: &str) -> Result<Scalar, UsernameError> {
let bytes: Option<Vec<u8>> = nickname.chars().map(char_to_byte).collect();
bytes
.map(|b| to_base_37_scalar(&b))
.ok_or(UsernameError::BadNicknameCharacter)
}
fn discriminator_scalar(discriminator: u64) -> Result<Scalar, UsernameError> {
Ok(Scalar::from(discriminator))
}
fn make_scalars(nickname: &str, discriminator: u64) -> Result<Vec<Scalar>, UsernameError> {
Ok(vec![
username_sha_scalar(nickname, discriminator)?,
nickname_scalar(nickname)?,
discriminator_scalar(discriminator)?,
])
}
// The mapping is only defined for the characters matching [_0-9a-z]
fn char_to_byte(c: char) -> Option<u8> {
match c {
'_' => Some(1),
'a'..='z' => Some(c as u8 - b'a' + 2),
'0'..='9' => Some(c as u8 - b'0' + 28),
_ => None,
}
}
fn to_base_37_scalar(bytes: &[u8]) -> Scalar {
let thirty_seven = Scalar::from(37u8);
let mut scalar = Scalar::zero();
for b in bytes.iter().skip(1).rev() {
scalar *= thirty_seven;
scalar += Scalar::from(*b);
}
scalar *= Scalar::from(27u8);
scalar += Scalar::from(bytes[0]);
scalar
}
fn validate_discriminator<T: FromStr + PartialOrd + From<u8>>(
discriminator: &str,
) -> Result<T, UsernameError> {
let n = T::from_str(discriminator).unwrap_or_else(|_| T::from(0));
if n == T::from(0) {
return Err(UsernameError::BadDiscriminator);
}
if n < T::from(10) && discriminator.len() != 2 {
return Err(UsernameError::BadDiscriminator);
}
Ok(n)
}
fn validate_nickname(nickname: &str, limits: &NicknameLimits) -> Result<(), UsernameError> {
let maybe_bytes: Option<Vec<_>> = nickname
.to_ascii_lowercase()
.chars()
.map(char_to_byte)
.collect();
let bytes = maybe_bytes.ok_or(UsernameError::BadNicknameCharacter)?;
limits.validate(bytes.len())
}
fn random_discriminators<R: Rng>(
rng: &mut R,
count_per_range: &[u8],
ranges: &[Range<u32>],
) -> Result<Vec<u32>, UsernameError> {
assert!(count_per_range.len() <= ranges.len(), "Not enough ranges");
let total_count: u8 = count_per_range.iter().sum();
let mut results = Vec::with_capacity(total_count as usize);
for (n, range) in count_per_range.iter().zip(ranges) {
results.extend((0..*n).map(|_| rng.gen_range(range.start, range.end)));
}
Ok(results)
}
#[cfg(test)]
mod test {
use proptest::prelude::*;
use super::*;
const NICKNAME_PATTERN: &str = "[_a-z][_a-z0-9]{2,31}";
const DISCRIMINATOR_MAX: u64 = 1_000_000_000_u64;
#[test]
fn valid_nickname_scalar() {
// the results should be 1 + 27*27 + 37*27*37^1 + 1*27*37^2 = 74656
let nickname = "_z9_";
assert_eq!(Scalar::from(74656_u32), nickname_scalar(nickname).unwrap());
}
#[test]
fn valid_usernames() {
for username in ["He110.01", "usr.999999999", "_identifier.42"] {
Username::new(username).map(|name| name.hash()).unwrap();
}
}
#[test]
fn invalid_usernames() {
for username in [
"no_discriminator",
"🦀.42",
"s p a c e s.01",
"zero.00",
"zeropad.001",
"short.1",
"short_zero.0",
] {
assert!(
Username::new(username).map(|n| n.hash()).is_err(),
"Unexpected success for username '{}'",
username
)
}
}
#[test]
fn valid_characters_mapping() {
let all_valid: Option<Vec<_>> = "_abcdefghijklmnopqrstuvwxyz0123456789"
.chars()
.map(char_to_byte)
.collect();
let unwrapped = all_valid.expect("char_to_byte defined for all valid characters");
let sorted = {
let mut xs = unwrapped.clone();
xs.sort();
xs
};
assert_eq!(sorted, unwrapped);
}
#[test]
fn valid_nicknames_should_produce_scalar() {
proptest!(|(nickname in NICKNAME_PATTERN)| {
nickname_scalar(&nickname).unwrap();
});
}
#[test]
fn valid_usernames_should_produce_scalar() {
proptest!(|(nickname in NICKNAME_PATTERN, discriminator in 1..DISCRIMINATOR_MAX)| {
username_sha_scalar(&nickname, discriminator).unwrap();
});
}
#[test]
fn discriminator_scalar_is_defined_on_range() {
proptest!(|(n in 1..DISCRIMINATOR_MAX)| {
discriminator_scalar(n).unwrap();
});
}
#[test]
fn valid_usernames_proof_and_verify() {
proptest!(|(nickname in NICKNAME_PATTERN, discriminator in 1..DISCRIMINATOR_MAX)| {
let username = Username::new(&format!("{nickname}.{discriminator:0>2}")).unwrap();
let hash = username.hash();
let randomness: Vec<u8> = (1..33).collect();
let proof = username.proof(&randomness).unwrap();
Username::verify_proof(&proof, hash).unwrap();
});
}
#[test]
fn many_random_makes_valid_usernames() {
let mut rng = rand::thread_rng();
let randomness: Vec<u8> = (1..33).collect();
let nickname = "_SiGNA1";
let candidates = Username::candidates_from(&mut rng, nickname, Default::default()).unwrap();
for c in &candidates {
assert!(c.starts_with(nickname));
let username = Username::new(c).unwrap();
let hash = username.hash();
let proof = username.proof(&randomness).unwrap();
Username::verify_proof(&proof, hash).unwrap();
}
}
#[test]
fn generate_discriminators() {
let mut rng = rand::thread_rng();
let ds = random_discriminators(&mut rng, &[4, 3, 2, 1], &DISCRIMINATOR_RANGES).unwrap();
assert!(DISCRIMINATOR_RANGES[0].contains(&ds[0]));
assert!(DISCRIMINATOR_RANGES[0].contains(&ds[1]));
assert!(DISCRIMINATOR_RANGES[0].contains(&ds[2]));
assert!(DISCRIMINATOR_RANGES[0].contains(&ds[3]));
assert!(DISCRIMINATOR_RANGES[1].contains(&ds[4]));
assert!(DISCRIMINATOR_RANGES[1].contains(&ds[5]));
assert!(DISCRIMINATOR_RANGES[1].contains(&ds[6]));
assert!(DISCRIMINATOR_RANGES[2].contains(&ds[7]));
assert!(DISCRIMINATOR_RANGES[2].contains(&ds[8]));
assert!(DISCRIMINATOR_RANGES[3].contains(&ds[9]));
}
#[test]
#[should_panic]
fn too_few_ranges() {
let mut rng = rand::thread_rng();
let counts: Vec<u8> = (0u8..DISCRIMINATOR_RANGES.len() as u8 + 1).collect();
let _ = random_discriminators(&mut rng, &counts, &DISCRIMINATOR_RANGES);
}
#[test]
fn nickname_limits() {
NicknameLimits::default(); // should not panic
NicknameLimits::new(0, 42).unwrap().validate(13).unwrap();
}
#[test]
#[should_panic]
fn invalid_nickname_limits() {
let _ = NicknameLimits::new(42, 0);
}
}