v0.3.5: Private blob encryption, blob prefetch, intent-based filtering, crypto refactoring

Private blob encryption:
- Encrypted posts (Friends/Circle/Direct) now encrypt attachment blobs with same CEK
- Public blobs unchanged, CID computed on ciphertext for private
- decrypt_blob_for_post/get_blob_for_post for transparent decryption on retrieval

Blob prefetch:
- Pull cycle and sync_with eagerly fetch missing blobs after post sync
- prefetch_blobs_from_peer scans for missing attachments, fetches via fallback chain
- Runs outside conn_mgr lock at Node level

Crypto refactoring:
- Extracted: encrypt/decrypt_bytes_with_cek, wrap/unwrap_cek_for_recipients
- unwrap_cek_for_recipient, unwrap_group_cek, random_cek
- encrypt_post_with_cek, encrypt_post_for_group_with_cek variants
- All existing functions refactored to delegate, 19 crypto tests pass

Intent-based filtering:
- intent_kind field on PostDto ("public"/"friends"/"circle"/"direct"/"unknown")
- Feed/MyPosts filter on intentKind !== 'direct' instead of visibility
- Messages filter with backward-compatible fallback for pre-intent posts
- get_post_intent storage method

IPC updates:
- resolve_blob_data helper using get_blob_for_post with network fallback
- sanitize_download_filename prevents path traversal
- get_blob_path accepts optional post_id_hex

Website:
- Mobile hamburger nav on all pages
- Mesh/Non-mesh N1 labels in network diagnostics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-20 12:44:07 -04:00
parent 0abc244ee9
commit a41b11c0b8
14 changed files with 562 additions and 325 deletions

View file

@ -628,7 +628,7 @@ async function loadStats() {
async function loadFeed(force) {
try {
const allPosts = await invoke('get_feed');
const posts = allPosts.filter(p => p.visibility !== 'encrypted-for-me');
const posts = allPosts.filter(p => p.intentKind !== 'direct');
// Fingerprint: post IDs + reaction counts + comment counts
const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _feedFingerprint) return;
@ -705,7 +705,7 @@ async function loadFeed(force) {
async function loadMyPosts(force) {
try {
const posts = await invoke('get_all_posts');
const mine = posts.filter(p => p.isMe && p.visibility !== 'encrypted-for-me' && !(p.recipients && p.recipients.length > 0));
const mine = posts.filter(p => p.isMe && p.intentKind !== 'direct');
const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
if (!force && fp === _myPostsFingerprint) return;
_myPostsFingerprint = fp;
@ -743,10 +743,12 @@ async function loadMessages(force) {
]);
const followSet = new Set(follows.map(f => f.nodeId));
// Collect DMs: received encrypted-for-me OR my sent encrypted with recipients
// Collect DMs: intent-based with fallback for old posts without intentKind
const dms = posts.filter(p => {
if (!p.isMe && p.visibility === 'encrypted-for-me') return true;
if (p.isMe && p.recipients && p.recipients.length > 0) return true;
if (p.intentKind === 'direct') return true;
// Fallback for pre-intent posts
if (p.intentKind === 'unknown' && !p.isMe && p.visibility === 'encrypted-for-me') return true;
if (p.intentKind === 'unknown' && p.isMe && p.recipients && p.recipients.length > 0) return true;
return false;
});
@ -1402,8 +1404,8 @@ async function loadNetworkSummary() {
networkSummaryEl.innerHTML = `<div class="diag-grid">
<div class="diag-item"><span class="diag-value">${s.totalConnections}</span><span class="diag-label">Connections</span></div>
<div class="diag-item"><span class="diag-value">${s.preferredCount}</span><span class="diag-label">Preferred</span></div>
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Local</span></div>
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Wide</span></div>
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Mesh</span></div>
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Non-mesh N1</span></div>
<div class="diag-item"><span class="diag-value">${s.n2Distinct}</span><span class="diag-label">N2 Reach</span></div>
<div class="diag-item"><span class="diag-value">${s.n3Distinct}</span><span class="diag-label">N3 Reach</span></div>
</div>`;
@ -1424,9 +1426,11 @@ async function loadConnections() {
const icon = generateIdenticon(c.nodeId, 18);
const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred'
: c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local';
const slotLabel = c.slotKind === 'Local' ? 'Mesh'
: c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind;
const duration = c.connectedAt ? relativeTime(c.connectedAt) : '';
return `<div class="peer-card">
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${c.slotKind}</span></div>
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${slotLabel}</span></div>
<div class="peer-card-meta"><span>${duration}</span></div>
</div>`;
}).join('');
@ -1707,7 +1711,7 @@ async function loadPostMedia(container) {
const postId = img.dataset.postId;
const mime = img.dataset.mime || 'image/jpeg';
try {
const filePath = await invoke('get_blob_path', { cidHex: cid });
const filePath = await invoke('get_blob_path', { cidHex: cid, postIdHex: postId });
if (filePath && window.__TAURI__?.core?.convertFileSrc) {
const assetUrl = window.__TAURI__.core.convertFileSrc(filePath);
img.onerror = async () => {