Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions crates/buzz-core/src/observer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,93 @@ pub fn content_looks_like_nip44(content: &str) -> bool {
(NIP44_MIN_CONTENT_LEN..=NIP44_MAX_CONTENT_LEN).contains(&content.len())
}

/// Strong syntactic validation that `content` is a plausible NIP-44 v2
/// ciphertext payload, beyond the length-envelope check of
/// [`content_looks_like_nip44`].
///
/// Checks:
/// - Standard base64 alphabet only (`A-Z`, `a-z`, `0-9`, `+`, `/`, `=`), with
/// padding only at the end and total length a multiple of 4.
/// - Decoded length >= 99 bytes (1 version + 32 nonce + 32 MAC + minimum 34
/// bytes of length-prefixed padded ciphertext required by NIP-44 v2).
/// - First decoded byte is `0x02` (NIP-44 version 2).
///
/// This is an envelope sanity check, not full validation: the MAC and actual
/// decryption happen at the reader. The relay cannot decrypt, so this is the
/// strongest guard it can apply to refuse a plaintext-leak ("client forgot to
/// encrypt") without holding any key. Use this — not the length-only
/// [`content_looks_like_nip44`] — anywhere a fail-visible "must be encrypted"
/// boundary is enforced.
pub fn validate_nip44_v2(content: &str) -> Result<(), Nip44SyntaxError> {
use Nip44SyntaxError::*;
if content.is_empty() {
return Err(Empty);
}
let bytes = content.as_bytes();
let len = bytes.len();
if !len.is_multiple_of(4) {
return Err(NotBase64);
}
let mut pad_count = 0usize;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' => {
if pad_count > 0 {
return Err(NotBase64);
}
}
b'=' => {
if i < len - 2 {
return Err(NotBase64);
}
pad_count += 1;
if pad_count > 2 {
return Err(NotBase64);
}
}
_ => return Err(NotBase64),
}
}
let decoded_len = (len / 4) * 3 - pad_count;
if decoded_len < 99 {
return Err(TooShort);
}
let b64_val = |c: u8| -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
};
let v0 = b64_val(bytes[0]).ok_or(NotBase64)?;
let v1 = b64_val(bytes[1]).ok_or(NotBase64)?;
let first_byte = (v0 << 2) | (v1 >> 4);
if first_byte != 0x02 {
return Err(WrongVersion);
}
Ok(())
}

/// Reasons [`validate_nip44_v2`] rejects content as non-NIP-44-v2 ciphertext.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum Nip44SyntaxError {
/// Content was empty.
#[error("content must not be empty (NIP-44 ciphertext)")]
Empty,
/// Content was not valid standard base64.
#[error("content is not valid base64")]
NotBase64,
/// Decoded content was shorter than the NIP-44 v2 minimum (99 bytes).
#[error("content too short for NIP-44 v2")]
TooShort,
/// First decoded byte was not the NIP-44 version-2 prefix (`0x02`).
#[error("content is not NIP-44 v2 (expected 0x02 version prefix)")]
WrongVersion,
}

/// Serialize and NIP-44 encrypt an observer payload for `recipient`.
pub fn encrypt_observer_payload<T: Serialize>(
sender_keys: &Keys,
Expand Down Expand Up @@ -153,4 +240,70 @@ mod tests {
Err(ObserverPayloadError::InvalidCiphertextLength(_))
));
}

#[test]
fn test_validate_nip44_v2_accepts_real_ciphertext() {
let sender = Keys::generate();
let recipient = Keys::generate();
let encrypted = nip44::encrypt(
sender.secret_key(),
&recipient.public_key(),
"hello over an encrypted channel",
nip44::Version::V2,
)
.expect("encrypt");
assert_eq!(validate_nip44_v2(&encrypted), Ok(()));
}

#[test]
fn test_validate_nip44_v2_rejects_long_plaintext_in_length_envelope() {
// A long plaintext message passes the length-only `content_looks_like_nip44`
// check but must be rejected by the strong validator (D2 — the whole point).
// Spaces are outside the base64 alphabet, so a human-readable message fails fast.
let plaintext =
"this is a long plaintext message a careless client forgot to encrypt ".repeat(2);
assert!(content_looks_like_nip44(&plaintext));
assert_eq!(
validate_nip44_v2(&plaintext),
Err(Nip44SyntaxError::NotBase64)
);
}

