Skip to content
Merged
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
2 changes: 1 addition & 1 deletion hugo-site/content/quickstart/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ browser.

## Step 2: Join the room

Click below to join the **Freenet Official** room. Invites are limited to 20 per day.
Click below to join the **Freenet Official** room. A small daily limit helps protect the room from spam.

{{< river-invite-button room="Freenet Official" >}}

Expand Down
190 changes: 146 additions & 44 deletions hugo-site/themes/freenet/layouts/shortcodes/river-invite-button.html
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@
</div>

<div id="invite-loading" style="display: none;">
<p class="loading-text">Joining the chat...</p>
<p id="invite-loading-message" class="loading-text">Preparing your invitation...</p>
</div>
</div>

Expand Down Expand Up @@ -182,6 +182,148 @@
const inviteLink = document.getElementById('invite-link');
const errorMessage = document.getElementById('error-message');
const retryMessage = document.getElementById('retry-message');
const loadingMessage = document.getElementById('invite-loading-message');

const powDomain = new TextEncoder().encode('freenet-river-invite-pow-v1');

function hexToBytes(hex) {
if (typeof hex !== 'string' || hex.length % 2 !== 0) {
throw new Error('The invite server returned an invalid challenge');
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}

function hasLeadingZeroBits(hash, difficulty) {
const bytes = new Uint8Array(hash);
const fullBytes = Math.floor(difficulty / 8);
for (let i = 0; i < fullBytes; i++) {
if (bytes[i] !== 0) return false;
}
const remaining = difficulty % 8;
return remaining === 0 || (bytes[fullBytes] >> (8 - remaining)) === 0;
}

async function solveChallenge(challenge) {
if (!window.crypto || !window.crypto.subtle) {
throw new Error('This browser cannot perform invite verification');
}
const id = hexToBytes(challenge.challenge);
if (id.length !== 16 || !Number.isInteger(challenge.difficulty)) {
throw new Error('The invite server returned an invalid challenge');
}

const prefix = new Uint8Array(powDomain.length + id.length + 8);
prefix.set(powDomain, 0);
prefix.set(id, powDomain.length);
const nonceOffset = powDomain.length + id.length;
const batchSize = 128;
let nonce = 0;
const started = performance.now();

while (nonce < Number.MAX_SAFE_INTEGER - batchSize) {
const attempts = [];
for (let i = 0; i < batchSize; i++) {
const candidate = nonce + i;
const input = prefix.slice();
new DataView(input.buffer).setBigUint64(nonceOffset, BigInt(candidate), false);
attempts.push(crypto.subtle.digest('SHA-256', input));
}
const hashes = await Promise.all(attempts);
for (let i = 0; i < hashes.length; i++) {
if (hasLeadingZeroBits(hashes[i], challenge.difficulty)) {
return nonce + i;
}
}
nonce += batchSize;
if (nonce % 4096 === 0) {
const elapsed = Math.max(1, Math.round((performance.now() - started) / 1000));
loadingMessage.textContent = `Preparing a spam-resistant invitation… ${elapsed}s`;
await new Promise(resolve => requestAnimationFrame(resolve));
}
}
throw new Error('Invite verification took too long');
}

async function readApiResponse(response) {
let data = {};
try {
data = await response.json();
} catch (_) {
// Preserve the HTTP status below when an upstream error is not JSON.
}
if (!response.ok) {
const error = new Error(data.error || `Invite server error (${response.status})`);
error.status = response.status;
error.retryAfter = data.retry_after_seconds;
throw error;
}
return data;
}

async function requestInvite() {
// A challenge is short-lived and single-use. Retry once if it expires or
// races with a duplicate submission, without bothering the user.
for (let attempt = 0; attempt < 2; attempt++) {
const challengeResponse = await fetch(`${apiUrl}/invite-challenge`);
// Deployment compatibility: publish this page first, then update
// gkapi. The old API has no challenge route, so it remains usable
// during that short rollout window. The new API never accepts this
// legacy request, so it cannot bypass proof of work afterward.
if (challengeResponse.status === 404) {
const legacyResponse = await fetch(`${apiUrl}/create-invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
return readApiResponse(legacyResponse);
}
const challenge = await readApiResponse(challengeResponse);
loadingMessage.textContent = 'Preparing a spam-resistant invitation…';
const nonce = await solveChallenge(challenge);
loadingMessage.textContent = 'Joining the chat…';

const response = await fetch(`${apiUrl}/create-invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: challenge.challenge,
issued_at: challenge.issued_at,
difficulty: challenge.difficulty,
signature: challenge.signature,
nonce
})
});
if ([400, 409, 410].includes(response.status) && attempt === 0) {
continue;
}
return readApiResponse(response);
}
throw new Error('Invite verification could not be completed');
}

