Building a Privacy-First Forum Platform

Written by Terrell Flautt on October 8, 2025

· 10 min · forum.snapitsoftware.com

The Chimera Vision

Problem: Discord, Reddit, and traditional forums sacrifice user privacy for convenience.
Solution: Zero-knowledge encrypted forum platform that literally cannot read your messages.

Architecture

React PWA Frontend → CloudFront → API Gateway
                    ↓
    Lambda Functions → DynamoDB + S3
                    ↓
        PGP 4096-bit End-to-End Encryption

Zero-Knowledge Principle

The server never has access to unencrypted content. All encryption/decryption happens client-side using PGP keys generated in the browser.

Core Features

1. End-to-End PGP Encryption

// Client-side message encryption
import openpgp from 'openpgp';

async function encryptMessage(message, recipientPublicKey) {
    const encrypted = await openpgp.encrypt({
        message: await openpgp.createMessage({ text: message }),
        encryptionKeys: await openpgp.readKey({ armoredKey: recipientPublicKey }),
        format: 'armored'
    });

    return encrypted;
}

async function decryptMessage(encryptedMessage, privateKey, passphrase) {
    const message = await openpgp.readMessage({ armoredMessage: encryptedMessage });
    const privateKeyObj = await openpgp.decryptKey({
        privateKey: await openpgp.readPrivateKey({ armoredKey: privateKey }),
        passphrase
    });

    const { data: decrypted } = await openpgp.decrypt({
        message,
        decryptionKeys: privateKeyObj
    });

    return decrypted;
}

2. Ephemeral Messaging

// Auto-delete after 60 seconds
const MESSAGE_TTL = 60;

exports.createMessage = async (event) => {
    const { encryptedContent, channelId } = JSON.parse(event.body);

    const message = {
        id: uuidv4(),
        channelId,
        encryptedContent,  // Server only stores encrypted blobs
        createdAt: Date.now(),
        ttl: Math.floor(Date.now() / 1000) + MESSAGE_TTL
    };

    await dynamodb.put({
        TableName: 'Messages',
        Item: message
    }).promise();

    // Publish to WebSocket subscribers
    await notifyChannel(channelId, message);

    return { statusCode: 200, body: JSON.stringify(message) };
};

3. Anonymous Forums

Users don't need accounts to participate. Temporary pseudonymous identities are generated per session.

// Session-based anonymous identity
function generateAnonymousId() {
    const sessionId = crypto.randomUUID();
    const fingerprint = generateFingerprint();

    return {
        displayName: `Anon_${sessionId.slice(0, 8)}`,
        sessionId,
        fingerprint,  // For anti-spam, not tracking
        publicKey: null  // Generated client-side if user wants encryption
    };
}

4. Dead Man's Switch

// Automatic message release if user doesn't check in
exports.checkDeadManSwitches = async () => {
    const switches = await dynamodb.query({
        TableName: 'DeadManSwitches',
        FilterExpression: 'lastCheckIn < :threshold',
        ExpressionAttributeValues: {
            ':threshold': Date.now() - (24 * 60 * 60 * 1000)  // 24 hours
        }
    }).promise();

    for (const switchItem of switches.Items) {
        // Release the encrypted message to designated recipients
        await releaseEncryptedMessage(switchItem.messageId, switchItem.recipients);
    }
};

5. Whistleblower-Safe Polling

// Zero-knowledge polls with encrypted votes
async function createPoll(question, options, anonymous = true) {
    const pollId = uuidv4();

    // Generate unique encryption key for this poll
    const pollKey = await openpgp.generateKey({
        type: 'rsa',
        rsaBits: 2048,
        userIDs: [{ name: `Poll ${pollId}` }]
    });

    await dynamodb.put({
        TableName: 'Polls',
        Item: {
            id: pollId,
            question: await encryptForPoll(question, pollKey.publicKey),
            options: await Promise.all(
                options.map(opt => encryptForPoll(opt, pollKey.publicKey))
            ),
            anonymous,
            privateKey: pollKey.privateKey,  // Only admin can decrypt results
            votes: []
        }
    }).promise();

    return pollId;
}

Progressive Web App

Service Worker for Offline Access

// sw.js - Offline-first forum
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((response) => {
            if (response) return response;

            return fetch(event.request).then((response) => {
                // Cache encrypted messages for offline viewing
                if (event.request.url.includes('/api/messages')) {
                    const responseClone = response.clone();
                    caches.open('forum-messages-v1').then((cache) => {
                        cache.put(event.request, responseClone);
                    });
                }
                return response;
            });
        })
    );
});

Mobile App (React Native)

Same codebase, native performance. Biometric authentication for key storage.

import * as LocalAuthentication from 'expo-local-authentication';
import * as SecureStore from 'expo-secure-store';

async function storePrivateKey(privateKey) {
    const hasAuth = await LocalAuthentication.hasHardwareAsync();

    if (hasAuth) {
        await LocalAuthentication.authenticateAsync({
            promptMessage: 'Authenticate to secure your encryption keys'
        });

        await SecureStore.setItemAsync('pgp_private_key', privateKey, {
            keychainAccessible: SecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY
        });
    }
}

Security Features

No User Tracking

Rate Limiting Without Identity

// Rate limit based on fingerprint, not user ID
function getRateLimitKey(event) {
    const fingerprint = event.headers['x-fingerprint'];
    const ip = event.requestContext.identity.sourceIp;

    // Combine but hash to prevent tracking
    return crypto
        .createHash('sha256')
        .update(`${fingerprint}:${ip}`)
        .digest('hex');
}

async function checkRateLimit(key) {
    const count = await redis.incr(key);
    await redis.expire(key, 60);  // 1 minute window

    return count <= 10;  // 10 requests per minute
}

Deployment

Infrastructure as Code

service: snapit-forum-api

provider:
  name: aws
  runtime: nodejs18.x
  environment:
    MESSAGES_TABLE: ${self:service}-messages-${self:provider.stage}
    POLLS_TABLE: ${self:service}-polls-${self:provider.stage}

functions:
  createMessage:
    handler: messages.create
    events:
      - http:
          path: messages
          method: post
          cors: true

  wsConnect:
    handler: websocket.connect
    events:
      - websocket:
          route: $connect

  wsDisconnect:
    handler: websocket.disconnect
    events:
      - websocket:
          route: $disconnect

What Makes Chimera Different

Tech Stack Summary

Try Chimera Forum →