|
@@ -68,14 +68,21 @@ pub fn write_u128(buf: &mut Vec<u8>, v: u128) {
|
|
|
///
|
|
///
|
|
|
/// An account is a base `id` plus a `subaccount`. `sub = 0` is the main account
|
|
/// An account is a base `id` plus a `subaccount`. `sub = 0` is the main account
|
|
|
/// (the default when subaccounts are not used); a non-zero `sub` is a
|
|
/// (the default when subaccounts are not used); a non-zero `sub` is a
|
|
|
-/// subaccount of the same base id. `sub` is an opaque id (an `i64`, like the
|
|
|
|
|
-/// base id), so the whole range is usable. Each `(id, sub)` is a full account
|
|
|
|
|
-/// record with its own policy and lifecycle. See ADR-0012.
|
|
|
|
|
|
|
+/// subaccount of the same base id. Each `(id, sub)` is a full account record
|
|
|
|
|
+/// with its own policy and lifecycle. See ADR-0012 and ADR-0015.
|
|
|
|
|
+///
|
|
|
|
|
+/// Both legs are stored as `i64` (they hash and persist as full `i64`), but the
|
|
|
|
|
+/// IBAN-style string form ([`Display`](fmt::Display) / [`FromStr`](std::str::FromStr))
|
|
|
|
|
+/// encodes only the low `ID_BITS` of `id` (a 63-bit snowflake never sets the
|
|
|
|
|
+/// sign bit) and the low `SUB_BITS` of `sub`. That is what lets the code fit in
|
|
|
|
|
+/// a fixed 20 characters. Values outside those ranges still hash, persist, and
|
|
|
|
|
+/// compare correctly, but do not round-trip through the string form.
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
|
|
pub struct AccountId {
|
|
pub struct AccountId {
|
|
|
- /// Base account id.
|
|
|
|
|
|
|
+ /// Base account id (a 63-bit snowflake; the sign bit is always 0).
|
|
|
pub id: i64,
|
|
pub id: i64,
|
|
|
- /// Subaccount id; `0` is the main account.
|
|
|
|
|
|
|
+ /// Subaccount id; `0` is the main account. The string form encodes the low
|
|
|
|
|
+ /// [`SUB_BITS`] bits, so a subaccount id must fit in that range to round-trip.
|
|
|
pub sub: i64,
|
|
pub sub: i64,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -140,16 +147,16 @@ impl fmt::Debug for AccountId {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl fmt::Display for AccountId {
|
|
impl fmt::Display for AccountId {
|
|
|
- /// IBAN-style machine format: two ISO 7064 mod-97 check digits, then a
|
|
|
|
|
- /// 26-character base-36 body. There is no country code. The `(id, sub)` pair
|
|
|
|
|
- /// is run through a keyed 128-bit Feistel permutation (see [`set_id_seed`])
|
|
|
|
|
- /// before encoding, so the body does not reveal the raw ids. Round-trips via
|
|
|
|
|
|
|
+ /// IBAN-style machine format: a fixed 20 characters, an 18-character base-36
|
|
|
|
|
+ /// body followed by two trailing ISO 7064 mod-97 check digits, with no
|
|
|
|
|
+ /// country code. The `(id, sub)` pair is packed into a 93-bit value and run
|
|
|
|
|
+ /// through a keyed format-preserving permutation (see [`set_id_seed`]) before
|
|
|
|
|
+ /// encoding, so the body does not reveal the raw ids. Round-trips via
|
|
|
/// [`FromStr`](std::str::FromStr); [`to_grouped`](AccountId::to_grouped) adds
|
|
/// [`FromStr`](std::str::FromStr); [`to_grouped`](AccountId::to_grouped) adds
|
|
|
- /// the presentation spacing.
|
|
|
|
|
|
|
+ /// the presentation spacing (five groups of four).
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
- let (l, r) = feistel(self.id as u64, self.sub as u64, id_seed());
|
|
|
|
|
- let body = format!("{}{}", base36_u64(l), base36_u64(r));
|
|
|
|
|
- write!(f, "{:02}{body}", check_digits(&body))
|
|
|
|
|
|
|
+ let body = base36_body(obfuscate(pack(self.id, self.sub), id_seed()));
|
|
|
|
|
+ write!(f, "{body}{:02}", check_digits(&body))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -182,12 +189,44 @@ fn hex(bytes: &[u8]) -> String {
|
|
|
// IBAN-style string view for AccountId (ADR-0012)
|
|
// IBAN-style string view for AccountId (ADR-0012)
|
|
|
// ---------------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
-/// Encode a `u64` as exactly 13 base-36 digits (`0-9A-Z`), zero-padded on the
|
|
|
|
|
-/// left. 13 digits is the widest a `u64` needs (`36^13 > u64::MAX`), so this
|
|
|
|
|
-/// never truncates.
|
|
|
|
|
-fn base36_u64(mut v: u64) -> String {
|
|
|
|
|
|
|
+/// Bits of the base `id` the string form encodes. A snowflake id keeps the sign
|
|
|
|
|
+/// bit clear, so its value fits in 63 bits.
|
|
|
|
|
+pub const ID_BITS: u32 = 63;
|
|
|
|
|
+
|
|
|
|
|
+/// Bits of the `sub` leg the string form encodes. Together with [`ID_BITS`] this
|
|
|
|
|
+/// is the 93-bit packed value the code carries, which fits in the 18 base-36
|
|
|
|
|
+/// characters of the code body. A subaccount id must fit in this many bits to
|
|
|
|
|
+/// round-trip through the string form.
|
|
|
|
|
+pub const SUB_BITS: u32 = 30;
|
|
|
|
|
+
|
|
|
|
|
+/// Number of base-36 characters in the code body. `36^18 > 2^93`, so the packed
|
|
|
|
|
+/// `(id, sub)` value always fits.
|
|
|
|
|
+const BODY_LEN: usize = 18;
|
|
|
|
|
+
|
|
|
|
|
+/// Mask selecting the low [`ID_BITS`] + [`SUB_BITS`] = 93 bits of the packed value.
|
|
|
|
|
+const PACK_MASK: u128 = (1u128 << (ID_BITS + SUB_BITS)) - 1;
|
|
|
|
|
+
|
|
|
|
|
+/// Pack `(id, sub)` into the 93-bit value the code encodes: the low [`ID_BITS`]
|
|
|
|
|
+/// of `id` in the high part, the low [`SUB_BITS`] of `sub` in the low part.
|
|
|
|
|
+fn pack(id: i64, sub: i64) -> u128 {
|
|
|
|
|
+ let id = (id as u128) & ((1u128 << ID_BITS) - 1);
|
|
|
|
|
+ let sub = (sub as u128) & ((1u128 << SUB_BITS) - 1);
|
|
|
|
|
+ (id << SUB_BITS) | sub
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Inverse of [`pack`]: split the 93-bit value back into the two legs. The `id`
|
|
|
|
|
+/// part is at most 63 bits, so it is always a non-negative `i64`.
|
|
|
|
|
+fn unpack(p: u128) -> (i64, i64) {
|
|
|
|
|
+ let sub = (p & ((1u128 << SUB_BITS) - 1)) as i64;
|
|
|
|
|
+ let id = (p >> SUB_BITS) as i64;
|
|
|
|
|
+ (id, sub)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Encode a value `< 2^93` as exactly [`BODY_LEN`] base-36 digits (`0-9A-Z`),
|
|
|
|
|
+/// zero-padded on the left. `36^18 > 2^93`, so this never truncates.
|
|
|
|
|
+fn base36_body(mut v: u128) -> String {
|
|
|
const D: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
const D: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
|
- let mut out = [b'0'; 13];
|
|
|
|
|
|
|
+ let mut out = [b'0'; BODY_LEN];
|
|
|
let mut i = out.len();
|
|
let mut i = out.len();
|
|
|
while v > 0 && i > 0 {
|
|
while v > 0 && i > 0 {
|
|
|
i -= 1;
|
|
i -= 1;
|
|
@@ -229,12 +268,16 @@ fn check_digits(body: &str) -> u32 {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------------
|
|
|
-// Account-code obfuscation (ADR-0012)
|
|
|
|
|
|
|
+// Account-code obfuscation (ADR-0012, ADR-0015)
|
|
|
|
|
+//
|
|
|
|
|
+// The account code's body is a base-36 rendering of the packed (id, sub) value.
|
|
|
|
|
+// Without mixing, small ids render as long runs of zeros that reveal their value
|
|
|
|
|
+// and sequence. To hide that from outsiders, the 93-bit packed value is run
|
|
|
|
|
+// through a keyed format-preserving permutation before encoding, and inverted on
|
|
|
|
|
+// parse. The permutation is a Feistel network over 2^94 restricted to the 93-bit
|
|
|
|
|
+// domain by cycle-walking (re-encrypt while the result exceeds the domain), which
|
|
|
|
|
+// keeps it a bijection on the exact set of packable values.
|
|
|
//
|
|
//
|
|
|
-// The account code's body is a base-36 rendering of the two i64 legs. Without
|
|
|
|
|
-// mixing, small ids render as long runs of zeros that reveal their value and
|
|
|
|
|
-// sequence. To hide that from outsiders, the (id, sub) pair is run through a
|
|
|
|
|
-// keyed 128-bit Feistel permutation before encoding, and inverted on parse.
|
|
|
|
|
// This is obfuscation, not security: anyone with the seed can decode it, so it
|
|
// This is obfuscation, not security: anyone with the seed can decode it, so it
|
|
|
// is not a substitute for authorization. The seed has a default and can be set
|
|
// is not a substitute for authorization. The seed has a default and can be set
|
|
|
// once at startup via `set_id_seed`; changing it changes every code, so it must
|
|
// once at startup via `set_id_seed`; changing it changes every code, so it must
|
|
@@ -277,24 +320,65 @@ fn round_key(seed: u64, round: usize) -> u64 {
|
|
|
mix64(seed ^ (round as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
|
|
mix64(seed ^ (round as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// Keyed 128-bit Feistel permutation over the two halves `(l, r)`.
|
|
|
|
|
-fn feistel(mut l: u64, mut r: u64, seed: u64) -> (u64, u64) {
|
|
|
|
|
|
|
+/// Bits per Feistel half. Two halves give a 94-bit block, the smallest even
|
|
|
|
|
+/// width that covers the 93-bit packed domain (one bit of headroom that
|
|
|
|
|
+/// cycle-walking absorbs).
|
|
|
|
|
+const FEISTEL_HALF_BITS: u32 = 47;
|
|
|
|
|
+
|
|
|
|
|
+/// Mask selecting one [`FEISTEL_HALF_BITS`]-wide half.
|
|
|
|
|
+const FEISTEL_HALF_MASK: u128 = (1u128 << FEISTEL_HALF_BITS) - 1;
|
|
|
|
|
+
|
|
|
|
|
+/// Keyed Feistel permutation over the 94-bit block `(l, r)` (two 47-bit halves).
|
|
|
|
|
+/// A balanced Feistel with any round function is a bijection on the full 2^94
|
|
|
|
|
+/// space, which is what [`obfuscate`] cycle-walks down to the 93-bit domain.
|
|
|
|
|
+fn feistel94(block: u128, seed: u64) -> u128 {
|
|
|
|
|
+ let mut l = (block >> FEISTEL_HALF_BITS) & FEISTEL_HALF_MASK;
|
|
|
|
|
+ let mut r = block & FEISTEL_HALF_MASK;
|
|
|
for round in 0..FEISTEL_ROUNDS {
|
|
for round in 0..FEISTEL_ROUNDS {
|
|
|
- let next = l ^ mix64(r ^ round_key(seed, round));
|
|
|
|
|
|
|
+ let f = (mix64(r as u64 ^ round_key(seed, round)) as u128) & FEISTEL_HALF_MASK;
|
|
|
|
|
+ let next = (l ^ f) & FEISTEL_HALF_MASK;
|
|
|
l = r;
|
|
l = r;
|
|
|
r = next;
|
|
r = next;
|
|
|
}
|
|
}
|
|
|
- (l, r)
|
|
|
|
|
|
|
+ (l << FEISTEL_HALF_BITS) | r
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// Inverse of [`feistel`] under the same seed.
|
|
|
|
|
-fn feistel_inv(mut l: u64, mut r: u64, seed: u64) -> (u64, u64) {
|
|
|
|
|
|
|
+/// Inverse of [`feistel94`] under the same seed.
|
|
|
|
|
+fn feistel94_inv(block: u128, seed: u64) -> u128 {
|
|
|
|
|
+ let mut l = (block >> FEISTEL_HALF_BITS) & FEISTEL_HALF_MASK;
|
|
|
|
|
+ let mut r = block & FEISTEL_HALF_MASK;
|
|
|
for round in (0..FEISTEL_ROUNDS).rev() {
|
|
for round in (0..FEISTEL_ROUNDS).rev() {
|
|
|
- let prev = r ^ mix64(l ^ round_key(seed, round));
|
|
|
|
|
|
|
+ let f = (mix64(l as u64 ^ round_key(seed, round)) as u128) & FEISTEL_HALF_MASK;
|
|
|
|
|
+ let prev = (r ^ f) & FEISTEL_HALF_MASK;
|
|
|
r = l;
|
|
r = l;
|
|
|
l = prev;
|
|
l = prev;
|
|
|
}
|
|
}
|
|
|
- (l, r)
|
|
|
|
|
|
|
+ (l << FEISTEL_HALF_BITS) | r
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Keyed format-preserving permutation over the 93-bit packed domain. Applies
|
|
|
|
|
+/// [`feistel94`] and cycle-walks (re-encrypts while the result exceeds the
|
|
|
|
|
+/// domain) so the output is always a valid packed value. Since the domain is
|
|
|
|
|
+/// half of 2^94, this averages about two iterations and always terminates.
|
|
|
|
|
+fn obfuscate(p: u128, seed: u64) -> u128 {
|
|
|
|
|
+ let mut v = p;
|
|
|
|
|
+ loop {
|
|
|
|
|
+ v = feistel94(v, seed);
|
|
|
|
|
+ if v <= PACK_MASK {
|
|
|
|
|
+ return v;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Inverse of [`obfuscate`] under the same seed: cycle-walk [`feistel94_inv`].
|
|
|
|
|
+fn deobfuscate(y: u128, seed: u64) -> u128 {
|
|
|
|
|
+ let mut v = y;
|
|
|
|
|
+ loop {
|
|
|
|
|
+ v = feistel94_inv(v, seed);
|
|
|
|
|
+ if v <= PACK_MASK {
|
|
|
|
|
+ return v;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------------
|
|
@@ -338,8 +422,9 @@ impl AccountId {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// IBAN-style presentation format: the machine [`Display`](fmt::Display)
|
|
/// IBAN-style presentation format: the machine [`Display`](fmt::Display)
|
|
|
- /// form grouped into blocks of four with a single space
|
|
|
|
|
- /// (e.g. `9200 0000 0000 0050 0000 0000 07`).
|
|
|
|
|
|
|
+ /// form grouped into blocks of four with a single space. The code is a fixed
|
|
|
|
|
+ /// 20 characters, so this is always five groups of four
|
|
|
|
|
+ /// (e.g. `K3P9 WM2X Q7ND V8HT 2R47`).
|
|
|
pub fn to_grouped(&self) -> String {
|
|
pub fn to_grouped(&self) -> String {
|
|
|
let machine = self.to_string();
|
|
let machine = self.to_string();
|
|
|
let mut out = String::with_capacity(machine.len() + machine.len() / 4);
|
|
let mut out = String::with_capacity(machine.len() + machine.len() / 4);
|
|
@@ -375,26 +460,26 @@ impl std::error::Error for ParseAccountIdError {}
|
|
|
impl std::str::FromStr for AccountId {
|
|
impl std::str::FromStr for AccountId {
|
|
|
type Err = ParseAccountIdError;
|
|
type Err = ParseAccountIdError;
|
|
|
|
|
|
|
|
- /// Parse an IBAN-style account code back into the two i64 legs. Any spaces
|
|
|
|
|
|
|
+ /// Parse an IBAN-style account code back into the two legs. Any spaces
|
|
|
/// (grouped display format) and dashes (URL-safe separator) are ignored and
|
|
/// (grouped display format) and dashes (URL-safe separator) are ignored and
|
|
|
- /// the input is upper-cased first, so `5000...`, `5000 0000 ...`, and
|
|
|
|
|
- /// `5000-0000-...` all parse to the same id. The value must reduce to two
|
|
|
|
|
- /// check digits followed by a 26-character base-36 body, and the ISO 7064
|
|
|
|
|
- /// mod-97 checksum must pass — so a mistyped or otherwise invalid id is
|
|
|
|
|
- /// rejected here rather than reaching the store. Each 13-char half is read as
|
|
|
|
|
- /// a `u64` bit pattern and reinterpreted as `i64`.
|
|
|
|
|
|
|
+ /// the input is upper-cased first, so `K3P9...`, `K3P9 WM2X ...`, and
|
|
|
|
|
+ /// `K3P9-WM2X-...` all parse to the same id. The value must reduce to an
|
|
|
|
|
+ /// 18-character base-36 body followed by two check digits, the ISO 7064
|
|
|
|
|
+ /// mod-97 checksum must pass, and the decoded body must lie in the packable
|
|
|
|
|
+ /// domain — so a mistyped or otherwise invalid id is rejected here rather
|
|
|
|
|
+ /// than reaching the store.
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
|
let cleaned: String = s
|
|
let cleaned: String = s
|
|
|
.chars()
|
|
.chars()
|
|
|
.filter(|c| !c.is_whitespace() && *c != '-')
|
|
.filter(|c| !c.is_whitespace() && *c != '-')
|
|
|
.map(|c| c.to_ascii_uppercase())
|
|
.map(|c| c.to_ascii_uppercase())
|
|
|
.collect();
|
|
.collect();
|
|
|
- // 2 check digits + 26-char base-36 body.
|
|
|
|
|
- if cleaned.len() != 28 {
|
|
|
|
|
|
|
+ // 18-char base-36 body + 2 trailing check digits.
|
|
|
|
|
+ if cleaned.len() != BODY_LEN + 2 {
|
|
|
return Err(ParseAccountIdError);
|
|
return Err(ParseAccountIdError);
|
|
|
}
|
|
}
|
|
|
- let check = &cleaned[0..2];
|
|
|
|
|
- let body = &cleaned[2..28];
|
|
|
|
|
|
|
+ let body = &cleaned[0..BODY_LEN];
|
|
|
|
|
+ let check = &cleaned[BODY_LEN..BODY_LEN + 2];
|
|
|
let is_base36 = |b: u8| b.is_ascii_digit() || b.is_ascii_uppercase();
|
|
let is_base36 = |b: u8| b.is_ascii_digit() || b.is_ascii_uppercase();
|
|
|
if !check.bytes().all(|b| b.is_ascii_digit()) || !body.bytes().all(is_base36) {
|
|
if !check.bytes().all(|b| b.is_ascii_digit()) || !body.bytes().all(is_base36) {
|
|
|
return Err(ParseAccountIdError);
|
|
return Err(ParseAccountIdError);
|
|
@@ -404,15 +489,14 @@ impl std::str::FromStr for AccountId {
|
|
|
if mod97(&iban_expand(&format!("{body}{check}"))) != 1 {
|
|
if mod97(&iban_expand(&format!("{body}{check}"))) != 1 {
|
|
|
return Err(ParseAccountIdError);
|
|
return Err(ParseAccountIdError);
|
|
|
}
|
|
}
|
|
|
- // Decode the two halves, then invert the Feistel permutation to recover
|
|
|
|
|
- // the raw legs.
|
|
|
|
|
- let l = u64::from_str_radix(&body[0..13], 36).map_err(|_| ParseAccountIdError)?;
|
|
|
|
|
- let r = u64::from_str_radix(&body[13..26], 36).map_err(|_| ParseAccountIdError)?;
|
|
|
|
|
- let (id, sub) = feistel_inv(l, r, id_seed());
|
|
|
|
|
- Ok(Self {
|
|
|
|
|
- id: id as i64,
|
|
|
|
|
- sub: sub as i64,
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ // Decode the body, reject anything outside the 93-bit packable domain
|
|
|
|
|
+ // (the permutation's image), then invert the permutation and unpack.
|
|
|
|
|
+ let obf = u128::from_str_radix(body, 36).map_err(|_| ParseAccountIdError)?;
|
|
|
|
|
+ if obf > PACK_MASK {
|
|
|
|
|
+ return Err(ParseAccountIdError);
|
|
|
|
|
+ }
|
|
|
|
|
+ let (id, sub) = unpack(deobfuscate(obf, id_seed()));
|
|
|
|
|
+ Ok(Self { id, sub })
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1194,15 +1278,21 @@ mod account_id_tests {
|
|
|
use super::*;
|
|
use super::*;
|
|
|
use std::str::FromStr;
|
|
use std::str::FromStr;
|
|
|
|
|
|
|
|
|
|
+ /// Largest subaccount id that round-trips through the string form.
|
|
|
|
|
+ const MAX_SUB: i64 = (1i64 << SUB_BITS) - 1;
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn code_structure() {
|
|
fn code_structure() {
|
|
|
let s = AccountId::with_sub(5, 7).to_string();
|
|
let s = AccountId::with_sub(5, 7).to_string();
|
|
|
- // 2 check digits + 26-char base-36 body. No country code.
|
|
|
|
|
- assert_eq!(s.len(), 28);
|
|
|
|
|
- assert!(s[0..2].bytes().all(|b| b.is_ascii_digit()));
|
|
|
|
|
- // The body is Feistel-permuted, so it does NOT expose the raw legs the
|
|
|
|
|
- // way an unmixed base-36 rendering (all zeros then "5"/"7") would.
|
|
|
|
|
- assert_ne!(&s[2..], "00000000000050000000000007");
|
|
|
|
|
|
|
+ // Fixed 20 chars: an 18-char base-36 body then two check digits, no
|
|
|
|
|
+ // country code.
|
|
|
|
|
+ assert_eq!(s.len(), 20);
|
|
|
|
|
+ assert_eq!(s.len(), BODY_LEN + 2);
|
|
|
|
|
+ // The two trailing check digits are numeric.
|
|
|
|
|
+ assert!(s[BODY_LEN..].bytes().all(|b| b.is_ascii_digit()));
|
|
|
|
|
+ // The body is permuted, so it does NOT expose the raw legs the way an
|
|
|
|
|
+ // unmixed base-36 rendering (a run of zeros then "5"/"7") would.
|
|
|
|
|
+ assert_ne!(&s[..BODY_LEN], "000000000000000527");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
@@ -1211,26 +1301,42 @@ mod account_id_tests {
|
|
|
AccountId::new(0),
|
|
AccountId::new(0),
|
|
|
AccountId::new(100),
|
|
AccountId::new(100),
|
|
|
AccountId::with_sub(5, 7),
|
|
AccountId::with_sub(5, 7),
|
|
|
- // High-bit subaccount: exercises the u64-bit-pattern reinterpretation.
|
|
|
|
|
- AccountId::with_sub(1, -1),
|
|
|
|
|
- AccountId::with_sub(i64::MAX, i64::MIN),
|
|
|
|
|
|
|
+ AccountId::with_sub(987654321, 12345),
|
|
|
|
|
+ // The corners of the encodable domain: a full 63-bit id and the
|
|
|
|
|
+ // widest subaccount.
|
|
|
|
|
+ AccountId::new(i64::MAX),
|
|
|
|
|
+ AccountId::with_sub(i64::MAX, MAX_SUB),
|
|
|
|
|
+ AccountId::with_sub(0, MAX_SUB),
|
|
|
] {
|
|
] {
|
|
|
let s = acc.to_string();
|
|
let s = acc.to_string();
|
|
|
|
|
+ assert_eq!(s.len(), 20, "length {s}");
|
|
|
assert_eq!(AccountId::from_str(&s).unwrap(), acc, "round-trip {s}");
|
|
assert_eq!(AccountId::from_str(&s).unwrap(), acc, "round-trip {s}");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
|
|
+ fn out_of_range_legs_truncate_to_the_encodable_domain() {
|
|
|
|
|
+ // The string form encodes only the low ID_BITS / SUB_BITS. A subaccount
|
|
|
|
|
+ // one past the encodable range wraps to 0 (documented narrowing), so it
|
|
|
|
|
+ // shares its code with the main account rather than corrupting it.
|
|
|
|
|
+ let over = AccountId::with_sub(7, 1i64 << SUB_BITS);
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ AccountId::from_str(&over.to_string()).unwrap(),
|
|
|
|
|
+ AccountId::new(7)
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
fn parses_a_fixed_vector() {
|
|
fn parses_a_fixed_vector() {
|
|
|
// A hardcoded, checksum-valid code (under DEFAULT_ID_SEED) pins the
|
|
// A hardcoded, checksum-valid code (under DEFAULT_ID_SEED) pins the
|
|
|
// exact encoding, permutation, and checksum, so an accidental change to
|
|
// exact encoding, permutation, and checksum, so an accidental change to
|
|
|
// any of them is caught by a failing parse.
|
|
// any of them is caught by a failing parse.
|
|
|
- let code = "123PER2Q81K52QL1HA26CYE1IZH5";
|
|
|
|
|
|
|
+ let code = "KUJLQEL8IX2XGTBK4425";
|
|
|
let expected = AccountId::with_sub(987654321, 12345);
|
|
let expected = AccountId::with_sub(987654321, 12345);
|
|
|
assert_eq!(AccountId::from_str(code).unwrap(), expected);
|
|
assert_eq!(AccountId::from_str(code).unwrap(), expected);
|
|
|
// The grouped (spaced, lower-cased) form parses to the same value.
|
|
// The grouped (spaced, lower-cased) form parses to the same value.
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
- AccountId::from_str("123p er2q 81k5 2ql1 ha26 cye1 izh5").unwrap(),
|
|
|
|
|
|
|
+ AccountId::from_str("kujl qel8 ix2x gtbk 4425").unwrap(),
|
|
|
expected
|
|
expected
|
|
|
);
|
|
);
|
|
|
// Display reproduces the exact machine form.
|
|
// Display reproduces the exact machine form.
|
|
@@ -1238,11 +1344,12 @@ mod account_id_tests {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn feistel_is_invertible_across_seeds() {
|
|
|
|
|
|
|
+ fn obfuscation_permutation_is_invertible_across_seeds() {
|
|
|
for &seed in &[0u64, 1, DEFAULT_ID_SEED, u64::MAX] {
|
|
for &seed in &[0u64, 1, DEFAULT_ID_SEED, u64::MAX] {
|
|
|
- for &(l, r) in &[(0u64, 0u64), (5, 7), (u64::MAX, 1), (42, u64::MAX)] {
|
|
|
|
|
- let (el, er) = feistel(l, r, seed);
|
|
|
|
|
- assert_eq!(feistel_inv(el, er, seed), (l, r), "seed={seed} l={l} r={r}");
|
|
|
|
|
|
|
+ for &p in &[0u128, 1, 5 << SUB_BITS | 7, PACK_MASK, PACK_MASK - 1] {
|
|
|
|
|
+ let y = obfuscate(p, seed);
|
|
|
|
|
+ assert!(y <= PACK_MASK, "output escapes the domain: {y}");
|
|
|
|
|
+ assert_eq!(deobfuscate(y, seed), p, "seed={seed} p={p}");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -1263,11 +1370,12 @@ mod account_id_tests {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn grouped_format_groups_by_four_and_re_parses() {
|
|
|
|
|
|
|
+ fn grouped_format_is_five_groups_of_four_and_re_parses() {
|
|
|
let acc = AccountId::with_sub(5, 7);
|
|
let acc = AccountId::with_sub(5, 7);
|
|
|
let grouped = acc.to_grouped();
|
|
let grouped = acc.to_grouped();
|
|
|
- assert!(grouped.contains(' '));
|
|
|
|
|
- assert!(grouped.split(' ').all(|g| g.len() <= 4));
|
|
|
|
|
|
|
+ let groups: Vec<&str> = grouped.split(' ').collect();
|
|
|
|
|
+ assert_eq!(groups.len(), 5);
|
|
|
|
|
+ assert!(groups.iter().all(|g| g.len() == 4));
|
|
|
// Grouped format (with spaces) and lower case both parse back.
|
|
// Grouped format (with spaces) and lower case both parse back.
|
|
|
assert_eq!(AccountId::from_str(&grouped).unwrap(), acc);
|
|
assert_eq!(AccountId::from_str(&grouped).unwrap(), acc);
|
|
|
assert_eq!(AccountId::from_str(&grouped.to_lowercase()).unwrap(), acc);
|
|
assert_eq!(AccountId::from_str(&grouped.to_lowercase()).unwrap(), acc);
|
|
@@ -1276,12 +1384,12 @@ mod account_id_tests {
|
|
|
#[test]
|
|
#[test]
|
|
|
fn parses_with_spaces_or_dashes_for_url_safety() {
|
|
fn parses_with_spaces_or_dashes_for_url_safety() {
|
|
|
let acc = AccountId::with_sub(987654321, 12345);
|
|
let acc = AccountId::with_sub(987654321, 12345);
|
|
|
- let machine = acc.to_string(); // 28 chars, no separators (URL-safe)
|
|
|
|
|
|
|
+ let machine = acc.to_string(); // 20 chars, no separators (URL-safe)
|
|
|
// The same code grouped with spaces (display) or dashes (URL-safe
|
|
// The same code grouped with spaces (display) or dashes (URL-safe
|
|
|
// separator) parses back to the same id, as does a mixed/irregular form.
|
|
// separator) parses back to the same id, as does a mixed/irregular form.
|
|
|
let spaced = acc.to_grouped();
|
|
let spaced = acc.to_grouped();
|
|
|
let dashed = spaced.replace(' ', "-");
|
|
let dashed = spaced.replace(' ', "-");
|
|
|
- let mixed = format!("{}-{} {}", &machine[0..4], &machine[4..20], &machine[20..]);
|
|
|
|
|
|
|
+ let mixed = format!("{}-{} {}", &machine[0..4], &machine[4..12], &machine[12..]);
|
|
|
for s in [&machine, &spaced, &dashed, &mixed] {
|
|
for s in [&machine, &spaced, &dashed, &mixed] {
|
|
|
assert_eq!(AccountId::from_str(s).unwrap(), acc, "parse {s}");
|
|
assert_eq!(AccountId::from_str(s).unwrap(), acc, "parse {s}");
|
|
|
}
|
|
}
|
|
@@ -1299,7 +1407,7 @@ mod account_id_tests {
|
|
|
v.into_iter().collect::<String>()
|
|
v.into_iter().collect::<String>()
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // Flip the last body digit: still base-36 and right length, but the
|
|
|
|
|
|
|
+ // Flip the last check digit: still numeric and right length, but the
|
|
|
// checksum no longer matches, so it is rejected.
|
|
// checksum no longer matches, so it is rejected.
|
|
|
let last = good.len() - 1;
|
|
let last = good.len() - 1;
|
|
|
let flipped = with_char_at(last, if good.ends_with('8') { '9' } else { '8' });
|
|
let flipped = with_char_at(last, if good.ends_with('8') { '9' } else { '8' });
|
|
@@ -1308,14 +1416,14 @@ mod account_id_tests {
|
|
|
// Structurally malformed inputs are all rejected.
|
|
// Structurally malformed inputs are all rejected.
|
|
|
assert!(AccountId::from_str("").is_err(), "empty");
|
|
assert!(AccountId::from_str("").is_err(), "empty");
|
|
|
assert!(AccountId::from_str("not-a-code").is_err(), "junk");
|
|
assert!(AccountId::from_str("not-a-code").is_err(), "junk");
|
|
|
- assert!(AccountId::from_str(&good[..27]).is_err(), "too short");
|
|
|
|
|
|
|
+ assert!(AccountId::from_str(&good[..19]).is_err(), "too short");
|
|
|
assert!(
|
|
assert!(
|
|
|
AccountId::from_str(&format!("{good}0")).is_err(),
|
|
AccountId::from_str(&format!("{good}0")).is_err(),
|
|
|
"too long"
|
|
"too long"
|
|
|
);
|
|
);
|
|
|
- // A check digit that is not a digit.
|
|
|
|
|
|
|
+ // A check digit that is not a digit (the last two positions).
|
|
|
assert!(
|
|
assert!(
|
|
|
- AccountId::from_str(&with_char_at(0, 'A')).is_err(),
|
|
|
|
|
|
|
+ AccountId::from_str(&with_char_at(BODY_LEN, 'A')).is_err(),
|
|
|
"alpha check"
|
|
"alpha check"
|
|
|
);
|
|
);
|
|
|
// A non-base-36 character in the body (survives space/dash stripping).
|
|
// A non-base-36 character in the body (survives space/dash stripping).
|