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
512 changes: 512 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions relay-server/relay-server-enhanced.js
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,7 @@ async function setSecureSession(res, req, user) {
}
const jwt = createJWT({ sub: user.sub, email: user.email });
appendSetCookie(res, `sessionId=${sessionId}; HttpOnly; Path=/; SameSite=None; Secure; Max-Age=604800`);
appendSetCookie(res, `jwt=${jwt}; Path=/; SameSite=None; Secure; Max-Age=604800`);
appendSetCookie(res, `jwt=${jwt}; HttpOnly; Path=/; SameSite=None; Secure; Max-Age=604800`);
return { sessionId, jwt };
}

Expand Down Expand Up @@ -1354,7 +1354,7 @@ server.on('request', async (req, res) => {
const cookie = req.headers['cookie'] || '';
const sid = cookie.split(';').find(c => c.trim().startsWith('sessionId='))?.split('=')[1];
if (sid) { sessions.delete(sid); if (db) await db.execute(`DELETE FROM sessions WHERE session_id = ?`, [sid]); }
res.setHeader('Set-Cookie', ['sessionId=; HttpOnly; Path=/; SameSite=None; Secure; Max-Age=0', 'jwt=; Path=/; SameSite=None; Secure; Max-Age=0']);
res.setHeader('Set-Cookie', ['sessionId=; HttpOnly; Path=/; SameSite=None; Secure; Max-Age=0', 'jwt=; HttpOnly; Path=/; SameSite=None; Secure; Max-Age=0']);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); return;
}
Expand Down
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ const config = {
get api() { return api(); },
},

/** Trusted backend origin for auth/session-gated requests */
auth: {
get api() { return defaults.api; },
},

