use crate::{Result, SealedError};
use alloc::format;
use offline_protocol_core::Address;
pub const ED25519_PUBLIC_KEY_LEN: usize = 32;
pub fn derive_address(public_key: &[u8]) -> Result<Address> {
use sha2::{Digest, Sha256};
if public_key.len() != ED25519_PUBLIC_KEY_LEN {
return Err(SealedError::InvalidPublicKey(format!(
"Ed25519 public key must be {} bytes, got {}",
ED25519_PUBLIC_KEY_LEN,
public_key.len()
)));
}
let hash = Sha256::digest(public_key);
let mut truncated = [0u8; Address::HASH_LEN];
truncated.copy_from_slice(&hash[..Address::HASH_LEN]);
Ok(Address::from_hash_bytes(truncated))
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
use alloc::string::ToString;
const RFC8032_TV1_PK: [u8; 32] = [
0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07,
0x3a, 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07,
0x51, 0x1a,
];
const RFC8032_TV1_ADDRESS: &str = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
#[test]
fn derive_address_matches_the_pinned_vector() {
let address = derive_address(&RFC8032_TV1_PK).expect("32-byte key derives");
assert_eq!(address.to_string(), RFC8032_TV1_ADDRESS);
assert_eq!(
RFC8032_TV1_ADDRESS.parse::<Address>().expect("parses"),
address,
"the pinned string must round-trip back to the same address"
);
}
#[test]
fn derive_address_rejects_wrong_key_lengths() {
for len in [0usize, 31, 33, 64] {
let err = derive_address(&vec![0u8; len]).unwrap_err();
assert!(
matches!(err, SealedError::InvalidPublicKey(_)),
"{len}-byte key must be refused, got: {err}"
);
}
}
}
#[cfg(all(test, feature = "std"))]
mod spec_vectors {
use super::*;
use crate::envelope::GroupId;
const VECTORS: &str = include_str!("../tests/data/derive-address-v1.vectors.json");
fn vectors() -> serde_json::Value {
serde_json::from_str(VECTORS).expect("the vector file is JSON")
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex digit"))
.collect()
}
#[test]
fn the_vector_file_is_the_size_it_was() {
let v = vectors();
assert_eq!(v["derive"].as_array().expect("derive").len(), 5);
assert_eq!(v["sessions"].as_array().expect("sessions").len(), 3);
assert!(
v["sessions"]
.as_array()
.expect("sessions")
.iter()
.any(|s| s["orders_disagree"] == true),
"at least one session pair must be one where hash order and string \
order disagree, or the file cannot catch the ordering bug it exists \
to catch"
);
}
#[test]
fn every_public_key_derives_its_address() {
for case in vectors()["derive"].as_array().expect("derive") {
let name = case["name"].as_str().expect("a name");
let pk = unhex(case["public_key_hex"].as_str().expect("a public key"));
let got = derive_address(&pk).unwrap_or_else(|e| panic!("[{name}]: {e}"));
assert_eq!(
got.to_string(),
case["address"].as_str().expect("an address"),
"[{name}] derived a different address than the chapter specifies"
);
}
}
#[test]
fn every_pair_derives_its_session_slot() {
for case in vectors()["sessions"].as_array().expect("sessions") {
let a = case["a"].as_str().expect("a");
let b = case["b"].as_str().expect("b");
let want = case["session_id"].as_str().expect("a slot");
let got = GroupId::for_session(a, b).expect("a slot");
assert_eq!(got.as_str(), want, "session slot for {a} and {b}");
let swapped = GroupId::for_session(b, a).expect("a slot");
assert_eq!(
swapped.as_str(),
want,
"the slot is not symmetric, so the two parties would name \
different sessions"
);
if case["orders_disagree"] == true {
assert_ne!(
want,
case["session_id_if_ordered_by_string"]
.as_str()
.expect("the string-ordered slot"),
"this pair no longer distinguishes the two orderings"
);
}
}
}
fn chapter() -> Option<String> {
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/identity.md");
std::fs::read_to_string(&path).ok().or_else(|| {
eprintln!("spec tree not present, skipping the identity chapter drift checks");
None
})
}
#[test]
fn the_chapter_states_the_derivation_the_code_performs() {
let Some(text) = chapter() else { return };
assert!(
text.contains("SHA-256(ed25519_public_key)[0..20]"),
"the chapter no longer states the derivation this function computes"
);
assert!(
text.contains("session:\" || lower || \":\" || higher"),
"the chapter no longer states the session slot construction"
);
assert!(
text.contains(&format!("{ED25519_PUBLIC_KEY_LEN}")),
"the chapter does not state the {ED25519_PUBLIC_KEY_LEN}-byte key length"
);
}
}