function showInviteError(error) {
inviteLoading.style.display = 'none';
inviteError.style.display = 'block';
errorMessage.textContent = error.message || 'Unable to create an invitation. Please try again.';
if (error.retryAfter != null) {
const secs = Math.max(0, Math.round(error.retryAfter));
let when;
if (secs < 60) {
when = `${Math.max(1, secs)} second(s)`;
} else if (secs < 3600) {
when = `${Math.ceil(secs / 60)} minute(s)`;
} else {
when = `${Math.ceil(secs / 3600)} hour(s)`;
}
retryMessage.textContent = `You can try again in approximately ${when}.`;
} else {
retryMessage.textContent = '';
}
}

document.getElementById('copy-invite-btn').addEventListener('click', function() {
const code = document.getElementById('invite-code-text').textContent;
Expand All @@ -196,47 +338,10 @@
inviteLoading.style.display = 'block';
inviteError.style.display = 'none';
inviteResult.style.display = 'none';
loadingMessage.textContent = 'Preparing your invitation…';

try {
const response = await fetch(`${apiUrl}/create-invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});

if (response.status === 429) {
const data = await response.json();
inviteLoading.style.display = 'none';
inviteError.style.display = 'block';
errorMessage.textContent = data.error;
// Retry hints can now be sub-hour: the shared Tor ceiling uses a
// 60-minute window, so values arrive anywhere in 0..3600s.
// Rounding everything up to hours told a user waiting 90 seconds
// to come back in "approximately 1 hour(s)".
// Note `!= null` rather than truthiness, so a legitimate 0 is
// rendered instead of leaving a stale message from a prior try.
if (data.retry_after_seconds != null) {
const secs = Math.max(0, Math.round(data.retry_after_seconds));
let when;
if (secs < 60) {
when = `${Math.max(1, secs)} second(s)`;
} else if (secs < 3600) {
when = `${Math.ceil(secs / 60)} minute(s)`;
} else {
when = `${Math.ceil(secs / 3600)} hour(s)`;
}
retryMessage.textContent = `You can try again in approximately ${when}.`;
} else {
retryMessage.textContent = '';
}
return;
}

if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}

const data = await response.json();
const data = await requestInvite();
const inviteCode = data.invite_code;
const riverUrl = `${riverBaseUrl}?invitation=${encodeURIComponent(inviteCode)}`;

Expand All @@ -253,10 +358,7 @@
inviteResult.style.display = 'block';

} catch (error) {
inviteLoading.style.display = 'none';
inviteError.style.display = 'block';
errorMessage.textContent = `Error: ${error.message}. Please try again later.`;
retryMessage.textContent = '';
showInviteError(error);
}
});
});
Expand Down
8 changes: 8 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions rust/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ env_logger = "0.11.3"
base64 = "0.22.1"
rand_core = "0.6.4"
sha2 = "0.10.6"
hmac = "0.12"
clap = { version = "4.3", features = ["derive", "env"] }
rand = "0.8"
fantoccini = "0.21.0"
Expand All @@ -32,6 +33,7 @@ serde = { version = "1.0", features = ["derive"] }
blind-rsa-signatures = "0.15.1"
ciborium = "0.2"
bs58 = "0.5"
data-encoding = "2.3.3"
# Tor exit-list fetch.
#
# NOTE: `rustls-tls` enables `rustls/ring`, while axum-server's `tls-rustls`
Expand Down
39 changes: 30 additions & 9 deletions rust/api/src/invite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! This module replicates the necessary types from river-core to generate
//! room invitations without depending on the full river-core crate.

use data_encoding::BASE32;
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use thiserror::Error;
Expand All @@ -26,6 +27,17 @@ pub struct FastHash(pub i64);
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MemberId(pub FastHash);

impl MemberId {
/// The same 8-character id shown by River and `riverctl member list`.
pub fn short(&self) -> String {
BASE32
.encode(&self.0 .0.to_le_bytes())
.chars()
.take(8)
.collect()
}
}

impl From<&VerifyingKey> for MemberId {
fn from(vk: &VerifyingKey) -> Self {
MemberId(fast_hash(&vk.to_bytes()))
Expand Down Expand Up @@ -70,6 +82,11 @@ pub enum InviteError {
Serialization(String),
}

pub struct CreatedInvitation {
pub code: String,
pub member_id: String,
}

/// Sign a serializable struct using CBOR encoding
/// Matches river_core::util::sign_struct
fn sign_struct<T: Serialize>(message: &T, signing_key: &SigningKey) -> Signature {
Expand All @@ -89,7 +106,7 @@ fn sign_struct<T: Serialize>(message: &T, signing_key: &SigningKey) -> Signature
pub fn create_invitation(
room_owner_vk: &VerifyingKey,
inviter_signing_key: &SigningKey,
) -> Result<String, InviteError> {
) -> Result<CreatedInvitation, InviteError> {
// Generate a new signing key for the invitee
let invitee_signing_key = SigningKey::generate(&mut rand::thread_rng());
let invitee_vk = invitee_signing_key.verifying_key();
Expand Down Expand Up @@ -117,7 +134,10 @@ pub fn create_invitation(
ciborium::ser::into_writer(&invitation, &mut data)
.map_err(|e| InviteError::Serialization(e.to_string()))?;

Ok(bs58::encode(data).into_string())
Ok(CreatedInvitation {
code: bs58::encode(data).into_string(),
member_id: MemberId::from(invitee_vk).short(),
})
}

#[cfg(test)]
Expand Down Expand Up @@ -148,11 +168,12 @@ mod tests {
let owner_vk = owner_signing_key.verifying_key();

// Create invitation
let invite_code = create_invitation(&owner_vk, &owner_signing_key).unwrap();
let invite = create_invitation(&owner_vk, &owner_signing_key).unwrap();

// Verify it's valid base58
assert!(!invite_code.is_empty());
let decoded = bs58::decode(&invite_code).into_vec().unwrap();
assert!(!invite.code.is_empty());
assert_eq!(invite.member_id.len(), 8);
let decoded = bs58::decode(&invite.code).into_vec().unwrap();
assert!(!decoded.is_empty());

// Verify we can deserialize it
Expand Down Expand Up @@ -181,12 +202,12 @@ mod tests {
create_invitation(&owner_vk, &signing_key).expect("Failed to create invitation");

println!("\n=== Generated Invite for freenet-chat ===");
println!("{}", invite);
println!("Length: {} chars\n", invite.len());
println!("{}", invite.code);
println!("Length: {} chars\n", invite.code.len());

// Verify format
assert!(!invite.is_empty());
let decoded = bs58::decode(&invite).into_vec().unwrap();
assert!(!invite.code.is_empty());
let decoded = bs58::decode(&invite.code).into_vec().unwrap();
let invitation: Invitation = ciborium::de::from_reader(&decoded[..]).unwrap();
assert_eq!(invitation.room, owner_vk);
}
Expand Down
Loading
Loading