use offline_protocol_core::{Address, Username};
use serde::{Deserialize, Serialize};
use crate::canonical::canonical_payload;
use crate::error::{MlsError, Result};
use crate::manager::MlsManager;
pub const DISCOVERY_SIGN_DOMAIN: &[u8] = b"offline-disc-v1";
pub const DISCOVERY_VERSION: u8 = 1;
const PUBLIC_KEY_LEN: usize = 32;
const SIGNATURE_LEN: usize = 64;
const NOSTR_KEY_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveryRecordV1 {
pub v: u8,
pub username: Username,
pub address: Address,
#[serde(with = "base64_bytes")]
pub pubkey: Vec<u8>,
#[serde(with = "hex_bytes")]
pub nostr_author: Vec<u8>,
pub issued_at_ms: i64,
#[serde(with = "base64_bytes")]
pub sig: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveryTombstoneV1 {
pub v: u8,
pub retracted: bool,
}
impl DiscoveryTombstoneV1 {
pub fn new() -> Self {
Self {
v: DISCOVERY_VERSION,
retracted: true,
}
}
}
impl Default for DiscoveryTombstoneV1 {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiscoveryBody {
Record(Box<DiscoveryRecordV1>),
Tombstone,
}
pub fn parse_discovery_body(bytes: &[u8]) -> Result<DiscoveryBody> {
if let Ok(tombstone) = serde_json::from_slice::<DiscoveryTombstoneV1>(bytes) {
if tombstone.retracted {
return Ok(DiscoveryBody::Tombstone);
}
}
let record: DiscoveryRecordV1 = serde_json::from_slice(bytes)
.map_err(|e| MlsError::Deserialization(format!("Discovery record is malformed: {}", e)))?;
Ok(DiscoveryBody::Record(Box::new(record)))
}
impl DiscoveryRecordV1 {
pub fn signing_payload(
username: &Username,
address: &Address,
pubkey: &[u8],
nostr_author: &[u8],
issued_at_ms: i64,
) -> Result<Vec<u8>> {
let address_string = address.to_string();
canonical_payload(
DISCOVERY_SIGN_DOMAIN,
&[
&[DISCOVERY_VERSION],
username.as_str().as_bytes(),
address_string.as_bytes(),
pubkey,
nostr_author,
&issued_at_ms.to_be_bytes(),
],
)
}
pub fn unsigned(
username: Username,
address: Address,
pubkey: Vec<u8>,
nostr_author: Vec<u8>,
issued_at_ms: i64,
) -> Self {
Self {
v: DISCOVERY_VERSION,
username,
address,
pubkey,
nostr_author,
issued_at_ms,
sig: Vec::new(),
}
}
pub fn sign_with<F>(mut self, signer: F) -> Result<Self>
where
F: FnOnce(&[u8]) -> Result<Vec<u8>>,
{
let payload = Self::signing_payload(
&self.username,
&self.address,
&self.pubkey,
&self.nostr_author,
self.issued_at_ms,
)?;
self.sig = signer(&payload)?;
Ok(self)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DiscoveryRejection {
#[error("discovery record version is {found}, expected {expected}")]
UnsupportedVersion {
found: u8,
expected: u8,
},
#[error("discovery record field '{field}' is {len} bytes, expected {expected}")]
FieldLength {
field: &'static str,
len: usize,
expected: usize,
},
#[error("discovery record claims '{claimed}' but '{queried}' was queried")]
UsernameMismatch {
claimed: String,
queried: String,
},
#[error("discovery record address is not the address its public key derives to")]
AddressNotDerived,
#[error("discovery record signature does not verify")]
BadSignature,
#[error("discovery record was published under a foreign Nostr key")]
ForeignAuthor,
}
pub fn verify_discovery_record(
record: &DiscoveryRecordV1,
queried: &Username,
event_author: &[u8],
) -> std::result::Result<(), DiscoveryRejection> {
if record.v != DISCOVERY_VERSION {
return Err(DiscoveryRejection::UnsupportedVersion {
found: record.v,
expected: DISCOVERY_VERSION,
});
}
if record.pubkey.len() != PUBLIC_KEY_LEN {
return Err(DiscoveryRejection::FieldLength {
field: "pubkey",
len: record.pubkey.len(),
expected: PUBLIC_KEY_LEN,
});
}
if record.nostr_author.len() != NOSTR_KEY_LEN {
return Err(DiscoveryRejection::FieldLength {
field: "nostr_author",
len: record.nostr_author.len(),
expected: NOSTR_KEY_LEN,
});
}
if record.sig.len() != SIGNATURE_LEN {
return Err(DiscoveryRejection::FieldLength {
field: "sig",
len: record.sig.len(),
expected: SIGNATURE_LEN,
});
}
if record.username != *queried {
return Err(DiscoveryRejection::UsernameMismatch {
claimed: record.username.as_str().to_string(),
queried: queried.as_str().to_string(),
});
}
let derived = MlsManager::derive_address(&record.pubkey)
.map_err(|_| DiscoveryRejection::AddressNotDerived)?;
if derived != record.address {
return Err(DiscoveryRejection::AddressNotDerived);
}
let payload = DiscoveryRecordV1::signing_payload(
&record.username,
&record.address,
&record.pubkey,
&record.nostr_author,
record.issued_at_ms,
)
.map_err(|_| DiscoveryRejection::BadSignature)?;
match MlsManager::verify_signature(&record.pubkey, &payload, &record.sig) {
Ok(true) => {}
_ => return Err(DiscoveryRejection::BadSignature),
}
if record.nostr_author != event_author {
return Err(DiscoveryRejection::ForeignAuthor);
}
Ok(())
}
mod base64_bytes {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&STANDARD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let encoded = String::deserialize(deserializer)?;
STANDARD.decode(&encoded).map_err(serde::de::Error::custom)
}
}
mod hex_bytes {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&hex::encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let encoded = String::deserialize(deserializer)?;
hex::decode(&encoded).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
use ed25519_dalek::{Signer, SigningKey};
fn identity(seed: u8) -> (SigningKey, Vec<u8>, Address) {
let signing = SigningKey::from_bytes(&[seed; 32]);
let public = signing.verifying_key().to_bytes().to_vec();
let address = MlsManager::derive_address(&public).expect("derive");
(signing, public, address)
}
fn username(s: &str) -> Username {
s.parse().expect("username")
}
fn signed_record(seed: u8, name: &str, author: &[u8]) -> DiscoveryRecordV1 {
let (signing, public, address) = identity(seed);
DiscoveryRecordV1::unsigned(
username(name),
address,
public,
author.to_vec(),
1_700_000_000_000,
)
.sign_with(|payload| Ok(signing.sign(payload).to_bytes().to_vec()))
.expect("sign")
}
fn author(seed: u8) -> Vec<u8> {
vec![seed; NOSTR_KEY_LEN]
}
#[test]
fn discovery_record_verifies() {
let author = author(9);
let record = signed_record(1, "alice", &author);
assert_eq!(
verify_discovery_record(&record, &username("alice"), &author),
Ok(())
);
}
#[test]
fn discovery_record_round_trips_through_json() {
let author = author(9);
let record = signed_record(1, "alice", &author);
let json = serde_json::to_vec(&record).expect("serialize");
match parse_discovery_body(&json).expect("parse") {
DiscoveryBody::Record(parsed) => assert_eq!(*parsed, record),
DiscoveryBody::Tombstone => panic!("a record must not parse as a tombstone"),
}
}
#[test]
fn discovery_tombstone_parses_as_a_retraction() {
let json = serde_json::to_vec(&DiscoveryTombstoneV1::new()).expect("serialize");
assert_eq!(
parse_discovery_body(&json).expect("parse"),
DiscoveryBody::Tombstone
);
}
#[test]
fn discovery_record_refuses_a_re_authored_copy() {
let genuine_author = author(9);
let record = signed_record(1, "alice", &genuine_author);
let squatter_author = author(200);
assert_eq!(
verify_discovery_record(&record, &username("alice"), &squatter_author),
Err(DiscoveryRejection::ForeignAuthor)
);
}
#[test]
fn discovery_record_refuses_a_rebound_author_without_the_identity_key() {
let record = signed_record(1, "alice", &author(9));
let mut rebound = record.clone();
rebound.nostr_author = author(200);
assert_eq!(
verify_discovery_record(&rebound, &username("alice"), &author(200)),
Err(DiscoveryRejection::BadSignature)
);
}
#[test]
fn discovery_record_refuses_a_foreign_address() {
let author = author(9);
let mut record = signed_record(1, "alice", &author);
let (_, _, other) = identity(2);
record.address = other;
assert_eq!(
verify_discovery_record(&record, &username("alice"), &author),
Err(DiscoveryRejection::AddressNotDerived)
);
}
#[test]
fn discovery_record_refuses_a_username_it_was_not_queried_for() {
let author = author(9);
let record = signed_record(1, "bob", &author);
assert_eq!(
verify_discovery_record(&record, &username("alice"), &author),
Err(DiscoveryRejection::UsernameMismatch {
claimed: "bob".to_string(),
queried: "alice".to_string(),
})
);
}
#[test]
fn discovery_record_refuses_a_tampered_username() {
let author = author(9);
let mut record = signed_record(1, "alice", &author);
record.username = username("mallory");
assert_eq!(
verify_discovery_record(&record, &username("mallory"), &author),
Err(DiscoveryRejection::BadSignature)
);
}
#[test]
fn discovery_record_refuses_a_tampered_issue_time() {
let author = author(9);
let mut record = signed_record(1, "alice", &author);
record.issued_at_ms += 1;
assert_eq!(
verify_discovery_record(&record, &username("alice"), &author),
Err(DiscoveryRejection::BadSignature)
);
}
#[test]
fn discovery_record_refuses_an_unsupported_version() {
let author = author(9);
let mut record = signed_record(1, "alice", &author);
record.v = 2;
assert!(matches!(
verify_discovery_record(&record, &username("alice"), &author),
Err(DiscoveryRejection::UnsupportedVersion { .. })
));
}
#[test]
fn discovery_record_refuses_wrong_length_fields() {
let author = author(9);
for mutate in [
(|r: &mut DiscoveryRecordV1| r.pubkey.truncate(31)) as fn(&mut DiscoveryRecordV1),
|r: &mut DiscoveryRecordV1| r.nostr_author.truncate(31),
|r: &mut DiscoveryRecordV1| r.sig.truncate(63),
] {
let mut record = signed_record(1, "alice", &author);
mutate(&mut record);
assert!(matches!(
verify_discovery_record(&record, &username("alice"), &author),
Err(DiscoveryRejection::FieldLength { .. })
));
}
}
#[test]
fn discovery_record_accepts_an_old_issue_time() {
let author = author(9);
let (signing, public, address) = identity(1);
let ancient = DiscoveryRecordV1::unsigned(
username("alice"),
address,
public,
author.clone(),
1_000_000_000_000,
)
.sign_with(|payload| Ok(signing.sign(payload).to_bytes().to_vec()))
.expect("sign");
assert_eq!(
verify_discovery_record(&ancient, &username("alice"), &author),
Ok(())
);
}
#[test]
fn discovery_record_refuses_an_unnormalized_username_on_the_wire() {
let author = author(9);
let record = signed_record(1, "alice", &author);
let mut value: serde_json::Value =
serde_json::from_slice(&serde_json::to_vec(&record).expect("serialize"))
.expect("value");
value["username"] = serde_json::Value::String("Alice".to_string());
let json = serde_json::to_vec(&value).expect("reserialize");
assert!(parse_discovery_body(&json).is_err());
}
#[test]
fn discovery_signing_payload_golden_vector() {
let (_, public, address) = identity(1);
assert_eq!(
address.to_string(),
"off1qy682ruch4vlely5dkj94247jva7z49yk5xpqee0",
"the golden identity's address changed"
);
let payload = DiscoveryRecordV1::signing_payload(
&username("alice"),
&address,
&public,
&author(9),
1_700_000_000_000,
)
.expect("payload");
assert_eq!(
base64::engine::general_purpose::STANDARD.encode(&payload),
"b2ZmbGluZS1kaXNjLXYxAAAAAQEAAAAFYWxpY2UAAAAsb2ZmMXF5NjgycnVjaDR2bGVseTVka2o5NDI0N2p2YTd6NDl5azV4cHFlZTAAAAAgiojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1wAAAAgCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkAAAAIAAABi8/laAA="
);
}
}