Compare commits

...

2 Commits

Author SHA1 Message Date
archipelago
cebbde7bde fix(ui): square mobile file tiles, files scroll clearance, apps-tab swipe guard
- Apps tab: a horizontal swipe that starts on an app icon no longer flips the
  top tab — it lets the app-page scroll / icon tap win (swipe empty space to
  change tab). Fixes the swipe conflict with two pages of apps.
- Files: file cover tiles are forced square on mobile (aspect driven by CSS,
  not a Tailwind arbitrary class) so the grid is uniform and tappable.
- Files: scroll container gets bottom safe-area + tab-bar padding so the last
  row clears the mobile back button / bottom nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:57:51 -04:00
archipelago
a0b80dd27d fix(mesh): authenticate !ai over LoRa via federation-twin binding + signed Text
A !ai (or any typed message) from a trusted, federated node was denied when
it arrived over the radio. The radio half of a node that is also a federation
peer carried no archipelago identity (identity adverts are no longer broadcast
on the public channel), so the trusted_only gate and signature verification
had no key to check the asker against — and the same node showed up as two
contacts (a radio twin + a federation twin).

- bind_federation_twins(): correlate a radio contact with its federation twin
  by exact, case-insensitive advert_name and copy the federation peer's
  arch_pubkey_hex/did/x25519 onto the radio record. Called from
  upsert_federation_peer and refresh_contacts. Ambiguous names (held by >1
  federation peer) are skipped. This is only a CANDIDATE key — security is
  unchanged: the inbound envelope signature must still verify against it.
- send_message now signs the typed Text envelope (new_signed) so a radio !ai
  authenticates against the bound key. A meshcore node merely named like a
  trusted node cannot forge the signature, so it is still denied.

Receiver-side verification (handle_typed_envelope_direct) and federation-trust
matching (is_sender_allowed) already existed; this supplies the missing key
binding and signature. Also resolves the radio/federation duplicate-contact
display for same-named nodes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:57:50 -04:00
5 changed files with 118 additions and 3 deletions

View File

@ -397,6 +397,14 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
};
peers.insert(contact_id, peer);
}
// A radio contact that shares an exact advert_name with a known
// federation peer is the same physical node — bind the federation
// peer's archipelago identity onto the radio record so a signed
// `!ai`/typed message over LoRa authenticates (and the contact stops
// showing as a radio/federation duplicate). Security is unchanged:
// the bound key is only a candidate the inbound signature must still
// verify against. See `bind_federation_twins`.
super::super::bind_federation_twins(&mut peers);
drop(peers);
state.update_peer_count().await;
if !contacts.is_empty() {

View File

@ -61,6 +61,72 @@ pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
0x8000_0000 | (low & 0x7FFF_FFFF)
}
/// Bind radio (LoRa) contacts to their federation twin's archipelago identity.
///
/// The same physical node commonly appears twice in the peer table: a radio
/// contact (low `contact_id`, firmware routing key only, `arch_pubkey_hex ==
/// None`) and a federation peer (high `contact_id`, `arch_pubkey_hex` set). The
/// radio half carries no archipelago identity because identity adverts are no
/// longer broadcast on the public channel (anti-spam), so the `!ai` trust gate
/// and envelope signature verification have no key to check a radio asker
/// against — a `!ai` from a trusted node over LoRa is therefore denied, and the
/// node shows up as two separate contacts.
///
/// We correlate the two halves by exact, case-insensitive `advert_name` and copy
/// the federation peer's `arch_pubkey_hex`/`did`/`x25519` onto the radio peer.
/// This only supplies a CANDIDATE identity key; it does NOT bypass
/// authentication. A radio envelope must still carry an Ed25519 signature that
/// verifies against this bound key (see `handle_typed_envelope_direct`), so a
/// meshcore node merely *named* like a trusted node cannot impersonate it — it
/// cannot produce the signature. The candidate key comes from the authenticated
/// federation handshake (`nodes.json`), never from anything the radio packet
/// claims. Names held by more than one federation peer are treated as ambiguous
/// and skipped so a duplicate name can't bind the wrong identity.
pub(crate) fn bind_federation_twins(peers: &mut std::collections::HashMap<u32, MeshPeer>) {
// name (lowercased) -> federation identity; `None` marks an ambiguous name
// (seen on more than one federation peer) which we must not bind.
type FedIdentity = (String, Option<String>, Option<[u8; 32]>);
let mut fed_by_name: std::collections::HashMap<String, Option<FedIdentity>> =
std::collections::HashMap::new();
for p in peers.values() {
if p.contact_id < FEDERATION_CONTACT_ID_BASE {
continue;
}
let Some(arch) = p.arch_pubkey_hex.clone() else {
continue;
};
let name = p.advert_name.trim().to_ascii_lowercase();
if name.is_empty() {
continue;
}
fed_by_name
.entry(name)
.and_modify(|e| *e = None) // a second federation peer with this name → ambiguous
.or_insert(Some((arch, p.did.clone(), p.x25519_pubkey)));
}
if fed_by_name.is_empty() {
return;
}
for p in peers.values_mut() {
if p.contact_id >= FEDERATION_CONTACT_ID_BASE || p.arch_pubkey_hex.is_some() {
continue;
}
let name = p.advert_name.trim().to_ascii_lowercase();
if name.is_empty() {
continue;
}
if let Some(Some((arch, did, x25519))) = fed_by_name.get(&name) {
p.arch_pubkey_hex = Some(arch.clone());
if p.did.is_none() {
p.did = did.clone();
}
if p.x25519_pubkey.is_none() {
p.x25519_pubkey = *x25519;
}
}
}
}
/// Upsert a mesh peer record representing a federation node so the UI can
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
/// Existing entries (same contact_id) are updated in place, preserving any
@ -96,6 +162,11 @@ pub(crate) async fn upsert_federation_peer(
reachable: true,
};
peers.insert(contact_id, peer);
// A radio twin of this node (same advert_name, no arch identity yet) can now
// inherit this federation peer's archipelago key — so a signed `!ai`/typed
// message arriving over LoRa from it authenticates and the duplicate radio
// contact resolves to the same identity.
bind_federation_twins(&mut peers);
drop(peers);
state.update_peer_count().await;
contact_id
@ -1332,8 +1403,18 @@ impl MeshService {
.record_sent_typed(contact_id, "text", text, None, seq)
.await);
}
// Sign the envelope with our archipelago identity key so the receiver
// can authenticate us over LoRa (it verifies against our bound
// `arch_pubkey_hex`). This is what lets a `!ai` typed in chat to a
// trusted node pass the receiver's `trusted_only` gate over the radio —
// an unsigned radio packet can never authenticate. The signature is
// optional on the wire and ignored by peers that don't know our key, so
// it stays backward compatible. (Federation/Tor sends already sign in
// `send_typed_wire_via_federation`.) `with_seq` is applied after signing
// — seq is not covered by the signature.
let envelope =
TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq);
TypedEnvelope::new_signed(MeshMessageType::Text, text.as_bytes().to_vec(), &self.signing_key)
.with_seq(seq);
let wire = envelope.to_wire()?;
self.send_typed_wire(contact_id, wire, "text", text, None, seq)
.await

