Skip to content

Commit

Permalink
fix: replace Luhn checksum with DammSum (#4639)
Browse files Browse the repository at this point in the history
Description
---
This replaces the [generalized Luhn](https://en.wikipedia.org/wiki/Luhn_mod_N_algorithm) checksum algorithm for emoji ID encoding with [DammSum](https://github.com/cypherstack/dammsum), and refactors `EmojiId` to be cleaner and easier to understand.

Fixes [issue 4638](#4638).

Motivation and Context
---
Emoji IDs are encoded with a checksum that is intended to detect simple errors; these include single character substitutions and single transpositions of adjacent characters. The generalized Luhn checksum algorithm cannot detect all transpositions, though it has the benefit of being a single character.

While checksums of longer length and greater complexity can provide more comprehensive error detection, this comes at the cost of a longer overall emoji ID encoding and (often) additional structures like lookup tables.

DammSum is a simple single-character checksum algorithm based on the [Damm algorithm](https://en.wikipedia.org/wiki/Damm_algorithm) that provably detects the two desired error types in their entirety. It requires no lookup tables and uses only a small number of bitwise operations.

Other options based on Reed-Solomon or CRC designs may be considered as well as alternatives.

How Has This Been Tested?
---
Existing tests should pass. New randomized tests are added that comprehensively check failure modes and error detection.
  • Loading branch information
AaronFeickert committed Sep 12, 2022
1 parent 5ed997c commit c01471a
Show file tree
Hide file tree
Showing 10 changed files with 415 additions and 233 deletions.
11 changes: 6 additions & 5 deletions applications/tari_app_utilities/src/utilities.rs
Expand Up @@ -46,7 +46,8 @@ pub fn setup_runtime() -> Result<Runtime, ExitError> {

/// Returns a CommsPublicKey from either a emoji id or a public key
pub fn parse_emoji_id_or_public_key(key: &str) -> Option<CommsPublicKey> {
EmojiId::str_to_pubkey(&key.trim().replace('|', ""))
EmojiId::from_emoji_string(&key.trim().replace('|', ""))
.map(|emoji_id| emoji_id.to_public_key())
.or_else(|_| CommsPublicKey::from_hex(key))
.ok()
}
Expand Down Expand Up @@ -79,8 +80,8 @@ impl FromStr for UniPublicKey {
type Err = UniIdError;

fn from_str(key: &str) -> Result<Self, Self::Err> {
if let Ok(public_key) = EmojiId::str_to_pubkey(&key.trim().replace('|', "")) {
Ok(Self(public_key))
if let Ok(emoji_id) = EmojiId::from_emoji_string(&key.trim().replace('|', "")) {
Ok(Self(emoji_id.to_public_key()))
} else if let Ok(public_key) = PublicKey::from_hex(key) {
Ok(Self(public_key))
} else {
Expand Down Expand Up @@ -113,8 +114,8 @@ impl FromStr for UniNodeId {
type Err = UniIdError;

fn from_str(key: &str) -> Result<Self, Self::Err> {
if let Ok(public_key) = EmojiId::str_to_pubkey(&key.trim().replace('|', "")) {
Ok(Self::PublicKey(public_key))
if let Ok(emoji_id) = EmojiId::from_emoji_string(&key.trim().replace('|', "")) {
Ok(Self::PublicKey(emoji_id.to_public_key()))
} else if let Ok(public_key) = PublicKey::from_hex(key) {
Ok(Self::PublicKey(public_key))
} else if let Ok(node_id) = NodeId::from_hex(key) {
Expand Down
Expand Up @@ -80,7 +80,7 @@ impl CommandContext {
}
};

let eid = EmojiId::from_pubkey(&peer.public_key);
let eid = EmojiId::from_public_key(&peer.public_key).to_emoji_string();
println!("Emoji ID: {}", eid);
println!("Public Key: {}", peer.public_key);
println!("NodeId: {}", peer.node_id);
Expand Down
Expand Up @@ -705,7 +705,7 @@ pub async fn command_runner(
},
Whois(args) => {
let public_key = args.public_key.into();
let emoji_id = EmojiId::from_pubkey(&public_key);
let emoji_id = EmojiId::from_public_key(&public_key).to_emoji_string();

println!("Public Key: {}", public_key.to_hex());
println!("Emoji ID : {}", emoji_id);
Expand Down
24 changes: 16 additions & 8 deletions applications/tari_console_wallet/src/ui/state/app_state.rs
Expand Up @@ -217,9 +217,9 @@ impl AppState {

let public_key = match CommsPublicKey::from_hex(public_key_or_emoji_id.as_str()) {
Ok(pk) => pk,
Err(_) => {
EmojiId::str_to_pubkey(public_key_or_emoji_id.as_str()).map_err(|_| UiError::PublicKeyParseError)?
},
Err(_) => EmojiId::from_emoji_string(public_key_or_emoji_id.as_str())
.map_err(|_| UiError::PublicKeyParseError)?
.to_public_key(),
};

let contact = Contact::new(alias, public_key, None, None);
Expand Down Expand Up @@ -250,7 +250,9 @@ impl AppState {
let mut inner = self.inner.write().await;
let public_key = match CommsPublicKey::from_hex(public_key.as_str()) {
Ok(pk) => pk,
Err(_) => EmojiId::str_to_pubkey(public_key.as_str()).map_err(|_| UiError::PublicKeyParseError)?,
Err(_) => EmojiId::from_emoji_string(public_key.as_str())
.map_err(|_| UiError::PublicKeyParseError)?
.to_public_key(),
};

inner.wallet.contacts_service.remove_contact(public_key).await?;
Expand All @@ -273,7 +275,9 @@ impl AppState {
let inner = self.inner.write().await;
let public_key = match CommsPublicKey::from_hex(public_key.as_str()) {
Ok(pk) => pk,
Err(_) => EmojiId::str_to_pubkey(public_key.as_str()).map_err(|_| UiError::PublicKeyParseError)?,
Err(_) => EmojiId::from_emoji_string(public_key.as_str())
.map_err(|_| UiError::PublicKeyParseError)?
.to_public_key(),
};

let output_features = OutputFeatures { ..Default::default() };
Expand Down Expand Up @@ -306,7 +310,9 @@ impl AppState {
let inner = self.inner.write().await;
let public_key = match CommsPublicKey::from_hex(public_key.as_str()) {
Ok(pk) => pk,
Err(_) => EmojiId::str_to_pubkey(public_key.as_str()).map_err(|_| UiError::PublicKeyParseError)?,
Err(_) => EmojiId::from_emoji_string(public_key.as_str())
.map_err(|_| UiError::PublicKeyParseError)?
.to_public_key(),
};

let output_features = OutputFeatures { ..Default::default() };
Expand Down Expand Up @@ -339,7 +345,9 @@ impl AppState {
let inner = self.inner.write().await;
let dest_pubkey = match CommsPublicKey::from_hex(dest_pubkey.as_str()) {
Ok(pk) => pk,
Err(_) => EmojiId::str_to_pubkey(dest_pubkey.as_str()).map_err(|_| UiError::PublicKeyParseError)?,
Err(_) => EmojiId::from_emoji_string(dest_pubkey.as_str())
.map_err(|_| UiError::PublicKeyParseError)?
.to_public_key(),
};

let output_features = OutputFeatures { ..Default::default() };
Expand Down Expand Up @@ -1087,7 +1095,7 @@ impl AppStateData {
base_node_selected: Peer,
base_node_config: PeerConfig,
) -> Self {
let eid = EmojiId::from_pubkey(node_identity.public_key()).to_string();
let eid = EmojiId::from_public_key(node_identity.public_key()).to_emoji_string();
let qr_link = format!("tari://{}/pubkey/{}", network, &node_identity.public_key().to_hex());
let code = QrCode::new(qr_link).unwrap();
let image = code
Expand Down
2 changes: 1 addition & 1 deletion applications/tari_console_wallet/src/ui/ui_contact.rs
Expand Up @@ -26,7 +26,7 @@ impl From<Contact> for UiContact {
Self {
alias: c.alias,
public_key: c.public_key.to_string(),
emoji_id: EmojiId::from_pubkey(&c.public_key).as_str().to_string(),
emoji_id: EmojiId::from_public_key(&c.public_key).to_emoji_string(),
last_seen: match c.last_seen {
Some(val) => DateTime::<Local>::from_utc(val, Local::now().offset().to_owned())
.format("%m-%dT%H:%M")
Expand Down
210 changes: 210 additions & 0 deletions base_layer/common_types/src/dammsum.rs
@@ -0,0 +1,210 @@
// Copyright 2020. The Tari Project
//
// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
// following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
// disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
// following disclaimer in the documentation and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
// products derived from this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

use thiserror::Error;

/// Calculates a checksum using the [DammSum](https://github.com/cypherstack/dammsum) algorithm.
///
/// This approach uses a dictionary whose size must be `2^k` for some `k > 0`.
/// The algorithm accepts an array of arbitrary size, each of whose elements are integers in the range `[0, 2^k)`.
/// The checksum is a single element also within this range.
/// DammSum detects all single transpositions and substitutions.
///
/// Note that for this implementation, we add the additional restriction that `k == 8`.
/// This is only because DammSum requires us to provide the coefficients for a certain type of polynomial, and
/// because it's unlikely for the alphabet size to change for this use case.
/// See the linked repository for more information, or if you need a different dictionary size.

#[derive(Debug, Error, PartialEq)]
pub enum ChecksumError {
#[error("Input data is too short")]
InputDataTooShort,
#[error("Invalid checksum")]
InvalidChecksum,
}

// Fixed for a dictionary size of `2^8 == 256`
const COEFFICIENTS: [u8; 3] = [4, 3, 1];

/// Compute the DammSum checksum for an array, each of whose elements are in the range `[0, 2^8)`
pub fn compute_checksum(data: &Vec<u8>) -> u8 {
let mut mask = 1u8;

// Compute the bitmask (if possible)
for bit in COEFFICIENTS {
mask += 1u8 << bit;
}

// Perform the Damm algorithm
let mut result = 0u8;

for digit in data {
result ^= *digit; // add
let overflow = (result & (1 << 7)) != 0;
result <<= 1; // double
if overflow {
// reduce
result ^= mask;
}
}

result
}

/// Determine whether the array ends with a valid checksum
pub fn validate_checksum(data: &Vec<u8>) -> Result<(), ChecksumError> {
// Empty data is not allowed, nor data only consisting of a checksum
if data.len() < 2 {
return Err(ChecksumError::InputDataTooShort);
}

// It's sufficient to check the entire array against a zero checksum
match compute_checksum(data) {
0u8 => Ok(()),
_ => Err(ChecksumError::InvalidChecksum),
}
}

#[cfg(test)]
mod test {
use rand::Rng;

use crate::dammsum::{compute_checksum, validate_checksum, ChecksumError};

#[test]
/// Check that valid checksums validate
fn checksum_validate() {
const SIZE: usize = 33;

// Generate random data
let mut rng = rand::thread_rng();
let mut data: Vec<u8> = (0..SIZE).map(|_| rng.gen::<u8>()).collect();

// Compute and append the checksum
data.push(compute_checksum(&data));

// Validate
assert!(validate_checksum(&data).is_ok());
}

#[test]
/// Sanity check against memory-specific checksums
fn identical_checksum() {
const SIZE: usize = 33;

// Generate identical random data
let mut rng = rand::thread_rng();
let data_0: Vec<u8> = (0..SIZE).map(|_| rng.gen::<u8>()).collect();
let data_1 = data_0.clone();

// Compute the checksums
let check_0 = compute_checksum(&data_0);
let check_1 = compute_checksum(&data_1);

// They should be equal
assert_eq!(check_0, check_1);
}

#[test]
/// Sanity check for known distinct checksums
fn distinct_checksum() {
// Fix two inputs that must have a unique checksum
let data_0 = vec![0u8];
let data_1 = vec![1u8];

// Compute the checksums
let check_0 = compute_checksum(&data_0);
let check_1 = compute_checksum(&data_1);

// They should be distinct
assert!(check_0 != check_1);
}

#[test]
/// Test validation failure modes
fn failure_modes_validate() {
// Empty input data
let mut data: Vec<u8> = vec![];
assert_eq!(validate_checksum(&data), Err(ChecksumError::InputDataTooShort));

// Input data is only a checksum
data = vec![0u8];
assert_eq!(validate_checksum(&data), Err(ChecksumError::InputDataTooShort));
}

#[test]
/// Check that all single subtitutions are detected
fn substitutions() {
const SIZE: usize = 33;

// Generate random data
let mut rng = rand::thread_rng();
let mut data: Vec<u8> = (0..SIZE).map(|_| rng.gen::<u8>()).collect();

// Compute the checksum
data.push(compute_checksum(&data));

// Validate
assert!(validate_checksum(&data).is_ok());

// Check all substitutions in all positions
for j in 0..data.len() {
let mut data_ = data.clone();
for i in 0..=u8::MAX {
if data[j] == i {
continue;
}
data_[j] = i;

assert_eq!(validate_checksum(&data_), Err(ChecksumError::InvalidChecksum));
}
}
}

#[test]
/// Check that all single transpositions are detected
fn transpositions() {
const SIZE: usize = 33;

// Generate random data
let mut rng = rand::thread_rng();
let mut data: Vec<u8> = (0..SIZE).map(|_| rng.gen::<u8>()).collect();

// Compute the checksum
data.push(compute_checksum(&data));

// Validate
assert!(validate_checksum(&data).is_ok());

// Check all transpositions
for j in 0..(data.len() - 1) {
if data[j] == data[j + 1] {
continue;
}

let mut data_ = data.clone();
data_.swap(j, j + 1);

assert_eq!(validate_checksum(&data_), Err(ChecksumError::InvalidChecksum));
}
}
}

0 comments on commit c01471a

Please sign in to comment.