/** Server-wide encryption settings (mutable at runtime) */
encryption: {
/** Whether all content should be encrypted by default */
Expand Down
14 changes: 9 additions & 5 deletions src/services/auditService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export class AuditService {
private static readonly CLOUD_USER_KEY = 'interpoll_cloud_user';
private static readonly RETURN_URL_KEY = 'interpoll_auth_return_url';

private static getTrustedApiBase(): string {
return config.auth.api;
}

static async logReceipt(type: ReceiptKind, payload: any): Promise<void> {
try {
const body = await IntegrityService.seal(
Expand Down Expand Up @@ -52,7 +56,7 @@ export class AuditService {
{ pollId, deviceId, requireLogin } as Record<string, unknown>,
'vote-authorize',
);
const res = await fetch(`${config.relay.api}/api/vote-authorize`, {
const res = await fetch(`${this.getTrustedApiBase()}/api/vote-authorize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -98,7 +102,7 @@ export class AuditService {
{ pollId, deviceId, reservationToken, requireLogin } as Record<string, unknown>,
'vote-confirm',
);
const res = await fetch(`${config.relay.api}/api/vote-confirm`, {
const res = await fetch(`${this.getTrustedApiBase()}/api/vote-confirm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand All @@ -124,7 +128,7 @@ export class AuditService {
{ pollId, requireLogin } as Record<string, unknown>,
'poll-policy',
);
const res = await fetch(`${config.relay.api}/api/poll-policy`, {
const res = await fetch(`${this.getTrustedApiBase()}/api/poll-policy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
Expand All @@ -138,7 +142,7 @@ export class AuditService {

static async getCloudUser(): Promise<Record<string, unknown> | null> {
try {
const res = await fetch(`${config.relay.api}/api/me`, {
const res = await fetch(`${this.getTrustedApiBase()}/api/me`, {
method: 'GET',
credentials: 'include',
});
Expand Down Expand Up @@ -190,7 +194,7 @@ export class AuditService {
}

static startOAuthLogin(provider: 'google' | 'microsoft' = 'google'): void {
window.location.href = `${config.relay.api}/auth/${provider}/start`;
window.location.href = `${this.getTrustedApiBase()}/auth/${provider}/start`;
}

private static clearCachedCloudUser(): void {
Expand Down
67 changes: 51 additions & 16 deletions src/services/chatService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// chatService.ts - P2P Chat Service for Vue

import { GunService } from './gunService';
import { StorageService } from './storageService';

export interface ChatMessage {
id: string;
Expand All @@ -20,6 +21,7 @@ export interface RecipientInfo {
}

class ChatService {
private static readonly KEYPAIR_STORAGE_PREFIX = 'chat-keypair';
private ws: WebSocket | null = null;
private wsUrl: string;
private userId: string;
Expand Down Expand Up @@ -67,38 +69,71 @@ class ChatService {

// ── RSA Key Management ──────────────────────────────────────────────────────

private getKeypairStorageKey(): string {
return `${ChatService.KEYPAIR_STORAGE_PREFIX}:${this.userId}`;
}

private getLegacyKeypairStorageKey(): string {
return `chat-keypair-${this.userId}`;
}

private async persistKeyPair(keyPair: CryptoKeyPair): Promise<void> {
await StorageService.setMetadata(this.getKeypairStorageKey(), keyPair);
}

private isStoredKeyPair(value: unknown): value is CryptoKeyPair {
return !!value
&& typeof value === 'object'
&& 'publicKey' in value
&& 'privateKey' in value;
}

private async loadOrGenerateKeyPair(): Promise<CryptoKeyPair> {
try {
const stored = localStorage.getItem(`chat-keypair-${this.userId}`);
if (stored) {
const { privateKey, publicKey } = JSON.parse(stored);
const stored = await StorageService.getMetadata(this.getKeypairStorageKey());
if (this.isStoredKeyPair(stored)) {
return stored;
}
} catch (error) {
console.warn('Failed to load stored chat keypair:', error);
}

const legacy = localStorage.getItem(this.getLegacyKeypairStorageKey());
if (legacy) {
try {
const parsed = JSON.parse(legacy);
if (typeof parsed?.publicKey !== 'string' || typeof parsed?.privateKey !== 'string') {
throw new Error('Legacy chat keypair is malformed');
}
const pub = await crypto.subtle.importKey(
'spki',
Uint8Array.from(atob(publicKey), c => c.charCodeAt(0)),
Uint8Array.from(atob(parsed.publicKey), c => c.charCodeAt(0)),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
true, ['encrypt']
true,
['encrypt'],
);
const priv = await crypto.subtle.importKey(
'pkcs8',
Uint8Array.from(atob(privateKey), c => c.charCodeAt(0)),
Uint8Array.from(atob(parsed.privateKey), c => c.charCodeAt(0)),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
true, ['decrypt']
false,
['decrypt'],
);
return { publicKey: pub, privateKey: priv };
const keyPair = { publicKey: pub, privateKey: priv };
await this.persistKeyPair(keyPair);
localStorage.removeItem(this.getLegacyKeypairStorageKey());
return keyPair;
} catch (error) {
localStorage.removeItem(this.getLegacyKeypairStorageKey());
console.warn('Failed to migrate legacy chat keypair:', error);
}
} catch {
// Corrupt stored key — regenerate
}

const pair = await crypto.subtle.generateKey(
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true, ['encrypt', 'decrypt']
false, ['encrypt', 'decrypt']
);

const pubExp = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey))));
const privExp = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey))));
localStorage.setItem(`chat-keypair-${this.userId}`, JSON.stringify({ publicKey: pubExp, privateKey: privExp }));

await this.persistKeyPair(pair);
return pair;
}

Expand Down
2 changes: 1 addition & 1 deletion src/services/copilot-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ All services are **static classes** — never instantiated with `new`. Initializ
| `commentService.ts` | `CommentService` | Comment CRUD in GunDB. Schnorr-signs comment content on create/edit for anti-sabotage verification (`authorPubkey`, `contentSignature`). Encrypts comment content via `EncryptionService`/`KeyVaultService` when community has an encryption key; encrypted comments are now written with redacted placeholder content/author from the first write (no plaintext pre-write), and encryption failure is fatal for encrypted communities. `decryptComment()` reverses at read time. `verifyCommentSignature()` returns `'verified' | 'unverified' | 'unsigned'`. |
| `userService.ts` | `UserService` | User profile CRUD in GunDB, keyed by device ID. Exposes Schnorr public key for identity. Supports `customUsername`, `showRealName` toggle, avatar images (`avatarIPFS`/`avatarThumbnail`), and derived identity trust metadata (`identityUsername`, `identityIssuer`, `identityTrustLevel`) based on username issuer parsing. |
| `trustService.ts` | `TrustService` | Trust-issuer discovery and verified username flow. Loads issuers from Gun (`trust-issuers`) plus a built-in Endless issuer (`https://interpoll.endless.sbs/trust`) and locally stored custom issuers, auto-hydrates missing public keys via `/public-key`, and prefers signed v2 claim flow (`/challenge-v2`, `/claim-v2`) where the client proves key ownership by signing a canonical auth payload (`authSig`) bound to challenge id/nonce/timestamp/nonce. If an issuer only supports legacy routes, it falls back to `/challenge` + `/claim` (404-only downgrade path, cached per issuer endpoint for the session). The issuer API is backward-compatible and now optionally supports provider-scoped app sessions for external auth providers (`/session/start`, `/session/me`, `/session/revoke`, `/session/actions`) so providers can scope permissions, revoke sessions, and audit actions without password handling in-app. Certificates are still verified locally before persisting verified username claims to Gun (`usernames`). PoW solving is time-bounded and uses smaller batches to keep UI responsive on slower devices. |
| `chatService.ts` | `ChatService` | **Instance-based** (not static). P2P DM chat over GunDB + WebSocket. Uses RSA-OAEP for message encryption between users. Each chat session needs `new ChatService(wsUrl, userId)`. Startup key publish now avoids redundant `chatPublicKey` rewrites when the existing Gun value already matches, reducing startup Gun churn. |
| `chatService.ts` | `ChatService` | **Instance-based** (not static). P2P DM chat over GunDB + WebSocket. Uses RSA-OAEP for message encryption between users. Each chat session needs `new ChatService(wsUrl, userId)`. Startup key publish now avoids redundant `chatPublicKey` rewrites when the existing Gun value already matches, reducing startup Gun churn. Chat keypairs are now stored as non-extractable CryptoKeys in IndexedDB metadata instead of serializing the private key into localStorage; legacy localStorage keypairs are migrated once and removed. |

## Media

Expand Down
2 changes: 1 addition & 1 deletion src/views/ResiliencePage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@
</ion-item>
<div v-if="expandedGuide === 'tor'" class="px-4 pb-3 text-sm opacity-80">
<ol class="list-decimal list-inside space-y-1">
<li>Download and install <a href="https://www.torproject.org" target="_blank" class="text-blue-400 underline">Tor Browser</a>.</li>
<li>Download and install <a href="https://www.torproject.org" target="_blank" rel="noopener noreferrer" class="text-blue-400 underline">Tor Browser</a>.</li>
<li>Open InterPoll in Tor Browser using the app URL.</li>
<li>Go to <strong>Settings → Network</strong> and add a <code>.onion</code> relay address.</li>
<li>The app will automatically detect Tor Browser and route traffic accordingly.</li>
Expand Down
17 changes: 13 additions & 4 deletions src/views/SettingsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1970,6 +1970,15 @@ function shortenUrl(url: string): string {
}
}

function escapeHtml(value: string): string {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function endpointToKnownServer(
endpoint: BootstrapEndpoint,
options: { addedBy: string; source: KnownServer['source']; signatureValid: boolean },
Expand Down Expand Up @@ -2152,9 +2161,9 @@ async function importBootstrapInvite() {
const switchDisabled = probe.overall === 'offline';
const hasSignatureMetadata = Boolean(artifact.signature?.alg && artifact.signature?.sig);
const signatureLabel = hasSignatureMetadata ? 'present' : 'none';
const sourcePeerLabel = artifact.handoff?.sourcePeerId || artifact.meta?.createdBy || 'unknown';
const sourcePeerLabel = escapeHtml(artifact.handoff?.sourcePeerId || artifact.meta?.createdBy || 'unknown');
const status = artifact.handoff?.status;
const connectedServerLabel = artifact.handoff?.connectedServer?.websocket || artifact.endpoint.websocket;
const connectedServerLabel = escapeHtml(artifact.handoff?.connectedServer?.websocket || artifact.endpoint.websocket);
const message = [
`Probe: ${probe.overall}`,
`WS: ${probe.ws.reachable ? 'ok' : 'fail'} · Gun: ${probe.gun.reachable ? 'ok' : 'fail'} · API: ${probe.api.reachable ? 'ok' : 'fail'}`,
Expand Down Expand Up @@ -2440,7 +2449,7 @@ async function probeAndSwitchToServer(server: KnownServer) {
}

const alert = await alertController.create({
header: `Switch to ${shortenUrl(server.websocket)}?`,
header: `Switch to ${escapeHtml(shortenUrl(server.websocket))}?`,
message: [
`Probe: ${probe.overall}`,
`WS: ${probe.ws.reachable ? 'ok' : 'fail'} · Gun: ${probe.gun.reachable ? 'ok' : 'fail'} · API: ${probe.api.reachable ? 'ok' : 'fail'}`,
Expand Down Expand Up @@ -2725,4 +2734,4 @@ const confirmClearAll = async () => {

await alert.present();
};
</script>
</script>
Loading