View File

@ -6,7 +6,7 @@
@click="handleClick"
>
<!-- Cover / Thumbnail area -->
<div class="cloud-grid-card-cover" :class="aspectClass">
<div class="cloud-grid-card-cover">
<!-- Image thumbnail -->
<img
v-if="isImage && thumbnailUrl && !imgFailed"
@ -155,7 +155,9 @@ const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value
// Uniform card cover ratio across every file type so folders, images, videos
// and documents all render at the same height in the grid (previously images/
// videos were square while folders were 4/3, giving a ragged, mismatched grid).
const aspectClass = computed(() => 'aspect-[4/3]')
// Aspect is now driven entirely by .cloud-grid-card-cover CSS (4/3 desktop,
// square on mobile) so the ratio is deterministic regardless of Tailwind layer
// ordering.
const coverBg = computed(() => {
if (props.item.isDir) return 'bg-amber-500/10'

View File

@ -1827,6 +1827,22 @@ html.modal-scroll-locked .dashboard-scroll-panel {
}
}
/* Mobile: square, tappable tiles + bottom clearance so the last row scrolls
above the tab bar / back button (matches .mobile-scroll-pad). */
@media (max-width: 767px) {
.cloud-card-grid,
.cloud-file-list {
padding-bottom: calc(
var(--mobile-tab-bar-height, 88px) +
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) +
var(--audio-player-height, 0px) + 24px
);
}
.cloud-grid-card-cover {
aspect-ratio: 1 / 1;
}
}
.cloud-grid-card {
display: flex;
flex-direction: column;
@ -1856,6 +1872,9 @@ html.modal-scroll-locked .dashboard-scroll-panel {
.cloud-grid-card-cover {
position: relative;
width: 100%;
/* Fallback aspect when the Tailwind aspect-[4/3] utility is unavailable, so
the cover never collapses to zero height. */
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 0.625rem;
}

View File

@ -290,14 +290,19 @@ function activeNetKey(): string {
let touchStartX = 0
let touchStartY = 0
let touchStartTime = 0
let swipeSuppressed = false
function onContentTouchStart(e: TouchEvent) {
const t = e.touches[0]
if (!t) return
// Don't begin a tab swipe when the gesture starts on an app icon let the
// icon handle the tap/long-press. Swiping anywhere else still changes tabs.
swipeSuppressed = !!(e.target instanceof Element && e.target.closest('.app-icon-item'))
touchStartX = t.clientX
touchStartY = t.clientY
touchStartTime = e.timeStamp
}
function onContentTouchEnd(e: TouchEvent) {
if (swipeSuppressed) { swipeSuppressed = false; return }
const t = e.changedTouches[0]
if (!t) return
const dx = t.clientX - touchStartX