#[test]
fn test_validate_nip44_v2_rejects_empty() {
assert_eq!(validate_nip44_v2(""), Err(Nip44SyntaxError::Empty));
}

#[test]
fn test_validate_nip44_v2_rejects_wrong_version_byte() {
// Take a real v2 ciphertext (first decoded byte 0x02 -> base64 starts "Ag")
// and flip the leading base64 char so the decoded version byte is no longer
// 0x02, while keeping valid base64 and length. 'A'(0) -> 'B'(1) shifts the
// first decoded byte off 0x02 without changing any other property.
let sender = Keys::generate();
let recipient = Keys::generate();
let ct = nip44::encrypt(
sender.secret_key(),
&recipient.public_key(),
"payload",
nip44::Version::V2,
)
.expect("encrypt");
assert!(
ct.starts_with('A'),
"v2 ciphertext base64 should start with 'A'"
);
let mutated = format!("B{}", &ct[1..]);
assert_eq!(
validate_nip44_v2(&mutated),
Err(Nip44SyntaxError::WrongVersion)
);
}

#[test]
fn test_validate_nip44_v2_rejects_too_short() {
// "AgAA" decodes to [0x02, 0x00, 0x00] — correct version prefix, only 3
// bytes, under the 99-byte NIP-44 v2 minimum.
assert_eq!(validate_nip44_v2("AgAA"), Err(Nip44SyntaxError::TooShort));
}
}
22 changes: 16 additions & 6 deletions crates/buzz-db/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ pub struct ChannelRecord {
pub ttl_seconds: Option<i32>,
/// Deadline by which a new message must arrive or the channel is auto-archived.
pub ttl_deadline: Option<DateTime<Utc>>,
/// Point from which messages in this channel MUST be NIP-44 v2 ciphertext.
///
/// The tamper-evident encryption-start marker for hybrid E2E. Relay-owned:
/// set by the relay (at DM creation) and never writable by a client, so the
/// boundary cannot be forged or backdated. `None` means the channel is not
/// E2E (legacy plaintext stays readable).
pub encryption_activated_at: Option<DateTime<Utc>>,
}

/// A channel membership row as returned from the database.
Expand Down Expand Up @@ -143,7 +150,7 @@ pub async fn create_channel(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline
ttl_seconds, ttl_deadline, encryption_activated_at
FROM channels WHERE id = $1
"#,
)
Expand Down Expand Up @@ -234,7 +241,7 @@ pub async fn create_channel_with_id(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline
ttl_seconds, ttl_deadline, encryption_activated_at
FROM channels WHERE id = $1
"#,
)
Expand All @@ -257,7 +264,7 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result<ChannelRecor
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline
ttl_seconds, ttl_deadline, encryption_activated_at
FROM channels WHERE id = $1 AND deleted_at IS NULL
"#,
)
Expand Down Expand Up @@ -583,7 +590,7 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result<Ve
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline
ttl_seconds, ttl_deadline, encryption_activated_at
FROM channels
WHERE deleted_at IS NULL AND visibility::text = $1
ORDER BY created_at DESC
Expand All @@ -602,7 +609,7 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result<Ve
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline
ttl_seconds, ttl_deadline, encryption_activated_at
FROM channels
WHERE deleted_at IS NULL
ORDER BY created_at DESC
Expand Down Expand Up @@ -646,7 +653,7 @@ async fn get_channel_tx(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline
ttl_seconds, ttl_deadline, encryption_activated_at
FROM channels WHERE id = $1 AND deleted_at IS NULL
"#,
)
Expand Down Expand Up @@ -860,6 +867,8 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
let purpose_set_at: Option<DateTime<Utc>> = row.try_get("purpose_set_at").unwrap_or(None);
let ttl_seconds: Option<i32> = row.try_get("ttl_seconds").unwrap_or(None);
let ttl_deadline: Option<DateTime<Utc>> = row.try_get("ttl_deadline").unwrap_or(None);
let encryption_activated_at: Option<DateTime<Utc>> =
row.try_get("encryption_activated_at").unwrap_or(None);

Ok(ChannelRecord {
id,
Expand All @@ -884,6 +893,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
purpose_set_at,
ttl_seconds,
ttl_deadline,
encryption_activated_at,
})
}

Expand Down
49 changes: 44 additions & 5 deletions crates/buzz-db/src/dm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ pub async fn find_dm_by_participants(
created_by, created_at, updated_at, archived_at, deleted_at,
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
purpose, purpose_set_by, purpose_set_at, encryption_activated_at
FROM channels
WHERE participant_hash = $1
AND channel_type = 'dm'
Expand All @@ -87,6 +87,18 @@ pub async fn find_dm_by_participants(
row.map(row_to_channel_record).transpose()
}

/// Whether a newly created DM with `participant_count` members is latched as
/// end-to-end encrypted at creation.
///
/// Phase 1 encryption is pairwise NIP-44, which only has a single peer for a
/// 2-party DM. Group DMs (3-9) have no single pairwise peer, so they stay
/// unlatched (legacy plaintext) until Phase 2 introduces group keys; latching
/// them would make them unsendable because ingest rule 15c rejects every
/// non-ciphertext kind:9 in a latched channel.
fn dm_latches_at_creation(participant_count: usize) -> bool {
participant_count == 2
}

/// Create a new DM channel for the given participant pubkeys, or return the
/// existing one if a DM with the same participant set already exists.
///
Expand Down Expand Up @@ -130,7 +142,7 @@ pub async fn create_dm(
created_by, created_at, updated_at, archived_at, deleted_at,
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
purpose, purpose_set_by, purpose_set_at, encryption_activated_at
FROM channels
WHERE participant_hash = $1
AND channel_type = 'dm'
Expand All @@ -156,17 +168,25 @@ pub async fn create_dm(

let id = Uuid::new_v4();

// Latch 2-party DMs as E2E at creation (see `dm_latches_at_creation`). The
// latch is set only here, server-side via NOW(); it is relay-owned, never
// client-writable, and has no UPDATE path (`ChannelUpdate` has no such
// field), so the encryption-start boundary can't be backdated or moved.
let latch_at_creation = dm_latches_at_creation(participants.len());
sqlx::query(
r#"
INSERT INTO channels
(id, name, channel_type, visibility, created_by, participant_hash)
VALUES ($1, $2, 'dm', 'private', $3, $4)
(id, name, channel_type, visibility, created_by, participant_hash,
encryption_activated_at)
VALUES ($1, $2, 'dm', 'private', $3, $4,
CASE WHEN $5 THEN NOW() ELSE NULL END)
"#,
)
.bind(id)
.bind(&name)
.bind(created_by)
.bind(hash.as_slice())
.bind(latch_at_creation)
.execute(&mut *tx)
.await?;

Expand Down Expand Up @@ -196,7 +216,7 @@ pub async fn create_dm(
created_by, created_at, updated_at, archived_at, deleted_at,
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
purpose, purpose_set_by, purpose_set_at, encryption_activated_at
FROM channels WHERE id = $1
"#,
)
Expand Down Expand Up @@ -467,6 +487,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
purpose_set_at: row.try_get("purpose_set_at").unwrap_or(None),
ttl_seconds: row.try_get("ttl_seconds").unwrap_or(None),
ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None),
encryption_activated_at: row.try_get("encryption_activated_at").unwrap_or(None),
})
}

Expand Down Expand Up @@ -510,4 +531,22 @@ mod tests {
let h = compute_participant_hash(&[&a, &b]);
assert_eq!(h.len(), 32);
}

#[test]
fn two_party_dm_latches_at_creation() {
assert!(
dm_latches_at_creation(2),
"2-party DMs are E2E-by-default and must latch at creation"
);
}

#[test]
fn group_dm_does_not_latch_at_creation() {
for count in 3..=9 {
assert!(
!dm_latches_at_creation(count),
"group DM of {count} must stay unlatched (Phase 1 has no group key)"
);
}
}
}
12 changes: 11 additions & 1 deletion crates/buzz-db/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ mod tests {
fn embedded_migrator_contains_all_schema_migrations() {
let migrations: Vec<_> = MIGRATOR.iter().collect();

assert_eq!(migrations.len(), 3);
assert_eq!(migrations.len(), 4);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(
Expand Down Expand Up @@ -160,6 +160,16 @@ mod tests {
&& migrations[2].sql.as_str().contains("idx_events_not_before"),
"third migration should add the NIP-ER reminder columns and index"
);

assert_eq!(migrations[3].version, 4);
assert_eq!(&*migrations[3].description, "channel encryption latch");
assert!(
migrations[3]
.sql
.as_str()
.contains("ADD COLUMN encryption_activated_at"),
"fourth migration should add the E2E encryption latch column"
);
}

async fn connect_test_pool() -> PgPool {
Expand Down
Loading
Loading