Reconcile the accepted safe federation helper with E2EE integration hardening and complete PIN-based encrypted DMs
Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0 Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908 Hop-Task: T_06FPAWA3279RBMJAR0BHM10 Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR
This commit is contained in:
@@ -10,6 +10,7 @@ PORT=43821
|
|||||||
|
|
||||||
# Required: used for sessions, node key encryption, and bot API key encryption
|
# Required: used for sessions, node key encryption, and bot API key encryption
|
||||||
AUTH_SECRET=replace-with-a-long-random-secret
|
AUTH_SECRET=replace-with-a-long-random-secret
|
||||||
|
E2EE_RECOVERY_SECRET=replace-with-a-separate-long-random-secret
|
||||||
|
|
||||||
# Required for admin access. Register with one of these emails locally.
|
# Required for admin access. Register with one of these emails locally.
|
||||||
ADMIN_EMAILS=admin@example.com
|
ADMIN_EMAILS=admin@example.com
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ Stuffbox is the default integration. New installs use `https://stuffbox.xyz`; se
|
|||||||
|
|
||||||
User-owned S3-compatible storage remains available as an advanced fallback. Supported providers include AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo.
|
User-owned S3-compatible storage remains available as an advanced fallback. Supported providers include AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo.
|
||||||
|
|
||||||
|
## Encrypted direct messages
|
||||||
|
|
||||||
|
New one-to-one text DMs use client-side end-to-end encryption with a separate PIN for new-device recovery. The PIN does not change the normal login flow or ordinary sending experience. Node installs generate a distinct `E2EE_RECOVERY_SECRET`; preserve it in a secret manager or protected operational backup because the database alone cannot restore PIN recovery.
|
||||||
|
|
||||||
|
V1 is intentionally limited and experimental: metadata remains visible, legacy DMs remain plaintext, and it does not provide forward secrecy or protection from a malicious home node that serves modified client code. See [the E2EE DM security and operations contract](docs/e2ee-dms.md) before enabling or describing the feature.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -38,12 +38,14 @@ chown -R synapsis:synapsis "$APP_DIR"
|
|||||||
|
|
||||||
if [[ ! -e "$ENV_FILE" ]]; then
|
if [[ ! -e "$ENV_FILE" ]]; then
|
||||||
auth_secret="$(openssl rand -base64 48 | tr -d '\n')"
|
auth_secret="$(openssl rand -base64 48 | tr -d '\n')"
|
||||||
|
e2ee_recovery_secret="$(openssl rand -base64 48 | tr -d '\n')"
|
||||||
port="43821"
|
port="43821"
|
||||||
install -m 0600 -o root -g synapsis /dev/null "$ENV_FILE"
|
install -m 0600 -o root -g synapsis /dev/null "$ENV_FILE"
|
||||||
{
|
{
|
||||||
echo "DATABASE_PATH=$DATA_DIR/synapsis.db"
|
echo "DATABASE_PATH=$DATA_DIR/synapsis.db"
|
||||||
echo "PORT=$port"
|
echo "PORT=$port"
|
||||||
echo "AUTH_SECRET=$auth_secret"
|
echo "AUTH_SECRET=$auth_secret"
|
||||||
|
echo "E2EE_RECOVERY_SECRET=$e2ee_recovery_secret"
|
||||||
echo "ADMIN_EMAILS=admin@example.com"
|
echo "ADMIN_EMAILS=admin@example.com"
|
||||||
echo "NEXT_PUBLIC_NODE_DOMAIN=localhost:$port"
|
echo "NEXT_PUBLIC_NODE_DOMAIN=localhost:$port"
|
||||||
echo "STUFFBOX_URL=https://stuffbox.xyz"
|
echo "STUFFBOX_URL=https://stuffbox.xyz"
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ set -a
|
|||||||
source "$ENV_FILE"
|
source "$ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
|
|
||||||
|
# Existing installations predate encrypted-message recovery. Enroll them with
|
||||||
|
# a distinct stable secret before migrations/builds make E2EE available.
|
||||||
|
if [[ -z "${E2EE_RECOVERY_SECRET:-}" ]]; then
|
||||||
|
command -v openssl >/dev/null || { echo "Missing required command: openssl" >&2; exit 1; }
|
||||||
|
E2EE_RECOVERY_SECRET="$(openssl rand -base64 48 | tr -d '\n')"
|
||||||
|
printf '\nE2EE_RECOVERY_SECRET=%s\n' "$E2EE_RECOVERY_SECRET" >> "$ENV_FILE"
|
||||||
|
export E2EE_RECOVERY_SECRET
|
||||||
|
echo "Added E2EE_RECOVERY_SECRET to $ENV_FILE. Back it up separately from the database."
|
||||||
|
fi
|
||||||
|
|
||||||
install_update_units() {
|
install_update_units() {
|
||||||
units_changed=0
|
units_changed=0
|
||||||
for unit in synapsis.service synapsis-maintenance.service synapsis-update.service synapsis-update.timer; do
|
for unit in synapsis.service synapsis-maintenance.service synapsis-update.service synapsis-update.timer; do
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# End-to-end encrypted direct messages (v1)
|
||||||
|
|
||||||
|
This document defines the security and product contract for Synapsis E2EE DMs v1. It is not a security audit or a claim that the feature is suitable for high-risk use.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
V1 encrypts the UTF-8 text body of new one-to-one direct messages. Existing messages are not protected retroactively. Group chats, images, video, audio, files, reactions, edits, calls, and server-generated link previews are not covered.
|
||||||
|
|
||||||
|
Routing metadata remains visible to the participating home nodes: sender and recipient identities and node domains, message and conversation identifiers, key versions, timestamps, delivery/read state, typing state, ciphertext size, request logs, and the social graph.
|
||||||
|
|
||||||
|
## Key and message flow
|
||||||
|
|
||||||
|
Each account has one static X25519 key pair in v1. The browser generates it during encrypted-message setup. Its public key, identifier, monotonic version, and replacement link are signed by the account's existing DID signing key.
|
||||||
|
|
||||||
|
For every message, the sender's browser:
|
||||||
|
|
||||||
|
1. Resolves and verifies the recipient's signed X25519 public-key record.
|
||||||
|
2. Generates a fresh random content key and XChaCha20-Poly1305 nonce.
|
||||||
|
3. Encrypts the text locally with authenticated sender, recipient, conversation, message, timestamp, protocol, and key-version context.
|
||||||
|
4. Seals the content key and a transcript hash independently to the sender and recipient X25519 keys. The sender copy makes sent history available on the sender's enrolled devices.
|
||||||
|
5. Computes a keyed commitment over the authenticated context, nonce, and ciphertext.
|
||||||
|
6. Signs the complete encrypted envelope with the existing DID signing key.
|
||||||
|
|
||||||
|
Nodes authorize, route, federate, and store the signed ciphertext envelope. They receive no plaintext body or content key. Server-side previews are generic. The recipient verifies the signature, bindings, commitment, and AEAD authentication before displaying locally decrypted text.
|
||||||
|
|
||||||
|
Delivery wrappers are node-signed and bind the source node, destination node, route, delivery identifier, and expiry. Durable message receipts make retries idempotent and prevent a deleted conversation from being recreated by replaying an old valid envelope.
|
||||||
|
|
||||||
|
Federation requests use bounded, redirect-free HTTPS with public-address DNS validation and per-request DNS pinning. Exact loopback HTTP is allowed only in development. Remote key-cache updates use compare-and-swap continuity checks so concurrent lookups cannot roll a cached key backward.
|
||||||
|
|
||||||
|
## PIN and device experience
|
||||||
|
|
||||||
|
The encrypted-message PIN is separate from login:
|
||||||
|
|
||||||
|
- Setup happens only when Chat is first opened, not during login and not for every message.
|
||||||
|
- The PIN is 6–12 digits and is processed in the browser. The raw PIN is not sent to the node.
|
||||||
|
- A recognized browser stores the account key under a non-extractable IndexedDB wrapping key, so ordinary visits do not prompt again. Logout clears active application memory while retaining this protected recognized-device record.
|
||||||
|
- A new or cleared browser completes normal login, then enters the encrypted-message PIN once to recover the same account key.
|
||||||
|
- Failed recovery attempts are stored durably. Ten failures lock recovery for one hour.
|
||||||
|
- A forgotten-PIN reset requires the current account password and creates a new encryption key. In v1, old encrypted history no longer opens after that reset; there is no historical-key recovery UI.
|
||||||
|
|
||||||
|
There is no administrator plaintext-recovery path. Reset does not decrypt or re-encrypt old messages.
|
||||||
|
|
||||||
|
## Recovery service
|
||||||
|
|
||||||
|
The private account key is encrypted by a key derived from both Argon2id PIN material and a random server contribution. The database stores the encrypted vault, an HMAC-protected PIN verifier, an encrypted server contribution, and the attempt state.
|
||||||
|
|
||||||
|
Every node must set `E2EE_RECOVERY_SECRET` to an independent, high-entropy secret. It must not reuse `AUTH_SECRET`, use a `NEXT_PUBLIC_` name, enter source control, appear in logs, or be included in ordinary exports. Recovery fails closed when it is absent or too short.
|
||||||
|
|
||||||
|
This secret is stable node state. Losing or changing it without a deliberate migration makes existing PIN recovery records unusable. A complete node backup needs the database and this secret stored separately in an operational secret manager.
|
||||||
|
|
||||||
|
The v1 counter is ordinary application/database enforcement. It is not a hardware-enforced, HSM-backed, threshold, or multi-operator guess limit. Anyone who compromises the running node and its secrets can test the small PIN space offline.
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
With uncompromised endpoints and the reviewed client build, v1 is intended to keep supported message bodies confidential from:
|
||||||
|
|
||||||
|
- a database or backup reader who does not also have the recovery secret;
|
||||||
|
- network intermediaries, in addition to the protection provided by TLS; and
|
||||||
|
- honest participating nodes that run the published code and do not capture endpoint secrets.
|
||||||
|
|
||||||
|
V1 does not protect message bodies from:
|
||||||
|
|
||||||
|
- a malicious home node that serves modified JavaScript capable of reading PINs, keys, or plaintext;
|
||||||
|
- XSS, a malicious browser extension, endpoint malware, or a compromised operating system;
|
||||||
|
- a recipient who copies, reports, photographs, or otherwise discloses plaintext; or
|
||||||
|
- compromise of the static account X25519 private key.
|
||||||
|
|
||||||
|
Nodes can still drop, delay, replay, reorder, or refuse ciphertext. Replay records protect local message state, not availability. Message timing, frequency, participant metadata, and approximate size remain observable.
|
||||||
|
|
||||||
|
When a node enables Cloudflare Turnstile, its third-party login script shares the credential and account-signing-key trust boundary. A hard navigation removes that realm before Chat creates or restores the E2EE account key, but it does not make a compromised login dependency harmless. Removing or isolating third-party login JavaScript is required before making a stronger endpoint-security claim.
|
||||||
|
|
||||||
|
## Legacy history and fail-closed behavior
|
||||||
|
|
||||||
|
Existing plaintext DMs remain legacy messages and are visibly marked as sent before encryption. Their original delivery cannot be made E2EE after the fact.
|
||||||
|
|
||||||
|
New sends have no plaintext fallback. Sending remains disabled when the recipient has no signed encryption key, key resolution fails, a key version changes, the crypto runtime fails, or the protocol is unsupported. The draft remains in the composer and the user receives an actionable error.
|
||||||
|
|
||||||
|
Invalid signatures, altered envelopes, authentication failures, and messages encrypted to an old reset key are displayed as unavailable ciphertext, never as partial or fabricated plaintext.
|
||||||
|
|
||||||
|
## Export and migration limits
|
||||||
|
|
||||||
|
Account export preserves new message envelopes as ciphertext and preserves legacy messages as legacy plaintext. Fresh v1.1 exports sign a canonical digest of the complete payload. A historical v1.0 account can still migrate without that digest, but all unauthenticated DM history is discarded and the destination warns that the remaining legacy payload must be reviewed for tampering. The server does not decrypt E2EE messages for export.
|
||||||
|
|
||||||
|
V1 does not migrate the account encryption private key or recovery enrollment between home nodes. It does migrate the signed public key-continuity anchor. On first opening Chat at the destination, the user sets a PIN once and the browser signs a monotonic replacement key. Preserved old ciphertext remains unreadable on the destination. Copying a node-wide `E2EE_RECOVERY_SECRET` for one account is not acceptable.
|
||||||
|
|
||||||
|
Federated DM relationships do not automatically follow a home-node move in v1. The broader federation layer has no signed handle-move proof that updates a peer's cached `user@node` mapping and existing conversation route. A peer that cached the old full handle can therefore reject the migrated handle even though the DID and encryption-key rotation are valid. The import UI warns about this limitation; seamless federated DM migration requires a separate signed move protocol.
|
||||||
|
|
||||||
|
Cross-node “delete for everyone” is not offered in v1 because a remote node cannot guarantee deletion. Deleting a federated conversation removes only the local copy.
|
||||||
|
|
||||||
|
## Explicit limitations
|
||||||
|
|
||||||
|
- No forward secrecy: compromise of the static account private key exposes retained history encrypted to it.
|
||||||
|
- No post-compromise security: future messages remain exposed until the account key is reset and peers use the replacement.
|
||||||
|
- One account key is shared across devices; there are no per-device sessions or independent device revocation.
|
||||||
|
- No key transparency or out-of-band safety-number verification.
|
||||||
|
- PIN recovery is not X's Juicebox design and is not equivalent to an HSM-backed threshold service.
|
||||||
|
- This is not Signal Protocol and has no Double Ratchet.
|
||||||
|
- This is not X Chat's protocol or implementation; only the low-friction PIN experience is a product inspiration.
|
||||||
|
- Coverage is text-only.
|
||||||
|
- A malicious home node or maliciously served web client can steal keys and plaintext.
|
||||||
|
|
||||||
|
## Review gate
|
||||||
|
|
||||||
|
Before broad production enablement or strong security claims, obtain an independent cryptographic and application-security review. It must cover serialization, DID/key binding and rotation, X25519 validation, XChaCha20-Poly1305 and sealed-key usage, nonce generation, authenticated context, replay behavior, local persistence, recovery derivation and limits, federation checks, migrations, XSS/CSP, dependencies, logs, and failure paths.
|
||||||
|
|
||||||
|
Publish interoperable protocol test vectors and resolve high-severity findings before presenting v1 as appropriate for sensitive communication. Until then, label it experimental.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
CREATE TABLE `e2ee_key_bundles` (
|
||||||
|
`user_id` text PRIMARY KEY,
|
||||||
|
`did` text NOT NULL,
|
||||||
|
`key_id` text NOT NULL,
|
||||||
|
`key_version` integer NOT NULL,
|
||||||
|
`public_key` text NOT NULL,
|
||||||
|
`proof_action` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
CONSTRAINT `fk_e2ee_key_bundles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `e2ee_key_vaults` (
|
||||||
|
`user_id` text PRIMARY KEY,
|
||||||
|
`key_id` text NOT NULL,
|
||||||
|
`key_version` integer NOT NULL,
|
||||||
|
`owner_did` text NOT NULL,
|
||||||
|
`public_key` text NOT NULL,
|
||||||
|
`ciphertext` text NOT NULL,
|
||||||
|
`nonce` text NOT NULL,
|
||||||
|
`salt` text NOT NULL,
|
||||||
|
`kdf_algorithm` text NOT NULL,
|
||||||
|
`kdf_ops_limit` integer NOT NULL,
|
||||||
|
`kdf_mem_limit` integer NOT NULL,
|
||||||
|
`pin_verifier_mac` text NOT NULL,
|
||||||
|
`server_share_encrypted` text NOT NULL,
|
||||||
|
`failed_attempts` integer DEFAULT 0 NOT NULL,
|
||||||
|
`locked_until` integer,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
CONSTRAINT `fk_e2ee_key_vaults_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `e2ee_message_receipts` (
|
||||||
|
`id` text PRIMARY KEY,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`sender_did` text NOT NULL,
|
||||||
|
`message_id` text NOT NULL,
|
||||||
|
`protocol_version` integer DEFAULT 1 NOT NULL,
|
||||||
|
`received_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
CONSTRAINT `fk_e2ee_message_receipts_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `e2ee_remote_key_bundles` (
|
||||||
|
`did` text PRIMARY KEY,
|
||||||
|
`handle` text NOT NULL,
|
||||||
|
`key_id` text NOT NULL,
|
||||||
|
`key_version` integer NOT NULL,
|
||||||
|
`public_key` text NOT NULL,
|
||||||
|
`proof_action` text NOT NULL,
|
||||||
|
`signing_public_key` text NOT NULL,
|
||||||
|
`first_seen_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `chat_conversations` ADD `encryption_mode` text DEFAULT 'legacy' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `chat_conversations` ADD `e2ee_activated_at` integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE `chat_messages` ADD `protocol_version` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `chat_messages` ADD `client_message_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `chat_messages` ADD `encrypted_envelope` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `chat_messages` ADD `e2ee_signature` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `chat_messages` ADD `e2ee_action_nonce` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `chat_messages` ADD `e2ee_action_ts` integer;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `chat_messages_conversation_client_id_unique` ON `chat_messages` (`conversation_id`,`client_message_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `e2ee_key_bundles_did_unique` ON `e2ee_key_bundles` (`did`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `e2ee_key_bundles_key_id_unique` ON `e2ee_key_bundles` (`key_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `e2ee_key_vaults_key_id_unique` ON `e2ee_key_vaults` (`key_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `e2ee_message_receipts_owner_sender_message_unique` ON `e2ee_message_receipts` (`owner_user_id`,`sender_did`,`message_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `e2ee_message_receipts_received_idx` ON `e2ee_message_receipts` (`received_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `e2ee_remote_key_bundles_key_id_idx` ON `e2ee_remote_key_bundles` (`key_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `e2ee_remote_key_bundles_handle_idx` ON `e2ee_remote_key_bundles` (`handle`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_handle_unique_idx` ON `users` (`handle`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_did_unique_idx` ON `users` (`did`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,21 @@ function getBuildCommitCount(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentSecurityPolicy = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
`script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''} https://challenges.cloudflare.com`,
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: blob: https:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self' https: wss:",
|
||||||
|
"frame-src https://challenges.cloudflare.com",
|
||||||
|
"worker-src 'self' blob:",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
env: {
|
env: {
|
||||||
APP_COMMIT: getBuildCommit(),
|
APP_COMMIT: getBuildCommit(),
|
||||||
@@ -41,6 +56,20 @@ const nextConfig: NextConfig = {
|
|||||||
turbopack: {
|
turbopack: {
|
||||||
root: process.cwd(),
|
root: process.cwd(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async headers() {
|
||||||
|
return [{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Content-Security-Policy', value: contentSecurityPolicy },
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||||
|
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin-allow-popups' },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -2,8 +2,43 @@ import { sql } from 'drizzle-orm';
|
|||||||
import { migrate } from 'drizzle-orm/tursodatabase/migrator';
|
import { migrate } from 'drizzle-orm/tursodatabase/migrator';
|
||||||
import { closeDb, db } from '../src/db';
|
import { closeDb, db } from '../src/db';
|
||||||
|
|
||||||
|
interface DuplicateIdentityRow {
|
||||||
|
field: 'did' | 'handle';
|
||||||
|
value: string;
|
||||||
|
duplicateCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertIdentityUniqueness(): Promise<void> {
|
||||||
|
const tables = await db.all<{ name: string }>(sql.raw(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users' LIMIT 1",
|
||||||
|
));
|
||||||
|
if (tables.length === 0) return;
|
||||||
|
|
||||||
|
const duplicates = await db.all<DuplicateIdentityRow>(sql.raw(`
|
||||||
|
SELECT 'did' AS field, did AS value, COUNT(*) AS duplicateCount
|
||||||
|
FROM users
|
||||||
|
GROUP BY did
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'handle' AS field, handle AS value, COUNT(*) AS duplicateCount
|
||||||
|
FROM users
|
||||||
|
GROUP BY handle
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
LIMIT 20
|
||||||
|
`));
|
||||||
|
if (duplicates.length === 0) return;
|
||||||
|
|
||||||
|
const sample = duplicates
|
||||||
|
.map((row) => `${row.field}=${JSON.stringify(row.value)} (${row.duplicateCount} rows)`)
|
||||||
|
.join(', ');
|
||||||
|
throw new Error(
|
||||||
|
`Database identity preflight failed: duplicate users must be resolved before migration: ${sample}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
|
await assertIdentityUniqueness();
|
||||||
const result = await migrate(db, { migrationsFolder: './drizzle' });
|
const result = await migrate(db, { migrationsFolder: './drizzle' });
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
createSign,
|
||||||
|
createVerify,
|
||||||
|
generateKeyPairSync,
|
||||||
|
randomBytes,
|
||||||
|
randomUUID,
|
||||||
|
} from 'node:crypto';
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { generateDID } from '@/lib/crypto/did-key';
|
||||||
|
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||||
|
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||||
|
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
|
||||||
|
import {
|
||||||
|
E2EE_CIPHER_SUITE,
|
||||||
|
E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
E2EE_PROTOCOL,
|
||||||
|
} from '@/lib/e2ee/protocol';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
tables: {
|
||||||
|
users: { table: 'users' },
|
||||||
|
posts: { table: 'posts' },
|
||||||
|
media: { table: 'media' },
|
||||||
|
follows: { table: 'follows' },
|
||||||
|
remoteFollows: { table: 'remoteFollows' },
|
||||||
|
chatConversations: { table: 'chatConversations' },
|
||||||
|
chatMessages: { table: 'chatMessages' },
|
||||||
|
e2eeKeyBundles: { table: 'e2eeKeyBundles' },
|
||||||
|
e2eeMessageReceipts: { table: 'e2eeMessageReceipts' },
|
||||||
|
bots: { table: 'bots' },
|
||||||
|
botContentSources: { table: 'botContentSources' },
|
||||||
|
botActivityLogs: { table: 'botActivityLogs' },
|
||||||
|
},
|
||||||
|
requireAuth: vi.fn(),
|
||||||
|
verifyPassword: vi.fn(),
|
||||||
|
hashPassword: vi.fn(),
|
||||||
|
createSession: vi.fn(),
|
||||||
|
findPosts: vi.fn(),
|
||||||
|
findFollows: vi.fn(),
|
||||||
|
findRemoteFollows: vi.fn(),
|
||||||
|
findConversations: vi.fn(),
|
||||||
|
findE2EEKeyBundle: vi.fn(),
|
||||||
|
findBots: vi.fn(),
|
||||||
|
findUsers: vi.fn(),
|
||||||
|
findNodes: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
insertedRows: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
|
||||||
|
safeFederationRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth', () => ({
|
||||||
|
requireAuth: mocks.requireAuth,
|
||||||
|
verifyPassword: mocks.verifyPassword,
|
||||||
|
hashPassword: mocks.hashPassword,
|
||||||
|
createSession: mocks.createSession,
|
||||||
|
}));
|
||||||
|
vi.mock('@/db', () => ({
|
||||||
|
...mocks.tables,
|
||||||
|
db: {
|
||||||
|
insert: mocks.insert,
|
||||||
|
update: mocks.update,
|
||||||
|
transaction: mocks.transaction,
|
||||||
|
query: {
|
||||||
|
users: { findFirst: mocks.findUsers },
|
||||||
|
nodes: { findFirst: mocks.findNodes },
|
||||||
|
posts: { findMany: mocks.findPosts },
|
||||||
|
follows: { findMany: mocks.findFollows },
|
||||||
|
remoteFollows: { findMany: mocks.findRemoteFollows },
|
||||||
|
chatConversations: { findMany: mocks.findConversations },
|
||||||
|
e2eeKeyBundles: { findFirst: mocks.findE2EEKeyBundle },
|
||||||
|
bots: { findMany: mocks.findBots },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('drizzle-orm', async (importOriginal) => ({
|
||||||
|
...await importOriginal<typeof import('drizzle-orm')>(),
|
||||||
|
eq: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/bots/encryption', () => ({
|
||||||
|
decryptApiKey: vi.fn(),
|
||||||
|
deserializeEncryptedData: vi.fn(),
|
||||||
|
encryptApiKey: vi.fn(),
|
||||||
|
serializeEncryptedData: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/federation/handles', () => ({ upsertHandleEntries: vi.fn() }));
|
||||||
|
vi.mock('@/lib/swarm/safe-federation-http', () => ({
|
||||||
|
safeFederationRequest: mocks.safeFederationRequest,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { POST } from './route';
|
||||||
|
import { POST as importAccount } from '../import/route';
|
||||||
|
|
||||||
|
interface ExportResponseBody {
|
||||||
|
export: {
|
||||||
|
manifest: {
|
||||||
|
version: '1.1';
|
||||||
|
did: string;
|
||||||
|
handle: string;
|
||||||
|
sourceNode: string;
|
||||||
|
exportedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
publicKey: string;
|
||||||
|
privateKeyEncrypted: string;
|
||||||
|
payloadDigestAlgorithm: 'sha256';
|
||||||
|
payloadDigest: string;
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
profile: unknown;
|
||||||
|
posts: unknown[];
|
||||||
|
following: unknown[];
|
||||||
|
dms: unknown[];
|
||||||
|
bots: unknown[];
|
||||||
|
e2eeKeyBundle: unknown | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('account export manifest integrity', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.insertedRows.length = 0;
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_NODE_DOMAIN', 'export.synapsis.social');
|
||||||
|
mocks.verifyPassword.mockResolvedValue(true);
|
||||||
|
mocks.hashPassword.mockResolvedValue('imported-password-hash');
|
||||||
|
mocks.createSession.mockResolvedValue('imported-session-token');
|
||||||
|
mocks.findPosts.mockResolvedValue([]);
|
||||||
|
mocks.findFollows.mockResolvedValue([]);
|
||||||
|
mocks.findRemoteFollows.mockResolvedValue([]);
|
||||||
|
mocks.findConversations.mockResolvedValue([]);
|
||||||
|
mocks.findE2EEKeyBundle.mockResolvedValue(undefined);
|
||||||
|
mocks.findBots.mockResolvedValue([]);
|
||||||
|
mocks.findUsers.mockResolvedValue(undefined);
|
||||||
|
mocks.findNodes.mockResolvedValue(undefined);
|
||||||
|
mocks.safeFederationRequest.mockResolvedValue({ status: 204 });
|
||||||
|
mocks.update.mockImplementation(() => ({
|
||||||
|
set: () => ({
|
||||||
|
where: () => Promise.resolve(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
mocks.insert.mockImplementation((table: unknown) => ({
|
||||||
|
values: (values: Record<string, unknown>) => {
|
||||||
|
mocks.insertedRows.push({ table, values });
|
||||||
|
const returnedRows = table === mocks.tables.users
|
||||||
|
? [{ id: 'imported-user-1', ...values }]
|
||||||
|
: table === mocks.tables.chatConversations
|
||||||
|
? [{ id: 'imported-conversation-1', ...values }]
|
||||||
|
: [];
|
||||||
|
return Object.assign(Promise.resolve(), {
|
||||||
|
returning: vi.fn(async () => returnedRows),
|
||||||
|
onConflictDoNothing: vi.fn(async () => undefined),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mocks.transaction.mockImplementation(async (
|
||||||
|
callback: (tx: { insert: typeof mocks.insert }) => Promise<unknown>,
|
||||||
|
) => callback({ insert: mocks.insert }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypts the stored key to sign v1.1 while exporting its encrypted blob unchanged', async () => {
|
||||||
|
const password = 'correct horse battery staple';
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('ec', {
|
||||||
|
namedCurve: 'P-256',
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||||
|
});
|
||||||
|
const storedPrivateKey = serializeEncryptedKey(encryptPrivateKey(privateKey, password));
|
||||||
|
const ownerDid = generateDID(publicKey);
|
||||||
|
const peerDid = 'did:synapsis:export-peer';
|
||||||
|
const envelopeCreatedAt = Math.floor(Date.now() / 1_000) * 1_000 + 537;
|
||||||
|
const storedCreatedAt = new Date(Math.floor(envelopeCreatedAt / 1_000) * 1_000);
|
||||||
|
const messageId = randomUUID();
|
||||||
|
const senderKeyId = `k1_${randomBytes(12).toString('base64url')}`;
|
||||||
|
const recipientKeyId = `k1_${randomBytes(12).toString('base64url')}`;
|
||||||
|
const envelope = {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
cipherSuite: E2EE_CIPHER_SUITE,
|
||||||
|
messageId,
|
||||||
|
conversationId: `dm1_${randomBytes(12).toString('base64url')}`,
|
||||||
|
senderDid: ownerDid,
|
||||||
|
senderHandle: 'alice',
|
||||||
|
recipientDid: peerDid,
|
||||||
|
recipientHandle: 'bob',
|
||||||
|
createdAt: envelopeCreatedAt,
|
||||||
|
senderKeyId,
|
||||||
|
senderKeyVersion: 1,
|
||||||
|
recipientKeyId,
|
||||||
|
recipientKeyVersion: 1,
|
||||||
|
nonce: randomBytes(24).toString('base64url'),
|
||||||
|
ciphertext: randomBytes(17).toString('base64url'),
|
||||||
|
keyCommitment: randomBytes(32).toString('base64url'),
|
||||||
|
keyEnvelopes: [
|
||||||
|
{
|
||||||
|
did: ownerDid,
|
||||||
|
keyId: senderKeyId,
|
||||||
|
keyVersion: 1,
|
||||||
|
sealedKey: randomBytes(112).toString('base64url'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
did: peerDid,
|
||||||
|
keyId: recipientKeyId,
|
||||||
|
keyVersion: 1,
|
||||||
|
sealedKey: randomBytes(112).toString('base64url'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const actionNonce = randomBytes(16).toString('base64url');
|
||||||
|
const actionPayload = {
|
||||||
|
action: 'chat_e2ee',
|
||||||
|
data: envelope,
|
||||||
|
did: ownerDid,
|
||||||
|
handle: 'alice',
|
||||||
|
nonce: actionNonce,
|
||||||
|
ts: envelopeCreatedAt,
|
||||||
|
};
|
||||||
|
const actionSigner = createSign('sha256');
|
||||||
|
actionSigner.update(canonicalize(actionPayload));
|
||||||
|
const actionSignature = actionSigner.sign({
|
||||||
|
key: privateKey,
|
||||||
|
dsaEncoding: 'ieee-p1363',
|
||||||
|
}).toString('base64url');
|
||||||
|
|
||||||
|
const encryptionPublicKey = randomBytes(32).toString('base64url');
|
||||||
|
const continuityKeyId = await encryptionKeyIdFromPublicKey(encryptionPublicKey);
|
||||||
|
if (!continuityKeyId) throw new Error('Could not derive test encryption key ID');
|
||||||
|
const continuityCreatedAt = Date.now();
|
||||||
|
const continuityBundle = {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
keyId: continuityKeyId,
|
||||||
|
version: 1,
|
||||||
|
publicKey: encryptionPublicKey,
|
||||||
|
createdAt: continuityCreatedAt,
|
||||||
|
recoveryCommitment: randomBytes(32).toString('base64url'),
|
||||||
|
};
|
||||||
|
const continuityProofPayload = {
|
||||||
|
action: E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
data: continuityBundle,
|
||||||
|
did: ownerDid,
|
||||||
|
handle: 'alice',
|
||||||
|
nonce: randomBytes(16).toString('base64url'),
|
||||||
|
ts: continuityCreatedAt,
|
||||||
|
};
|
||||||
|
const continuitySigner = createSign('sha256');
|
||||||
|
continuitySigner.update(canonicalize(continuityProofPayload));
|
||||||
|
const continuityProof = {
|
||||||
|
...continuityProofPayload,
|
||||||
|
sig: continuitySigner.sign({
|
||||||
|
key: privateKey,
|
||||||
|
dsaEncoding: 'ieee-p1363',
|
||||||
|
}).toString('base64url'),
|
||||||
|
};
|
||||||
|
|
||||||
|
mocks.findConversations.mockResolvedValue([{
|
||||||
|
id: 'conversation-1',
|
||||||
|
type: 'direct',
|
||||||
|
participant2Handle: 'bob',
|
||||||
|
lastMessageAt: storedCreatedAt,
|
||||||
|
lastMessagePreview: 'Encrypted message',
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: storedCreatedAt,
|
||||||
|
messages: [{
|
||||||
|
senderHandle: 'alice',
|
||||||
|
senderDisplayName: 'Alice',
|
||||||
|
senderAvatarUrl: null,
|
||||||
|
senderNodeDomain: null,
|
||||||
|
senderDid: ownerDid,
|
||||||
|
content: null,
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientMessageId: messageId,
|
||||||
|
encryptedEnvelope: JSON.stringify(envelope),
|
||||||
|
e2eeSignature: actionSignature,
|
||||||
|
e2eeActionNonce: actionNonce,
|
||||||
|
e2eeActionTs: envelopeCreatedAt,
|
||||||
|
deliveredAt: null,
|
||||||
|
readAt: null,
|
||||||
|
createdAt: storedCreatedAt,
|
||||||
|
}],
|
||||||
|
}]);
|
||||||
|
mocks.requireAuth.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
did: ownerDid,
|
||||||
|
handle: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
bio: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
headerUrl: null,
|
||||||
|
passwordHash: 'password-hash',
|
||||||
|
publicKey,
|
||||||
|
privateKeyEncrypted: storedPrivateKey,
|
||||||
|
movedTo: null,
|
||||||
|
});
|
||||||
|
mocks.findE2EEKeyBundle.mockResolvedValue({
|
||||||
|
did: ownerDid,
|
||||||
|
keyId: continuityKeyId,
|
||||||
|
keyVersion: 1,
|
||||||
|
publicKey: encryptionPublicKey,
|
||||||
|
proofAction: JSON.stringify(continuityProof),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(new NextRequest('https://export.synapsis.social/api/account/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}));
|
||||||
|
const body = await response.json() as ExportResponseBody;
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.export.manifest.version).toBe('1.1');
|
||||||
|
expect(body.export.manifest.privateKeyEncrypted).toBe(storedPrivateKey);
|
||||||
|
expect(body.export.manifest).not.toHaveProperty('salt');
|
||||||
|
expect(body.export.manifest).not.toHaveProperty('iv');
|
||||||
|
expect(body.export.e2eeKeyBundle).toEqual({
|
||||||
|
did: ownerDid,
|
||||||
|
keyId: continuityKeyId,
|
||||||
|
keyVersion: 1,
|
||||||
|
publicKey: encryptionPublicKey,
|
||||||
|
proofAction: continuityProof,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { signature, ...manifestData } = body.export.manifest;
|
||||||
|
const verifier = createVerify('sha256');
|
||||||
|
verifier.update(canonicalize(manifestData));
|
||||||
|
expect(verifier.verify(publicKey, signature, 'base64')).toBe(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
profile: body.export.profile,
|
||||||
|
posts: body.export.posts,
|
||||||
|
following: body.export.following,
|
||||||
|
dms: body.export.dms,
|
||||||
|
bots: body.export.bots,
|
||||||
|
e2eeKeyBundle: body.export.e2eeKeyBundle,
|
||||||
|
};
|
||||||
|
expect(body.export.manifest.payloadDigest).toBe(
|
||||||
|
createHash('sha256').update(canonicalize(payload)).digest('hex'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exported message carries millisecond-authenticated envelope
|
||||||
|
// time while its DB timestamp is second-precision. A fresh importer
|
||||||
|
// must accept the round-trip and preserve account login plus the
|
||||||
|
// signed public continuity anchor (but never a PIN vault).
|
||||||
|
const importResponse = await importAccount(new NextRequest('https://new.example/api/account/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
exportData: body.export,
|
||||||
|
password,
|
||||||
|
destinationEmail: ' Alice@New.Example ',
|
||||||
|
newHandle: 'alice_new',
|
||||||
|
acceptedCompliance: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(importResponse.status).toBe(200);
|
||||||
|
await expect(importResponse.json()).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
stats: { dmsImported: 1 },
|
||||||
|
warnings: expect.arrayContaining([
|
||||||
|
expect.stringContaining('decryption key is not portable yet'),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
expect(mocks.hashPassword).toHaveBeenCalledWith(password);
|
||||||
|
|
||||||
|
const importedUser = mocks.insertedRows.find(({ table }) => table === mocks.tables.users);
|
||||||
|
expect(importedUser?.values).toMatchObject({
|
||||||
|
did: ownerDid,
|
||||||
|
email: 'alice@new.example',
|
||||||
|
passwordHash: 'imported-password-hash',
|
||||||
|
privateKeyEncrypted: storedPrivateKey,
|
||||||
|
});
|
||||||
|
expect(mocks.createSession).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.createSession).toHaveBeenCalledWith('imported-user-1');
|
||||||
|
const importedContinuity = mocks.insertedRows.find(
|
||||||
|
({ table }) => table === mocks.tables.e2eeKeyBundles,
|
||||||
|
);
|
||||||
|
expect(importedContinuity?.values).toMatchObject({
|
||||||
|
userId: 'imported-user-1',
|
||||||
|
did: ownerDid,
|
||||||
|
keyId: continuityKeyId,
|
||||||
|
keyVersion: 1,
|
||||||
|
publicKey: encryptionPublicKey,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(importedContinuity?.values.proofAction))).toEqual(continuityProof);
|
||||||
|
expect(importedContinuity?.values).not.toHaveProperty('ciphertext');
|
||||||
|
expect(importedContinuity?.values).not.toHaveProperty('pinVerifierMac');
|
||||||
|
expect(mocks.safeFederationRequest).toHaveBeenCalledWith(
|
||||||
|
'https://export.synapsis.social/api/account/moved',
|
||||||
|
expect.objectContaining({ method: 'POST', timeoutMs: 5_000 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
mocks.findUsers
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockResolvedValueOnce({ id: 'existing-email-user' });
|
||||||
|
const duplicateEmailResponse = await importAccount(new NextRequest('https://new.example/api/account/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
exportData: body.export,
|
||||||
|
password,
|
||||||
|
destinationEmail: 'alice@new.example',
|
||||||
|
newHandle: 'alice_other',
|
||||||
|
acceptedCompliance: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(duplicateEmailResponse.status).toBe(409);
|
||||||
|
await expect(duplicateEmailResponse.json()).resolves.toMatchObject({
|
||||||
|
error: 'Email is already registered on this node',
|
||||||
|
});
|
||||||
|
expect(mocks.createSession).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
mocks.findUsers.mockResolvedValue(undefined);
|
||||||
|
mocks.createSession.mockRejectedValueOnce(new Error('session store unavailable'));
|
||||||
|
const sessionFailureResponse = await importAccount(new NextRequest('https://new.example/api/account/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
exportData: body.export,
|
||||||
|
password,
|
||||||
|
destinationEmail: 'alice-session-fallback@new.example',
|
||||||
|
newHandle: 'alice_retry',
|
||||||
|
acceptedCompliance: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(sessionFailureResponse.status).toBe(200);
|
||||||
|
await expect(sessionFailureResponse.json()).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
warnings: expect.arrayContaining([
|
||||||
|
expect.stringContaining('automatic sign-in failed'),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
expect(mocks.createSession).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,25 +7,36 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireAuth, verifyPassword } from '@/lib/auth';
|
import { requireAuth, verifyPassword } from '@/lib/auth';
|
||||||
import { db, posts, media, follows, users, remoteFollows, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
|
import { db } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { decryptApiKey, deserializeEncryptedData } from '@/lib/bots/encryption';
|
import { decryptApiKey, deserializeEncryptedData } from '@/lib/bots/encryption';
|
||||||
|
import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing';
|
||||||
|
import {
|
||||||
|
decryptPrivateKey as decryptStoredPrivateKey,
|
||||||
|
deserializeEncryptedKey,
|
||||||
|
} from '@/lib/crypto/private-key';
|
||||||
|
import {
|
||||||
|
E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
e2eeKeyBundleSchema,
|
||||||
|
signedUserActionSchema,
|
||||||
|
type SignedUserAction,
|
||||||
|
} from '@/lib/e2ee/protocol';
|
||||||
|
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
|
||||||
|
|
||||||
// We'll use a simple in-memory zip approach
|
// We'll use a simple in-memory zip approach
|
||||||
// For production, consider using a streaming zip library
|
// For production, consider using a streaming zip library
|
||||||
|
|
||||||
interface ExportManifest {
|
interface ExportManifest {
|
||||||
version: string;
|
version: '1.1';
|
||||||
did: string;
|
did: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
sourceNode: string;
|
sourceNode: string;
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
expiresAt: string; // Export expiration timestamp
|
expiresAt: string; // Export expiration timestamp
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
privateKeyEncrypted: string; // Encrypted with user's password
|
privateKeyEncrypted: string; // Original serialized AES-GCM key blob
|
||||||
salt: string; // For key derivation
|
payloadDigestAlgorithm: 'sha256';
|
||||||
iv: string; // For AES encryption
|
payloadDigest: string; // Canonical digest of every non-manifest export field
|
||||||
signature: string; // Proof of ownership
|
signature: string; // Proof of ownership
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +70,8 @@ interface ExportDMConversation {
|
|||||||
participant2Handle: string;
|
participant2Handle: string;
|
||||||
lastMessageAt: string | null;
|
lastMessageAt: string | null;
|
||||||
lastMessagePreview: string | null;
|
lastMessagePreview: string | null;
|
||||||
|
encryptionMode: string;
|
||||||
|
e2eeActivatedAt: string | null;
|
||||||
messages: ExportDMMessage[];
|
messages: ExportDMMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +82,25 @@ interface ExportDMMessage {
|
|||||||
senderNodeDomain: string | null;
|
senderNodeDomain: string | null;
|
||||||
senderDid: string | null;
|
senderDid: string | null;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
|
protocolVersion: number;
|
||||||
|
clientMessageId: string | null;
|
||||||
|
encryptedEnvelope: string | null;
|
||||||
|
e2eeSignature: string | null;
|
||||||
|
e2eeActionNonce: string | null;
|
||||||
|
e2eeActionTs: number | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
readAt: string | null;
|
readAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExportE2EEContinuityAnchor {
|
||||||
|
did: string;
|
||||||
|
keyId: string;
|
||||||
|
keyVersion: number;
|
||||||
|
publicKey: string;
|
||||||
|
proofAction: SignedUserAction;
|
||||||
|
}
|
||||||
|
|
||||||
interface ExportBot {
|
interface ExportBot {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -81,55 +108,91 @@ interface ExportBot {
|
|||||||
bio: string | null;
|
bio: string | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
headerUrl: string | null;
|
headerUrl: string | null;
|
||||||
personalityConfig: any;
|
personalityConfig: unknown;
|
||||||
llmProvider: string;
|
llmProvider: string;
|
||||||
llmModel: string;
|
llmModel: string;
|
||||||
llmApiKey: string; // Decrypted
|
llmApiKey: string; // Decrypted
|
||||||
botPrivateKey: string; // Decrypted
|
botPrivateKey: string; // Decrypted
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
scheduleConfig: any;
|
scheduleConfig: unknown;
|
||||||
autonomousMode: boolean;
|
autonomousMode: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
sources: any[];
|
sources: unknown[];
|
||||||
activityLogs: any[];
|
activityLogs: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface ExportPayload {
|
||||||
* Encrypt the private key with user's password using AES-256-GCM
|
profile: ExportProfile;
|
||||||
*/
|
posts: ExportPost[];
|
||||||
function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } {
|
following: ExportFollowing[];
|
||||||
const salt = crypto.randomBytes(32);
|
dms: ExportDMConversation[];
|
||||||
const iv = crypto.randomBytes(16);
|
bots: ExportBot[];
|
||||||
|
e2eeKeyBundle: ExportE2EEContinuityAnchor | null;
|
||||||
// Derive key from password
|
|
||||||
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
|
|
||||||
|
|
||||||
// Encrypt
|
|
||||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
||||||
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
|
|
||||||
encrypted += cipher.final('base64');
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
|
|
||||||
// Combine encrypted data with auth tag
|
|
||||||
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
|
|
||||||
|
|
||||||
return {
|
|
||||||
encrypted: combined,
|
|
||||||
salt: salt.toString('base64'),
|
|
||||||
iv: iv.toString('base64'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign the manifest to prove ownership
|
* Sign the manifest to prove ownership
|
||||||
*/
|
*/
|
||||||
function signManifest(manifest: Omit<ExportManifest, 'signature'>, privateKey: string): string {
|
function signManifest(manifest: Omit<ExportManifest, 'signature'>, privateKey: string): string {
|
||||||
const data = JSON.stringify(manifest);
|
const data = canonicalize(manifest);
|
||||||
const sign = crypto.createSign('sha256');
|
const sign = crypto.createSign('sha256');
|
||||||
sign.update(data);
|
sign.update(data);
|
||||||
return sign.sign(privateKey, 'base64');
|
return sign.sign(privateKey, 'base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function digestExportPayload(payload: ExportPayload): string {
|
||||||
|
return crypto.createHash('sha256').update(canonicalize(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function signingKeyMatchesPublicKey(privateKey: string, publicKey: string): boolean {
|
||||||
|
try {
|
||||||
|
const derivedPublicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
|
||||||
|
const expectedPublicKey = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
|
||||||
|
return derivedPublicKey.length === expectedPublicKey.length
|
||||||
|
&& crypto.timingSafeEqual(derivedPublicKey, expectedPublicKey);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportE2EEContinuityAnchor(
|
||||||
|
row: {
|
||||||
|
did: string;
|
||||||
|
keyId: string;
|
||||||
|
keyVersion: number;
|
||||||
|
publicKey: string;
|
||||||
|
proofAction: string;
|
||||||
|
} | undefined,
|
||||||
|
user: { did: string; handle: string; publicKey: string },
|
||||||
|
): Promise<ExportE2EEContinuityAnchor | null> {
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const proof = signedUserActionSchema.parse(JSON.parse(row.proofAction));
|
||||||
|
const bundle = e2eeKeyBundleSchema.parse(proof.data);
|
||||||
|
if (proof.action !== E2EE_KEY_BUNDLE_ACTION
|
||||||
|
|| proof.did !== user.did
|
||||||
|
|| proof.handle.toLowerCase() !== user.handle.toLowerCase()
|
||||||
|
|| row.did !== proof.did
|
||||||
|
|| row.keyId !== bundle.keyId
|
||||||
|
|| row.keyVersion !== bundle.version
|
||||||
|
|| row.publicKey !== bundle.publicKey
|
||||||
|
|| Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1_000
|
||||||
|
|| Buffer.from(bundle.publicKey, 'base64url').length !== 32
|
||||||
|
|| Buffer.from(bundle.recoveryCommitment, 'base64url').length !== 32
|
||||||
|
|| await encryptionKeyIdFromPublicKey(bundle.publicKey) !== bundle.keyId
|
||||||
|
|| !await verifySignedActionSignature(proof, user.publicKey)) {
|
||||||
|
throw new Error('Stored E2EE continuity proof is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
did: row.did,
|
||||||
|
keyId: row.keyId,
|
||||||
|
keyVersion: row.keyVersion,
|
||||||
|
publicKey: row.publicKey,
|
||||||
|
proofAction: proof,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth();
|
const user = await requireAuth();
|
||||||
@@ -156,6 +219,23 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 });
|
return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.privateKeyEncrypted) {
|
||||||
|
return NextResponse.json({ error: 'Account signing key is unavailable' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let signingPrivateKey: string;
|
||||||
|
try {
|
||||||
|
signingPrivateKey = decryptStoredPrivateKey(
|
||||||
|
deserializeEncryptedKey(user.privateKeyEncrypted),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Account signing key could not be unlocked' }, { status: 500 });
|
||||||
|
}
|
||||||
|
if (!signingKeyMatchesPublicKey(signingPrivateKey, user.publicKey)) {
|
||||||
|
return NextResponse.json({ error: 'Account signing key does not match this account' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||||
|
|
||||||
// Fetch user's posts
|
// Fetch user's posts
|
||||||
@@ -187,6 +267,11 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentE2EEKeyBundle = await db.query.e2eeKeyBundles.findFirst({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
const e2eeKeyBundle = await exportE2EEContinuityAnchor(currentE2EEKeyBundle, user);
|
||||||
|
|
||||||
// Fetch Bots
|
// Fetch Bots
|
||||||
const userBots = await db.query.bots.findMany({
|
const userBots = await db.query.bots.findMany({
|
||||||
where: { ownerId: user.id },
|
where: { ownerId: user.id },
|
||||||
@@ -248,13 +333,21 @@ export async function POST(req: NextRequest) {
|
|||||||
participant2Handle: conv.participant2Handle,
|
participant2Handle: conv.participant2Handle,
|
||||||
lastMessageAt: conv.lastMessageAt?.toISOString() || null,
|
lastMessageAt: conv.lastMessageAt?.toISOString() || null,
|
||||||
lastMessagePreview: conv.lastMessagePreview,
|
lastMessagePreview: conv.lastMessagePreview,
|
||||||
|
encryptionMode: conv.encryptionMode,
|
||||||
|
e2eeActivatedAt: conv.e2eeActivatedAt?.toISOString() || null,
|
||||||
messages: conv.messages.map(msg => ({
|
messages: conv.messages.map(msg => ({
|
||||||
senderHandle: msg.senderHandle,
|
senderHandle: msg.senderHandle,
|
||||||
senderDisplayName: msg.senderDisplayName,
|
senderDisplayName: msg.senderDisplayName,
|
||||||
senderAvatarUrl: msg.senderAvatarUrl,
|
senderAvatarUrl: msg.senderAvatarUrl,
|
||||||
senderNodeDomain: msg.senderNodeDomain,
|
senderNodeDomain: msg.senderNodeDomain,
|
||||||
senderDid: msg.senderDid,
|
senderDid: msg.senderDid,
|
||||||
content: msg.content,
|
content: msg.protocolVersion === 0 ? msg.content : null,
|
||||||
|
protocolVersion: msg.protocolVersion,
|
||||||
|
clientMessageId: msg.clientMessageId,
|
||||||
|
encryptedEnvelope: msg.encryptedEnvelope,
|
||||||
|
e2eeSignature: msg.e2eeSignature,
|
||||||
|
e2eeActionNonce: msg.e2eeActionNonce,
|
||||||
|
e2eeActionTs: msg.e2eeActionTs,
|
||||||
deliveredAt: msg.deliveredAt?.toISOString() || null,
|
deliveredAt: msg.deliveredAt?.toISOString() || null,
|
||||||
readAt: msg.readAt?.toISOString() || null,
|
readAt: msg.readAt?.toISOString() || null,
|
||||||
createdAt: msg.createdAt.toISOString()
|
createdAt: msg.createdAt.toISOString()
|
||||||
@@ -311,39 +404,41 @@ export async function POST(req: NextRequest) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Encrypt private key
|
const exportPayload: ExportPayload = {
|
||||||
const privateKey = user.privateKeyEncrypted || '';
|
profile,
|
||||||
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
|
posts: exportPosts,
|
||||||
|
following: exportFollowing,
|
||||||
|
dms: exportDMs,
|
||||||
|
bots: exportBots,
|
||||||
|
e2eeKeyBundle,
|
||||||
|
};
|
||||||
|
|
||||||
// Build manifest (without signature first)
|
// Version 1.1 fails closed on older importers and binds a canonical
|
||||||
|
// digest of every non-manifest field into the signed manifest.
|
||||||
const exportedAt = new Date();
|
const exportedAt = new Date();
|
||||||
const expiresAt = new Date(exportedAt.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
const expiresAt = new Date(exportedAt.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||||
const manifestData: Omit<ExportManifest, 'signature'> = {
|
const manifestData: Omit<ExportManifest, 'signature'> = {
|
||||||
version: '1.0',
|
version: '1.1',
|
||||||
did: user.did,
|
did: user.did,
|
||||||
handle: user.handle,
|
handle: user.handle,
|
||||||
sourceNode: nodeDomain,
|
sourceNode: nodeDomain,
|
||||||
exportedAt: exportedAt.toISOString(),
|
exportedAt: exportedAt.toISOString(),
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: expiresAt.toISOString(),
|
||||||
publicKey: user.publicKey,
|
publicKey: user.publicKey,
|
||||||
privateKeyEncrypted: encrypted,
|
privateKeyEncrypted: user.privateKeyEncrypted,
|
||||||
salt,
|
payloadDigestAlgorithm: 'sha256',
|
||||||
iv,
|
payloadDigest: digestExportPayload(exportPayload),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sign the manifest
|
// Sign the manifest
|
||||||
const signature = signManifest(manifestData, privateKey);
|
const signature = signManifest(manifestData, signingPrivateKey);
|
||||||
const manifest: ExportManifest = { ...manifestData, signature };
|
const manifest: ExportManifest = { ...manifestData, signature };
|
||||||
|
|
||||||
// Build the export package as JSON (ZIP would require additional library)
|
// Build the export package as JSON (ZIP would require additional library)
|
||||||
// For MVP, we'll use a JSON format that can be easily converted to ZIP later
|
// For MVP, we'll use a JSON format that can be easily converted to ZIP later
|
||||||
const exportPackage = {
|
const exportPackage = {
|
||||||
manifest,
|
manifest,
|
||||||
profile,
|
...exportPayload,
|
||||||
posts: exportPosts,
|
|
||||||
following: exportFollowing,
|
|
||||||
dms: exportDMs,
|
|
||||||
bots: exportBots,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
import { generateKeyPairSync, createHash, createSign, randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||||
|
import { generateDID } from '@/lib/crypto/did-key';
|
||||||
|
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||||
|
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
|
||||||
|
import {
|
||||||
|
E2EE_CIPHER_SUITE,
|
||||||
|
E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
E2EE_PROTOCOL,
|
||||||
|
} from '@/lib/e2ee/protocol';
|
||||||
|
import { POST } from './route';
|
||||||
|
|
||||||
|
const peerDid = 'did:synapsis:account-import-peer';
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('ec', {
|
||||||
|
namedCurve: 'P-256',
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||||
|
});
|
||||||
|
const ownerDid = generateDID(publicKey);
|
||||||
|
const storedPrivateKey = serializeEncryptedKey(encryptPrivateKey(privateKey, 'correct-password'));
|
||||||
|
|
||||||
|
function basePayload(dms: unknown[]) {
|
||||||
|
return {
|
||||||
|
profile: {
|
||||||
|
displayName: 'Alice',
|
||||||
|
bio: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
headerUrl: null,
|
||||||
|
},
|
||||||
|
posts: [],
|
||||||
|
following: [],
|
||||||
|
dms,
|
||||||
|
bots: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedExport(payload: ReturnType<typeof basePayload>, integrityProtected = true) {
|
||||||
|
const manifestData = {
|
||||||
|
version: '1.0' as const,
|
||||||
|
did: ownerDid,
|
||||||
|
handle: 'alice',
|
||||||
|
sourceNode: 'old.synapsis.social',
|
||||||
|
exportedAt: new Date(Date.now() - 1_000).toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
publicKey,
|
||||||
|
privateKeyEncrypted: 'AA==',
|
||||||
|
salt: randomBytes(32).toString('base64'),
|
||||||
|
iv: randomBytes(16).toString('base64'),
|
||||||
|
...(integrityProtected ? {
|
||||||
|
payloadDigestAlgorithm: 'sha256' as const,
|
||||||
|
payloadDigest: createHash('sha256').update(canonicalize(payload)).digest('hex'),
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
const signer = createSign('sha256');
|
||||||
|
signer.update(JSON.stringify(manifestData));
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
...manifestData,
|
||||||
|
signature: signer.sign(privateKey, 'base64'),
|
||||||
|
},
|
||||||
|
...payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedV11Export(
|
||||||
|
payload: ReturnType<typeof basePayload>,
|
||||||
|
options: {
|
||||||
|
did?: string;
|
||||||
|
sourceNode?: string;
|
||||||
|
e2eeKeyBundle?: unknown;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const signedPayload = {
|
||||||
|
...payload,
|
||||||
|
e2eeKeyBundle: options.e2eeKeyBundle ?? null,
|
||||||
|
};
|
||||||
|
const manifestData = {
|
||||||
|
version: '1.1' as const,
|
||||||
|
did: options.did ?? ownerDid,
|
||||||
|
handle: 'alice',
|
||||||
|
sourceNode: options.sourceNode ?? 'old.synapsis.social',
|
||||||
|
exportedAt: new Date(Date.now() - 1_000).toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
publicKey,
|
||||||
|
privateKeyEncrypted: storedPrivateKey,
|
||||||
|
payloadDigestAlgorithm: 'sha256' as const,
|
||||||
|
payloadDigest: createHash('sha256').update(canonicalize(signedPayload)).digest('hex'),
|
||||||
|
};
|
||||||
|
const signer = createSign('sha256');
|
||||||
|
signer.update(canonicalize(manifestData));
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
...manifestData,
|
||||||
|
signature: signer.sign(privateKey, 'base64'),
|
||||||
|
},
|
||||||
|
...signedPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function continuityAnchor() {
|
||||||
|
const encryptionPublicKey = randomBytes(32).toString('base64url');
|
||||||
|
const keyId = await encryptionKeyIdFromPublicKey(encryptionPublicKey);
|
||||||
|
if (!keyId) throw new Error('Could not derive test encryption key ID');
|
||||||
|
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const bundle = {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
keyId,
|
||||||
|
version: 1,
|
||||||
|
publicKey: encryptionPublicKey,
|
||||||
|
createdAt,
|
||||||
|
recoveryCommitment: randomBytes(32).toString('base64url'),
|
||||||
|
};
|
||||||
|
const proofPayload = {
|
||||||
|
action: E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
data: bundle,
|
||||||
|
did: ownerDid,
|
||||||
|
handle: 'alice',
|
||||||
|
nonce: randomBytes(16).toString('base64url'),
|
||||||
|
ts: createdAt,
|
||||||
|
};
|
||||||
|
const proofSigner = createSign('sha256');
|
||||||
|
proofSigner.update(canonicalize(proofPayload));
|
||||||
|
|
||||||
|
return {
|
||||||
|
did: ownerDid,
|
||||||
|
keyId,
|
||||||
|
keyVersion: 1,
|
||||||
|
publicKey: encryptionPublicKey,
|
||||||
|
proofAction: {
|
||||||
|
...proofPayload,
|
||||||
|
sig: proofSigner.sign({
|
||||||
|
key: privateKey,
|
||||||
|
dsaEncoding: 'ieee-p1363',
|
||||||
|
}).toString('base64url'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importResponse(
|
||||||
|
exportData: unknown,
|
||||||
|
requestOverrides: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
return POST(new NextRequest('http://localhost/api/account/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
exportData,
|
||||||
|
password: 'wrong-on-purpose',
|
||||||
|
destinationEmail: 'alice@new.example',
|
||||||
|
newHandle: 'alice_new',
|
||||||
|
acceptedCompliance: true,
|
||||||
|
...requestOverrides,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonMessage(createdAt: string) {
|
||||||
|
return {
|
||||||
|
senderHandle: 'alice',
|
||||||
|
senderDisplayName: 'Alice',
|
||||||
|
senderAvatarUrl: null,
|
||||||
|
senderNodeDomain: null,
|
||||||
|
senderDid: ownerDid,
|
||||||
|
deliveredAt: null,
|
||||||
|
readAt: null,
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function modernConversation(messages: unknown[], overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
type: 'direct',
|
||||||
|
participant2Handle: 'bob',
|
||||||
|
lastMessageAt: null,
|
||||||
|
lastMessagePreview: null,
|
||||||
|
encryptionMode: 'legacy',
|
||||||
|
e2eeActivatedAt: null,
|
||||||
|
messages,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptedMessage(createdAtMs: number) {
|
||||||
|
const messageId = randomUUID();
|
||||||
|
const senderKeyId = `k1_${randomBytes(12).toString('base64url')}`;
|
||||||
|
const recipientKeyId = `k1_${randomBytes(12).toString('base64url')}`;
|
||||||
|
const envelope = {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
cipherSuite: E2EE_CIPHER_SUITE,
|
||||||
|
messageId,
|
||||||
|
conversationId: `dm1_${randomBytes(12).toString('base64url')}`,
|
||||||
|
senderDid: ownerDid,
|
||||||
|
senderHandle: 'alice',
|
||||||
|
recipientDid: peerDid,
|
||||||
|
recipientHandle: 'bob',
|
||||||
|
createdAt: createdAtMs,
|
||||||
|
senderKeyId,
|
||||||
|
senderKeyVersion: 1,
|
||||||
|
recipientKeyId,
|
||||||
|
recipientKeyVersion: 1,
|
||||||
|
nonce: randomBytes(24).toString('base64url'),
|
||||||
|
ciphertext: randomBytes(17).toString('base64url'),
|
||||||
|
keyCommitment: randomBytes(32).toString('base64url'),
|
||||||
|
keyEnvelopes: [
|
||||||
|
{
|
||||||
|
did: ownerDid,
|
||||||
|
keyId: senderKeyId,
|
||||||
|
keyVersion: 1,
|
||||||
|
sealedKey: randomBytes(112).toString('base64url'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
did: peerDid,
|
||||||
|
keyId: recipientKeyId,
|
||||||
|
keyVersion: 1,
|
||||||
|
sealedKey: randomBytes(112).toString('base64url'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionNonce = randomBytes(16).toString('base64url');
|
||||||
|
const actionPayload = {
|
||||||
|
action: 'chat_e2ee',
|
||||||
|
data: envelope,
|
||||||
|
did: ownerDid,
|
||||||
|
handle: 'alice',
|
||||||
|
nonce: actionNonce,
|
||||||
|
ts: createdAtMs,
|
||||||
|
};
|
||||||
|
const actionSigner = createSign('sha256');
|
||||||
|
actionSigner.update(canonicalize(actionPayload));
|
||||||
|
|
||||||
|
return {
|
||||||
|
// SQLite timestamp columns retain seconds, while the authenticated
|
||||||
|
// envelope retains the original millisecond value.
|
||||||
|
...commonMessage(new Date(Math.floor(createdAtMs / 1_000) * 1_000).toISOString()),
|
||||||
|
content: null,
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientMessageId: messageId,
|
||||||
|
encryptedEnvelope: JSON.stringify(envelope),
|
||||||
|
e2eeSignature: actionSigner.sign({
|
||||||
|
key: privateKey,
|
||||||
|
dsaEncoding: 'ieee-p1363',
|
||||||
|
}).toString('base64url'),
|
||||||
|
e2eeActionNonce: actionNonce,
|
||||||
|
e2eeActionTs: createdAtMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('account import DM integrity', () => {
|
||||||
|
it('requires a valid destination email before importing', async () => {
|
||||||
|
const response = await importResponse(
|
||||||
|
signedV11Export(basePayload([])),
|
||||||
|
{ destinationEmail: 'not-an-email' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: 'Enter a valid destination email address',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a v1.1 export with no E2EE continuity anchor', async () => {
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([])));
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid account-signed E2EE continuity anchor', async () => {
|
||||||
|
const anchor = await continuityAnchor();
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([]), {
|
||||||
|
e2eeKeyBundle: anchor,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a continuity anchor with a forged proof signature', async () => {
|
||||||
|
const anchor = await continuityAnchor();
|
||||||
|
anchor.proofAction.sig = randomBytes(64).toString('base64url');
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([]), {
|
||||||
|
e2eeKeyBundle: anchor,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: expect.stringContaining('continuity anchor signature is invalid'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects continuity row fields that do not match the signed bundle', async () => {
|
||||||
|
const anchor = await continuityAnchor();
|
||||||
|
anchor.keyVersion = 2;
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([]), {
|
||||||
|
e2eeKeyBundle: anchor,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: expect.stringContaining('does not match its signed proof'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a self-certifying DID claimed by a different signing key', async () => {
|
||||||
|
const otherKeys = generateKeyPairSync('ec', {
|
||||||
|
namedCurve: 'P-256',
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||||
|
});
|
||||||
|
const squattedDid = generateDID(otherKeys.publicKey);
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([]), {
|
||||||
|
did: squattedDid,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: 'Export identity must be a self-certifying did:key that matches its signing key',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects legacy identity methods that are not self-certifying', async () => {
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([]), {
|
||||||
|
did: 'did:synapsis:legacy-account',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: 'Export identity must be a self-certifying did:key that matches its signing key',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'127.0.0.1',
|
||||||
|
'localhost:43821',
|
||||||
|
'10.0.0.8',
|
||||||
|
'https://old.synapsis.social',
|
||||||
|
'old.synapsis.social/api/account/moved',
|
||||||
|
'user@old.synapsis.social',
|
||||||
|
])('rejects unsafe migration source node %s', async (sourceNode) => {
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([]), {
|
||||||
|
sourceNode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: 'Export source node is unsafe or invalid',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects edits to any signed v1.1 export payload field', async () => {
|
||||||
|
const exportData = signedV11Export(basePayload([]));
|
||||||
|
exportData.profile.displayName = 'Mallory';
|
||||||
|
|
||||||
|
const response = await importResponse(exportData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: 'Export payload integrity check failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also verifies the digest when a legacy v1.0 manifest carries one', async () => {
|
||||||
|
const exportData = signedExport(basePayload([]));
|
||||||
|
exportData.profile.displayName = 'Mallory';
|
||||||
|
|
||||||
|
const response = await importResponse(exportData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: 'Export payload integrity check failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown protocol versions instead of treating them as plaintext', async () => {
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const unknownMessage = {
|
||||||
|
...commonMessage(createdAt),
|
||||||
|
content: 'must not be imported',
|
||||||
|
protocolVersion: 2,
|
||||||
|
clientMessageId: null,
|
||||||
|
encryptedEnvelope: null,
|
||||||
|
e2eeSignature: null,
|
||||||
|
e2eeActionNonce: null,
|
||||||
|
e2eeActionTs: null,
|
||||||
|
};
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([
|
||||||
|
modernConversation([unknownMessage]),
|
||||||
|
])));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: expect.stringContaining('Unsupported DM protocol version'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects plaintext at or after a conversation E2EE cutover', async () => {
|
||||||
|
const cutoverMs = Math.floor(Date.now() / 1_000) * 1_000 + 789;
|
||||||
|
const cutover = new Date(cutoverMs);
|
||||||
|
const legacyMessage = {
|
||||||
|
...commonMessage(new Date(Math.floor(cutoverMs / 1_000) * 1_000).toISOString()),
|
||||||
|
content: 'late plaintext',
|
||||||
|
protocolVersion: 0,
|
||||||
|
clientMessageId: null,
|
||||||
|
encryptedEnvelope: null,
|
||||||
|
e2eeSignature: null,
|
||||||
|
e2eeActionNonce: null,
|
||||||
|
e2eeActionTs: null,
|
||||||
|
};
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([
|
||||||
|
modernConversation([legacyMessage], {
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: cutover.toISOString(),
|
||||||
|
}),
|
||||||
|
])));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
error: expect.stringContaining('plaintext at or after its E2EE cutover'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows legacy plaintext from the second before the E2EE cutover', async () => {
|
||||||
|
const cutoverMs = Math.floor(Date.now() / 1_000) * 1_000 + 789;
|
||||||
|
const legacyMessage = {
|
||||||
|
...commonMessage(new Date(Math.floor(cutoverMs / 1_000) * 1_000 - 1_000).toISOString()),
|
||||||
|
content: 'plaintext before cutover',
|
||||||
|
protocolVersion: 0,
|
||||||
|
clientMessageId: null,
|
||||||
|
encryptedEnvelope: null,
|
||||||
|
e2eeSignature: null,
|
||||||
|
e2eeActionNonce: null,
|
||||||
|
e2eeActionTs: null,
|
||||||
|
};
|
||||||
|
const response = await importResponse(signedV11Export(basePayload([
|
||||||
|
modernConversation([legacyMessage], {
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: new Date(cutoverMs).toISOString(),
|
||||||
|
}),
|
||||||
|
])));
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid password' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strictly validates an encrypted envelope and its stored signature metadata', async () => {
|
||||||
|
const createdAt = Math.floor(Date.now() / 1_000) * 1_000 + 537;
|
||||||
|
const validMessage = encryptedMessage(createdAt);
|
||||||
|
const validResponse = await importResponse(signedV11Export(basePayload([
|
||||||
|
modernConversation([validMessage], {
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: new Date(createdAt).toISOString(),
|
||||||
|
}),
|
||||||
|
])));
|
||||||
|
expect(validResponse.status).toBe(401);
|
||||||
|
|
||||||
|
const invalidMessage = { ...validMessage, senderHandle: 'mallory' };
|
||||||
|
const invalidResponse = await importResponse(signedV11Export(basePayload([
|
||||||
|
modernConversation([invalidMessage], {
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: new Date(createdAt).toISOString(),
|
||||||
|
}),
|
||||||
|
])));
|
||||||
|
expect(invalidResponse.status).toBe(400);
|
||||||
|
await expect(invalidResponse.json()).resolves.toMatchObject({
|
||||||
|
error: expect.stringContaining('sender handle does not match'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidSignature = {
|
||||||
|
...validMessage,
|
||||||
|
e2eeSignature: randomBytes(64).toString('base64url'),
|
||||||
|
};
|
||||||
|
const invalidSignatureResponse = await importResponse(signedV11Export(basePayload([
|
||||||
|
modernConversation([invalidSignature], {
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: new Date(createdAt).toISOString(),
|
||||||
|
}),
|
||||||
|
])));
|
||||||
|
expect(invalidSignatureResponse.status).toBe(400);
|
||||||
|
await expect(invalidSignatureResponse.json()).resolves.toMatchObject({
|
||||||
|
error: expect.stringContaining('signature is invalid'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects digest-less historical exports before importing unauthenticated payloads', async () => {
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const historicalMessage = {
|
||||||
|
...commonMessage(createdAt),
|
||||||
|
content: 'historical plaintext',
|
||||||
|
};
|
||||||
|
const historicalConversation = {
|
||||||
|
id: randomUUID(),
|
||||||
|
type: 'direct',
|
||||||
|
participant2Handle: 'bob',
|
||||||
|
lastMessageAt: createdAt,
|
||||||
|
lastMessagePreview: 'historical plaintext',
|
||||||
|
messages: [historicalMessage],
|
||||||
|
};
|
||||||
|
const historical = await importResponse(signedExport(
|
||||||
|
basePayload([historicalConversation]),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
expect(historical.status).toBe(400);
|
||||||
|
await expect(historical.json()).resolves.toMatchObject({
|
||||||
|
error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const injectedConversation = {
|
||||||
|
...historicalConversation,
|
||||||
|
messages: [{ ...historicalMessage, protocolVersion: 2 }],
|
||||||
|
};
|
||||||
|
const injected = await importResponse(signedExport(
|
||||||
|
basePayload([injectedConversation]),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
expect(injected.status).toBe(400);
|
||||||
|
await expect(injected.json()).resolves.toMatchObject({
|
||||||
|
error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,25 +6,101 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, users, posts, media, follows, remoteFollows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
|
import { db, users, posts, media, follows, remoteFollows, chatConversations, chatMessages, e2eeKeyBundles, e2eeMessageReceipts, bots, botContentSources, botActivityLogs } from '@/db';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { z } from 'zod';
|
||||||
import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption';
|
import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
|
import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing';
|
||||||
|
import {
|
||||||
|
decryptPrivateKey as decryptStoredPrivateKey,
|
||||||
|
deserializeEncryptedKey,
|
||||||
|
serializeEncryptedKey,
|
||||||
|
} from '@/lib/crypto/private-key';
|
||||||
|
import { didKeyMatchesPublicKey } from '@/lib/crypto/did-key';
|
||||||
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import {
|
||||||
|
E2EE_CHAT_ACTION,
|
||||||
|
E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
e2eeKeyBundleSchema,
|
||||||
|
e2eeMessageEnvelopeSchema,
|
||||||
|
signedUserActionSchema,
|
||||||
|
validateMessageBindings,
|
||||||
|
} from '@/lib/e2ee/protocol';
|
||||||
|
import { encryptionKeyIdFromPublicKey } from '@/lib/e2ee/bundle-proof';
|
||||||
|
import { getPublicSwarmDomain, normalizeNodeDomain } from '@/lib/swarm/node-domain';
|
||||||
|
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
|
||||||
|
|
||||||
interface ImportManifest {
|
const isoTimestampSchema = z.iso.datetime({ offset: true });
|
||||||
version: string;
|
const didSchema = z.string().min(8).max(2_048).regex(/^did:/);
|
||||||
did: string;
|
const base64Schema = z.string().min(1).max(32_768).regex(/^[A-Za-z0-9+/=_-]+$/);
|
||||||
handle: string;
|
const destinationEmailSchema = z.string().trim().email().max(320);
|
||||||
sourceNode: string;
|
const serializedEncryptedKeySchema = z.string().min(2).max(65_536).refine((value) => {
|
||||||
exportedAt: string;
|
try {
|
||||||
expiresAt?: string; // Optional for backward compatibility
|
const parsed = JSON.parse(value) as Record<string, unknown>;
|
||||||
publicKey: string;
|
return Object.keys(parsed).length === 3
|
||||||
privateKeyEncrypted: string;
|
&& typeof parsed.encrypted === 'string' && base64Schema.safeParse(parsed.encrypted).success
|
||||||
salt: string;
|
&& typeof parsed.salt === 'string' && base64Schema.max(256).safeParse(parsed.salt).success
|
||||||
iv: string;
|
&& typeof parsed.iv === 'string' && base64Schema.max(256).safeParse(parsed.iv).success;
|
||||||
signature: string;
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, { message: 'Invalid encrypted private key' });
|
||||||
|
|
||||||
|
// The property order of the 1.0 schema intentionally matches the historical
|
||||||
|
// exporter because those manifests were signed with JSON.stringify().
|
||||||
|
const importManifestV10Schema = z.strictObject({
|
||||||
|
version: z.literal('1.0'),
|
||||||
|
did: didSchema,
|
||||||
|
handle: z.string().min(1).max(320),
|
||||||
|
sourceNode: z.string().min(1).max(320),
|
||||||
|
exportedAt: isoTimestampSchema,
|
||||||
|
expiresAt: isoTimestampSchema.optional(),
|
||||||
|
publicKey: z.string().min(1).max(16_384),
|
||||||
|
privateKeyEncrypted: base64Schema,
|
||||||
|
salt: base64Schema.max(256),
|
||||||
|
iv: base64Schema.max(256),
|
||||||
|
payloadDigestAlgorithm: z.literal('sha256').optional(),
|
||||||
|
payloadDigest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
||||||
|
signature: base64Schema,
|
||||||
|
}).superRefine((manifest, context) => {
|
||||||
|
if ((manifest.payloadDigestAlgorithm === undefined) !== (manifest.payloadDigest === undefined)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Payload digest fields must be provided together',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const importManifestV11Schema = z.strictObject({
|
||||||
|
version: z.literal('1.1'),
|
||||||
|
did: didSchema,
|
||||||
|
handle: z.string().min(1).max(320),
|
||||||
|
sourceNode: z.string().min(1).max(320),
|
||||||
|
exportedAt: isoTimestampSchema,
|
||||||
|
expiresAt: isoTimestampSchema,
|
||||||
|
publicKey: z.string().min(1).max(16_384),
|
||||||
|
privateKeyEncrypted: serializedEncryptedKeySchema,
|
||||||
|
payloadDigestAlgorithm: z.literal('sha256'),
|
||||||
|
payloadDigest: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
|
signature: base64Schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const importManifestSchema = z.union([
|
||||||
|
importManifestV10Schema,
|
||||||
|
importManifestV11Schema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
type ImportManifest = z.infer<typeof importManifestSchema>;
|
||||||
|
|
||||||
|
function hasPayloadDigest(manifest: ImportManifest): manifest is ImportManifest & {
|
||||||
|
payloadDigestAlgorithm: 'sha256';
|
||||||
|
payloadDigest: string;
|
||||||
|
} {
|
||||||
|
return manifest.payloadDigestAlgorithm === 'sha256'
|
||||||
|
&& typeof manifest.payloadDigest === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportProfile {
|
interface ImportProfile {
|
||||||
@@ -56,6 +132,8 @@ interface ImportDMConversation {
|
|||||||
participant2Handle: string;
|
participant2Handle: string;
|
||||||
lastMessageAt: string | null;
|
lastMessageAt: string | null;
|
||||||
lastMessagePreview: string | null;
|
lastMessagePreview: string | null;
|
||||||
|
encryptionMode: 'legacy' | 'e2ee';
|
||||||
|
e2eeActivatedAt: string | null;
|
||||||
messages: ImportDMMessage[];
|
messages: ImportDMMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +144,12 @@ interface ImportDMMessage {
|
|||||||
senderNodeDomain: string | null;
|
senderNodeDomain: string | null;
|
||||||
senderDid: string | null;
|
senderDid: string | null;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
|
protocolVersion: 0 | 1;
|
||||||
|
clientMessageId: string | null;
|
||||||
|
encryptedEnvelope: string | null;
|
||||||
|
e2eeSignature: string | null;
|
||||||
|
e2eeActionNonce: string | null;
|
||||||
|
e2eeActionTs: number | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
readAt: string | null;
|
readAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -78,32 +162,379 @@ interface ImportBot {
|
|||||||
bio: string | null;
|
bio: string | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
headerUrl: string | null;
|
headerUrl: string | null;
|
||||||
personalityConfig: any;
|
personalityConfig: unknown;
|
||||||
llmProvider: string;
|
llmProvider: string;
|
||||||
llmModel: string;
|
llmModel: string;
|
||||||
llmApiKey: string;
|
llmApiKey: string;
|
||||||
botPrivateKey: string;
|
botPrivateKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
scheduleConfig: any;
|
scheduleConfig: unknown;
|
||||||
autonomousMode: boolean;
|
autonomousMode: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
sources: any[];
|
sources: ImportBotSource[];
|
||||||
activityLogs: any[];
|
activityLogs: ImportBotActivityLog[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportPackage {
|
interface ImportBotSource {
|
||||||
manifest: ImportManifest;
|
type: string;
|
||||||
profile: ImportProfile;
|
url: string;
|
||||||
posts: ImportPost[];
|
subreddit?: string | null;
|
||||||
following: ImportFollowing[];
|
apiKeyEncrypted?: string | null;
|
||||||
dms: ImportDMConversation[];
|
sourceConfig?: unknown;
|
||||||
bots: ImportBot[];
|
keywords?: unknown;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportBotActivityLog {
|
||||||
|
action: string;
|
||||||
|
details: unknown;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmMessageCommonSchema = {
|
||||||
|
senderHandle: z.string().min(1).max(640),
|
||||||
|
senderDisplayName: z.string().max(1_000).nullable(),
|
||||||
|
senderAvatarUrl: z.string().max(16_384).nullable(),
|
||||||
|
senderNodeDomain: z.string().max(320).nullable(),
|
||||||
|
deliveredAt: isoTimestampSchema.nullable(),
|
||||||
|
readAt: isoTimestampSchema.nullable(),
|
||||||
|
createdAt: isoTimestampSchema,
|
||||||
|
};
|
||||||
|
|
||||||
|
const historicalLegacyDMMessageSchema = z.strictObject({
|
||||||
|
...dmMessageCommonSchema,
|
||||||
|
senderDid: didSchema.nullable(),
|
||||||
|
content: z.string().max(100_000).nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const legacyDMMessageSchema = z.strictObject({
|
||||||
|
...dmMessageCommonSchema,
|
||||||
|
senderDid: didSchema.nullable(),
|
||||||
|
content: z.string().max(100_000).nullable(),
|
||||||
|
protocolVersion: z.literal(0),
|
||||||
|
clientMessageId: z.null(),
|
||||||
|
encryptedEnvelope: z.null(),
|
||||||
|
e2eeSignature: z.null(),
|
||||||
|
e2eeActionNonce: z.null(),
|
||||||
|
e2eeActionTs: z.null(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptedDMMessageSchema = z.strictObject({
|
||||||
|
...dmMessageCommonSchema,
|
||||||
|
senderDid: didSchema,
|
||||||
|
content: z.null(),
|
||||||
|
protocolVersion: z.literal(1),
|
||||||
|
clientMessageId: z.string().uuid(),
|
||||||
|
encryptedEnvelope: z.string().min(2).max(32_768),
|
||||||
|
e2eeSignature: z.string().min(1).max(512).regex(/^[A-Za-z0-9_-]+$/),
|
||||||
|
e2eeActionNonce: z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/),
|
||||||
|
e2eeActionTs: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const e2eeContinuityAnchorSchema = z.strictObject({
|
||||||
|
did: didSchema,
|
||||||
|
keyId: z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/),
|
||||||
|
keyVersion: z.number().int().positive().max(1_000_000),
|
||||||
|
publicKey: z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/),
|
||||||
|
proofAction: signedUserActionSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ImportedE2EEContinuityAnchor {
|
||||||
|
did: string;
|
||||||
|
keyId: string;
|
||||||
|
keyVersion: number;
|
||||||
|
publicKey: string;
|
||||||
|
proofAction: z.infer<typeof signedUserActionSchema>;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmConversationCommonSchema = {
|
||||||
|
id: z.string().min(1).max(128),
|
||||||
|
type: z.string().min(1).max(32),
|
||||||
|
participant2Handle: z.string().min(1).max(640),
|
||||||
|
lastMessageAt: isoTimestampSchema.nullable(),
|
||||||
|
lastMessagePreview: z.string().max(100_000).nullable(),
|
||||||
|
messages: z.array(z.unknown()).max(100_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dmConversationV10Schema = z.strictObject({
|
||||||
|
...dmConversationCommonSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dmConversationV11Schema = z.strictObject({
|
||||||
|
...dmConversationCommonSchema,
|
||||||
|
encryptionMode: z.enum(['legacy', 'e2ee']),
|
||||||
|
e2eeActivatedAt: isoTimestampSchema.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExactDevelopmentLoopbackNode(value: string): boolean {
|
||||||
|
const match = value.match(/^(?:localhost|127\.0\.0\.1|\[::1\])(?::(\d{1,5}))?$/);
|
||||||
|
if (!match) return false;
|
||||||
|
if (!match[1]) return true;
|
||||||
|
const port = Number(match[1]);
|
||||||
|
return Number.isInteger(port) && port >= 1 && port <= 65_535;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatedSourceNode(value: string): string | null {
|
||||||
|
if (value !== value.trim()) return null;
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
if (!normalized || normalizeNodeDomain(normalized) !== normalized) return null;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development' && isExactDevelopmentLoopbackNode(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicDomain = getPublicSwarmDomain(normalized);
|
||||||
|
return publicDomain === normalized ? publicDomain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceNodeProtocol(sourceNode: string): 'http' | 'https' {
|
||||||
|
return process.env.NODE_ENV === 'development'
|
||||||
|
&& isExactDevelopmentLoopbackNode(sourceNode)
|
||||||
|
? 'http'
|
||||||
|
: 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateE2EEContinuityAnchor(
|
||||||
|
rawAnchor: unknown,
|
||||||
|
manifest: ImportManifest,
|
||||||
|
): Promise<ImportedE2EEContinuityAnchor> {
|
||||||
|
const anchor = e2eeContinuityAnchorSchema.parse(rawAnchor);
|
||||||
|
const proof = anchor.proofAction;
|
||||||
|
const bundle = e2eeKeyBundleSchema.parse(proof.data);
|
||||||
|
|
||||||
|
if (proof.action !== E2EE_KEY_BUNDLE_ACTION
|
||||||
|
|| proof.did !== manifest.did
|
||||||
|
|| proof.handle.toLowerCase() !== manifest.handle.toLowerCase()
|
||||||
|
|| anchor.did !== manifest.did
|
||||||
|
|| anchor.did !== proof.did
|
||||||
|
|| anchor.keyId !== bundle.keyId
|
||||||
|
|| anchor.keyVersion !== bundle.version
|
||||||
|
|| anchor.publicKey !== bundle.publicKey
|
||||||
|
|| Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1_000) {
|
||||||
|
throw new Error('E2EE continuity anchor does not match its signed proof');
|
||||||
|
}
|
||||||
|
if (Buffer.from(bundle.publicKey, 'base64url').length !== 32
|
||||||
|
|| Buffer.from(bundle.recoveryCommitment, 'base64url').length !== 32
|
||||||
|
|| await encryptionKeyIdFromPublicKey(bundle.publicKey) !== bundle.keyId) {
|
||||||
|
throw new Error('E2EE continuity anchor key is invalid');
|
||||||
|
}
|
||||||
|
if (!await verifySignedActionSignature(proof, manifest.publicKey)) {
|
||||||
|
throw new Error('E2EE continuity anchor signature is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
did: anchor.did,
|
||||||
|
keyId: anchor.keyId,
|
||||||
|
keyVersion: anchor.keyVersion,
|
||||||
|
publicKey: anchor.publicKey,
|
||||||
|
proofAction: proof,
|
||||||
|
createdAt: bundle.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function digestImportPayload(exportData: Record<string, unknown>): string {
|
||||||
|
const payload = Object.fromEntries(
|
||||||
|
Object.entries(exportData).filter(([key]) => key !== 'manifest'),
|
||||||
|
);
|
||||||
|
return crypto.createHash('sha256').update(canonicalize(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadDigestMatches(expected: string, actual: string): boolean {
|
||||||
|
const expectedBytes = Buffer.from(expected, 'hex');
|
||||||
|
const actualBytes = Buffer.from(actual, 'hex');
|
||||||
|
return expectedBytes.length === actualBytes.length
|
||||||
|
&& expectedBytes.length === 32
|
||||||
|
&& crypto.timingSafeEqual(expectedBytes, actualBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqliteTimestampSecond(timestamp: number): number {
|
||||||
|
return Math.floor(timestamp / 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCiphertextSizes(envelope: z.infer<typeof e2eeMessageEnvelopeSchema>): void {
|
||||||
|
if (Buffer.from(envelope.nonce, 'base64url').length !== 24
|
||||||
|
|| Buffer.from(envelope.ciphertext, 'base64url').length < 17
|
||||||
|
|| Buffer.from(envelope.ciphertext, 'base64url').length > 8_192
|
||||||
|
|| Buffer.from(envelope.keyCommitment, 'base64url').length !== 32
|
||||||
|
|| envelope.keyEnvelopes.some((item) => Buffer.from(item.sealedKey, 'base64url').length !== 112)) {
|
||||||
|
throw new Error('Encrypted DM has invalid field sizes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeEncryptedDMMessage(
|
||||||
|
rawMessage: unknown,
|
||||||
|
ownerDid: string,
|
||||||
|
ownerPublicKey: string,
|
||||||
|
): Promise<ImportDMMessage> {
|
||||||
|
const message = encryptedDMMessageSchema.parse(rawMessage);
|
||||||
|
let rawEnvelope: unknown;
|
||||||
|
try {
|
||||||
|
rawEnvelope = JSON.parse(message.encryptedEnvelope);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Encrypted DM envelope is not valid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = e2eeMessageEnvelopeSchema.parse(rawEnvelope);
|
||||||
|
const storedSenderHandle = message.senderHandle.toLowerCase().replace(/^@/, '');
|
||||||
|
const signedSenderHandle = envelope.senderHandle.toLowerCase().replace(/^@/, '');
|
||||||
|
if (storedSenderHandle !== signedSenderHandle
|
||||||
|
&& !storedSenderHandle.startsWith(`${signedSenderHandle}@`)) {
|
||||||
|
throw new Error('Encrypted DM sender handle does not match its envelope');
|
||||||
|
}
|
||||||
|
const signedAction = signedUserActionSchema.parse({
|
||||||
|
action: E2EE_CHAT_ACTION,
|
||||||
|
data: envelope,
|
||||||
|
did: message.senderDid,
|
||||||
|
handle: envelope.senderHandle,
|
||||||
|
ts: message.e2eeActionTs,
|
||||||
|
nonce: message.e2eeActionNonce,
|
||||||
|
sig: message.e2eeSignature,
|
||||||
|
});
|
||||||
|
validateMessageBindings(envelope, signedAction);
|
||||||
|
validateCiphertextSizes(envelope);
|
||||||
|
|
||||||
|
// The export carries the imported account's signing key, so messages sent
|
||||||
|
// by that account can and must retain a valid original action signature.
|
||||||
|
// Incoming peer signatures remain structurally validated here and are
|
||||||
|
// verified against resolved peer keys when displayed by the chat client.
|
||||||
|
if (envelope.senderDid === ownerDid
|
||||||
|
&& !await verifySignedActionSignature(signedAction, ownerPublicKey)) {
|
||||||
|
throw new Error('Encrypted DM signature is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.clientMessageId !== envelope.messageId) {
|
||||||
|
throw new Error('Encrypted DM message ID does not match its envelope');
|
||||||
|
}
|
||||||
|
if (sqliteTimestampSecond(Date.parse(message.createdAt))
|
||||||
|
!== sqliteTimestampSecond(envelope.createdAt)) {
|
||||||
|
throw new Error('Encrypted DM timestamp does not match its envelope');
|
||||||
|
}
|
||||||
|
if (envelope.senderDid !== ownerDid && envelope.recipientDid !== ownerDid) {
|
||||||
|
throw new Error('Encrypted DM does not include the imported account');
|
||||||
|
}
|
||||||
|
if (envelope.senderDid === envelope.recipientDid || envelope.keyEnvelopes.length !== 2) {
|
||||||
|
throw new Error('Encrypted DM participants are invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
encryptedEnvelope: JSON.stringify(envelope),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegacyDMMessage(
|
||||||
|
rawMessage: unknown,
|
||||||
|
integrityProtected: boolean,
|
||||||
|
): ImportDMMessage {
|
||||||
|
const message = integrityProtected
|
||||||
|
? legacyDMMessageSchema.parse(rawMessage)
|
||||||
|
: historicalLegacyDMMessageSchema.parse(rawMessage);
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
protocolVersion: 0,
|
||||||
|
clientMessageId: null,
|
||||||
|
encryptedEnvelope: null,
|
||||||
|
e2eeSignature: null,
|
||||||
|
e2eeActionNonce: null,
|
||||||
|
e2eeActionTs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeImportedDMs(
|
||||||
|
rawConversations: unknown[],
|
||||||
|
manifest: ImportManifest,
|
||||||
|
payloadIntegrityVerified: boolean,
|
||||||
|
): Promise<ImportDMConversation[]> {
|
||||||
|
const integrityProtected = payloadIntegrityVerified;
|
||||||
|
return Promise.all(rawConversations.map(async (rawConversation, conversationIndex) => {
|
||||||
|
const protectedConversation = integrityProtected
|
||||||
|
? dmConversationV11Schema.parse(rawConversation)
|
||||||
|
: null;
|
||||||
|
const conversation = protectedConversation
|
||||||
|
?? dmConversationV10Schema.parse(rawConversation);
|
||||||
|
const messages: ImportDMMessage[] = [];
|
||||||
|
let sawEncrypted = false;
|
||||||
|
|
||||||
|
for (const [messageIndex, rawMessage] of conversation.messages.entries()) {
|
||||||
|
const rawVersion = isRecord(rawMessage) ? rawMessage.protocolVersion : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!integrityProtected) {
|
||||||
|
// Historical 1.0 exports predate encrypted DM fields. The
|
||||||
|
// strict historical schema rejects injected protocol
|
||||||
|
// markers instead of guessing that they are plaintext.
|
||||||
|
messages.push(normalizeLegacyDMMessage(rawMessage, false));
|
||||||
|
} else if (rawVersion === 0) {
|
||||||
|
messages.push(normalizeLegacyDMMessage(rawMessage, true));
|
||||||
|
} else if (rawVersion === 1) {
|
||||||
|
messages.push(await normalizeEncryptedDMMessage(
|
||||||
|
rawMessage,
|
||||||
|
manifest.did,
|
||||||
|
manifest.publicKey,
|
||||||
|
));
|
||||||
|
sawEncrypted = true;
|
||||||
|
} else {
|
||||||
|
throw new Error('Unsupported DM protocol version');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : 'invalid message';
|
||||||
|
throw new Error(`Invalid DM ${conversationIndex + 1}.${messageIndex + 1}: ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryptionMode: 'legacy' | 'e2ee' = 'legacy';
|
||||||
|
let activatedAt: number | null = null;
|
||||||
|
if (protectedConversation) {
|
||||||
|
encryptionMode = protectedConversation.encryptionMode;
|
||||||
|
activatedAt = protectedConversation.e2eeActivatedAt
|
||||||
|
? Date.parse(protectedConversation.e2eeActivatedAt)
|
||||||
|
: null;
|
||||||
|
if (encryptionMode === 'legacy' && activatedAt !== null) {
|
||||||
|
throw new Error(`Legacy DM conversation ${conversationIndex + 1} has an E2EE activation time`);
|
||||||
|
}
|
||||||
|
if (encryptionMode === 'legacy' && sawEncrypted) {
|
||||||
|
throw new Error(`Legacy DM conversation ${conversationIndex + 1} contains encrypted messages`);
|
||||||
|
}
|
||||||
|
if (encryptionMode === 'e2ee' && activatedAt === null) {
|
||||||
|
throw new Error(`Encrypted DM conversation ${conversationIndex + 1} has no activation time`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activatedAt !== null) {
|
||||||
|
const legacyAfterCutover = messages.some((message) => (
|
||||||
|
message.protocolVersion === 0
|
||||||
|
&& sqliteTimestampSecond(Date.parse(message.createdAt))
|
||||||
|
>= sqliteTimestampSecond(activatedAt!)
|
||||||
|
));
|
||||||
|
if (legacyAfterCutover) {
|
||||||
|
throw new Error(`DM conversation ${conversationIndex + 1} contains plaintext at or after its E2EE cutover`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: conversation.id,
|
||||||
|
type: conversation.type,
|
||||||
|
participant2Handle: conversation.participant2Handle,
|
||||||
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
|
lastMessagePreview: conversation.lastMessagePreview,
|
||||||
|
encryptionMode,
|
||||||
|
e2eeActivatedAt: activatedAt === null ? null : new Date(activatedAt).toISOString(),
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt the private key using the user's password
|
* Decrypt the private key using the user's password
|
||||||
*/
|
*/
|
||||||
function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
|
function decryptLegacyExportPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
|
||||||
const saltBuffer = Buffer.from(salt, 'base64');
|
const saltBuffer = Buffer.from(salt, 'base64');
|
||||||
const ivBuffer = Buffer.from(iv, 'base64');
|
const ivBuffer = Buffer.from(iv, 'base64');
|
||||||
const encryptedBuffer = Buffer.from(encrypted, 'base64');
|
const encryptedBuffer = Buffer.from(encrypted, 'base64');
|
||||||
@@ -125,13 +556,26 @@ function decryptPrivateKey(encrypted: string, password: string, salt: string, iv
|
|||||||
return decrypted.toString('utf8');
|
return decrypted.toString('utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signingKeyMatchesPublicKey(privateKey: string, publicKey: string): boolean {
|
||||||
|
try {
|
||||||
|
const derivedPublicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
|
||||||
|
const expectedPublicKey = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
|
||||||
|
return derivedPublicKey.length === expectedPublicKey.length
|
||||||
|
&& crypto.timingSafeEqual(derivedPublicKey, expectedPublicKey);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the manifest signature
|
* Verify the manifest signature
|
||||||
*/
|
*/
|
||||||
function verifyManifestSignature(manifest: ImportManifest): boolean {
|
function verifyManifestSignature(manifest: ImportManifest): boolean {
|
||||||
try {
|
try {
|
||||||
const { signature, ...manifestData } = manifest;
|
const { signature, ...manifestData } = manifest;
|
||||||
const data = JSON.stringify(manifestData);
|
const data = manifest.version === '1.1'
|
||||||
|
? canonicalize(manifestData)
|
||||||
|
: JSON.stringify(manifestData);
|
||||||
|
|
||||||
const verify = crypto.createVerify('sha256');
|
const verify = crypto.createVerify('sha256');
|
||||||
verify.update(data);
|
verify.update(data);
|
||||||
@@ -145,31 +589,39 @@ function verifyManifestSignature(manifest: ImportManifest): boolean {
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body: unknown = await req.json();
|
||||||
const { exportData, password, newHandle, acceptedCompliance } = body as {
|
if (!isRecord(body)) {
|
||||||
exportData: ImportPackage;
|
return NextResponse.json({ error: 'Invalid import request' }, { status: 400 });
|
||||||
password: string;
|
}
|
||||||
newHandle: string;
|
const { exportData, password, newHandle, destinationEmail, acceptedCompliance } = body;
|
||||||
acceptedCompliance: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!exportData || !password || !newHandle) {
|
if (!isRecord(exportData)
|
||||||
|
|| typeof password !== 'string' || password.length === 0
|
||||||
|
|| typeof newHandle !== 'string' || newHandle.length === 0
|
||||||
|
|| typeof destinationEmail !== 'string' || destinationEmail.length === 0) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!acceptedCompliance) {
|
const destinationEmailResult = destinationEmailSchema.safeParse(destinationEmail);
|
||||||
|
if (!destinationEmailResult.success) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Enter a valid destination email address',
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
const destinationEmailClean = destinationEmailResult.data.toLowerCase();
|
||||||
|
|
||||||
|
if (acceptedCompliance !== true) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: 'You must accept the content compliance agreement'
|
error: 'You must accept the content compliance agreement'
|
||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { manifest, profile, posts: importPosts, following } = exportData;
|
const manifestResult = importManifestSchema.safeParse(exportData.manifest);
|
||||||
|
if (!manifestResult.success) {
|
||||||
// Validate manifest version
|
return NextResponse.json({ error: 'Invalid or unsupported export manifest' }, { status: 400 });
|
||||||
if (manifest.version !== '1.0') {
|
|
||||||
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
const manifest = manifestResult.data;
|
||||||
|
|
||||||
// Check if export has expired
|
// Check if export has expired
|
||||||
if (manifest.expiresAt) {
|
if (manifest.expiresAt) {
|
||||||
@@ -181,25 +633,103 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dms: importDMs = [], bots: importBots = [] } = exportData;
|
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if (!verifyManifestSignature(manifest)) {
|
if (!verifyManifestSignature(manifest)) {
|
||||||
return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (!didKeyMatchesPublicKey(manifest.did, manifest.publicKey)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Export identity must be a self-certifying did:key that matches its signing key',
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
const sourceNode = validatedSourceNode(manifest.sourceNode);
|
||||||
|
if (!sourceNode) {
|
||||||
|
return NextResponse.json({ error: 'Export source node is unsafe or invalid' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPayloadDigest(manifest)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Historical exports without an authenticated payload are not supported. Create a fresh export from your old node.',
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
const actualDigest = digestImportPayload(exportData);
|
||||||
|
if (!payloadDigestMatches(manifest.payloadDigest, actualDigest)) {
|
||||||
|
return NextResponse.json({ error: 'Export payload integrity check failed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { profile: rawProfile, posts: rawPosts, following: rawFollowing } = exportData;
|
||||||
|
if (!isRecord(rawProfile) || !Array.isArray(rawPosts) || !Array.isArray(rawFollowing)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid export payload' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const rawDMs = exportData.dms === undefined ? [] : exportData.dms;
|
||||||
|
const rawBots = exportData.bots === undefined ? [] : exportData.bots;
|
||||||
|
if (!Array.isArray(rawDMs) || !Array.isArray(rawBots)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid export payload' }, { status: 400 });
|
||||||
|
}
|
||||||
|
let importedE2EEKeyBundle: ImportedE2EEContinuityAnchor | null = null;
|
||||||
|
if (manifest.version === '1.1' && exportData.e2eeKeyBundle != null) {
|
||||||
|
try {
|
||||||
|
importedE2EEKeyBundle = await validateE2EEContinuityAnchor(
|
||||||
|
exportData.e2eeKeyBundle,
|
||||||
|
manifest,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : 'invalid E2EE continuity anchor';
|
||||||
|
return NextResponse.json({ error: `Invalid E2EE continuity anchor: ${reason}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let importDMs: ImportDMConversation[];
|
||||||
|
try {
|
||||||
|
importDMs = await normalizeImportedDMs(rawDMs, manifest, true);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : 'invalid direct-message history';
|
||||||
|
return NextResponse.json({ error: `Invalid direct-message history: ${reason}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
const encryptedDMImportWarning = importDMs.some((conversation) => (
|
||||||
|
conversation.messages.some((message) => message.protocolVersion === 1)
|
||||||
|
))
|
||||||
|
? 'Encrypted DM records were preserved, but their decryption key is not portable yet. This history cannot be opened on this node.'
|
||||||
|
: null;
|
||||||
|
const federatedMoveWarning = importedE2EEKeyBundle
|
||||||
|
? 'Federated DM relationships do not automatically follow a home-node move. Peers that cached your old full handle may reject the new handle until signed account-move support is implemented.'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const profile = rawProfile as unknown as ImportProfile;
|
||||||
|
const importPosts = rawPosts as ImportPost[];
|
||||||
|
const following = rawFollowing as ImportFollowing[];
|
||||||
|
const importBots = rawBots as ImportBot[];
|
||||||
|
|
||||||
// Decrypt private key to verify password is correct
|
// Decrypt private key to verify password is correct
|
||||||
let privateKey: string;
|
let privateKey: string;
|
||||||
|
let privateKeyEncryptedForStorage: string;
|
||||||
try {
|
try {
|
||||||
privateKey = decryptPrivateKey(
|
if (manifest.version === '1.1') {
|
||||||
manifest.privateKeyEncrypted,
|
privateKey = decryptStoredPrivateKey(
|
||||||
password,
|
deserializeEncryptedKey(manifest.privateKeyEncrypted),
|
||||||
manifest.salt,
|
password,
|
||||||
manifest.iv
|
);
|
||||||
);
|
privateKeyEncryptedForStorage = manifest.privateKeyEncrypted;
|
||||||
} catch (error) {
|
} else {
|
||||||
|
privateKey = decryptLegacyExportPrivateKey(
|
||||||
|
manifest.privateKeyEncrypted,
|
||||||
|
password,
|
||||||
|
manifest.salt,
|
||||||
|
manifest.iv,
|
||||||
|
);
|
||||||
|
privateKeyEncryptedForStorage = serializeEncryptedKey({
|
||||||
|
encrypted: manifest.privateKeyEncrypted,
|
||||||
|
salt: manifest.salt,
|
||||||
|
iv: manifest.iv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
if (!signingKeyMatchesPublicKey(privateKey, manifest.publicKey)) {
|
||||||
|
return NextResponse.json({ error: 'Export private key does not match its signing key' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const importedPasswordHash = await hashPassword(password);
|
||||||
|
|
||||||
// Check if DID already exists on this node
|
// Check if DID already exists on this node
|
||||||
const existingDid = await db.query.users.findFirst({
|
const existingDid = await db.query.users.findFirst({
|
||||||
@@ -212,6 +742,16 @@ export async function POST(req: NextRequest) {
|
|||||||
}, { status: 409 });
|
}, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingEmail = await db.query.users.findFirst({
|
||||||
|
where: { email: destinationEmailClean },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Email is already registered on this node',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
// Validate handle format
|
// Validate handle format
|
||||||
const handleClean = newHandle.toLowerCase().replace(/^@/, '').trim();
|
const handleClean = newHandle.toLowerCase().replace(/^@/, '').trim();
|
||||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handleClean)) {
|
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handleClean)) {
|
||||||
@@ -233,23 +773,43 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||||
const oldActorUrl = `https://${manifest.sourceNode}/users/${manifest.handle}`;
|
const oldActorUrl = `${sourceNodeProtocol(sourceNode)}://${sourceNode}/users/${manifest.handle}`;
|
||||||
const newActorUrl = `https://${nodeDomain}/users/${handleClean}`;
|
const newActorUrl = `https://${nodeDomain}/users/${handleClean}`;
|
||||||
|
|
||||||
// Create the user with the same DID
|
// The identity and its public continuity anchor are one unit. A key-ID
|
||||||
const [newUser] = await db.insert(users).values({
|
// conflict must not leave behind an account that cannot be retried.
|
||||||
did: manifest.did,
|
const newUser = await db.transaction(async (tx) => {
|
||||||
handle: handleClean,
|
const [createdUser] = await tx.insert(users).values({
|
||||||
displayName: profile.displayName,
|
did: manifest.did,
|
||||||
bio: profile.bio,
|
handle: handleClean,
|
||||||
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
|
email: destinationEmailClean,
|
||||||
headerUrl: profile.headerUrl,
|
displayName: profile.displayName,
|
||||||
publicKey: manifest.publicKey,
|
bio: profile.bio,
|
||||||
privateKeyEncrypted: privateKey,
|
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
|
||||||
movedFrom: oldActorUrl,
|
headerUrl: profile.headerUrl,
|
||||||
migratedAt: new Date(),
|
publicKey: manifest.publicKey,
|
||||||
postsCount: importPosts.length,
|
privateKeyEncrypted: privateKeyEncryptedForStorage,
|
||||||
}).returning();
|
passwordHash: importedPasswordHash,
|
||||||
|
movedFrom: oldActorUrl,
|
||||||
|
migratedAt: new Date(),
|
||||||
|
postsCount: importPosts.length,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (importedE2EEKeyBundle) {
|
||||||
|
await tx.insert(e2eeKeyBundles).values({
|
||||||
|
userId: createdUser.id,
|
||||||
|
did: importedE2EEKeyBundle.did,
|
||||||
|
keyId: importedE2EEKeyBundle.keyId,
|
||||||
|
keyVersion: importedE2EEKeyBundle.keyVersion,
|
||||||
|
publicKey: importedE2EEKeyBundle.publicKey,
|
||||||
|
proofAction: JSON.stringify(importedE2EEKeyBundle.proofAction),
|
||||||
|
createdAt: new Date(importedE2EEKeyBundle.createdAt),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdUser;
|
||||||
|
});
|
||||||
|
|
||||||
// Check if this is an NSFW node and auto-enable NSFW settings
|
// Check if this is an NSFW node and auto-enable NSFW settings
|
||||||
const node = await db.query.nodes.findFirst({
|
const node = await db.query.nodes.findFirst({
|
||||||
@@ -346,31 +906,53 @@ export async function POST(req: NextRequest) {
|
|||||||
.set({ followingCount: importedFollowing })
|
.set({ followingCount: importedFollowing })
|
||||||
.where(eq(users.id, newUser.id));
|
.where(eq(users.id, newUser.id));
|
||||||
|
|
||||||
// Import DMs
|
// Import each conversation and all of its messages atomically. A
|
||||||
|
// failed message cannot leave a partial conversation behind.
|
||||||
|
let importedDMs = 0;
|
||||||
for (const conv of importDMs) {
|
for (const conv of importDMs) {
|
||||||
try {
|
try {
|
||||||
const [newConv] = await db.insert(chatConversations).values({
|
await db.transaction(async (tx) => {
|
||||||
participant1Id: newUser.id,
|
const [newConv] = await tx.insert(chatConversations).values({
|
||||||
participant2Handle: conv.participant2Handle,
|
participant1Id: newUser.id,
|
||||||
type: conv.type,
|
participant2Handle: conv.participant2Handle,
|
||||||
lastMessageAt: conv.lastMessageAt ? new Date(conv.lastMessageAt) : null,
|
type: conv.type,
|
||||||
lastMessagePreview: conv.lastMessagePreview
|
lastMessageAt: conv.lastMessageAt ? new Date(conv.lastMessageAt) : null,
|
||||||
}).returning();
|
lastMessagePreview: conv.encryptionMode === 'e2ee'
|
||||||
|
? 'Encrypted message'
|
||||||
|
: conv.lastMessagePreview,
|
||||||
|
encryptionMode: conv.encryptionMode,
|
||||||
|
e2eeActivatedAt: conv.e2eeActivatedAt ? new Date(conv.e2eeActivatedAt) : null,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
for (const msg of conv.messages) {
|
for (const msg of conv.messages) {
|
||||||
await db.insert(chatMessages).values({
|
await tx.insert(chatMessages).values({
|
||||||
conversationId: newConv.id,
|
conversationId: newConv.id,
|
||||||
senderHandle: msg.senderHandle,
|
senderHandle: msg.senderHandle,
|
||||||
senderDisplayName: msg.senderDisplayName,
|
senderDisplayName: msg.senderDisplayName,
|
||||||
senderAvatarUrl: msg.senderAvatarUrl,
|
senderAvatarUrl: msg.senderAvatarUrl,
|
||||||
senderNodeDomain: msg.senderNodeDomain,
|
senderNodeDomain: msg.senderNodeDomain,
|
||||||
senderDid: msg.senderDid,
|
senderDid: msg.senderDid,
|
||||||
content: msg.content,
|
content: msg.protocolVersion === 0 ? msg.content : null,
|
||||||
deliveredAt: msg.deliveredAt ? new Date(msg.deliveredAt) : null,
|
protocolVersion: msg.protocolVersion,
|
||||||
readAt: msg.readAt ? new Date(msg.readAt) : null,
|
clientMessageId: msg.clientMessageId,
|
||||||
createdAt: new Date(msg.createdAt)
|
encryptedEnvelope: msg.encryptedEnvelope,
|
||||||
});
|
e2eeSignature: msg.e2eeSignature,
|
||||||
}
|
e2eeActionNonce: msg.e2eeActionNonce,
|
||||||
|
e2eeActionTs: msg.e2eeActionTs,
|
||||||
|
deliveredAt: msg.deliveredAt ? new Date(msg.deliveredAt) : null,
|
||||||
|
readAt: msg.readAt ? new Date(msg.readAt) : null,
|
||||||
|
createdAt: new Date(msg.createdAt),
|
||||||
|
});
|
||||||
|
if (msg.protocolVersion === 1 && msg.clientMessageId && msg.senderDid) {
|
||||||
|
await tx.insert(e2eeMessageReceipts).values({
|
||||||
|
ownerUserId: newUser.id,
|
||||||
|
senderDid: msg.senderDid,
|
||||||
|
messageId: msg.clientMessageId,
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
importedDMs += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to import DM conversation:', error);
|
console.error('Failed to import DM conversation:', error);
|
||||||
}
|
}
|
||||||
@@ -447,12 +1029,23 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// Notify old node about the migration
|
// Notify old node about the migration
|
||||||
try {
|
try {
|
||||||
await notifyOldNode(manifest.sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
|
await notifyOldNode(sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to notify old node:', error);
|
console.error('Failed to notify old node:', error);
|
||||||
// Don't fail the import if notification fails
|
// Don't fail the import if notification fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match registration/login behavior so the successful import can
|
||||||
|
// redirect directly into the authenticated app. The imported email
|
||||||
|
// and password hash also make ordinary email login work thereafter.
|
||||||
|
let sessionWarning: string | null = null;
|
||||||
|
try {
|
||||||
|
await createSession(newUser.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Account imported but automatic sign-in failed:', error);
|
||||||
|
sessionWarning = 'The account was imported, but automatic sign-in failed. Sign in with the destination email and import password.';
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: {
|
||||||
@@ -464,10 +1057,12 @@ export async function POST(req: NextRequest) {
|
|||||||
stats: {
|
stats: {
|
||||||
postsImported: importedPosts,
|
postsImported: importedPosts,
|
||||||
followingImported: importedFollowing,
|
followingImported: importedFollowing,
|
||||||
dmsImported: importDMs.length,
|
dmsImported: importedDMs,
|
||||||
botsImported: importBots.length,
|
botsImported: importBots.length,
|
||||||
},
|
},
|
||||||
message: 'Account imported successfully. Your followers on other Synapsis nodes will be automatically migrated.',
|
warnings: [encryptedDMImportWarning, federatedMoveWarning, sessionWarning]
|
||||||
|
.filter((warning): warning is string => warning !== null),
|
||||||
|
message: 'Account imported successfully. The old node was notified of the move when reachable.',
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -498,7 +1093,8 @@ async function notifyOldNode(
|
|||||||
sign.update(JSON.stringify(payload));
|
sign.update(JSON.stringify(payload));
|
||||||
const signature = sign.sign(privateKey, 'base64');
|
const signature = sign.sign(privateKey, 'base64');
|
||||||
|
|
||||||
const response = await fetch(`https://${sourceNode}/api/account/moved`, {
|
const response = await safeFederationRequest(
|
||||||
|
`${sourceNodeProtocol(sourceNode)}://${sourceNode}/api/account/moved`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -507,9 +1103,13 @@ async function notifyOldNode(
|
|||||||
...payload,
|
...payload,
|
||||||
signature,
|
signature,
|
||||||
}),
|
}),
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
maxResponseBytes: 64 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
// The safe requester never follows redirects. Treat every non-2xx,
|
||||||
|
// including redirects, as a failed best-effort notification.
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
throw new Error(`Old node returned ${response.status}`);
|
throw new Error(`Old node returned ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+349
-301
@@ -1,313 +1,361 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/db';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
|
||||||
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
|
|
||||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
|
||||||
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
chatConversations,
|
||||||
|
chatMessages,
|
||||||
|
db,
|
||||||
|
e2eeMessageReceipts,
|
||||||
|
handleRegistry,
|
||||||
|
} from '@/db';
|
||||||
|
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
|
||||||
|
import { normalizeSigningPublicKey } from '@/lib/crypto/did-key';
|
||||||
|
import { signingPublicKeyFromDid } from '@/lib/e2ee/bundle-proof';
|
||||||
|
import {
|
||||||
|
E2EE_PROTOCOL_VERSION,
|
||||||
|
e2eeMessageEnvelopeSchema,
|
||||||
|
signedUserActionSchema,
|
||||||
|
validateMessageBindings,
|
||||||
|
} from '@/lib/e2ee/protocol';
|
||||||
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||||
|
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
|
||||||
|
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||||
|
import { isRateLimited } from '@/lib/rate-limit';
|
||||||
|
|
||||||
const signedChatActionSchema = z.object({
|
const federatedEnvelopeSchema = z.strictObject({
|
||||||
action: z.string().min(1),
|
userAction: signedUserActionSchema,
|
||||||
did: z.string().regex(/^did:/, 'Must be a valid DID'),
|
fullSenderHandle: z.string().min(3).max(640),
|
||||||
handle: z.string().min(3).max(30),
|
sourceDomain: z.string().min(1).max(253),
|
||||||
ts: z.number(),
|
destinationDomain: z.string().min(1).max(253),
|
||||||
nonce: z.string().min(1),
|
route: z.literal('/api/chat/receive'),
|
||||||
sig: z.string().min(1),
|
deliveryId: z.string().min(12).max(512),
|
||||||
data: z.object({
|
ts: z.number().int().positive(),
|
||||||
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
|
expiresAt: z.number().int().positive(),
|
||||||
content: z.string().min(1).max(5000),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Backward compatibility for older nodes that sent legacy field names.
|
const remoteProfileResponseSchema = z.object({
|
||||||
const legacyChatActionSchema = z.object({
|
user: z.object({
|
||||||
did: z.string().regex(/^did:/, 'Must be a valid DID'),
|
did: z.string(),
|
||||||
handle: z.string().min(3).max(30),
|
publicKey: z.string(),
|
||||||
data: z.object({
|
displayName: z.string().nullish(),
|
||||||
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
|
avatarUrl: z.string().nullish(),
|
||||||
content: z.string().min(1).max(5000),
|
isBot: z.boolean().optional(),
|
||||||
}),
|
}).passthrough(),
|
||||||
signature: z.string().min(1),
|
}).passthrough();
|
||||||
timestamp: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const incomingChatActionSchema = z.union([signedChatActionSchema, legacyChatActionSchema]);
|
class E2EEIdentityContinuityError extends Error {
|
||||||
|
constructor() {
|
||||||
const federatedEnvelopeSchema = z.object({
|
super('Sender identity continuity check failed');
|
||||||
userAction: incomingChatActionSchema,
|
this.name = 'E2EEIdentityContinuityError';
|
||||||
fullSenderHandle: z.string().min(3).max(60),
|
}
|
||||||
sourceDomain: z.string().min(1),
|
|
||||||
ts: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function normalizeSignedAction(action: z.infer<typeof incomingChatActionSchema>): SignedAction {
|
|
||||||
if ('sig' in action) {
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
action: 'chat',
|
|
||||||
data: action.data,
|
|
||||||
did: action.did,
|
|
||||||
handle: action.handle,
|
|
||||||
ts: action.timestamp,
|
|
||||||
nonce: `legacy:${action.did}:${action.timestamp}`,
|
|
||||||
sig: action.signature,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const MAX_CONCURRENT_NODE_VERIFICATIONS = 32;
|
||||||
* POST /api/chat/receive
|
let activeNodeVerifications = 0;
|
||||||
* Endpoint for receiving federated chat messages from other nodes.
|
|
||||||
* Expects either:
|
|
||||||
* 1. A SignedAction payload from the sender (legacy, for backward compatibility)
|
|
||||||
* 2. A federated envelope with node's signature and user's signed action
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const swarmSignature = request.headers.get('X-Swarm-Signature');
|
||||||
|
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
|
||||||
// Check if this is a federated envelope (node-signed)
|
if (!swarmSignature || !sourceDomainHeader) {
|
||||||
const swarmSignature = request.headers.get('X-Swarm-Signature');
|
return NextResponse.json({
|
||||||
const sourceDomain = request.headers.get('X-Swarm-Source-Domain');
|
error: 'Encrypted federated messages require a node-signed envelope',
|
||||||
|
code: 'E2EE_REQUIRED',
|
||||||
let signedAction: SignedAction;
|
}, { status: 426 });
|
||||||
let fullSenderHandle: string | null = null;
|
|
||||||
|
|
||||||
if (swarmSignature && sourceDomain && body.userAction) {
|
|
||||||
const normalizedSourceDomain = normalizeNodeDomain(sourceDomain);
|
|
||||||
if (await isNodeBlocked(normalizedSourceDomain)) {
|
|
||||||
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Federated envelope format - validate and verify node signature
|
|
||||||
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
|
|
||||||
if (!envelopeValidation.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid envelope payload', details: envelopeValidation.error.issues },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidNodeSig = await verifySwarmRequest(
|
|
||||||
{ userAction: body.userAction, fullSenderHandle: body.fullSenderHandle, sourceDomain: body.sourceDomain, ts: body.ts },
|
|
||||||
swarmSignature,
|
|
||||||
sourceDomain
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValidNodeSig) {
|
|
||||||
console.error('[Chat Receive] Invalid node signature from:', sourceDomain);
|
|
||||||
return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract user's signed action and full handle from envelope
|
|
||||||
signedAction = normalizeSignedAction(body.userAction);
|
|
||||||
fullSenderHandle = body.fullSenderHandle;
|
|
||||||
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
|
|
||||||
} else {
|
|
||||||
// Legacy format - direct user signed action
|
|
||||||
const actionValidation = incomingChatActionSchema.safeParse(body);
|
|
||||||
if (!actionValidation.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid action payload', details: actionValidation.error.issues },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
signedAction = normalizeSignedAction(actionValidation.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { did, handle, data } = signedAction;
|
|
||||||
const { recipientDid, content } = data || {};
|
|
||||||
|
|
||||||
// Use full handle if provided in envelope, otherwise fall back to signed handle
|
|
||||||
const senderHandle = fullSenderHandle || handle;
|
|
||||||
const senderDomainFromHandle = senderHandle.includes('@')
|
|
||||||
? normalizeNodeDomain(senderHandle.split('@').pop() || '')
|
|
||||||
: null;
|
|
||||||
if (senderDomainFromHandle && await isNodeBlocked(senderDomainFromHandle)) {
|
|
||||||
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
|
|
||||||
}
|
|
||||||
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
|
|
||||||
|
|
||||||
// 1. Resolve Sender Public Key
|
|
||||||
let senderUser = await db.query.users.findFirst({
|
|
||||||
where: { did: did }
|
|
||||||
});
|
|
||||||
|
|
||||||
let publicKey = senderUser?.publicKey;
|
|
||||||
let senderDisplayName = senderUser?.displayName || senderHandle;
|
|
||||||
let senderAvatarUrl = senderUser?.avatarUrl;
|
|
||||||
let senderNodeDomain: string | null = null;
|
|
||||||
|
|
||||||
if (!senderUser) {
|
|
||||||
// Unknown user - likely remote. We need to fetch their profile to get the public key.
|
|
||||||
// Derive domain from full sender handle if possible
|
|
||||||
if (senderHandle.includes('@')) {
|
|
||||||
const parts = senderHandle.split('@');
|
|
||||||
senderNodeDomain = normalizeNodeDomain(parts[parts.length - 1]);
|
|
||||||
} else {
|
|
||||||
// Try to get from header first
|
|
||||||
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
|
|
||||||
if (sourceDomainHeader) {
|
|
||||||
senderNodeDomain = normalizeNodeDomain(sourceDomainHeader);
|
|
||||||
} else {
|
|
||||||
// Try handle registry (though we likely don't have it if we don't have the user)
|
|
||||||
const registryEntry = await db.query.handleRegistry.findFirst({
|
|
||||||
where: { did: did }
|
|
||||||
});
|
|
||||||
if (registryEntry) senderNodeDomain = normalizeNodeDomain(registryEntry.nodeDomain);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (senderNodeDomain && await isNodeBlocked(senderNodeDomain)) {
|
|
||||||
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (senderNodeDomain) {
|
|
||||||
try {
|
|
||||||
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
|
|
||||||
const remoteHandle = senderHandle.includes('@') ? senderHandle.split('@')[0] : senderHandle;
|
|
||||||
|
|
||||||
// Fetch public key with TOFU validation
|
|
||||||
const { publicKey: cachedOrFreshKey, fromCache, keyChanged } = await fetchAndCacheRemoteKey(
|
|
||||||
did,
|
|
||||||
senderHandle,
|
|
||||||
senderNodeDomain,
|
|
||||||
async () => {
|
|
||||||
// Fetch profile from remote node
|
|
||||||
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const profileData = await res.json();
|
|
||||||
const remoteProfile = profileData.user;
|
|
||||||
if (remoteProfile?.did !== did) {
|
|
||||||
console.error('[Chat Receive] DID mismatch for remote user');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return remoteProfile?.publicKey || null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cachedOrFreshKey) {
|
|
||||||
publicKey = cachedOrFreshKey;
|
|
||||||
console.log(`[Chat Receive] Using ${fromCache ? 'cached' : 'fetched'} public key for ${senderHandle}${keyChanged ? ' (KEY CHANGED!)' : ''}`);
|
|
||||||
|
|
||||||
// Also fetch display name/avatar if not from cache (or if we need fresh data)
|
|
||||||
if (!fromCache || keyChanged) {
|
|
||||||
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const profileData = await res.json();
|
|
||||||
const remoteProfile = profileData.user;
|
|
||||||
senderDisplayName = remoteProfile?.displayName || senderHandle;
|
|
||||||
senderAvatarUrl = remoteProfile?.avatarUrl;
|
|
||||||
|
|
||||||
// CACHE: Upsert the remote user into our local database
|
|
||||||
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
|
||||||
await upsertRemoteUser({
|
|
||||||
handle: senderHandle, // use full handle (user@domain)
|
|
||||||
displayName: senderDisplayName,
|
|
||||||
avatarUrl: senderAvatarUrl || null,
|
|
||||||
did: did || '',
|
|
||||||
isBot: remoteProfile.isBot || false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch remote profile: no key returned');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Remote profile fetch failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!publicKey) {
|
|
||||||
return NextResponse.json({ error: 'Could not resolve sender public key' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Verify Signature
|
|
||||||
const isValid = await verifyActionSignature(signedAction, publicKey);
|
|
||||||
if (!isValid) {
|
|
||||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Find Local Recipient
|
|
||||||
const recipientUser = await db.query.users.findFirst({
|
|
||||||
where: { did: recipientDid }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!recipientUser) {
|
|
||||||
return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Find or Create Conversation
|
|
||||||
// For the RECIPIENT, the conversation is with the SENDER
|
|
||||||
// Use full handle from envelope if available, otherwise construct from handle + domain
|
|
||||||
const computedFullSenderHandle = senderHandle.includes('@') ? senderHandle : (senderNodeDomain ? `${senderHandle}@${senderNodeDomain}` : senderHandle);
|
|
||||||
|
|
||||||
let conversation = await db.query.chatConversations.findFirst({
|
|
||||||
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: computedFullSenderHandle }] }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
const [newConv] = await db.insert(chatConversations).values({
|
|
||||||
participant1Id: recipientUser.id,
|
|
||||||
participant2Handle: computedFullSenderHandle,
|
|
||||||
lastMessageAt: new Date(),
|
|
||||||
lastMessagePreview: content.slice(0, 50)
|
|
||||||
}).returning();
|
|
||||||
conversation = newConv;
|
|
||||||
} else {
|
|
||||||
// Update preview
|
|
||||||
await db.update(chatConversations)
|
|
||||||
.set({
|
|
||||||
lastMessageAt: new Date(),
|
|
||||||
lastMessagePreview: content.slice(0, 50),
|
|
||||||
updatedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(chatConversations.id, conversation.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Store Message
|
|
||||||
await db.insert(chatMessages).values({
|
|
||||||
conversationId: conversation.id,
|
|
||||||
senderHandle: computedFullSenderHandle,
|
|
||||||
senderDisplayName: senderDisplayName,
|
|
||||||
senderAvatarUrl: senderAvatarUrl,
|
|
||||||
senderNodeDomain: senderNodeDomain,
|
|
||||||
senderDid: did,
|
|
||||||
content: content,
|
|
||||||
deliveredAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Update Registry (to ensure we can reply efficiently)
|
|
||||||
if (senderNodeDomain) {
|
|
||||||
await db.insert(handleRegistry).values({
|
|
||||||
handle: computedFullSenderHandle, // user@domain
|
|
||||||
did: did,
|
|
||||||
nodeDomain: senderNodeDomain
|
|
||||||
}).onConflictDoUpdate({
|
|
||||||
target: handleRegistry.handle,
|
|
||||||
set: {
|
|
||||||
did: did,
|
|
||||||
nodeDomain: senderNodeDomain
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Federated Receive Failed:', error);
|
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid input', details: error.issues },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = federatedEnvelopeSchema.parse(await request.json());
|
||||||
|
const sourceDomain = normalizeNodeDomain(sourceDomainHeader);
|
||||||
|
if (normalizeNodeDomain(body.sourceDomain) !== sourceDomain) {
|
||||||
|
return NextResponse.json({ error: 'Source node mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || '');
|
||||||
|
if (!localDomain || normalizeNodeDomain(body.destinationDomain) !== localDomain) {
|
||||||
|
return NextResponse.json({ error: 'Destination node mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (await isNodeBlocked(sourceDomain)) {
|
||||||
|
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (Math.abs(Date.now() - body.ts) > 5 * 60 * 1000 || body.expiresAt < Date.now()
|
||||||
|
|| body.expiresAt - body.ts > 5 * 60 * 1000) {
|
||||||
|
return NextResponse.json({ error: 'Federated envelope is stale' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (isRateLimited('e2ee-federation-preauth-global', 1_200, 60 * 1000)
|
||||||
|
|| activeNodeVerifications >= MAX_CONCURRENT_NODE_VERIFICATIONS) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Federated encrypted-message verification is busy',
|
||||||
|
code: 'E2EE_REMOTE_RATE_LIMITED',
|
||||||
|
}, { status: 429, headers: { 'Retry-After': '60' } });
|
||||||
|
}
|
||||||
|
activeNodeVerifications += 1;
|
||||||
|
let nodeSignatureValid = false;
|
||||||
|
try {
|
||||||
|
nodeSignatureValid = await verifySwarmRequest(body, swarmSignature, sourceDomain);
|
||||||
|
} finally {
|
||||||
|
activeNodeVerifications -= 1;
|
||||||
|
}
|
||||||
|
if (!nodeSignatureValid) {
|
||||||
|
return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (isRateLimited(`e2ee-federation-node:${sourceDomain}`, 600, 60 * 1000)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'This node is sending encrypted messages too quickly',
|
||||||
|
code: 'E2EE_REMOTE_RATE_LIMITED',
|
||||||
|
}, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedAction = body.userAction as SignedAction;
|
||||||
|
const envelope = e2eeMessageEnvelopeSchema.parse(signedAction.data);
|
||||||
|
validateMessageBindings(envelope, signedAction);
|
||||||
|
if (Math.abs(Date.now() - signedAction.ts) > 5 * 60 * 1000) {
|
||||||
|
return NextResponse.json({ error: 'Encrypted message action is stale' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (body.deliveryId !== `${envelope.messageId}:${normalizeNodeDomain(body.destinationDomain)}`) {
|
||||||
|
return NextResponse.json({ error: 'Federated delivery identity mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderHandle = body.fullSenderHandle.toLowerCase().replace(/^@/, '');
|
||||||
|
const senderParts = senderHandle.split('@');
|
||||||
|
if (senderParts.length !== 2
|
||||||
|
|| senderParts[0] !== signedAction.handle.toLowerCase()
|
||||||
|
|| normalizeNodeDomain(senderParts[1]) !== sourceDomain) {
|
||||||
|
return NextResponse.json({ error: 'Federated sender handle mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (Buffer.from(envelope.nonce, 'base64url').length !== 24
|
||||||
|
|| Buffer.from(envelope.ciphertext, 'base64url').length < 17
|
||||||
|
|| Buffer.from(envelope.ciphertext, 'base64url').length > 8_192
|
||||||
|
|| Buffer.from(envelope.keyCommitment, 'base64url').length !== 32
|
||||||
|
|| envelope.keyEnvelopes.some((item) => Buffer.from(item.sealedKey, 'base64url').length !== 112)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid encrypted message sizes' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject nonexistent, unwilling, or stale-key recipients before resolving
|
||||||
|
// or persisting any attacker-controlled remote user profile.
|
||||||
|
const recipient = await db.query.users.findFirst({ where: { did: envelope.recipientDid } });
|
||||||
|
if (!recipient || recipient.handle.includes('@') || recipient.id.startsWith('swarm:')) {
|
||||||
|
return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 });
|
||||||
|
}
|
||||||
|
const envelopeRecipientHandle = envelope.recipientHandle.toLowerCase().replace(/^@/, '');
|
||||||
|
const recipientHandleParts = envelopeRecipientHandle.split('@');
|
||||||
|
const recipientHandleMatches = recipientHandleParts.length === 1
|
||||||
|
? recipientHandleParts[0] === recipient.handle.toLowerCase()
|
||||||
|
: recipientHandleParts.length === 2
|
||||||
|
&& recipientHandleParts[0] === recipient.handle.toLowerCase()
|
||||||
|
&& normalizeNodeDomain(recipientHandleParts[1]) === localDomain;
|
||||||
|
if (!recipientHandleMatches) {
|
||||||
|
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (recipient.isBot || recipient.dmPrivacy === 'none') {
|
||||||
|
return NextResponse.json({ error: 'Recipient does not accept direct messages' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (recipient.dmPrivacy === 'following') {
|
||||||
|
const followsSender = await db.query.remoteFollows.findFirst({
|
||||||
|
where: { AND: [{ followerId: recipient.id }, { targetHandle: senderHandle }] },
|
||||||
|
});
|
||||||
|
if (!followsSender) {
|
||||||
|
return NextResponse.json({ error: 'Recipient only accepts messages from accounts they follow' }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recipientKey, recipientVault] = await Promise.all([
|
||||||
|
db.query.e2eeKeyBundles.findFirst({ where: { userId: recipient.id } }),
|
||||||
|
db.query.e2eeKeyVaults.findFirst({ where: { userId: recipient.id } }),
|
||||||
|
]);
|
||||||
|
if (!recipientKey || !recipientVault) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: recipientKey
|
||||||
|
? 'Recipient needs to finish encrypted message setup on this node'
|
||||||
|
: 'Recipient has not set up encrypted messages',
|
||||||
|
code: 'E2EE_NOT_CONFIGURED',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
if (recipientKey.keyId !== recipientVault.keyId
|
||||||
|
|| recipientKey.keyVersion !== recipientVault.keyVersion
|
||||||
|
|| recipientKey.publicKey !== recipientVault.publicKey
|
||||||
|
|| recipientVault.ownerDid !== recipient.did
|
||||||
|
|| recipientKey.keyId !== envelope.recipientKeyId
|
||||||
|
|| recipientKey.keyVersion !== envelope.recipientKeyVersion) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Recipient encryption key changed',
|
||||||
|
code: 'E2EE_RECIPIENT_KEY_STALE',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderUser = await db.query.users.findFirst({ where: { did: signedAction.did } });
|
||||||
|
if (senderUser && !senderUser.handle.includes('@') && !senderUser.id.startsWith('swarm:')) {
|
||||||
|
return NextResponse.json({ error: 'A remote node cannot claim a local identity' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (senderUser && senderUser.handle !== senderHandle) {
|
||||||
|
return NextResponse.json({ error: 'Sender identity continuity check failed' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const didPublicKey = signingPublicKeyFromDid(signedAction.did);
|
||||||
|
let signingPublicKey = didPublicKey || senderUser?.publicKey || null;
|
||||||
|
let senderDisplayName = senderUser?.displayName || signedAction.handle;
|
||||||
|
let senderAvatarUrl = senderUser?.avatarUrl || null;
|
||||||
|
let resolvedProfile: z.infer<typeof remoteProfileResponseSchema>['user'] | null = null;
|
||||||
|
let signatureVerified = !!signingPublicKey
|
||||||
|
&& await verifyActionSignature(signedAction, signingPublicKey);
|
||||||
|
|
||||||
|
if (didPublicKey && !signatureVerified) {
|
||||||
|
return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!senderUser || !signatureVerified) {
|
||||||
|
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
|
||||||
|
&& /^localhost(?::\d{1,5})?$/i.test(sourceDomain);
|
||||||
|
const protocol = isDevelopmentLoopback ? 'http' : 'https';
|
||||||
|
const profileResponse = await safeFederationRequest(`${protocol}://${sourceDomain}/api/users/${encodeURIComponent(signedAction.handle)}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
maxResponseBytes: 64 * 1024,
|
||||||
|
});
|
||||||
|
if (profileResponse.status < 200 || profileResponse.status >= 300) {
|
||||||
|
return NextResponse.json({ error: 'Could not resolve sender identity' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const profileData = remoteProfileResponseSchema.parse(profileResponse.json());
|
||||||
|
const profile = profileData.user;
|
||||||
|
if (profile?.did !== signedAction.did || !profile.publicKey) {
|
||||||
|
return NextResponse.json({ error: 'Sender identity does not match their node profile' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const profileSigningPublicKey = normalizeSigningPublicKey(profile.publicKey);
|
||||||
|
if (!profileSigningPublicKey) {
|
||||||
|
return NextResponse.json({ error: 'Sender profile contains an invalid signing key' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (didPublicKey && didPublicKey !== profileSigningPublicKey) {
|
||||||
|
return NextResponse.json({ error: 'Sender signing key does not match their DID' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const resolvedSigningPublicKey = didPublicKey || profileSigningPublicKey;
|
||||||
|
signingPublicKey = resolvedSigningPublicKey;
|
||||||
|
signatureVerified = await verifyActionSignature(signedAction, resolvedSigningPublicKey);
|
||||||
|
if (!signatureVerified) {
|
||||||
|
return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 });
|
||||||
|
}
|
||||||
|
senderDisplayName = profile.displayName || signedAction.handle;
|
||||||
|
senderAvatarUrl = profile.avatarUrl || null;
|
||||||
|
resolvedProfile = profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signingPublicKey || !signatureVerified) {
|
||||||
|
return NextResponse.json({ error: 'Invalid sender signature' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRateLimited(`e2ee-federation-sender:${sourceDomain}:${signedAction.did}`, 120, 60 * 1000)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'This sender is sending encrypted messages too quickly',
|
||||||
|
code: 'E2EE_REMOTE_RATE_LIMITED',
|
||||||
|
}, { status: 429 });
|
||||||
|
}
|
||||||
|
if (senderUser) {
|
||||||
|
const blocked = await db.query.blocks.findFirst({
|
||||||
|
where: { AND: [{ userId: recipient.id }, { blockedUserId: senderUser.id }] },
|
||||||
|
});
|
||||||
|
if (blocked) return NextResponse.json({ error: 'Message not permitted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedProfile) {
|
||||||
|
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
||||||
|
await upsertRemoteUser({
|
||||||
|
handle: senderHandle,
|
||||||
|
displayName: senderDisplayName,
|
||||||
|
avatarUrl: senderAvatarUrl,
|
||||||
|
did: signedAction.did,
|
||||||
|
isBot: resolvedProfile.isBot || false,
|
||||||
|
publicKey: normalizeSigningPublicKey(resolvedProfile.publicKey)!,
|
||||||
|
});
|
||||||
|
senderUser = await db.query.users.findFirst({ where: { did: signedAction.did } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date(envelope.createdAt);
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [receipt] = await tx.insert(e2eeMessageReceipts).values({
|
||||||
|
ownerUserId: recipient.id,
|
||||||
|
senderDid: signedAction.did,
|
||||||
|
messageId: envelope.messageId,
|
||||||
|
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
|
||||||
|
if (!receipt) return;
|
||||||
|
|
||||||
|
const [insertedRegistryEntry] = await tx.insert(handleRegistry).values({
|
||||||
|
handle: senderHandle,
|
||||||
|
did: signedAction.did,
|
||||||
|
nodeDomain: sourceDomain,
|
||||||
|
}).onConflictDoNothing().returning({
|
||||||
|
did: handleRegistry.did,
|
||||||
|
nodeDomain: handleRegistry.nodeDomain,
|
||||||
|
});
|
||||||
|
const [registryEntry] = insertedRegistryEntry
|
||||||
|
? [insertedRegistryEntry]
|
||||||
|
: await tx.select({
|
||||||
|
did: handleRegistry.did,
|
||||||
|
nodeDomain: handleRegistry.nodeDomain,
|
||||||
|
}).from(handleRegistry).where(eq(handleRegistry.handle, senderHandle)).limit(1);
|
||||||
|
if (!registryEntry
|
||||||
|
|| registryEntry.did !== signedAction.did
|
||||||
|
|| normalizeNodeDomain(registryEntry.nodeDomain) !== sourceDomain) {
|
||||||
|
throw new E2EEIdentityContinuityError();
|
||||||
|
}
|
||||||
|
|
||||||
|
let [conversation] = await tx.select().from(chatConversations).where(and(
|
||||||
|
eq(chatConversations.participant1Id, recipient.id),
|
||||||
|
eq(chatConversations.participant2Handle, senderHandle),
|
||||||
|
)).limit(1);
|
||||||
|
if (!conversation) {
|
||||||
|
[conversation] = await tx.insert(chatConversations).values({
|
||||||
|
participant1Id: recipient.id,
|
||||||
|
participant2Handle: senderHandle,
|
||||||
|
lastMessageAt: createdAt,
|
||||||
|
lastMessagePreview: 'Encrypted message',
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: createdAt,
|
||||||
|
}).returning();
|
||||||
|
} else {
|
||||||
|
await tx.update(chatConversations).set({
|
||||||
|
lastMessageAt: conversation.lastMessageAt && conversation.lastMessageAt > createdAt
|
||||||
|
? conversation.lastMessageAt
|
||||||
|
: createdAt,
|
||||||
|
lastMessagePreview: 'Encrypted message',
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: conversation.e2eeActivatedAt ?? createdAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(chatConversations.id, conversation.id));
|
||||||
|
}
|
||||||
|
if (!conversation) throw new Error('Failed to create encrypted conversation');
|
||||||
|
|
||||||
|
await tx.insert(chatMessages).values({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
senderHandle,
|
||||||
|
senderDisplayName,
|
||||||
|
senderAvatarUrl,
|
||||||
|
senderNodeDomain: sourceDomain,
|
||||||
|
senderDid: signedAction.did,
|
||||||
|
content: null,
|
||||||
|
protocolVersion: E2EE_PROTOCOL_VERSION,
|
||||||
|
clientMessageId: envelope.messageId,
|
||||||
|
encryptedEnvelope: JSON.stringify(envelope),
|
||||||
|
e2eeSignature: signedAction.sig,
|
||||||
|
e2eeActionNonce: signedAction.nonce,
|
||||||
|
e2eeActionTs: signedAction.ts,
|
||||||
|
deliveredAt: new Date(),
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, messageId: envelope.messageId });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'This node only accepts encrypted message envelopes',
|
||||||
|
code: 'E2EE_REQUIRED',
|
||||||
|
details: error.issues,
|
||||||
|
}, { status: 426 });
|
||||||
|
}
|
||||||
|
if (error instanceof E2EEIdentityContinuityError) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: error.message,
|
||||||
|
code: 'E2EE_IDENTITY_KEY_CHANGED',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
console.error('[E2EE Chat] Federated receive failed:', error);
|
||||||
|
return NextResponse.json({ error: error instanceof Error ? error.message : 'Receive failed' }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+408
-256
@@ -1,263 +1,415 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/db';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { chatConversations, chatMessages, users, handleRegistry, follows } from '@/db/schema';
|
|
||||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
chatConversations,
|
||||||
|
chatMessages,
|
||||||
|
db,
|
||||||
|
e2eeMessageReceipts,
|
||||||
|
} from '@/db';
|
||||||
|
import { requireSignedAction, SignedActionError, type SignedAction } from '@/lib/auth/verify-signature';
|
||||||
|
import {
|
||||||
|
E2EE_PROTOCOL_VERSION,
|
||||||
|
e2eeMessageEnvelopeSchema,
|
||||||
|
signedUserActionSchema,
|
||||||
|
validateMessageBindings,
|
||||||
|
} from '@/lib/e2ee/protocol';
|
||||||
import { createSignedPayload } from '@/lib/swarm/signature';
|
import { createSignedPayload } from '@/lib/swarm/signature';
|
||||||
import { federatedHandleSchema } from '@/lib/utils/federation';
|
|
||||||
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||||
|
import { getPublicSwarmDomain } from '@/lib/swarm/node-domain';
|
||||||
|
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
|
||||||
|
|
||||||
const chatSendSchema = z.object({
|
function validateCiphertextLengths(envelope: z.infer<typeof e2eeMessageEnvelopeSchema>): void {
|
||||||
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
|
if (Buffer.from(envelope.nonce, 'base64url').length !== 24) throw new Error('Invalid message nonce');
|
||||||
recipientHandle: federatedHandleSchema,
|
const ciphertextLength = Buffer.from(envelope.ciphertext, 'base64url').length;
|
||||||
content: z.string().min(1).max(5000),
|
if (ciphertextLength < 17 || ciphertextLength > 8_192) throw new Error('Invalid encrypted message');
|
||||||
});
|
if (Buffer.from(envelope.keyCommitment, 'base64url').length !== 32) {
|
||||||
|
throw new Error('Invalid encrypted message key commitment');
|
||||||
/**
|
}
|
||||||
* POST /api/chat/send
|
for (const wrappedKey of envelope.keyEnvelopes) {
|
||||||
* Send a signed chat message (verified with DID)
|
if (Buffer.from(wrappedKey.sealedKey, 'base64url').length !== 112) {
|
||||||
* Stores plain text content.
|
throw new Error('Invalid encrypted message key');
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Parse the signed action from the request body
|
|
||||||
const signedAction = await request.json();
|
|
||||||
|
|
||||||
// Strictly verify the signature and get the user
|
|
||||||
const user = await requireSignedAction(signedAction);
|
|
||||||
|
|
||||||
// Extract message data
|
|
||||||
const data = chatSendSchema.parse(signedAction.data);
|
|
||||||
const { recipientDid, recipientHandle, content } = data;
|
|
||||||
|
|
||||||
// Check if recipient is local
|
|
||||||
const recipientUser = await db.query.users.findFirst({
|
|
||||||
where: { did: recipientDid }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if recipient is a local user (not remote/swarm cached)
|
|
||||||
// Remote users have handles with @domain or IDs starting with "swarm:"
|
|
||||||
const isRemoteUser = recipientUser && (
|
|
||||||
recipientUser.handle.includes('@') ||
|
|
||||||
recipientUser.id.startsWith('swarm:')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recipientUser && !isRemoteUser) {
|
|
||||||
// Reject if recipient is a bot
|
|
||||||
if (recipientUser.isBot) {
|
|
||||||
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check DM privacy settings
|
|
||||||
if (recipientUser.dmPrivacy === 'none') {
|
|
||||||
return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 });
|
|
||||||
} else if (recipientUser.dmPrivacy === 'following') {
|
|
||||||
// Check if recipient follows the sender
|
|
||||||
const isFollowingSender = await db.query.follows.findFirst({
|
|
||||||
where: { AND: [{ followerId: recipientUser.id }, { followingId: user.id }] }
|
|
||||||
});
|
|
||||||
if (!isFollowingSender) {
|
|
||||||
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// LOCAL RECIPIENT
|
|
||||||
|
|
||||||
// Ensure conversations exist for both sides
|
|
||||||
// 1. Recipient's Inbox (Recipient -> User)
|
|
||||||
let recipientConv = await db.query.chatConversations.findFirst({
|
|
||||||
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: user.handle }] }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!recipientConv) {
|
|
||||||
const [newConv] = await db.insert(chatConversations).values({
|
|
||||||
participant1Id: recipientUser.id,
|
|
||||||
participant2Handle: user.handle,
|
|
||||||
lastMessageAt: new Date(),
|
|
||||||
lastMessagePreview: content.slice(0, 50)
|
|
||||||
}).returning();
|
|
||||||
recipientConv = newConv;
|
|
||||||
} else {
|
|
||||||
// Update preview
|
|
||||||
await db.update(chatConversations)
|
|
||||||
.set({
|
|
||||||
lastMessageAt: new Date(),
|
|
||||||
lastMessagePreview: content.slice(0, 50),
|
|
||||||
updatedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(chatConversations.id, recipientConv.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Sender's Sent Box (User -> Recipient)
|
|
||||||
let senderConv = await db.query.chatConversations.findFirst({
|
|
||||||
where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientUser.handle }] }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!senderConv) {
|
|
||||||
const [newConv] = await db.insert(chatConversations).values({
|
|
||||||
participant1Id: user.id,
|
|
||||||
participant2Handle: recipientUser.handle,
|
|
||||||
lastMessageAt: new Date(),
|
|
||||||
lastMessagePreview: content.slice(0, 50)
|
|
||||||
}).returning();
|
|
||||||
senderConv = newConv;
|
|
||||||
} else {
|
|
||||||
await db.update(chatConversations)
|
|
||||||
.set({
|
|
||||||
lastMessageAt: new Date(),
|
|
||||||
lastMessagePreview: content.slice(0, 50),
|
|
||||||
updatedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(chatConversations.id, senderConv.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create message for recipient (Inbox)
|
|
||||||
await db.insert(chatMessages).values({
|
|
||||||
conversationId: recipientConv.id,
|
|
||||||
senderHandle: user.handle,
|
|
||||||
senderDisplayName: user.displayName,
|
|
||||||
senderAvatarUrl: user.avatarUrl,
|
|
||||||
senderNodeDomain: null,
|
|
||||||
senderDid: user.did,
|
|
||||||
content: content,
|
|
||||||
deliveredAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create message for sender (Sent)
|
|
||||||
await db.insert(chatMessages).values({
|
|
||||||
conversationId: senderConv.id,
|
|
||||||
senderHandle: user.handle,
|
|
||||||
senderDisplayName: user.displayName,
|
|
||||||
senderAvatarUrl: user.avatarUrl,
|
|
||||||
senderNodeDomain: null,
|
|
||||||
senderDid: user.did,
|
|
||||||
content: content,
|
|
||||||
deliveredAt: new Date(),
|
|
||||||
readAt: new Date() // Sender has read their own message
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} else {
|
|
||||||
// REMOTE RECIPIENT
|
|
||||||
|
|
||||||
// 1. Resolve recipient node
|
|
||||||
const registryEntry = await db.query.handleRegistry.findFirst({
|
|
||||||
where: { did: recipientDid }
|
|
||||||
});
|
|
||||||
|
|
||||||
// If not in registry, try to parse from handle if it has domain
|
|
||||||
let targetDomain: string | null = registryEntry?.nodeDomain || null;
|
|
||||||
let targetHandle = recipientHandle;
|
|
||||||
|
|
||||||
if (!targetDomain && recipientHandle.includes('@')) {
|
|
||||||
const parts = recipientHandle.split('@');
|
|
||||||
targetDomain = parts[parts.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetDomain) {
|
|
||||||
console.error('Recipient node domain not found for:', recipientHandle);
|
|
||||||
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDomain = normalizeNodeDomain(targetDomain);
|
|
||||||
if (await isNodeBlocked(targetDomain)) {
|
|
||||||
return NextResponse.json({ error: 'This node is blocked by the server administrator' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
|
|
||||||
|
|
||||||
// 2. Send to Remote Node (Forward the Signed Action)
|
|
||||||
try {
|
|
||||||
const protocol = targetDomain.includes('localhost') ? 'http' : 'https';
|
|
||||||
const sourceDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || '';
|
|
||||||
|
|
||||||
// Construct full sender handle for federation
|
|
||||||
const fullSenderHandle = `${user.handle}@${sourceDomain}`;
|
|
||||||
|
|
||||||
console.log(`[Remote Send] Debug:`, {
|
|
||||||
targetDomain,
|
|
||||||
targetUrl: `${protocol}://${targetDomain}/api/chat/receive`,
|
|
||||||
sourceDomainEnv: sourceDomain,
|
|
||||||
fullSenderHandle,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a federated envelope with node's signature
|
|
||||||
// This wraps the user's signed action with the full handle
|
|
||||||
const federatedPayload = {
|
|
||||||
userAction: signedAction,
|
|
||||||
fullSenderHandle,
|
|
||||||
sourceDomain,
|
|
||||||
ts: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { payload, signature } = await createSignedPayload(federatedPayload);
|
|
||||||
|
|
||||||
const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Swarm-Source-Domain': sourceDomain,
|
|
||||||
'X-Swarm-Signature': signature,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errText = await res.text();
|
|
||||||
console.error(`[Remote Send] Remote node rejected chat (${res.status}):`, errText);
|
|
||||||
return NextResponse.json({ error: `Remote delivery failed: ${res.statusText} - ${errText}` }, { status: 502 });
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[Remote Send] Failed to contact remote node:', err);
|
|
||||||
return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Store "Sent" copy locally
|
|
||||||
// Ensure conversation exists locally
|
|
||||||
let senderConv = await db.query.chatConversations.findFirst({
|
|
||||||
where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientHandle }] }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!senderConv) {
|
|
||||||
const [newConv] = await db.insert(chatConversations).values({
|
|
||||||
participant1Id: user.id,
|
|
||||||
participant2Handle: recipientHandle,
|
|
||||||
lastMessageAt: new Date(),
|
|
||||||
lastMessagePreview: content.slice(0, 50)
|
|
||||||
}).returning();
|
|
||||||
senderConv = newConv;
|
|
||||||
} else {
|
|
||||||
await db.update(chatConversations)
|
|
||||||
.set({
|
|
||||||
lastMessageAt: new Date(),
|
|
||||||
lastMessagePreview: content.slice(0, 50),
|
|
||||||
updatedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(chatConversations.id, senderConv.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.insert(chatMessages).values({
|
|
||||||
conversationId: senderConv.id,
|
|
||||||
senderHandle: user.handle,
|
|
||||||
senderDisplayName: user.displayName,
|
|
||||||
senderAvatarUrl: user.avatarUrl,
|
|
||||||
senderNodeDomain: null,
|
|
||||||
senderDid: user.did,
|
|
||||||
content: content,
|
|
||||||
deliveredAt: new Date(),
|
|
||||||
readAt: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Send chat failed:', error);
|
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageValues(input: {
|
||||||
|
conversationId: string;
|
||||||
|
senderHandle: string;
|
||||||
|
senderDisplayName: string | null;
|
||||||
|
senderAvatarUrl: string | null;
|
||||||
|
senderDid: string;
|
||||||
|
envelope: z.infer<typeof e2eeMessageEnvelopeSchema>;
|
||||||
|
signedAction: SignedAction;
|
||||||
|
createdAt: Date;
|
||||||
|
readAt?: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
senderHandle: input.senderHandle,
|
||||||
|
senderDisplayName: input.senderDisplayName,
|
||||||
|
senderAvatarUrl: input.senderAvatarUrl,
|
||||||
|
senderNodeDomain: null,
|
||||||
|
senderDid: input.senderDid,
|
||||||
|
content: null,
|
||||||
|
protocolVersion: E2EE_PROTOCOL_VERSION,
|
||||||
|
clientMessageId: input.envelope.messageId,
|
||||||
|
encryptedEnvelope: JSON.stringify(input.envelope),
|
||||||
|
e2eeSignature: input.signedAction.sig,
|
||||||
|
e2eeActionNonce: input.signedAction.nonce,
|
||||||
|
e2eeActionTs: input.signedAction.ts,
|
||||||
|
deliveredAt: new Date(),
|
||||||
|
readAt: input.readAt,
|
||||||
|
createdAt: input.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const signedAction = signedUserActionSchema.parse(await request.json()) as SignedAction;
|
||||||
|
const user = await requireSignedAction(signedAction);
|
||||||
|
const envelope = e2eeMessageEnvelopeSchema.parse(signedAction.data);
|
||||||
|
validateMessageBindings(envelope, signedAction);
|
||||||
|
validateCiphertextLengths(envelope);
|
||||||
|
|
||||||
|
if (envelope.senderDid !== user.did || envelope.senderHandle !== user.handle) {
|
||||||
|
return NextResponse.json({ error: 'Sender identity mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [senderKey, senderVault] = await Promise.all([
|
||||||
|
db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }),
|
||||||
|
db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }),
|
||||||
|
]);
|
||||||
|
if (!senderKey || !senderVault
|
||||||
|
|| senderKey.keyId !== senderVault.keyId
|
||||||
|
|| senderKey.keyVersion !== senderVault.keyVersion
|
||||||
|
|| senderKey.publicKey !== senderVault.publicKey
|
||||||
|
|| senderVault.ownerDid !== user.did
|
||||||
|
|| senderKey.keyId !== envelope.senderKeyId
|
||||||
|
|| senderKey.keyVersion !== envelope.senderKeyVersion) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: senderKey && !senderVault
|
||||||
|
? 'Finish encrypted message setup on this node before sending'
|
||||||
|
: 'Your encrypted message key changed. Reload Chat and try again.',
|
||||||
|
code: 'E2EE_SENDER_KEY_STALE',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientUser = await db.query.users.findFirst({ where: { did: envelope.recipientDid } });
|
||||||
|
const isRemoteRecipient = !recipientUser
|
||||||
|
|| recipientUser.handle.includes('@')
|
||||||
|
|| recipientUser.id.startsWith('swarm:');
|
||||||
|
const createdAt = new Date(envelope.createdAt);
|
||||||
|
|
||||||
|
if (recipientUser && !isRemoteRecipient) {
|
||||||
|
if (recipientUser.id === user.id) {
|
||||||
|
return NextResponse.json({ error: 'Cannot send an encrypted DM to yourself' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (envelope.recipientHandle.toLowerCase() !== recipientUser.handle.toLowerCase()) {
|
||||||
|
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (recipientUser.isBot) {
|
||||||
|
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (recipientUser.dmPrivacy === 'none') {
|
||||||
|
return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (recipientUser.dmPrivacy === 'following') {
|
||||||
|
const followsSender = await db.query.follows.findFirst({
|
||||||
|
where: { AND: [{ followerId: recipientUser.id }, { followingId: user.id }] },
|
||||||
|
});
|
||||||
|
if (!followsSender) {
|
||||||
|
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [recipientBlockedSender, senderBlockedRecipient] = await Promise.all([
|
||||||
|
db.query.blocks.findFirst({
|
||||||
|
where: { AND: [{ userId: recipientUser.id }, { blockedUserId: user.id }] },
|
||||||
|
}),
|
||||||
|
db.query.blocks.findFirst({
|
||||||
|
where: { AND: [{ userId: user.id }, { blockedUserId: recipientUser.id }] },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (recipientBlockedSender || senderBlockedRecipient) {
|
||||||
|
return NextResponse.json({ error: 'Message not permitted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recipientKey, recipientVault] = await Promise.all([
|
||||||
|
db.query.e2eeKeyBundles.findFirst({ where: { userId: recipientUser.id } }),
|
||||||
|
db.query.e2eeKeyVaults.findFirst({ where: { userId: recipientUser.id } }),
|
||||||
|
]);
|
||||||
|
if (!recipientKey || !recipientVault) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: recipientKey
|
||||||
|
? 'Recipient needs to finish encrypted message setup on this node'
|
||||||
|
: 'Recipient has not set up encrypted messages',
|
||||||
|
code: 'E2EE_NOT_CONFIGURED',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
if (recipientKey.keyId !== recipientVault.keyId
|
||||||
|
|| recipientKey.keyVersion !== recipientVault.keyVersion
|
||||||
|
|| recipientKey.publicKey !== recipientVault.publicKey
|
||||||
|
|| recipientVault.ownerDid !== recipientUser.did
|
||||||
|
|| recipientKey.keyId !== envelope.recipientKeyId
|
||||||
|
|| recipientKey.keyVersion !== envelope.recipientKeyVersion) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Recipient encryption key changed. Reload Chat and try again.',
|
||||||
|
code: 'E2EE_RECIPIENT_KEY_STALE',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [recipientReceipt] = await tx.insert(e2eeMessageReceipts).values({
|
||||||
|
ownerUserId: recipientUser.id,
|
||||||
|
senderDid: user.did,
|
||||||
|
messageId: envelope.messageId,
|
||||||
|
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
|
||||||
|
if (!recipientReceipt) return;
|
||||||
|
|
||||||
|
const [senderReceipt] = await tx.insert(e2eeMessageReceipts).values({
|
||||||
|
ownerUserId: user.id,
|
||||||
|
senderDid: user.did,
|
||||||
|
messageId: envelope.messageId,
|
||||||
|
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
|
||||||
|
if (!senderReceipt) throw new Error('Encrypted message receipt state is inconsistent');
|
||||||
|
|
||||||
|
let [recipientConversation] = await tx.select().from(chatConversations).where(and(
|
||||||
|
eq(chatConversations.participant1Id, recipientUser.id),
|
||||||
|
eq(chatConversations.participant2Handle, user.handle),
|
||||||
|
)).limit(1);
|
||||||
|
if (!recipientConversation) {
|
||||||
|
[recipientConversation] = await tx.insert(chatConversations).values({
|
||||||
|
participant1Id: recipientUser.id,
|
||||||
|
participant2Handle: user.handle,
|
||||||
|
lastMessageAt: createdAt,
|
||||||
|
lastMessagePreview: 'Encrypted message',
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: createdAt,
|
||||||
|
}).returning();
|
||||||
|
} else {
|
||||||
|
await tx.update(chatConversations).set({
|
||||||
|
lastMessageAt: recipientConversation.lastMessageAt && recipientConversation.lastMessageAt > createdAt
|
||||||
|
? recipientConversation.lastMessageAt
|
||||||
|
: createdAt,
|
||||||
|
lastMessagePreview: 'Encrypted message',
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: recipientConversation.e2eeActivatedAt ?? createdAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(chatConversations.id, recipientConversation.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let [senderConversation] = await tx.select().from(chatConversations).where(and(
|
||||||
|
eq(chatConversations.participant1Id, user.id),
|
||||||
|
eq(chatConversations.participant2Handle, recipientUser.handle),
|
||||||
|
)).limit(1);
|
||||||
|
if (!senderConversation) {
|
||||||
|
[senderConversation] = await tx.insert(chatConversations).values({
|
||||||
|
participant1Id: user.id,
|
||||||
|
participant2Handle: recipientUser.handle,
|
||||||
|
lastMessageAt: createdAt,
|
||||||
|
lastMessagePreview: 'Encrypted message',
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: createdAt,
|
||||||
|
}).returning();
|
||||||
|
} else {
|
||||||
|
await tx.update(chatConversations).set({
|
||||||
|
lastMessageAt: senderConversation.lastMessageAt && senderConversation.lastMessageAt > createdAt
|
||||||
|
? senderConversation.lastMessageAt
|
||||||
|
: createdAt,
|
||||||
|
lastMessagePreview: 'Encrypted message',
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: senderConversation.e2eeActivatedAt ?? createdAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(chatConversations.id, senderConversation.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipientConversation || !senderConversation) {
|
||||||
|
throw new Error('Failed to create encrypted conversations');
|
||||||
|
}
|
||||||
|
await tx.insert(chatMessages).values([
|
||||||
|
messageValues({
|
||||||
|
conversationId: recipientConversation.id,
|
||||||
|
senderHandle: user.handle,
|
||||||
|
senderDisplayName: user.displayName,
|
||||||
|
senderAvatarUrl: user.avatarUrl,
|
||||||
|
senderDid: user.did,
|
||||||
|
envelope,
|
||||||
|
signedAction,
|
||||||
|
createdAt,
|
||||||
|
}),
|
||||||
|
messageValues({
|
||||||
|
conversationId: senderConversation.id,
|
||||||
|
senderHandle: user.handle,
|
||||||
|
senderDisplayName: user.displayName,
|
||||||
|
senderAvatarUrl: user.avatarUrl,
|
||||||
|
senderDid: user.did,
|
||||||
|
envelope,
|
||||||
|
signedAction,
|
||||||
|
createdAt,
|
||||||
|
readAt: new Date(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, messageId: envelope.messageId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedRecipientKey = await db.query.e2eeRemoteKeyBundles.findFirst({
|
||||||
|
where: { did: envelope.recipientDid },
|
||||||
|
});
|
||||||
|
if (!cachedRecipientKey || cachedRecipientKey.keyId !== envelope.recipientKeyId
|
||||||
|
|| cachedRecipientKey.keyVersion !== envelope.recipientKeyVersion) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Recipient encryption key must be verified again',
|
||||||
|
code: 'E2EE_RECIPIENT_KEY_STALE',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
if (cachedRecipientKey.handle.toLowerCase().replace(/^@/, '')
|
||||||
|
!== envelope.recipientHandle.toLowerCase().replace(/^@/, '')) {
|
||||||
|
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (recipientUser) {
|
||||||
|
const senderBlockedRecipient = await db.query.blocks.findFirst({
|
||||||
|
where: { AND: [{ userId: user.id }, { blockedUserId: recipientUser.id }] },
|
||||||
|
});
|
||||||
|
if (senderBlockedRecipient) {
|
||||||
|
return NextResponse.json({ error: 'Message not permitted' }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullRecipientHandle = envelope.recipientHandle.toLowerCase().replace(/^@/, '');
|
||||||
|
const recipientHandleParts = fullRecipientHandle.split('@');
|
||||||
|
if (recipientHandleParts.length !== 2 || !recipientHandleParts[0] || !recipientHandleParts[1]) {
|
||||||
|
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
let targetDomain: string | null = normalizeNodeDomain(recipientHandleParts[1]);
|
||||||
|
const registryEntry = await db.query.handleRegistry.findFirst({
|
||||||
|
where: { handle: fullRecipientHandle },
|
||||||
|
});
|
||||||
|
if (registryEntry && normalizeNodeDomain(registryEntry.nodeDomain) !== targetDomain) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Recipient node identity does not match the verified handle',
|
||||||
|
code: 'E2EE_IDENTITY_KEY_CHANGED',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
const normalizedTargetDomain = normalizeNodeDomain(targetDomain);
|
||||||
|
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
|
||||||
|
&& /^localhost(?::\d{1,5})?$/i.test(normalizedTargetDomain);
|
||||||
|
targetDomain = isDevelopmentLoopback
|
||||||
|
? normalizedTargetDomain
|
||||||
|
: getPublicSwarmDomain(normalizedTargetDomain);
|
||||||
|
if (!targetDomain) {
|
||||||
|
return NextResponse.json({ error: 'Recipient node domain is invalid' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (await isNodeBlocked(targetDomain)) {
|
||||||
|
return NextResponse.json({ error: 'Recipient node is blocked' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = isDevelopmentLoopback ? 'http' : 'https';
|
||||||
|
const sourceDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || '');
|
||||||
|
if (!sourceDomain) {
|
||||||
|
return NextResponse.json({ error: 'This node is not configured for federated messages' }, { status: 503 });
|
||||||
|
}
|
||||||
|
const deliveredAt = Date.now();
|
||||||
|
const federatedPayload = {
|
||||||
|
userAction: signedAction,
|
||||||
|
fullSenderHandle: `${user.handle}@${sourceDomain}`,
|
||||||
|
sourceDomain,
|
||||||
|
destinationDomain: targetDomain,
|
||||||
|
route: '/api/chat/receive' as const,
|
||||||
|
deliveryId: `${envelope.messageId}:${targetDomain}`,
|
||||||
|
ts: deliveredAt,
|
||||||
|
expiresAt: deliveredAt + 5 * 60 * 1000,
|
||||||
|
};
|
||||||
|
const { payload, signature } = await createSignedPayload(federatedPayload);
|
||||||
|
const remoteResponse = await safeFederationRequest(`${protocol}://${targetDomain}/api/chat/receive`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Swarm-Source-Domain': sourceDomain,
|
||||||
|
'X-Swarm-Signature': signature,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
maxResponseBytes: 64 * 1024,
|
||||||
|
});
|
||||||
|
if (remoteResponse.status < 200 || remoteResponse.status >= 300) {
|
||||||
|
let remoteBody: { error?: string; code?: string } | null = null;
|
||||||
|
try {
|
||||||
|
remoteBody = remoteResponse.json() as { error?: string; code?: string };
|
||||||
|
} catch {
|
||||||
|
// Preserve a generic delivery error for non-JSON remote failures.
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
error: remoteBody?.error || 'Recipient node rejected the encrypted message',
|
||||||
|
code: remoteBody?.code || 'E2EE_REMOTE_DELIVERY_FAILED',
|
||||||
|
}, { status: remoteResponse.status === 426 ? 409 : 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [receipt] = await tx.insert(e2eeMessageReceipts).values({
|
||||||
|
ownerUserId: user.id,
|
||||||
|
senderDid: user.did,
|
||||||
|
messageId: envelope.messageId,
|
||||||
|
}).onConflictDoNothing().returning({ id: e2eeMessageReceipts.id });
|
||||||
|
if (!receipt) return;
|
||||||
|
|
||||||
|
let [senderConversation] = await tx.select().from(chatConversations).where(and(
|
||||||
|
eq(chatConversations.participant1Id, user.id),
|
||||||
|
eq(chatConversations.participant2Handle, envelope.recipientHandle),
|
||||||
|
)).limit(1);
|
||||||
|
if (!senderConversation) {
|
||||||
|
[senderConversation] = await tx.insert(chatConversations).values({
|
||||||
|
participant1Id: user.id,
|
||||||
|
participant2Handle: envelope.recipientHandle,
|
||||||
|
lastMessageAt: createdAt,
|
||||||
|
lastMessagePreview: 'Encrypted message',
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: createdAt,
|
||||||
|
}).returning();
|
||||||
|
} else {
|
||||||
|
await tx.update(chatConversations).set({
|
||||||
|
lastMessageAt: senderConversation.lastMessageAt && senderConversation.lastMessageAt > createdAt
|
||||||
|
? senderConversation.lastMessageAt
|
||||||
|
: createdAt,
|
||||||
|
lastMessagePreview: 'Encrypted message',
|
||||||
|
encryptionMode: 'e2ee',
|
||||||
|
e2eeActivatedAt: senderConversation.e2eeActivatedAt ?? createdAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(chatConversations.id, senderConversation.id));
|
||||||
|
}
|
||||||
|
if (!senderConversation) throw new Error('Failed to create encrypted conversation');
|
||||||
|
|
||||||
|
await tx.insert(chatMessages).values(messageValues({
|
||||||
|
conversationId: senderConversation.id,
|
||||||
|
senderHandle: user.handle,
|
||||||
|
senderDisplayName: user.displayName,
|
||||||
|
senderAvatarUrl: user.avatarUrl,
|
||||||
|
senderDid: user.did,
|
||||||
|
envelope,
|
||||||
|
signedAction,
|
||||||
|
createdAt,
|
||||||
|
readAt: new Date(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, messageId: envelope.messageId });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'This client must send an encrypted message envelope',
|
||||||
|
code: 'E2EE_REQUIRED',
|
||||||
|
details: error.issues,
|
||||||
|
}, { status: 426 });
|
||||||
|
}
|
||||||
|
if (error instanceof SignedActionError) {
|
||||||
|
const rateLimited = error.message === 'RATE_LIMITED';
|
||||||
|
return NextResponse.json({
|
||||||
|
error: rateLimited ? 'Too many encrypted messages; try again shortly' : 'Message signature was rejected',
|
||||||
|
code: rateLimited ? 'E2EE_RATE_LIMITED' : 'E2EE_SIGNATURE_REJECTED',
|
||||||
|
}, { status: rateLimited ? 429 : 403 });
|
||||||
|
}
|
||||||
|
console.error('[E2EE Chat] Send failed:', error);
|
||||||
|
return NextResponse.json({ error: error instanceof Error ? error.message : 'Send failed' }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { e2eeKeyBundleSchema, signedUserActionSchema } from '@/lib/e2ee/protocol';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ did: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: Request, context: RouteContext) {
|
||||||
|
const { did } = await context.params;
|
||||||
|
const user = await db.query.users.findFirst({ where: { did } });
|
||||||
|
if (!user || user.handle.includes('@') || user.id.startsWith('swarm:')) {
|
||||||
|
return NextResponse.json({ error: 'Encryption key not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row, vault] = await Promise.all([
|
||||||
|
db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }),
|
||||||
|
db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }),
|
||||||
|
]);
|
||||||
|
if (!row && !vault) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Encrypted messages are not configured for this account',
|
||||||
|
code: 'E2EE_NOT_CONFIGURED',
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!row || !vault || row.keyId !== vault.keyId || row.keyVersion !== vault.keyVersion
|
||||||
|
|| row.publicKey !== vault.publicKey || vault.ownerDid !== user.did) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: vault ? 'Encrypted message key state is inconsistent' : 'Encrypted messages need setup on this node',
|
||||||
|
code: vault ? 'E2EE_KEY_STATE_INVALID' : 'E2EE_NOT_CONFIGURED',
|
||||||
|
}, { status: vault ? 500 : 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proof = signedUserActionSchema.parse(JSON.parse(row.proofAction));
|
||||||
|
const bundle = e2eeKeyBundleSchema.parse(proof.data);
|
||||||
|
return NextResponse.json({
|
||||||
|
bundle,
|
||||||
|
proof,
|
||||||
|
signingPublicKey: user.publicKey,
|
||||||
|
}, { headers: { 'Cache-Control': 'public, max-age=60, must-revalidate' } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[E2EE Keys] Stored key proof is invalid:', error);
|
||||||
|
return NextResponse.json({ error: 'Encryption key proof is invalid' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { db, e2eeRemoteKeyBundles } from '@/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { normalizeSigningPublicKey } from '@/lib/crypto/did-key';
|
||||||
|
import { signingPublicKeyFromDid, verifyE2EEPublicBundle } from '@/lib/e2ee/bundle-proof';
|
||||||
|
import { e2eePublicBundleResponseSchema } from '@/lib/e2ee/protocol';
|
||||||
|
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
||||||
|
import { getPublicSwarmDomain } from '@/lib/swarm/node-domain';
|
||||||
|
import { safeFederationRequest } from '@/lib/swarm/safe-federation-http';
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
did: z.string().min(8).max(2_048).regex(/^did:/),
|
||||||
|
handle: z.string().min(1).max(320),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const query = querySchema.parse({
|
||||||
|
did: request.nextUrl.searchParams.get('did'),
|
||||||
|
handle: request.nextUrl.searchParams.get('handle'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const localUser = await db.query.users.findFirst({ where: { did: query.did } });
|
||||||
|
const isLocal = localUser && !localUser.handle.includes('@') && !localUser.id.startsWith('swarm:');
|
||||||
|
if (isLocal) {
|
||||||
|
const requestedHandle = query.handle.toLowerCase().replace(/^@/, '').split('@')[0];
|
||||||
|
if (requestedHandle !== localUser.handle.toLowerCase()) {
|
||||||
|
return NextResponse.json({ error: 'Recipient identity mismatch' }, { status: 409 });
|
||||||
|
}
|
||||||
|
const [bundle, vault] = await Promise.all([
|
||||||
|
db.query.e2eeKeyBundles.findFirst({ where: { userId: localUser.id } }),
|
||||||
|
db.query.e2eeKeyVaults.findFirst({ where: { userId: localUser.id } }),
|
||||||
|
]);
|
||||||
|
if (!bundle && !vault) {
|
||||||
|
return NextResponse.json({ code: 'E2EE_NOT_CONFIGURED', error: 'Recipient has not set up encrypted messages' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!bundle || !vault || bundle.keyId !== vault.keyId || bundle.keyVersion !== vault.keyVersion
|
||||||
|
|| bundle.publicKey !== vault.publicKey || vault.ownerDid !== localUser.did) {
|
||||||
|
return NextResponse.json({
|
||||||
|
code: vault ? 'E2EE_KEY_STATE_INVALID' : 'E2EE_NOT_CONFIGURED',
|
||||||
|
error: vault
|
||||||
|
? 'Recipient encryption key state is inconsistent'
|
||||||
|
: 'Recipient needs to finish encrypted message setup on this node',
|
||||||
|
}, { status: vault ? 500 : 404 });
|
||||||
|
}
|
||||||
|
const response = e2eePublicBundleResponseSchema.parse({
|
||||||
|
bundle: JSON.parse(bundle.proofAction).data,
|
||||||
|
proof: JSON.parse(bundle.proofAction),
|
||||||
|
signingPublicKey: localUser.publicKey,
|
||||||
|
});
|
||||||
|
return NextResponse.json(response, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleParts = query.handle.toLowerCase().replace(/^@/, '').split('@');
|
||||||
|
if (handleParts.length < 2) {
|
||||||
|
return NextResponse.json({ error: 'Remote recipient domain is missing' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const remoteHandle = handleParts[0];
|
||||||
|
const normalizedDomain = normalizeNodeDomain(handleParts.at(-1) || '');
|
||||||
|
const isDevelopmentLoopback = process.env.NODE_ENV === 'development'
|
||||||
|
&& /^localhost(?::\d{1,5})?$/i.test(normalizedDomain);
|
||||||
|
const domain = isDevelopmentLoopback
|
||||||
|
? normalizedDomain
|
||||||
|
: getPublicSwarmDomain(normalizedDomain);
|
||||||
|
if (!domain) {
|
||||||
|
return NextResponse.json({ error: 'Recipient node domain is invalid' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (await isNodeBlocked(domain)) {
|
||||||
|
return NextResponse.json({ error: 'Recipient node is blocked' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = isDevelopmentLoopback ? 'http' : 'https';
|
||||||
|
const remote = await safeFederationRequest(`${protocol}://${domain}/api/e2ee/keys/${encodeURIComponent(query.did)}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
maxResponseBytes: 64 * 1024,
|
||||||
|
});
|
||||||
|
if (remote.status < 200 || remote.status >= 300) {
|
||||||
|
let remoteBody: { code?: string } | null = null;
|
||||||
|
try {
|
||||||
|
remoteBody = remote.json() as { code?: string };
|
||||||
|
} catch {
|
||||||
|
// A non-JSON error response still maps to a bounded lookup failure.
|
||||||
|
}
|
||||||
|
const code = remoteBody?.code === 'E2EE_NOT_CONFIGURED' ? 'E2EE_NOT_CONFIGURED' : 'E2EE_KEY_LOOKUP_FAILED';
|
||||||
|
return NextResponse.json({
|
||||||
|
error: code === 'E2EE_NOT_CONFIGURED'
|
||||||
|
? 'Recipient has not set up encrypted messages'
|
||||||
|
: 'Recipient encryption key could not be verified',
|
||||||
|
code,
|
||||||
|
}, { status: code === 'E2EE_NOT_CONFIGURED' ? 404 : 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = e2eePublicBundleResponseSchema.parse(remote.json());
|
||||||
|
if (response.proof.handle.toLowerCase() !== remoteHandle || !await verifyE2EEPublicBundle(response, query.did)) {
|
||||||
|
return NextResponse.json({ error: 'Recipient encryption key proof is invalid' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const resolvedSigningPublicKey = normalizeSigningPublicKey(response.signingPublicKey);
|
||||||
|
if (!resolvedSigningPublicKey) {
|
||||||
|
return NextResponse.json({ error: 'Recipient signing key is invalid' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const canonicalSigningPublicKey = signingPublicKeyFromDid(query.did)
|
||||||
|
|| resolvedSigningPublicKey;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const persistedValues = {
|
||||||
|
did: query.did,
|
||||||
|
handle: query.handle,
|
||||||
|
keyId: response.bundle.keyId,
|
||||||
|
keyVersion: response.bundle.version,
|
||||||
|
publicKey: response.bundle.publicKey,
|
||||||
|
proofAction: JSON.stringify(response.proof),
|
||||||
|
signingPublicKey: canonicalSigningPublicKey,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
let persisted = false;
|
||||||
|
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||||
|
const cached = await db.query.e2eeRemoteKeyBundles.findFirst({ where: { did: query.did } });
|
||||||
|
const cachedSigningPublicKey = cached
|
||||||
|
? normalizeSigningPublicKey(cached.signingPublicKey)
|
||||||
|
: null;
|
||||||
|
if (cached && cachedSigningPublicKey !== canonicalSigningPublicKey) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Recipient signing identity changed unexpectedly',
|
||||||
|
code: 'E2EE_IDENTITY_KEY_CHANGED',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
if (cached && (
|
||||||
|
response.bundle.version < cached.keyVersion
|
||||||
|
|| (response.bundle.version === cached.keyVersion && response.bundle.keyId !== cached.keyId)
|
||||||
|
|| (response.bundle.version === cached.keyVersion && response.bundle.publicKey !== cached.publicKey)
|
||||||
|
|| (response.bundle.version > cached.keyVersion && !response.bundle.replacesKeyId)
|
||||||
|
)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Recipient encryption key continuity check failed',
|
||||||
|
code: 'E2EE_KEY_ROLLBACK',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
const [inserted] = await db.insert(e2eeRemoteKeyBundles)
|
||||||
|
.values(persistedValues)
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ did: e2eeRemoteKeyBundles.did });
|
||||||
|
if (inserted) {
|
||||||
|
persisted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db.update(e2eeRemoteKeyBundles).set(persistedValues).where(and(
|
||||||
|
eq(e2eeRemoteKeyBundles.did, cached.did),
|
||||||
|
eq(e2eeRemoteKeyBundles.signingPublicKey, cached.signingPublicKey),
|
||||||
|
eq(e2eeRemoteKeyBundles.keyId, cached.keyId),
|
||||||
|
eq(e2eeRemoteKeyBundles.keyVersion, cached.keyVersion),
|
||||||
|
eq(e2eeRemoteKeyBundles.publicKey, cached.publicKey),
|
||||||
|
)).returning({ did: e2eeRemoteKeyBundles.did });
|
||||||
|
if (updated) {
|
||||||
|
persisted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!persisted) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Recipient encryption key changed during verification; try again',
|
||||||
|
code: 'E2EE_KEY_CONFLICT',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ...response, signingPublicKey: canonicalSigningPublicKey }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid key lookup request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
console.error('[E2EE Keys] Resolution failed:', error);
|
||||||
|
return NextResponse.json({ error: 'Recipient encryption key lookup failed', code: 'E2EE_KEY_LOOKUP_FAILED' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { db, e2eeKeyBundles, e2eeKeyVaults } from '@/db';
|
||||||
|
import { requireSignedAction, SignedActionError, type SignedAction } from '@/lib/auth/verify-signature';
|
||||||
|
import {
|
||||||
|
E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
E2EE_MAX_UNLOCK_ATTEMPTS,
|
||||||
|
e2eeKeyBundleSchema,
|
||||||
|
e2eeVaultSetupSchema,
|
||||||
|
signedUserActionSchema,
|
||||||
|
} from '@/lib/e2ee/protocol';
|
||||||
|
import { createPinVerifierMac, sealServerShare } from '@/lib/e2ee/server-secrets';
|
||||||
|
import { getSession, verifyPassword } from '@/lib/auth';
|
||||||
|
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||||
|
|
||||||
|
const setupRequestSchema = z.strictObject({
|
||||||
|
proof: signedUserActionSchema,
|
||||||
|
recovery: e2eeVaultSetupSchema,
|
||||||
|
currentPassword: z.string().min(8).max(256).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
class E2EEKeyConflictError extends Error {}
|
||||||
|
|
||||||
|
function byteLength(value: string): number {
|
||||||
|
return Buffer.from(value, 'base64url').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEncodedLengths(recovery: z.infer<typeof e2eeVaultSetupSchema>): void {
|
||||||
|
if (byteLength(recovery.vault.publicKey) !== 32) throw new Error('Invalid encryption public key');
|
||||||
|
if (byteLength(recovery.serverShare) !== 32) throw new Error('Invalid recovery share');
|
||||||
|
if (byteLength(recovery.pinVerifier) !== 32) throw new Error('Invalid PIN verifier');
|
||||||
|
if (byteLength(recovery.vault.salt) !== 16) throw new Error('Invalid PIN salt');
|
||||||
|
if (byteLength(recovery.vault.nonce) !== 24) throw new Error('Invalid vault nonce');
|
||||||
|
const ciphertextLength = byteLength(recovery.vault.ciphertext);
|
||||||
|
if (ciphertextLength < 64 || ciphertextLength > 3_072) throw new Error('Invalid encrypted vault');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const [bundle, vault] = await Promise.all([
|
||||||
|
db.query.e2eeKeyBundles.findFirst({ where: { userId: session.user.id } }),
|
||||||
|
db.query.e2eeKeyVaults.findFirst({ where: { userId: session.user.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!bundle && !vault) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ownerDid: session.user.did, configured: false },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bundle && !vault && bundle.did === session.user.did) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ownerDid: session.user.did,
|
||||||
|
configured: false,
|
||||||
|
previousKey: {
|
||||||
|
keyId: bundle.keyId,
|
||||||
|
keyVersion: bundle.keyVersion,
|
||||||
|
},
|
||||||
|
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
|
if (!bundle || !vault
|
||||||
|
|| bundle.did !== session.user.did
|
||||||
|
|| vault.ownerDid !== session.user.did
|
||||||
|
|| bundle.keyId !== vault.keyId
|
||||||
|
|| bundle.keyVersion !== vault.keyVersion
|
||||||
|
|| bundle.publicKey !== vault.publicKey) {
|
||||||
|
return NextResponse.json({ error: 'Encrypted message recovery is inconsistent' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ownerDid: session.user.did,
|
||||||
|
configured: true,
|
||||||
|
keyId: bundle.keyId,
|
||||||
|
keyVersion: bundle.keyVersion,
|
||||||
|
publicKey: bundle.publicKey,
|
||||||
|
salt: vault.salt,
|
||||||
|
kdfAlgorithm: vault.kdfAlgorithm,
|
||||||
|
kdfOpsLimit: vault.kdfOpsLimit,
|
||||||
|
kdfMemLimit: vault.kdfMemLimit,
|
||||||
|
failedAttempts: vault.failedAttempts,
|
||||||
|
attemptsRemaining: vault.lockedUntil && vault.lockedUntil > new Date()
|
||||||
|
? 0
|
||||||
|
: Math.max(0, E2EE_MAX_UNLOCK_ATTEMPTS - vault.failedAttempts),
|
||||||
|
lockedUntil: vault.lockedUntil?.toISOString() ?? null,
|
||||||
|
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = setupRequestSchema.parse(await request.json());
|
||||||
|
const proof = body.proof;
|
||||||
|
if (proof.action !== E2EE_KEY_BUNDLE_ACTION) {
|
||||||
|
return NextResponse.json({ error: 'Invalid encryption key proof' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (proof.did !== session.user.did || proof.handle !== session.user.handle) {
|
||||||
|
return NextResponse.json({ error: 'Active account does not match encryption setup' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await requireSignedAction(proof as SignedAction);
|
||||||
|
if (user.id !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: 'Active account does not match encryption setup' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const bundle = e2eeKeyBundleSchema.parse(proof.data);
|
||||||
|
if (byteLength(bundle.recoveryCommitment) !== 32) {
|
||||||
|
return NextResponse.json({ error: 'Invalid recovery commitment' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const recovery = body.recovery;
|
||||||
|
validateEncodedLengths(recovery);
|
||||||
|
|
||||||
|
const recoveryCommitment = crypto.createHash('sha256')
|
||||||
|
.update(canonicalize(recovery))
|
||||||
|
.digest('base64url');
|
||||||
|
if (bundle.recoveryCommitment !== recoveryCommitment) {
|
||||||
|
return NextResponse.json({ error: 'Recovery vault does not match the signed key proof' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recovery.vault.ownerDid !== proof.did
|
||||||
|
|| recovery.vault.keyId !== bundle.keyId
|
||||||
|
|| recovery.vault.keyVersion !== bundle.version
|
||||||
|
|| recovery.vault.publicKey !== bundle.publicKey) {
|
||||||
|
return NextResponse.json({ error: 'Recovery vault does not match the signed key' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (Math.abs(bundle.createdAt - proof.ts) > 5 * 60 * 1000) {
|
||||||
|
return NextResponse.json({ error: 'Encryption key timestamp is invalid' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing, existingVault] = await Promise.all([
|
||||||
|
db.query.e2eeKeyBundles.findFirst({ where: { userId: user.id } }),
|
||||||
|
db.query.e2eeKeyVaults.findFirst({ where: { userId: user.id } }),
|
||||||
|
]);
|
||||||
|
if (existing && existing.did !== user.did) {
|
||||||
|
return NextResponse.json({ error: 'Encryption key identity is inconsistent' }, { status: 409 });
|
||||||
|
}
|
||||||
|
if (!existing && bundle.replacesKeyId) {
|
||||||
|
return NextResponse.json({ error: 'There is no encryption key to replace' }, { status: 409 });
|
||||||
|
}
|
||||||
|
if (existing && bundle.replacesKeyId !== existing.keyId) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Encrypted messages are already configured',
|
||||||
|
code: 'E2EE_ALREADY_CONFIGURED',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
if ((!existing && bundle.version !== 1)
|
||||||
|
|| (existing && bundle.version !== existing.keyVersion + 1)) {
|
||||||
|
return NextResponse.json({ error: 'Encryption key version is invalid' }, { status: 409 });
|
||||||
|
}
|
||||||
|
if (existingVault) {
|
||||||
|
if (!body.currentPassword || !user.passwordHash
|
||||||
|
|| !await verifyPassword(body.currentPassword, user.passwordHash)) {
|
||||||
|
return NextResponse.json({ error: 'Current password is required to reset encrypted messages' }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const verifierMac = createPinVerifierMac(recovery.pinVerifier, user.id, bundle.keyId);
|
||||||
|
const sealedShare = sealServerShare(recovery.serverShare, user.id, bundle.keyId);
|
||||||
|
const vaultValues = {
|
||||||
|
keyId: bundle.keyId,
|
||||||
|
keyVersion: bundle.version,
|
||||||
|
ownerDid: proof.did,
|
||||||
|
publicKey: bundle.publicKey,
|
||||||
|
ciphertext: recovery.vault.ciphertext,
|
||||||
|
nonce: recovery.vault.nonce,
|
||||||
|
salt: recovery.vault.salt,
|
||||||
|
kdfAlgorithm: recovery.vault.kdfAlgorithm,
|
||||||
|
kdfOpsLimit: recovery.vault.kdfOpsLimit,
|
||||||
|
kdfMemLimit: recovery.vault.kdfMemLimit,
|
||||||
|
pinVerifierMac: verifierMac,
|
||||||
|
serverShareEncrypted: sealedShare,
|
||||||
|
failedAttempts: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
if (existing) {
|
||||||
|
const [updatedBundle] = await tx.update(e2eeKeyBundles).set({
|
||||||
|
did: user.did,
|
||||||
|
keyId: bundle.keyId,
|
||||||
|
keyVersion: bundle.version,
|
||||||
|
publicKey: bundle.publicKey,
|
||||||
|
proofAction: JSON.stringify(proof),
|
||||||
|
updatedAt: now,
|
||||||
|
}).where(and(
|
||||||
|
eq(e2eeKeyBundles.userId, user.id),
|
||||||
|
eq(e2eeKeyBundles.keyId, existing.keyId),
|
||||||
|
eq(e2eeKeyBundles.keyVersion, existing.keyVersion),
|
||||||
|
)).returning({ userId: e2eeKeyBundles.userId });
|
||||||
|
if (!updatedBundle) throw new E2EEKeyConflictError('Encryption key changed during reset');
|
||||||
|
|
||||||
|
if (existingVault) {
|
||||||
|
const [updatedVault] = await tx.update(e2eeKeyVaults).set(vaultValues).where(and(
|
||||||
|
eq(e2eeKeyVaults.userId, user.id),
|
||||||
|
eq(e2eeKeyVaults.keyId, existing.keyId),
|
||||||
|
eq(e2eeKeyVaults.keyVersion, existing.keyVersion),
|
||||||
|
)).returning({ userId: e2eeKeyVaults.userId });
|
||||||
|
if (!updatedVault) throw new E2EEKeyConflictError('Recovery vault changed during reset');
|
||||||
|
} else {
|
||||||
|
await tx.insert(e2eeKeyVaults).values({ userId: user.id, ...vaultValues });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(e2eeKeyBundles).values({
|
||||||
|
userId: user.id,
|
||||||
|
did: user.did,
|
||||||
|
keyId: bundle.keyId,
|
||||||
|
keyVersion: bundle.version,
|
||||||
|
publicKey: bundle.publicKey,
|
||||||
|
proofAction: JSON.stringify(proof),
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await tx.insert(e2eeKeyVaults).values({ userId: user.id, ...vaultValues });
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, keyId: bundle.keyId });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid encrypted message setup', details: error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof SignedActionError) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: error.message === 'RATE_LIMITED'
|
||||||
|
? 'Too many encryption setup attempts; try again shortly'
|
||||||
|
: 'Encryption key proof was rejected',
|
||||||
|
code: error.message === 'RATE_LIMITED' ? 'E2EE_RATE_LIMITED' : 'E2EE_SIGNATURE_REJECTED',
|
||||||
|
}, { status: error.message === 'RATE_LIMITED' ? 429 : 403 });
|
||||||
|
}
|
||||||
|
if (error instanceof E2EEKeyConflictError
|
||||||
|
|| (error instanceof Error && /unique|constraint/i.test(error.message))) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Encrypted messages changed in another tab. Reload Chat and try again.',
|
||||||
|
code: 'E2EE_KEY_CONFLICT',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
console.error('[E2EE Vault] Setup failed:', error);
|
||||||
|
return NextResponse.json({ error: error instanceof Error ? error.message : 'Setup failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { db, e2eeKeyVaults } from '@/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import {
|
||||||
|
E2EE_LOCKOUT_MS,
|
||||||
|
E2EE_MAX_UNLOCK_ATTEMPTS,
|
||||||
|
E2EE_PROTOCOL,
|
||||||
|
} from '@/lib/e2ee/protocol';
|
||||||
|
import { openServerShare, pinVerifierMatches } from '@/lib/e2ee/server-secrets';
|
||||||
|
|
||||||
|
const unlockSchema = z.strictObject({
|
||||||
|
ownerDid: z.string().min(8).max(2_048).regex(/^did:/),
|
||||||
|
keyId: z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/),
|
||||||
|
keyVersion: z.number().int().positive().max(1_000_000),
|
||||||
|
pinVerifier: z.string().min(40).max(64).regex(/^[A-Za-z0-9_-]+$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_CAS_RETRIES = 16;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { ownerDid, keyId, keyVersion, pinVerifier } = unlockSchema.parse(await request.json());
|
||||||
|
|
||||||
|
let observedKey: { keyId: string; keyVersion: number } | null = null;
|
||||||
|
|
||||||
|
for (let retry = 0; retry < MAX_CAS_RETRIES; retry += 1) {
|
||||||
|
const vault = await db.query.e2eeKeyVaults.findFirst({ where: { userId: session.user.id } });
|
||||||
|
if (!vault) return NextResponse.json({ error: 'Encrypted messages are not configured' }, { status: 404 });
|
||||||
|
|
||||||
|
if (ownerDid !== session.user.did || vault.ownerDid !== ownerDid
|
||||||
|
|| vault.keyId !== keyId || vault.keyVersion !== keyVersion) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'The active account or encryption key changed during unlock',
|
||||||
|
code: 'E2EE_ACCOUNT_CHANGED',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observedKey && (vault.keyId !== observedKey.keyId || vault.keyVersion !== observedKey.keyVersion)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Encryption key changed during unlock',
|
||||||
|
code: 'E2EE_KEY_CONFLICT',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
observedKey ??= { keyId: vault.keyId, keyVersion: vault.keyVersion };
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (vault.lockedUntil && vault.lockedUntil > now) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Too many PIN attempts',
|
||||||
|
code: 'E2EE_PIN_LOCKED',
|
||||||
|
lockedUntil: vault.lockedUntil.toISOString(),
|
||||||
|
}, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const unchangedVault = and(
|
||||||
|
eq(e2eeKeyVaults.userId, session.user.id),
|
||||||
|
eq(e2eeKeyVaults.keyId, vault.keyId),
|
||||||
|
eq(e2eeKeyVaults.keyVersion, vault.keyVersion),
|
||||||
|
eq(e2eeKeyVaults.pinVerifierMac, vault.pinVerifierMac),
|
||||||
|
eq(e2eeKeyVaults.failedAttempts, vault.failedAttempts),
|
||||||
|
vault.lockedUntil
|
||||||
|
? eq(e2eeKeyVaults.lockedUntil, vault.lockedUntil)
|
||||||
|
: isNull(e2eeKeyVaults.lockedUntil),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pinVerifierMatches(pinVerifier, vault.pinVerifierMac, session.user.id, vault.keyId)) {
|
||||||
|
const priorAttempts = vault.lockedUntil && vault.lockedUntil <= now ? 0 : vault.failedAttempts;
|
||||||
|
const failedAttempts = priorAttempts + 1;
|
||||||
|
const shouldLock = failedAttempts >= E2EE_MAX_UNLOCK_ATTEMPTS;
|
||||||
|
const lockedUntil = shouldLock ? new Date(Date.now() + E2EE_LOCKOUT_MS) : null;
|
||||||
|
const [updated] = await db.update(e2eeKeyVaults).set({
|
||||||
|
failedAttempts: shouldLock ? 0 : failedAttempts,
|
||||||
|
lockedUntil,
|
||||||
|
updatedAt: now,
|
||||||
|
}).where(unchangedVault).returning({ userId: e2eeKeyVaults.userId });
|
||||||
|
|
||||||
|
if (!updated) continue;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
error: shouldLock ? 'Too many PIN attempts' : 'Incorrect PIN',
|
||||||
|
code: shouldLock ? 'E2EE_PIN_LOCKED' : 'E2EE_PIN_INCORRECT',
|
||||||
|
attemptsRemaining: shouldLock ? 0 : E2EE_MAX_UNLOCK_ATTEMPTS - failedAttempts,
|
||||||
|
lockedUntil: lockedUntil?.toISOString() ?? null,
|
||||||
|
}, { status: shouldLock ? 429 : 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db.update(e2eeKeyVaults).set({
|
||||||
|
failedAttempts: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
updatedAt: now,
|
||||||
|
}).where(unchangedVault).returning({ userId: e2eeKeyVaults.userId });
|
||||||
|
|
||||||
|
if (!updated) continue;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
serverShare: openServerShare(vault.serverShareEncrypted, session.user.id, vault.keyId),
|
||||||
|
vault: {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
ownerDid: session.user.did,
|
||||||
|
keyId: vault.keyId,
|
||||||
|
keyVersion: vault.keyVersion,
|
||||||
|
publicKey: vault.publicKey,
|
||||||
|
ciphertext: vault.ciphertext,
|
||||||
|
nonce: vault.nonce,
|
||||||
|
salt: vault.salt,
|
||||||
|
kdfAlgorithm: vault.kdfAlgorithm,
|
||||||
|
kdfOpsLimit: vault.kdfOpsLimit,
|
||||||
|
kdfMemLimit: vault.kdfMemLimit,
|
||||||
|
},
|
||||||
|
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Encrypted message unlock was busy; retry',
|
||||||
|
code: 'E2EE_UNLOCK_CONFLICT',
|
||||||
|
}, { status: 409 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid unlock request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
console.error('[E2EE Vault] Unlock failed:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to unlock encrypted messages' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ const announcementSchema = z.object({
|
|||||||
userCount: z.number().optional(),
|
userCount: z.number().optional(),
|
||||||
postCount: z.number().optional(),
|
postCount: z.number().optional(),
|
||||||
isNsfw: z.boolean().optional(),
|
isNsfw: z.boolean().optional(),
|
||||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
|
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
|
||||||
timestamp: z.string().optional(),
|
timestamp: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
import { db, chatConversations } from '@/db';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Schema for conversation ID parameter
|
// Schema for conversation ID parameter
|
||||||
@@ -62,67 +61,31 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (deleteFor === 'both') {
|
if (deleteFor === 'both') {
|
||||||
|
const participant2Handle = conversation.participant2Handle;
|
||||||
|
if (participant2Handle.includes('@')) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Delete for everyone is not supported across nodes. You can still delete this conversation for yourself.',
|
||||||
|
code: 'REMOTE_DELETE_FOR_EVERYONE_UNSUPPORTED',
|
||||||
|
}, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the entire conversation and all messages (cascade will handle messages)
|
// Delete the entire conversation and all messages (cascade will handle messages)
|
||||||
await db.delete(chatConversations).where(eq(chatConversations.id, id));
|
await db.delete(chatConversations).where(eq(chatConversations.id, id));
|
||||||
|
|
||||||
// Send deletion request to the other party
|
// Local user - find and delete their conversation too.
|
||||||
const participant2Handle = conversation.participant2Handle;
|
const recipientUser = await db.query.users.findFirst({
|
||||||
const isRemote = participant2Handle.includes('@');
|
where: { handle: participant2Handle },
|
||||||
|
});
|
||||||
|
|
||||||
if (isRemote) {
|
if (recipientUser) {
|
||||||
// Extract domain from handle (format: handle@domain)
|
// Find their conversation with us
|
||||||
const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
|
const recipientConversation = await db.query.chatConversations.findFirst({
|
||||||
const handle = participant2Handle.split('@')[0];
|
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
|
||||||
|
|
||||||
try {
|
|
||||||
if (await isNodeBlocked(domain)) {
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
|
||||||
|
|
||||||
// SECURITY: Sign the deletion request
|
|
||||||
const { signPayload, getNodePrivateKey } = await import('@/lib/swarm/signature');
|
|
||||||
const privateKey = await getNodePrivateKey();
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
senderHandle: session.user.handle,
|
|
||||||
senderNodeDomain: nodeDomain,
|
|
||||||
recipientHandle: handle,
|
|
||||||
conversationId: id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const signature = signPayload(payload, privateKey);
|
|
||||||
|
|
||||||
await fetch(`${protocol}://${domain}/api/swarm/chat/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ...payload, signature }),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[Chat Delete] Sent deletion request to ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Chat Delete] Failed to notify remote node:', error);
|
|
||||||
// Continue anyway - local deletion succeeded
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Local user - find and delete their conversation too
|
|
||||||
const recipientUser = await db.query.users.findFirst({
|
|
||||||
where: { handle: participant2Handle },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (recipientUser) {
|
if (recipientConversation) {
|
||||||
// Find their conversation with us
|
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
|
||||||
const recipientConversation = await db.query.chatConversations.findFirst({
|
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
|
||||||
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (recipientConversation) {
|
|
||||||
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
|
|
||||||
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
* GET: List all conversations for the current user
|
* GET: List all conversations for the current user
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
import { db, chatMessages } from '@/db';
|
||||||
import { eq, desc, and, isNull, sql } from 'drizzle-orm';
|
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return NextResponse.json({ conversations: [] });
|
return NextResponse.json({ conversations: [] });
|
||||||
@@ -87,12 +88,13 @@ export async function GET(request: NextRequest) {
|
|||||||
avatarUrl: profileData.profile.avatarUrl || null,
|
avatarUrl: profileData.profile.avatarUrl || null,
|
||||||
did: profileData.profile.did || '',
|
did: profileData.profile.did || '',
|
||||||
isBot: profileData.profile.isBot || false,
|
isBot: profileData.profile.isBot || false,
|
||||||
|
publicKey: profileData.profile.publicKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-query to get the new cached user
|
// Re-query to get the new cached user
|
||||||
cachedUser = await db.query.users.findFirst({
|
cachedUser = await db.query.users.findFirst({
|
||||||
where: { handle: participant2Handle },
|
where: { handle: participant2Handle },
|
||||||
}) as any;
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Lazy Load] Failed for ${participant2Handle}:`, e);
|
console.error(`[Lazy Load] Failed for ${participant2Handle}:`, e);
|
||||||
@@ -102,18 +104,66 @@ export async function GET(request: NextRequest) {
|
|||||||
if (cachedUser) {
|
if (cachedUser) {
|
||||||
participant2Info = {
|
participant2Info = {
|
||||||
handle: cachedUser.handle,
|
handle: cachedUser.handle,
|
||||||
displayName: (cachedUser as any).displayName || cachedUser.handle,
|
displayName: cachedUser.displayName || cachedUser.handle,
|
||||||
avatarUrl: (cachedUser as any).avatarUrl || null,
|
avatarUrl: cachedUser.avatarUrl || null,
|
||||||
did: (cachedUser as any).did || '',
|
did: cachedUser.did || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const latest = conv.messages[0] || null;
|
||||||
|
let lastMessage: {
|
||||||
|
protocolVersion: number;
|
||||||
|
content: string | null;
|
||||||
|
encryptedEnvelope: unknown;
|
||||||
|
signedAction: unknown;
|
||||||
|
senderPublicKey: string | null;
|
||||||
|
} | null = latest ? {
|
||||||
|
protocolVersion: 0,
|
||||||
|
content: latest.content,
|
||||||
|
encryptedEnvelope: null,
|
||||||
|
signedAction: null,
|
||||||
|
senderPublicKey: null as string | null,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
if (latest?.protocolVersion === E2EE_PROTOCOL_VERSION && latest.encryptedEnvelope
|
||||||
|
&& latest.senderDid && latest.e2eeSignature && latest.e2eeActionNonce && latest.e2eeActionTs) {
|
||||||
|
try {
|
||||||
|
const encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(latest.encryptedEnvelope));
|
||||||
|
const senderPublicKey = latest.senderDid === session.user.did
|
||||||
|
? session.user.publicKey
|
||||||
|
: cachedUser?.did === latest.senderDid
|
||||||
|
? cachedUser.publicKey
|
||||||
|
: null;
|
||||||
|
lastMessage = {
|
||||||
|
protocolVersion: E2EE_PROTOCOL_VERSION,
|
||||||
|
content: null,
|
||||||
|
encryptedEnvelope,
|
||||||
|
signedAction: {
|
||||||
|
action: E2EE_CHAT_ACTION,
|
||||||
|
data: encryptedEnvelope,
|
||||||
|
did: latest.senderDid,
|
||||||
|
handle: encryptedEnvelope.senderHandle,
|
||||||
|
ts: latest.e2eeActionTs,
|
||||||
|
nonce: latest.e2eeActionNonce,
|
||||||
|
sig: latest.e2eeSignature,
|
||||||
|
},
|
||||||
|
senderPublicKey,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[E2EE Chat] Invalid conversation preview ${latest.id}:`, error);
|
||||||
|
lastMessage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messages: _rawMessages, ...conversation } = conv;
|
||||||
|
void _rawMessages;
|
||||||
return {
|
return {
|
||||||
...conv,
|
...conversation,
|
||||||
participant2: {
|
participant2: {
|
||||||
...participant2Info,
|
...participant2Info,
|
||||||
isBot: (cachedUser as any)?.isBot || false,
|
isBot: cachedUser?.isBot || false,
|
||||||
},
|
},
|
||||||
|
lastMessage,
|
||||||
unreadCount: Number(unreadCount[0]?.count || 0),
|
unreadCount: Number(unreadCount[0]?.count || 0),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -121,7 +171,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
conversations: conversationsWithUnread.filter(c => !c.participant2.isBot),
|
conversations: conversationsWithUnread.filter(c => !c.participant2.isBot),
|
||||||
});
|
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('List conversations error:', error);
|
console.error('List conversations error:', error);
|
||||||
return NextResponse.json({ error: 'Failed to list conversations' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to list conversations' }, { status: 500 });
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
import { db, chatMessages, users } from '@/db';
|
||||||
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
|
import { eq, and, isNull } from 'drizzle-orm';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
|
||||||
|
|
||||||
// Schema for query parameters
|
// Schema for query parameters
|
||||||
const messagesQuerySchema = z.object({
|
const messagesQuerySchema = z.object({
|
||||||
@@ -23,6 +24,8 @@ const markReadSchema = z.object({
|
|||||||
conversationId: z.string().uuid(),
|
conversationId: z.string().uuid(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ChatUser = typeof users.$inferSelect;
|
||||||
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -83,8 +86,8 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch users
|
// Fetch users
|
||||||
const usersByDid: Record<string, any> = {};
|
const usersByDid: Record<string, ChatUser> = {};
|
||||||
const usersByHandle: Record<string, any> = {};
|
const usersByHandle: Record<string, ChatUser> = {};
|
||||||
|
|
||||||
if (senderDids.size > 0) {
|
if (senderDids.size > 0) {
|
||||||
const found = await db.query.users.findMany({
|
const found = await db.query.users.findMany({
|
||||||
@@ -102,7 +105,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messagesMapped = messages.map((msg) => {
|
const messagesMapped = messages.map((msg) => {
|
||||||
const isSentByMe = msg.senderHandle === session.user.handle;
|
const isSentByMe = msg.senderDid === session.user.did || msg.senderHandle === session.user.handle;
|
||||||
|
|
||||||
// Resolve fresh user data
|
// Resolve fresh user data
|
||||||
const user = msg.senderDid ? usersByDid[msg.senderDid] : usersByHandle[msg.senderHandle];
|
const user = msg.senderDid ? usersByDid[msg.senderDid] : usersByHandle[msg.senderHandle];
|
||||||
@@ -110,13 +113,39 @@ export async function GET(request: NextRequest) {
|
|||||||
const displayName = user?.displayName || msg.senderDisplayName || msg.senderHandle;
|
const displayName = user?.displayName || msg.senderDisplayName || msg.senderHandle;
|
||||||
const avatarUrl = user?.avatarUrl || msg.senderAvatarUrl;
|
const avatarUrl = user?.avatarUrl || msg.senderAvatarUrl;
|
||||||
|
|
||||||
|
let encryptedEnvelope = null;
|
||||||
|
let signedAction = null;
|
||||||
|
if (msg.protocolVersion === E2EE_PROTOCOL_VERSION && msg.encryptedEnvelope) {
|
||||||
|
try {
|
||||||
|
encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(msg.encryptedEnvelope));
|
||||||
|
if (!msg.senderDid || !msg.e2eeSignature || !msg.e2eeActionNonce || !msg.e2eeActionTs) {
|
||||||
|
throw new Error('Encrypted message signature metadata is incomplete');
|
||||||
|
}
|
||||||
|
signedAction = {
|
||||||
|
action: E2EE_CHAT_ACTION,
|
||||||
|
data: encryptedEnvelope,
|
||||||
|
did: msg.senderDid,
|
||||||
|
handle: encryptedEnvelope.senderHandle,
|
||||||
|
ts: msg.e2eeActionTs,
|
||||||
|
nonce: msg.e2eeActionNonce,
|
||||||
|
sig: msg.e2eeSignature,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[E2EE Chat] Invalid stored envelope ${msg.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
senderHandle: msg.senderHandle,
|
senderHandle: msg.senderHandle,
|
||||||
senderDisplayName: displayName,
|
senderDisplayName: displayName,
|
||||||
senderAvatarUrl: avatarUrl,
|
senderAvatarUrl: avatarUrl,
|
||||||
senderDid: msg.senderDid,
|
senderDid: msg.senderDid,
|
||||||
content: msg.content,
|
content: msg.protocolVersion === 0 ? msg.content : null,
|
||||||
|
protocolVersion: msg.protocolVersion,
|
||||||
|
encryptedEnvelope,
|
||||||
|
signedAction,
|
||||||
|
senderPublicKey: user?.publicKey || null,
|
||||||
deliveredAt: msg.deliveredAt,
|
deliveredAt: msg.deliveredAt,
|
||||||
readAt: msg.readAt,
|
readAt: msg.readAt,
|
||||||
createdAt: msg.createdAt,
|
createdAt: msg.createdAt,
|
||||||
@@ -127,7 +156,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
messages: messagesMapped.reverse(), // Oldest first for display
|
messages: messagesMapped.reverse(), // Oldest first for display
|
||||||
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
|
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
|
||||||
});
|
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const nodeInfoSchema = z.object({
|
|||||||
userCount: z.number().optional(),
|
userCount: z.number().optional(),
|
||||||
postCount: z.number().optional(),
|
postCount: z.number().optional(),
|
||||||
isNsfw: z.boolean().optional(),
|
isNsfw: z.boolean().optional(),
|
||||||
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
|
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
|
||||||
lastSeenAt: z.string().optional(),
|
lastSeenAt: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+825
-222
File diff suppressed because it is too large
Load Diff
+73
-23
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { TriangleAlert, X } from 'lucide-react';
|
import { TriangleAlert, X } from 'lucide-react';
|
||||||
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
|
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
|
||||||
@@ -31,7 +30,6 @@ interface AuthScreenProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) {
|
export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -53,10 +51,12 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
|
|
||||||
const [importFile, setImportFile] = useState<File | null>(null);
|
const [importFile, setImportFile] = useState<File | null>(null);
|
||||||
const [importPassword, setImportPassword] = useState('');
|
const [importPassword, setImportPassword] = useState('');
|
||||||
|
const [importEmail, setImportEmail] = useState('');
|
||||||
const [importHandle, setImportHandle] = useState('');
|
const [importHandle, setImportHandle] = useState('');
|
||||||
const [acceptedCompliance, setAcceptedCompliance] = useState(false);
|
const [acceptedCompliance, setAcceptedCompliance] = useState(false);
|
||||||
const [importAgeVerified, setImportAgeVerified] = useState(false);
|
const [importAgeVerified, setImportAgeVerified] = useState(false);
|
||||||
const [importSuccess, setImportSuccess] = useState<string | null>(null);
|
const [importSuccess, setImportSuccess] = useState<string | null>(null);
|
||||||
|
const [importWarnings, setImportWarnings] = useState<string[]>([]);
|
||||||
|
|
||||||
// Fetch node info
|
// Fetch node info
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,7 +105,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
if (turnstileWidgetId.current && window.turnstile) {
|
if (turnstileWidgetId.current && window.turnstile) {
|
||||||
try {
|
try {
|
||||||
window.turnstile.remove(turnstileWidgetId.current);
|
window.turnstile.remove(turnstileWidgetId.current);
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
if (turnstileWidgetId.current && window.turnstile) {
|
if (turnstileWidgetId.current && window.turnstile) {
|
||||||
try {
|
try {
|
||||||
window.turnstile.remove(turnstileWidgetId.current);
|
window.turnstile.remove(turnstileWidgetId.current);
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
|
|
||||||
const handleImport = async (e: React.FormEvent) => {
|
const handleImport = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!importFile || !importPassword || !importHandle || !acceptedCompliance) {
|
if (!importFile || !importPassword || !importEmail || !importHandle || !acceptedCompliance) {
|
||||||
setError('Please fill in all fields and accept the compliance agreement');
|
setError('Please fill in all fields and accept the compliance agreement');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,6 +178,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
setImportSuccess(null);
|
setImportSuccess(null);
|
||||||
|
setImportWarnings([]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileContent = await importFile.text();
|
const fileContent = await importFile.text();
|
||||||
@@ -189,6 +190,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
exportData,
|
exportData,
|
||||||
password: importPassword,
|
password: importPassword,
|
||||||
|
destinationEmail: importEmail,
|
||||||
newHandle: importHandle,
|
newHandle: importHandle,
|
||||||
acceptedCompliance,
|
acceptedCompliance,
|
||||||
}),
|
}),
|
||||||
@@ -200,16 +202,21 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
throw new Error(data.error || 'Import failed');
|
throw new Error(data.error || 'Import failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
setImportSuccess(data.message);
|
const warnings = Array.isArray(data.warnings)
|
||||||
// Soft navigation to preserve AuthContext/KeyStore state
|
? data.warnings.filter((warning: unknown): warning is string => typeof warning === 'string')
|
||||||
setTimeout(() => {
|
: [];
|
||||||
router.refresh();
|
setImportSuccess(data.message || 'Account imported successfully.');
|
||||||
if (onSuccess) {
|
setImportWarnings(warnings);
|
||||||
onSuccess();
|
if (warnings.length === 0) {
|
||||||
} else {
|
setTimeout(() => {
|
||||||
router.push('/');
|
if (onSuccess) {
|
||||||
}
|
onSuccess();
|
||||||
}, 2000);
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
window.location.assign('/');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Import failed');
|
setError(err instanceof Error ? err.message : 'Import failed');
|
||||||
@@ -280,7 +287,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
|
|
||||||
// Import and set in memory store
|
// Import and set in memory store
|
||||||
// Remove PEM headers if present and clean whitespace
|
// Remove PEM headers if present and clean whitespace
|
||||||
let cleanKey = privateKeyDecrypted
|
const cleanKey = privateKeyDecrypted
|
||||||
.replace(/-----BEGIN [A-Z ]+-----/, '')
|
.replace(/-----BEGIN [A-Z ]+-----/, '')
|
||||||
.replace(/-----END [A-Z ]+-----/, '')
|
.replace(/-----END [A-Z ]+-----/, '')
|
||||||
.replace(/\s/g, '');
|
.replace(/\s/g, '');
|
||||||
@@ -306,7 +313,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
if (data.user?.privateKeyEncrypted) {
|
if (data.user?.privateKeyEncrypted) {
|
||||||
try {
|
try {
|
||||||
// Update AuthContext first so it has the user and key
|
// Update AuthContext first so it has the user and key
|
||||||
login(data.user);
|
await login(data.user);
|
||||||
|
|
||||||
// Now unlock (passing user explicitly to avoid async state delay)
|
// Now unlock (passing user explicitly to avoid async state delay)
|
||||||
await unlockIdentity(password, data.user);
|
await unlockIdentity(password, data.user);
|
||||||
@@ -315,12 +322,14 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft navigation to preserve AuthContext/KeyStore state
|
// Start Chat in a fresh JavaScript realm before it creates or loads
|
||||||
router.refresh();
|
// the E2EE account key. Turnstile-enabled credential handling remains
|
||||||
|
// part of the login trust boundary and is documented accordingly.
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess();
|
onSuccess();
|
||||||
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
router.push('/');
|
window.location.assign('/');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
@@ -688,7 +697,29 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
color: '#000',
|
color: '#000',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
}}>
|
}}>
|
||||||
{importSuccess} Redirecting...
|
<div>{importSuccess}</div>
|
||||||
|
{importWarnings.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ul style={{ margin: '8px 0 10px', paddingLeft: 20 }}>
|
||||||
|
{importWarnings.map((warning) => <li key={warning}>{warning}</li>)}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
window.location.assign('/');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : ' Redirecting…'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -738,6 +769,25 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
|
Email on this node
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="input"
|
||||||
|
value={importEmail}
|
||||||
|
onChange={(e) => setImportEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
maxLength={320}
|
||||||
|
/>
|
||||||
|
<span style={{ display: 'block', color: 'var(--foreground-tertiary)', fontSize: '12px', marginTop: '4px' }}>
|
||||||
|
You'll use this email to sign in after the import.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
Handle on this node
|
Handle on this node
|
||||||
@@ -801,7 +851,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
|
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
|
||||||
<strong style={{ color: 'var(--warning)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
<strong style={{ color: 'var(--warning)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<TriangleAlert size={12} /> Compliance:
|
<TriangleAlert size={12} /> Compliance:
|
||||||
</strong> I agree to comply with this node's rules and take responsibility for my migrated content.
|
</strong> I agree to comply with this node's rules and take responsibility for my migrated content.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -834,7 +884,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary btn-lg"
|
className="btn btn-primary btn-lg"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
|
disabled={loading || !importFile || !importPassword || !importEmail || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
|
||||||
>
|
>
|
||||||
{loading ? 'Importing...' : 'Import Account'}
|
{loading ? 'Importing...' : 'Import Account'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
@@ -114,8 +114,8 @@ export default function MigrationPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
|
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
|
||||||
Download a complete backup of your account including your identity, posts, and media.
|
Download a signed backup of your account identity, posts, and media.
|
||||||
You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node.
|
You can use this file to migrate to another Synapsis node by selecting "Import" on the login page of that node.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -139,11 +139,28 @@ export default function MigrationPage() {
|
|||||||
<li>Your profile information</li>
|
<li>Your profile information</li>
|
||||||
<li>All your posts</li>
|
<li>All your posts</li>
|
||||||
<li>Your following list</li>
|
<li>Your following list</li>
|
||||||
<li>All DMs and conversation history</li>
|
<li>DM conversation records and encrypted message envelopes</li>
|
||||||
<li>Your automated bots and their configuration</li>
|
<li>Your automated bots and their configuration</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
background: 'rgba(245, 158, 11, 0.1)',
|
||||||
|
border: '1px solid rgba(245, 158, 11, 0.35)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}>
|
||||||
|
<TriangleAlert size={20} style={{ flexShrink: 0, color: '#f59e0b' }} />
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0, lineHeight: 1.5 }}>
|
||||||
|
Encrypted DM keys are not portable yet. This export preserves encrypted message records,
|
||||||
|
but E2EE history will not open after importing it on another node. Keep access to this
|
||||||
|
browser and node if you need to read that history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||||
Confirm your password
|
Confirm your password
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Eye, EyeOff, Loader2, LockKeyhole } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { E2EEIdentityState } from '@/lib/e2ee/use-e2ee-identity';
|
||||||
|
|
||||||
|
interface E2EEChatGateProps {
|
||||||
|
state: E2EEIdentityState;
|
||||||
|
busy: boolean;
|
||||||
|
error: string | null;
|
||||||
|
identityUnlocked: boolean;
|
||||||
|
onSetup: (pin: string) => Promise<void>;
|
||||||
|
onUnlock: (pin: string) => Promise<void>;
|
||||||
|
onReset: (pin: string, currentPassword: string) => Promise<void>;
|
||||||
|
onRetry: () => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(totalSeconds: number): string {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return minutes > 0 ? `${minutes}m ${seconds.toString().padStart(2, '0')}s` : `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function E2EEChatGate(props: E2EEChatGateProps) {
|
||||||
|
const { busy, onCancel } = props;
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
|
const [confirmation, setConfirmation] = useState('');
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [showPin, setShowPin] = useState(false);
|
||||||
|
const [resetMode, setResetMode] = useState(false);
|
||||||
|
const [understandsReset, setUnderstandsReset] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const pinRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const lockedVault = props.state.status === 'locked' && props.state.vault.configured
|
||||||
|
? props.state.vault
|
||||||
|
: null;
|
||||||
|
const lockedUntilMs = lockedVault?.lockedUntil
|
||||||
|
? new Date(lockedVault.lockedUntil).getTime()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.state.status === 'setup_required' || props.state.status === 'locked') {
|
||||||
|
pinRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [props.state.status, resetMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lockedUntilMs || lockedUntilMs <= Date.now()) return;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const next = Date.now();
|
||||||
|
setNow(next);
|
||||||
|
if (next >= lockedUntilMs) window.clearInterval(timer);
|
||||||
|
}, 1_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [lockedUntilMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape' || busy) return;
|
||||||
|
if (resetMode) {
|
||||||
|
setResetMode(false);
|
||||||
|
setLocalError(null);
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [busy, onCancel, resetMode]);
|
||||||
|
|
||||||
|
if (props.state.status === 'loading') {
|
||||||
|
return (
|
||||||
|
<main aria-busy="true" style={{ minHeight: '100vh', display: 'grid', placeItems: 'center' }}>
|
||||||
|
<div style={{ textAlign: 'center', color: 'var(--foreground-secondary)' }}>
|
||||||
|
<Loader2 className="animate-spin" size={28} style={{ margin: '0 auto 12px' }} aria-hidden="true" />
|
||||||
|
<p>Opening encrypted messages…</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.state.status === 'error') {
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
|
||||||
|
<section style={{ maxWidth: 420, textAlign: 'center' }}>
|
||||||
|
<h1 style={{ fontSize: 20 }}>Encrypted messages unavailable</h1>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)' }}>{props.state.message}</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => void props.onRetry()}>Try again</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.state.status === 'ready') return null;
|
||||||
|
|
||||||
|
const isSetup = props.state.status === 'setup_required';
|
||||||
|
const isMigrationSetup = props.state.status === 'setup_required' && !!props.state.previousKey;
|
||||||
|
const lockedUntil = lockedVault?.lockedUntil
|
||||||
|
? new Date(lockedVault.lockedUntil)
|
||||||
|
: null;
|
||||||
|
const remainingLockSeconds = lockedUntilMs ? Math.max(0, Math.ceil((lockedUntilMs - now) / 1_000)) : 0;
|
||||||
|
// A recovery lock blocks further PIN guesses, but password-authorized key
|
||||||
|
// reset remains available to someone who genuinely forgot the PIN.
|
||||||
|
const isRateLimited = !resetMode && remainingLockSeconds > 0;
|
||||||
|
const attemptsRemaining = lockedVault?.attemptsRemaining;
|
||||||
|
const title = resetMode
|
||||||
|
? 'Reset encrypted messages?'
|
||||||
|
: isSetup
|
||||||
|
? 'Set up encrypted messages'
|
||||||
|
: 'Unlock encrypted messages';
|
||||||
|
const description = resetMode
|
||||||
|
? 'Resetting starts a new encryption key. Messages encrypted with your old key will no longer open. This cannot be undone.'
|
||||||
|
: isSetup
|
||||||
|
? isMigrationSetup
|
||||||
|
? 'Create a PIN for this node. A new encryption key will safely replace your previous node’s key; old encrypted history will remain unavailable here.'
|
||||||
|
: 'Create a PIN to protect and restore your encrypted message history. You’ll enter it only on a new or cleared device. This PIN is separate from your login password.'
|
||||||
|
: 'Enter your encrypted messages PIN to restore your history on this device. This is not your login password.';
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLocalError(null);
|
||||||
|
if (!/^\d{6,12}$/.test(pin)) {
|
||||||
|
setLocalError('PIN must contain 6–12 digits.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((isSetup || resetMode) && pin !== confirmation) {
|
||||||
|
setLocalError('PINs don’t match. Try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!props.identityUnlocked) {
|
||||||
|
setLocalError('Your identity is locked. Sign in again before setting up encrypted messages.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resetMode) {
|
||||||
|
if (!understandsReset || !currentPassword) {
|
||||||
|
setLocalError('Confirm the warning and enter your current login password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await props.onReset(pin, currentPassword).catch(() => undefined);
|
||||||
|
} else if (isSetup) {
|
||||||
|
await props.onSetup(pin).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
await props.onUnlock(pin).catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
|
||||||
|
<section
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="e2ee-gate-title"
|
||||||
|
aria-describedby="e2ee-gate-description"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 440,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 20,
|
||||||
|
background: 'var(--background)',
|
||||||
|
padding: 24,
|
||||||
|
boxShadow: '0 18px 60px rgba(0,0,0,.28)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LockKeyhole size={30} aria-hidden="true" style={{ color: 'var(--accent)', marginBottom: 16 }} />
|
||||||
|
<h1 id="e2ee-gate-title" style={{ fontSize: 22, margin: '0 0 10px' }}>{title}</h1>
|
||||||
|
<p id="e2ee-gate-description" style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5 }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={submit} aria-busy={props.busy} style={{ display: 'grid', gap: 14, marginTop: 20 }}>
|
||||||
|
{resetMode && (
|
||||||
|
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: 14 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={understandsReset}
|
||||||
|
onChange={(event) => setUnderstandsReset(event.target.checked)}
|
||||||
|
style={{ marginTop: 3 }}
|
||||||
|
/>
|
||||||
|
<span>I understand that my old encrypted message history will be unavailable.</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600 }}>{resetMode ? 'New encrypted messages PIN' : 'Encrypted messages PIN'}</span>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
ref={pinRef}
|
||||||
|
className="input"
|
||||||
|
type={showPin ? 'text' : 'password'}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete={isSetup || resetMode ? 'new-password' : 'off'}
|
||||||
|
minLength={6}
|
||||||
|
maxLength={12}
|
||||||
|
value={pin}
|
||||||
|
onChange={(event) => setPin(event.target.value.replace(/\D/g, '').slice(0, 12))}
|
||||||
|
aria-invalid={!!(localError || props.error)}
|
||||||
|
aria-describedby="e2ee-pin-hint e2ee-gate-error"
|
||||||
|
disabled={props.busy || isRateLimited}
|
||||||
|
style={{ width: '100%', paddingRight: 48, fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={showPin ? 'Hide PIN' : 'Show PIN'}
|
||||||
|
onClick={() => setShowPin((value) => !value)}
|
||||||
|
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 0, color: 'var(--foreground-secondary)', minWidth: 36, minHeight: 36 }}
|
||||||
|
>
|
||||||
|
{showPin ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span id="e2ee-pin-hint" style={{ fontSize: 12, color: 'var(--foreground-tertiary)' }}>6–12 digits; avoid birthdays and repeated or sequential numbers.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{(isSetup || resetMode) && (
|
||||||
|
<label style={{ display: 'grid', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600 }}>Confirm PIN</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={12}
|
||||||
|
value={confirmation}
|
||||||
|
onChange={(event) => setConfirmation(event.target.value.replace(/\D/g, '').slice(0, 12))}
|
||||||
|
disabled={props.busy}
|
||||||
|
style={{ fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetMode && (
|
||||||
|
<label style={{ display: 'grid', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600 }}>Current login password</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
disabled={props.busy}
|
||||||
|
style={{ fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRateLimited && (
|
||||||
|
<p role="status" aria-live="polite" style={{ color: 'var(--destructive)', fontSize: 14, margin: 0 }}>
|
||||||
|
Too many attempts. Try again in {formatCountdown(remainingLockSeconds)}
|
||||||
|
{lockedUntil ? ` (${lockedUntil.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})` : ''}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isSetup && !resetMode && !isRateLimited && typeof attemptsRemaining === 'number' && attemptsRemaining > 0 && (
|
||||||
|
<p role="status" aria-live="polite" style={{ color: 'var(--foreground-secondary)', fontSize: 13, margin: 0 }}>
|
||||||
|
{attemptsRemaining} {attemptsRemaining === 1 ? 'attempt' : 'attempts'} remaining before a temporary lock.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(localError || props.error) && (
|
||||||
|
<p id="e2ee-gate-error" role="alert" style={{ color: 'var(--destructive)', fontSize: 14, margin: 0 }}>
|
||||||
|
{localError || props.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={resetMode ? 'btn btn-danger' : 'btn btn-primary'}
|
||||||
|
disabled={props.busy || isRateLimited}
|
||||||
|
style={{ justifyContent: 'center', minHeight: 44 }}
|
||||||
|
>
|
||||||
|
{props.busy ? <Loader2 size={18} className="animate-spin" aria-hidden="true" /> : null}
|
||||||
|
{props.busy
|
||||||
|
? resetMode ? 'Resetting…' : isSetup ? 'Setting up…' : 'Unlocking…'
|
||||||
|
: resetMode ? 'Reset encrypted messages' : isSetup ? 'Set up encrypted messages' : 'Unlock'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isSetup && !resetMode && (
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setResetMode(true)}>
|
||||||
|
Forgot PIN?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{resetMode && (
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setResetMode(false)}>
|
||||||
|
Back to PIN
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={props.onCancel}>Not now</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+99
-2
@@ -85,6 +85,8 @@ export const users = sqliteTable('users', {
|
|||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('users_handle_idx').on(table.handle),
|
index('users_handle_idx').on(table.handle),
|
||||||
index('users_did_idx').on(table.did),
|
index('users_did_idx').on(table.did),
|
||||||
|
uniqueIndex('users_handle_unique_idx').on(table.handle),
|
||||||
|
uniqueIndex('users_did_unique_idx').on(table.did),
|
||||||
index('users_suspended_idx').on(table.isSuspended),
|
index('users_suspended_idx').on(table.isSuspended),
|
||||||
index('users_silenced_idx').on(table.isSilenced),
|
index('users_silenced_idx').on(table.isSilenced),
|
||||||
index('users_is_bot_idx').on(table.isBot),
|
index('users_is_bot_idx').on(table.isBot),
|
||||||
@@ -807,6 +809,11 @@ export const chatConversations = sqliteTable('chat_conversations', {
|
|||||||
lastMessageAt: integer('last_message_at', { mode: 'timestamp' }),
|
lastMessageAt: integer('last_message_at', { mode: 'timestamp' }),
|
||||||
lastMessagePreview: text('last_message_preview'),
|
lastMessagePreview: text('last_message_preview'),
|
||||||
|
|
||||||
|
// Existing conversations begin as legacy. The first encrypted message marks
|
||||||
|
// the cutover without pretending older plaintext history was retroactively protected.
|
||||||
|
encryptionMode: text('encryption_mode').default('legacy').notNull(),
|
||||||
|
e2eeActivatedAt: integer('e2ee_activated_at', { mode: 'timestamp' }),
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
@@ -820,8 +827,8 @@ export const chatConversations = sqliteTable('chat_conversations', {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual chat messages within conversations.
|
* Individual chat messages within conversations.
|
||||||
* Messages are stored as plain text on the server.
|
* Legacy messages can contain plaintext. E2EE v1 messages store only a signed,
|
||||||
* Both sender and recipient can view the message content.
|
* opaque encrypted envelope and leave `content` null.
|
||||||
*/
|
*/
|
||||||
export const chatMessages = sqliteTable('chat_messages', {
|
export const chatMessages = sqliteTable('chat_messages', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
|
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
|
||||||
@@ -839,6 +846,14 @@ export const chatMessages = sqliteTable('chat_messages', {
|
|||||||
// Message content (plain text for verified chat)
|
// Message content (plain text for verified chat)
|
||||||
content: text('content'),
|
content: text('content'),
|
||||||
|
|
||||||
|
// End-to-end encrypted message fields. Protocol version 0 means legacy plaintext.
|
||||||
|
protocolVersion: integer('protocol_version').default(0).notNull(),
|
||||||
|
clientMessageId: text('client_message_id'),
|
||||||
|
encryptedEnvelope: text('encrypted_envelope'),
|
||||||
|
e2eeSignature: text('e2ee_signature'),
|
||||||
|
e2eeActionNonce: text('e2ee_action_nonce'),
|
||||||
|
e2eeActionTs: integer('e2ee_action_ts'),
|
||||||
|
|
||||||
// Swarm sync info
|
// Swarm sync info
|
||||||
swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid
|
swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid
|
||||||
|
|
||||||
@@ -852,6 +867,7 @@ export const chatMessages = sqliteTable('chat_messages', {
|
|||||||
index('chat_messages_conversation_idx').on(table.conversationId),
|
index('chat_messages_conversation_idx').on(table.conversationId),
|
||||||
index('chat_messages_created_idx').on(table.createdAt),
|
index('chat_messages_created_idx').on(table.createdAt),
|
||||||
index('chat_messages_swarm_id_idx').on(table.swarmMessageId),
|
index('chat_messages_swarm_id_idx').on(table.swarmMessageId),
|
||||||
|
uniqueIndex('chat_messages_conversation_client_id_unique').on(table.conversationId, table.clientMessageId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
@@ -879,6 +895,87 @@ export const chatTypingIndicators = sqliteTable('chat_typing_indicators', {
|
|||||||
// CRYPTO & SECURITY
|
// CRYPTO & SECURITY
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current account encryption key, certified by the account's existing DID key.
|
||||||
|
* Private material is never stored here.
|
||||||
|
*/
|
||||||
|
export const e2eeKeyBundles = sqliteTable('e2ee_key_bundles', {
|
||||||
|
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
did: text('did').notNull(),
|
||||||
|
keyId: text('key_id').notNull(),
|
||||||
|
keyVersion: integer('key_version').notNull(),
|
||||||
|
publicKey: text('public_key').notNull(),
|
||||||
|
proofAction: text('proof_action').notNull(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
uniqueIndex('e2ee_key_bundles_did_unique').on(table.did),
|
||||||
|
uniqueIndex('e2ee_key_bundles_key_id_unique').on(table.keyId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PIN recovery vault. The PIN verifier is HMACed with a node secret and the
|
||||||
|
* server share is separately encrypted, so a database-only leak is not an
|
||||||
|
* offline PIN oracle. This is not a substitute for threshold/HSM recovery.
|
||||||
|
*/
|
||||||
|
export const e2eeKeyVaults = sqliteTable('e2ee_key_vaults', {
|
||||||
|
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
keyId: text('key_id').notNull(),
|
||||||
|
keyVersion: integer('key_version').notNull(),
|
||||||
|
ownerDid: text('owner_did').notNull(),
|
||||||
|
publicKey: text('public_key').notNull(),
|
||||||
|
ciphertext: text('ciphertext').notNull(),
|
||||||
|
nonce: text('nonce').notNull(),
|
||||||
|
salt: text('salt').notNull(),
|
||||||
|
kdfAlgorithm: text('kdf_algorithm').notNull(),
|
||||||
|
kdfOpsLimit: integer('kdf_ops_limit').notNull(),
|
||||||
|
kdfMemLimit: integer('kdf_mem_limit').notNull(),
|
||||||
|
pinVerifierMac: text('pin_verifier_mac').notNull(),
|
||||||
|
serverShareEncrypted: text('server_share_encrypted').notNull(),
|
||||||
|
failedAttempts: integer('failed_attempts').default(0).notNull(),
|
||||||
|
lockedUntil: integer('locked_until', { mode: 'timestamp' }),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
uniqueIndex('e2ee_key_vaults_key_id_unique').on(table.keyId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verified cache of remote users' DID-certified encryption keys. Key changes
|
||||||
|
* must carry a fresh signature by the same account signing identity.
|
||||||
|
*/
|
||||||
|
export const e2eeRemoteKeyBundles = sqliteTable('e2ee_remote_key_bundles', {
|
||||||
|
did: text('did').primaryKey(),
|
||||||
|
handle: text('handle').notNull(),
|
||||||
|
keyId: text('key_id').notNull(),
|
||||||
|
keyVersion: integer('key_version').notNull(),
|
||||||
|
publicKey: text('public_key').notNull(),
|
||||||
|
proofAction: text('proof_action').notNull(),
|
||||||
|
signingPublicKey: text('signing_public_key').notNull(),
|
||||||
|
firstSeenAt: integer('first_seen_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('e2ee_remote_key_bundles_key_id_idx').on(table.keyId),
|
||||||
|
index('e2ee_remote_key_bundles_handle_idx').on(table.handle),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable replay tombstones survive conversation deletion, preventing a valid
|
||||||
|
* old signed envelope from recreating a message after the user removed it.
|
||||||
|
*/
|
||||||
|
export const e2eeMessageReceipts = sqliteTable('e2ee_message_receipts', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
|
||||||
|
ownerUserId: text('owner_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
senderDid: text('sender_did').notNull(),
|
||||||
|
messageId: text('message_id').notNull(),
|
||||||
|
protocolVersion: integer('protocol_version').default(1).notNull(),
|
||||||
|
receivedAt: integer('received_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
uniqueIndex('e2ee_message_receipts_owner_sender_message_unique')
|
||||||
|
.on(table.ownerUserId, table.senderDid, table.messageId),
|
||||||
|
index('e2ee_message_receipts_received_idx').on(table.receivedAt),
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replay protection for signed user actions.
|
* Replay protection for signed user actions.
|
||||||
* Enforces uniqueness of (did, nonce) within the valid timeframe.
|
* Enforces uniqueness of (did, nonce) within the valid timeframe.
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignedAction, hasUserPrivateKey } from '@/lib/crypto/user-signing';
|
import { createSignedAction, hasUserPrivateKey } from '@/lib/crypto/user-signing';
|
||||||
|
import type { E2EEMessageEnvelope } from '@/lib/e2ee/protocol';
|
||||||
|
|
||||||
export interface SignedFetchOptions {
|
export interface SignedFetchOptions {
|
||||||
method?: string;
|
method?: string;
|
||||||
body?: any;
|
body?: unknown;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export interface SignedFetchOptions {
|
|||||||
export async function signedFetch(
|
export async function signedFetch(
|
||||||
url: string,
|
url: string,
|
||||||
action: string,
|
action: string,
|
||||||
data: any,
|
data: unknown,
|
||||||
userDid: string,
|
userDid: string,
|
||||||
userHandle: string,
|
userHandle: string,
|
||||||
options: SignedFetchOptions = {}
|
options: SignedFetchOptions = {}
|
||||||
@@ -115,9 +116,9 @@ export const signedAPI = {
|
|||||||
async createPost(
|
async createPost(
|
||||||
content: string,
|
content: string,
|
||||||
mediaIds: string[],
|
mediaIds: string[],
|
||||||
linkPreview: any,
|
linkPreview: unknown,
|
||||||
replyToId: string | undefined,
|
replyToId: string | undefined,
|
||||||
swarmReplyTo: any | undefined,
|
swarmReplyTo: unknown | undefined,
|
||||||
isNsfw: boolean,
|
isNsfw: boolean,
|
||||||
userDid: string,
|
userDid: string,
|
||||||
userHandle: string
|
userHandle: string
|
||||||
@@ -213,11 +214,11 @@ export const signedAPI = {
|
|||||||
/**
|
/**
|
||||||
* Send a chat message
|
* Send a chat message
|
||||||
*/
|
*/
|
||||||
async sendChat(recipientDid: string, recipientHandle: string, content: string, userDid: string, userHandle: string) {
|
async sendChat(envelope: E2EEMessageEnvelope, userDid: string, userHandle: string) {
|
||||||
return signedFetch(
|
return signedFetch(
|
||||||
'/api/chat/send',
|
'/api/chat/send',
|
||||||
'chat',
|
'chat_e2ee',
|
||||||
{ recipientDid, recipientHandle, content },
|
envelope,
|
||||||
userDid,
|
userDid,
|
||||||
userHandle
|
userHandle
|
||||||
);
|
);
|
||||||
|
|||||||
+22
-13
@@ -7,8 +7,8 @@ import { eq, inArray } from 'drizzle-orm';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||||
|
import { didKeyMatchesPublicKey, generateDID } from '@/lib/crypto/did-key';
|
||||||
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||||
import { base58btc } from 'multiformats/bases/base58';
|
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
import { registrationDisplayName } from '@/lib/auth/display-name';
|
import { registrationDisplayName } from '@/lib/auth/display-name';
|
||||||
@@ -133,18 +133,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
|||||||
return bcrypt.compare(password, hash);
|
return bcrypt.compare(password, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { generateDID } from '@/lib/crypto/did-key';
|
||||||
* Generate a DID for a new user
|
|
||||||
* Uses did:key format (W3C standard) - the DID contains the public key itself
|
|
||||||
*/
|
|
||||||
export function generateDID(publicKey: string): string {
|
|
||||||
// Encode the SPKI public key in base58btc (multibase)
|
|
||||||
const publicKeyBytes = Buffer.from(publicKey, 'base64');
|
|
||||||
const encoded = base58btc.encode(new Uint8Array(publicKeyBytes));
|
|
||||||
|
|
||||||
// Create did:key - the 'z' prefix indicates base58btc encoding
|
|
||||||
return `did:key:${encoded}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate legacy DID format (for backward compatibility)
|
* Generate legacy DID format (for backward compatibility)
|
||||||
@@ -399,6 +388,26 @@ export async function authenticateUser(
|
|||||||
user.privateKeyEncrypted = serializeEncryptedKey(encryptedPrivateKey);
|
user.privateKeyEncrypted = serializeEncryptedKey(encryptedPrivateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older login migrations rotated an RSA signing key without rotating the
|
||||||
|
// self-certifying did:key identifier. Repair that mismatch transparently so
|
||||||
|
// clients can verify the current account key from the DID itself.
|
||||||
|
if (user.did.startsWith('did:key:') && !didKeyMatchesPublicKey(user.did, user.publicKey)) {
|
||||||
|
const expectedDid = generateDID(user.publicKey);
|
||||||
|
if (user.did !== expectedDid) {
|
||||||
|
await db.update(users)
|
||||||
|
.set({ did: expectedDid, updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
user.did = expectedDid;
|
||||||
|
|
||||||
|
await upsertHandleEntries([{
|
||||||
|
handle: user.handle,
|
||||||
|
did: expectedDid,
|
||||||
|
nodeDomain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,36 @@
|
|||||||
|
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { users, signedActionDedupe } from '@/db/schema';
|
import { users, signedActionDedupe } from '@/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, lt } from 'drizzle-orm';
|
||||||
import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing';
|
import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing';
|
||||||
// Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability)
|
// Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability)
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { isRateLimited } from '@/lib/rate-limit';
|
import { isRateLimited } from '@/lib/rate-limit';
|
||||||
|
|
||||||
// Use Node's webcrypto for server-side if not global
|
// Use Node's webcrypto for server-side if not global
|
||||||
const cryptoSubtle = globalThis.crypto?.subtle || require('crypto').webcrypto.subtle;
|
const cryptoSubtle = globalThis.crypto?.subtle || crypto.webcrypto.subtle;
|
||||||
|
const DEDUPE_RETENTION_MS = 10 * 60 * 1000;
|
||||||
|
const DEDUPE_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||||
|
let nextDedupeCleanupAt = 0;
|
||||||
|
|
||||||
export interface SignedAction {
|
async function pruneExpiredSignedActions(now: number): Promise<void> {
|
||||||
|
if (now < nextDedupeCleanupAt) return;
|
||||||
|
nextDedupeCleanupAt = now + DEDUPE_CLEANUP_INTERVAL_MS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(signedActionDedupe).where(
|
||||||
|
lt(signedActionDedupe.createdAt, new Date(now - DEDUPE_RETENTION_MS)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Replay verification must not become unavailable because maintenance
|
||||||
|
// failed; the next process or interval will retry the bounded cleanup.
|
||||||
|
console.error('[Verify] Signed-action cleanup failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedAction<TData = Record<string, string>> {
|
||||||
action: string;
|
action: string;
|
||||||
data: any;
|
data: TData;
|
||||||
did: string;
|
did: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
@@ -39,7 +57,7 @@ export class SignedActionError extends Error {
|
|||||||
/**
|
/**
|
||||||
* Verify a signed action against a specific public key
|
* Verify a signed action against a specific public key
|
||||||
*/
|
*/
|
||||||
export async function verifyActionSignature(signedAction: SignedAction, publicKeyStr: string): Promise<boolean> {
|
export async function verifyActionSignature(signedAction: SignedAction<unknown>, publicKeyStr: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { sig, ...payload } = signedAction;
|
const { sig, ...payload } = signedAction;
|
||||||
const canonicalString = canonicalize(payload);
|
const canonicalString = canonicalize(payload);
|
||||||
@@ -74,7 +92,7 @@ export async function verifyActionSignature(signedAction: SignedAction, publicKe
|
|||||||
* @param signedAction - The signed action payload
|
* @param signedAction - The signed action payload
|
||||||
* @returns The user if signature is valid and not replayed
|
* @returns The user if signature is valid and not replayed
|
||||||
*/
|
*/
|
||||||
export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
export async function verifyUserAction(signedAction: SignedAction<unknown>): Promise<{
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
user?: typeof users.$inferSelect;
|
user?: typeof users.$inferSelect;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -83,15 +101,16 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
|||||||
return { valid: false, error: 'Database not available' };
|
return { valid: false, error: 'Database not available' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sig, ...payload } = signedAction;
|
const payload = {
|
||||||
|
action: signedAction.action,
|
||||||
|
data: signedAction.data,
|
||||||
|
did: signedAction.did,
|
||||||
|
handle: signedAction.handle,
|
||||||
|
nonce: signedAction.nonce,
|
||||||
|
ts: signedAction.ts,
|
||||||
|
};
|
||||||
|
|
||||||
// 1. RATE LIMIT CHECK (Fail fast before heavy operations)
|
// 1. FRESHNESS CHECK (Fail fast before DB/Crypto)
|
||||||
// 5 requests per minute per DID
|
|
||||||
if (isRateLimited(payload.did, 5, 60 * 1000)) {
|
|
||||||
return { valid: false, error: 'RATE_LIMITED' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. FRESHNESS CHECK (Fail fast before DB/Crypto)
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diff = Math.abs(now - payload.ts);
|
const diff = Math.abs(now - payload.ts);
|
||||||
// Allow 5 minutes clock skew
|
// Allow 5 minutes clock skew
|
||||||
@@ -101,7 +120,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
|||||||
return { valid: false, error: 'INVALID_TIMESTAMP: Request too old or in future' };
|
return { valid: false, error: 'INVALID_TIMESTAMP: Request too old or in future' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. FETCH USER & KEY
|
// 2. FETCH USER & KEY
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: { did: payload.did },
|
where: { did: payload.did },
|
||||||
});
|
});
|
||||||
@@ -114,18 +133,43 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
|||||||
return { valid: false, error: 'Handle mismatch' };
|
return { valid: false, error: 'Handle mismatch' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. CRYPTOGRAPHIC VERIFICATION
|
// 3. CRYPTOGRAPHIC VERIFICATION
|
||||||
const isValid = await verifyActionSignature(signedAction, user.publicKey);
|
const isValid = await verifyActionSignature(signedAction, user.publicKey);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return { valid: false, error: 'INVALID_SIGNATURE' };
|
return { valid: false, error: 'INVALID_SIGNATURE' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. ACTION ID HASH COMPUTATION
|
await pruneExpiredSignedActions(now);
|
||||||
|
|
||||||
|
// 4. ACTION ID HASH COMPUTATION
|
||||||
const canonicalString = canonicalize(payload);
|
const canonicalString = canonicalize(payload);
|
||||||
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
|
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
|
||||||
|
|
||||||
// 6. REPLAY PROTECTION (DB)
|
// 5. EXISTING REPLAY CHECK. Avoid charging the authenticated account bucket
|
||||||
|
// for an action already recorded in durable replay storage. The unique
|
||||||
|
// insert below remains the authoritative guard for concurrent requests.
|
||||||
|
const existingReplay = await db
|
||||||
|
.select({ actionId: signedActionDedupe.actionId })
|
||||||
|
.from(signedActionDedupe)
|
||||||
|
.where(eq(signedActionDedupe.actionId, actionIdHash))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingReplay.length > 0) {
|
||||||
|
return { valid: false, error: 'REPLAYED_NONCE' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. AUTHENTICATED RATE LIMIT. Charge quota before creating durable replay
|
||||||
|
// state so a fresh, unique action rejected for excess volume leaves no row.
|
||||||
|
// Invalid signatures and attacker-controlled public DIDs never create
|
||||||
|
// per-account limiter entries or consume quota.
|
||||||
|
const requestsPerMinute = payload.action === 'chat_e2ee' ? 120 : 5;
|
||||||
|
if (isRateLimited(`${user.id}:${payload.action}`, requestsPerMinute, 60 * 1000)) {
|
||||||
|
return { valid: false, error: 'RATE_LIMITED' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. AUTHORITATIVE REPLAY INSERT. Another request can race the read above,
|
||||||
|
// so rely on the primary-key constraint to reject concurrent duplicates.
|
||||||
try {
|
try {
|
||||||
await db.insert(signedActionDedupe).values({
|
await db.insert(signedActionDedupe).values({
|
||||||
actionId: actionIdHash,
|
actionId: actionIdHash,
|
||||||
@@ -133,9 +177,13 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
|||||||
nonce: payload.nonce,
|
nonce: payload.nonce,
|
||||||
ts: payload.ts,
|
ts: payload.ts,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
// Check for unique constraint violation (duplicate key)
|
// Check for unique constraint violation (duplicate key)
|
||||||
if (err.code === '23505') { // Postgres unique_violation code
|
const errorCode = typeof err === 'object' && err !== null && 'code' in err
|
||||||
|
? (err as { code?: unknown }).code
|
||||||
|
: undefined;
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
if (errorCode === '23505' || /unique|constraint/i.test(errorMessage)) {
|
||||||
return { valid: false, error: 'REPLAYED_NONCE' };
|
return { valid: false, error: 'REPLAYED_NONCE' };
|
||||||
}
|
}
|
||||||
console.error('[Verify] Dedupe error:', err);
|
console.error('[Verify] Dedupe error:', err);
|
||||||
@@ -149,7 +197,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
|||||||
* Middleware to require a signed action
|
* Middleware to require a signed action
|
||||||
* Throws an error if signature is invalid
|
* Throws an error if signature is invalid
|
||||||
*/
|
*/
|
||||||
export async function requireSignedAction(signedAction: SignedAction): Promise<typeof users.$inferSelect> {
|
export async function requireSignedAction(signedAction: SignedAction<unknown>): Promise<typeof users.$inferSelect> {
|
||||||
const result = await verifyUserAction(signedAction);
|
const result = await verifyUserAction(signedAction);
|
||||||
|
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { base58btc } from 'multiformats/bases/base58';
|
||||||
|
|
||||||
|
// DER SubjectPublicKeyInfo prefix for an uncompressed ECDSA P-256 public key.
|
||||||
|
// The remaining 64 bytes are the affine X and Y coordinates.
|
||||||
|
const P256_SPKI_PREFIX = Uint8Array.from([
|
||||||
|
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
|
||||||
|
0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48,
|
||||||
|
0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, 0x04,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Historical generateDID() passed the complete PEM string to
|
||||||
|
// Buffer.from(value, 'base64'). Node treats the five '-' characters on each
|
||||||
|
// side of BEGIN PUBLIC KEY as base64url data, producing this exact 18-byte
|
||||||
|
// prefix before the otherwise valid SPKI DER bytes.
|
||||||
|
const LEGACY_PEM_AS_BASE64_PREFIX = Uint8Array.from([
|
||||||
|
0xfb, 0xef, 0xbe, 0xf8, 0x11, 0x06, 0x20, 0xd3, 0xd4,
|
||||||
|
0x04, 0xb2, 0x02, 0x28, 0x46, 0x3e, 0xfb, 0xef, 0xbe,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function startsWith(bytes: Uint8Array, prefix: Uint8Array): boolean {
|
||||||
|
if (bytes.length < prefix.length) return false;
|
||||||
|
for (let index = 0; index < prefix.length; index += 1) {
|
||||||
|
if (bytes[index] !== prefix[index]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function strictBase64ToBytes(value: string): Uint8Array | null {
|
||||||
|
const unpadded = value.replace(/=+$/, '');
|
||||||
|
if (
|
||||||
|
!unpadded
|
||||||
|
|| unpadded.length % 4 === 1
|
||||||
|
|| !/^[A-Za-z0-9+/]+$/.test(unpadded)
|
||||||
|
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(value)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const padded = unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||||
|
return bytesToBase64(bytes).replace(/=+$/, '') === unpadded ? bytes : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isP256SpkiDer(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.length === P256_SPKI_PREFIX.length + 64
|
||||||
|
&& startsWith(bytes, P256_SPKI_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a PEM or base64 SPKI ECDSA P-256 public key to canonical DER.
|
||||||
|
*/
|
||||||
|
export function signingPublicKeyDer(publicKey: string): Uint8Array | null {
|
||||||
|
const trimmed = publicKey.trim();
|
||||||
|
let encoded = trimmed;
|
||||||
|
|
||||||
|
if (trimmed.includes('-----BEGIN')) {
|
||||||
|
const match = trimmed.match(
|
||||||
|
/^-----BEGIN PUBLIC KEY-----\s+([A-Za-z0-9+/=\s]+?)\s+-----END PUBLIC KEY-----$/,
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
|
encoded = match[1].replace(/\s/g, '');
|
||||||
|
} else if (/\s/.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = strictBase64ToBytes(encoded);
|
||||||
|
return bytes && isP256SpkiDer(bytes) ? bytes : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSigningPublicKey(publicKey: string): string | null {
|
||||||
|
const bytes = signingPublicKeyDer(publicKey);
|
||||||
|
return bytes ? bytesToBase64(bytes) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the project's did:key representation from canonical SPKI DER.
|
||||||
|
*/
|
||||||
|
export function generateDID(publicKey: string): string {
|
||||||
|
const bytes = signingPublicKeyDer(publicKey);
|
||||||
|
if (!bytes) throw new Error('Invalid ECDSA P-256 public key');
|
||||||
|
return `did:key:${base58btc.encode(bytes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the canonical signing key from a project did:key. Historical DIDs
|
||||||
|
* are accepted only when they have the exact prefix emitted by the old PEM
|
||||||
|
* decoding bug and a valid P-256 SPKI DER suffix.
|
||||||
|
*/
|
||||||
|
export function signingPublicKeyFromDid(did: string): string | null {
|
||||||
|
if (!did.startsWith('did:key:')) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encoded = base58btc.decode(did.slice('did:key:'.length));
|
||||||
|
if (isP256SpkiDer(encoded)) return bytesToBase64(encoded);
|
||||||
|
|
||||||
|
if (
|
||||||
|
encoded.length === LEGACY_PEM_AS_BASE64_PREFIX.length + P256_SPKI_PREFIX.length + 64
|
||||||
|
&& startsWith(encoded, LEGACY_PEM_AS_BASE64_PREFIX)
|
||||||
|
) {
|
||||||
|
const der = encoded.slice(LEGACY_PEM_AS_BASE64_PREFIX.length);
|
||||||
|
if (isP256SpkiDer(der)) return bytesToBase64(der);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function didKeyMatchesPublicKey(did: string, publicKey: string): boolean {
|
||||||
|
const didPublicKey = signingPublicKeyFromDid(did);
|
||||||
|
const normalizedPublicKey = normalizeSigningPublicKey(publicKey);
|
||||||
|
return didPublicKey !== null && didPublicKey === normalizedPublicKey;
|
||||||
|
}
|
||||||
@@ -15,18 +15,17 @@ import {
|
|||||||
keyStore,
|
keyStore,
|
||||||
createSignedAction,
|
createSignedAction,
|
||||||
canonicalize,
|
canonicalize,
|
||||||
exportPublicKey,
|
exportPublicKey
|
||||||
importPublicKey,
|
|
||||||
base64UrlToBase64
|
|
||||||
} from './user-signing';
|
} from './user-signing';
|
||||||
import { verifyUserAction, type SignedAction } from '../auth/verify-signature';
|
import { verifyUserAction } from '../auth/verify-signature';
|
||||||
|
|
||||||
// Mock DB interactions
|
const { mockIsRateLimited } = vi.hoisted(() => ({
|
||||||
const mockDbMethods = {
|
mockIsRateLimited: vi.fn(() => false),
|
||||||
findFirst: vi.fn(),
|
}));
|
||||||
values: vi.fn(() => ({ onConflictDoUpdate: vi.fn() })),
|
|
||||||
insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate: vi.fn() })) }))
|
vi.mock('@/lib/rate-limit', () => ({
|
||||||
};
|
isRateLimited: mockIsRateLimited,
|
||||||
|
}));
|
||||||
|
|
||||||
// We need to hoist the variable if we use it in vi.mock
|
// We need to hoist the variable if we use it in vi.mock
|
||||||
// Or simpler for this case, simply define it inline or use a factory that doesn't capture outer scope incorrectly.
|
// Or simpler for this case, simply define it inline or use a factory that doesn't capture outer scope incorrectly.
|
||||||
@@ -36,7 +35,15 @@ vi.mock('@/db', () => ({
|
|||||||
users: { findFirst: vi.fn() },
|
users: { findFirst: vi.fn() },
|
||||||
remoteIdentityCache: { findFirst: vi.fn() }
|
remoteIdentityCache: { findFirst: vi.fn() }
|
||||||
},
|
},
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => ({
|
||||||
|
limit: vi.fn().mockResolvedValue([]),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
insert: vi.fn(() => ({ values: vi.fn() })),
|
insert: vi.fn(() => ({ values: vi.fn() })),
|
||||||
|
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||||
},
|
},
|
||||||
users: { did: 'did', publicKey: 'publicKey' },
|
users: { did: 'did', publicKey: 'publicKey' },
|
||||||
signedActionDedupe: { actionId: 'actionId' },
|
signedActionDedupe: { actionId: 'actionId' },
|
||||||
@@ -46,10 +53,6 @@ vi.mock('@/db', () => ({
|
|||||||
// Access the mocked module to manipulate it in tests
|
// Access the mocked module to manipulate it in tests
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
|
|
||||||
// Mock Database unique constraint error for replay test
|
|
||||||
const duplicateKeyError = new Error('Duplicate key');
|
|
||||||
(duplicateKeyError as any).code = '23505';
|
|
||||||
|
|
||||||
describe('Cryptographic User Signing', () => {
|
describe('Cryptographic User Signing', () => {
|
||||||
let userKeyPair: CryptoKeyPair;
|
let userKeyPair: CryptoKeyPair;
|
||||||
let userPublicKeyBase64: string;
|
let userPublicKeyBase64: string;
|
||||||
@@ -63,6 +66,7 @@ describe('Cryptographic User Signing', () => {
|
|||||||
userPublicKeyBase64 = await exportPublicKey(userKeyPair.publicKey);
|
userPublicKeyBase64 = await exportPublicKey(userKeyPair.publicKey);
|
||||||
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockIsRateLimited.mockReturnValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should canonicalize objects strictly', () => {
|
it('should canonicalize objects strictly', () => {
|
||||||
@@ -97,17 +101,17 @@ describe('Cryptographic User Signing', () => {
|
|||||||
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
|
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
|
||||||
|
|
||||||
// Mock DB finding the user
|
// Mock DB finding the user
|
||||||
(db.query.users.findFirst as any).mockResolvedValue({
|
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||||
id: 'uuid-123',
|
id: 'uuid-123',
|
||||||
did: testDid,
|
did: testDid,
|
||||||
handle: testHandle,
|
handle: testHandle,
|
||||||
publicKey: userPublicKeyBase64,
|
publicKey: userPublicKeyBase64,
|
||||||
});
|
} as never);
|
||||||
|
|
||||||
// Mock DB insert (dedupe) success
|
// Mock DB insert (dedupe) success
|
||||||
(db.insert as any).mockReturnValue({
|
vi.mocked(db.insert).mockReturnValue({
|
||||||
values: vi.fn().mockResolvedValue(true)
|
values: vi.fn().mockResolvedValue(true)
|
||||||
});
|
} as never);
|
||||||
|
|
||||||
const result = await verifyUserAction(signed);
|
const result = await verifyUserAction(signed);
|
||||||
|
|
||||||
@@ -124,12 +128,12 @@ describe('Cryptographic User Signing', () => {
|
|||||||
// Tamper with data
|
// Tamper with data
|
||||||
signed.data.content = 'Hacked';
|
signed.data.content = 'Hacked';
|
||||||
|
|
||||||
(db.query.users.findFirst as any).mockResolvedValue({
|
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||||
id: 'uuid-123',
|
id: 'uuid-123',
|
||||||
did: testDid,
|
did: testDid,
|
||||||
handle: testHandle,
|
handle: testHandle,
|
||||||
publicKey: userPublicKeyBase64,
|
publicKey: userPublicKeyBase64,
|
||||||
});
|
} as never);
|
||||||
|
|
||||||
const result = await verifyUserAction(signed);
|
const result = await verifyUserAction(signed);
|
||||||
|
|
||||||
@@ -141,25 +145,77 @@ describe('Cryptographic User Signing', () => {
|
|||||||
const payload = { content: 'Replay Me' };
|
const payload = { content: 'Replay Me' };
|
||||||
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
|
const signed = await createSignedAction('create_post', payload, testDid, testHandle);
|
||||||
|
|
||||||
(db.query.users.findFirst as any).mockResolvedValue({
|
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||||
id: 'uuid-123',
|
id: 'uuid-123',
|
||||||
did: testDid,
|
did: testDid,
|
||||||
handle: testHandle,
|
handle: testHandle,
|
||||||
publicKey: userPublicKeyBase64,
|
publicKey: userPublicKeyBase64,
|
||||||
});
|
} as never);
|
||||||
|
|
||||||
// Mock Duplicate Key Error
|
// Mock Duplicate Key Error
|
||||||
const duplicateKeyError = new Error('Duplicate key');
|
const duplicateKeyError = Object.assign(new Error('Duplicate key'), { code: '23505' });
|
||||||
(duplicateKeyError as any).code = '23505';
|
|
||||||
|
|
||||||
// Second attempt fails with unique violation
|
// Second attempt fails with unique violation
|
||||||
(db.insert as any).mockReturnValue({
|
vi.mocked(db.insert).mockReturnValue({
|
||||||
values: vi.fn().mockRejectedValue(duplicateKeyError)
|
values: vi.fn().mockRejectedValue(duplicateKeyError)
|
||||||
});
|
} as never);
|
||||||
|
|
||||||
// Verify failure path
|
// Verify failure path
|
||||||
const result = await verifyUserAction(signed);
|
const result = await verifyUserAction(signed);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.error).toBe('REPLAYED_NONCE');
|
expect(result.error).toBe('REPLAYED_NONCE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not persist a unique action rejected by the authenticated rate limit', async () => {
|
||||||
|
const signed = await createSignedAction(
|
||||||
|
'create_post',
|
||||||
|
{ content: 'Over limit' },
|
||||||
|
testDid,
|
||||||
|
testHandle,
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||||
|
id: 'uuid-123',
|
||||||
|
did: testDid,
|
||||||
|
handle: testHandle,
|
||||||
|
publicKey: userPublicKeyBase64,
|
||||||
|
} as never);
|
||||||
|
mockIsRateLimited.mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await verifyUserAction(signed);
|
||||||
|
|
||||||
|
expect(result).toEqual({ valid: false, error: 'RATE_LIMITED' });
|
||||||
|
expect(db.select).toHaveBeenCalledOnce();
|
||||||
|
expect(mockIsRateLimited).toHaveBeenCalledOnce();
|
||||||
|
expect(db.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject an existing replay without charging quota or inserting again', async () => {
|
||||||
|
const signed = await createSignedAction(
|
||||||
|
'create_post',
|
||||||
|
{ content: 'Already accepted' },
|
||||||
|
testDid,
|
||||||
|
testHandle,
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(db.query.users.findFirst).mockResolvedValue({
|
||||||
|
id: 'uuid-123',
|
||||||
|
did: testDid,
|
||||||
|
handle: testHandle,
|
||||||
|
publicKey: userPublicKeyBase64,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(db.select).mockReturnValue({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => ({
|
||||||
|
limit: vi.fn().mockResolvedValue([{ actionId: 'already-stored' }]),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await verifyUserAction(signed);
|
||||||
|
|
||||||
|
expect(result).toEqual({ valid: false, error: 'REPLAYED_NONCE' });
|
||||||
|
expect(mockIsRateLimited).not.toHaveBeenCalled();
|
||||||
|
expect(db.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
* - Nonce: 16+ bytes random base64url.
|
* - Nonce: 16+ bytes random base64url.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// KEY STORAGE (In-Memory Only)
|
// KEY STORAGE (In-Memory Only)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -79,10 +77,8 @@ export function clearUserPrivateKey(): void {
|
|||||||
// CRYPTO HELPERS (WebCrypto / Node)
|
// CRYPTO HELPERS (WebCrypto / Node)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Detect environment for Crypto
|
// Modern browsers and the supported Node.js runtime both expose WebCrypto.
|
||||||
const cryptoSubtle = typeof window !== 'undefined'
|
const cryptoSubtle = globalThis.crypto?.subtle;
|
||||||
? window.crypto.subtle // Browser
|
|
||||||
: (globalThis.crypto as any)?.subtle || require('crypto').webcrypto?.subtle; // Node
|
|
||||||
|
|
||||||
if (!cryptoSubtle) {
|
if (!cryptoSubtle) {
|
||||||
throw new Error('WebCrypto is not supported in this environment');
|
throw new Error('WebCrypto is not supported in this environment');
|
||||||
@@ -115,9 +111,12 @@ export async function exportPrivateKey(key: CryptoKey): Promise<ArrayBuffer> {
|
|||||||
* Import Private Key from PKCS8 (after decryption)
|
* Import Private Key from PKCS8 (after decryption)
|
||||||
*/
|
*/
|
||||||
export async function importPrivateKey(keyData: ArrayBuffer | Uint8Array): Promise<CryptoKey> {
|
export async function importPrivateKey(keyData: ArrayBuffer | Uint8Array): Promise<CryptoKey> {
|
||||||
|
const normalizedKeyData = keyData instanceof ArrayBuffer
|
||||||
|
? keyData
|
||||||
|
: Uint8Array.from(keyData).buffer;
|
||||||
return await cryptoSubtle.importKey(
|
return await cryptoSubtle.importKey(
|
||||||
'pkcs8',
|
'pkcs8',
|
||||||
keyData,
|
normalizedKeyData,
|
||||||
{
|
{
|
||||||
name: 'ECDSA',
|
name: 'ECDSA',
|
||||||
namedCurve: 'P-256',
|
namedCurve: 'P-256',
|
||||||
@@ -170,7 +169,7 @@ export async function importPublicKey(base64Key: string): Promise<CryptoKey> {
|
|||||||
* - No Dates, Maps, Sets, Functions
|
* - No Dates, Maps, Sets, Functions
|
||||||
* - No NaN, Infinity
|
* - No NaN, Infinity
|
||||||
*/
|
*/
|
||||||
export function canonicalize(obj: any): string {
|
export function canonicalize(obj: unknown): string {
|
||||||
if (obj === undefined) return ''; // Should not happen for valid inputs
|
if (obj === undefined) return ''; // Should not happen for valid inputs
|
||||||
if (obj === null) return 'null';
|
if (obj === null) return 'null';
|
||||||
|
|
||||||
@@ -199,12 +198,13 @@ export function canonicalize(obj: any): string {
|
|||||||
if (obj instanceof Date) throw new Error('Serialization failed: Date objects not allowed');
|
if (obj instanceof Date) throw new Error('Serialization failed: Date objects not allowed');
|
||||||
if (obj instanceof RegExp) throw new Error('Serialization failed: RegExp objects not allowed');
|
if (obj instanceof RegExp) throw new Error('Serialization failed: RegExp objects not allowed');
|
||||||
|
|
||||||
const keys = Object.keys(obj).sort();
|
const record = obj as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(record).sort();
|
||||||
const pairs: string[] = [];
|
const pairs: string[] = [];
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (obj[key] === undefined) continue;
|
if (record[key] === undefined) continue;
|
||||||
const val = canonicalize(obj[key]);
|
const val = canonicalize(record[key]);
|
||||||
pairs.push(`${JSON.stringify(key)}:${val}`);
|
pairs.push(`${JSON.stringify(key)}:${val}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,14 +218,14 @@ export function canonicalize(obj: any): string {
|
|||||||
* Create a Signed Action
|
* Create a Signed Action
|
||||||
* @returns { SignedAction } Includes ts, nonce, and strict signature
|
* @returns { SignedAction } Includes ts, nonce, and strict signature
|
||||||
*/
|
*/
|
||||||
export async function createSignedAction(
|
export async function createSignedAction<TData>(
|
||||||
action: string,
|
action: string,
|
||||||
data: any,
|
data: TData,
|
||||||
userDid: string,
|
userDid: string,
|
||||||
userHandle: string
|
userHandle: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
action: string;
|
action: string;
|
||||||
data: any;
|
data: TData;
|
||||||
did: string;
|
did: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
@@ -275,6 +275,34 @@ export async function createSignedAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function verifySignedActionSignature(
|
||||||
|
signedAction: {
|
||||||
|
action: string;
|
||||||
|
data: unknown;
|
||||||
|
did: string;
|
||||||
|
handle: string;
|
||||||
|
ts: number;
|
||||||
|
nonce: string;
|
||||||
|
sig: string;
|
||||||
|
},
|
||||||
|
publicKeyString: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { sig, ...payload } = signedAction;
|
||||||
|
const canonicalString = canonicalize(payload);
|
||||||
|
const signature = base64ToArrayBuffer(base64UrlToBase64(sig));
|
||||||
|
const publicKey = await importPublicKey(publicKeyString);
|
||||||
|
return cryptoSubtle.verify(
|
||||||
|
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
|
||||||
|
publicKey,
|
||||||
|
signature,
|
||||||
|
new TextEncoder().encode(canonicalString),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// UTILS
|
// UTILS
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { base58btc } from 'multiformats/bases/base58';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { generateDID } from '@/lib/auth';
|
||||||
|
import { generateKeyPair as generatePemKeyPair } from '@/lib/crypto/keys';
|
||||||
|
import {
|
||||||
|
didKeyMatchesPublicKey,
|
||||||
|
normalizeSigningPublicKey,
|
||||||
|
signingPublicKeyFromDid,
|
||||||
|
} from '@/lib/crypto/did-key';
|
||||||
|
import {
|
||||||
|
clearUserPrivateKey,
|
||||||
|
createSignedAction,
|
||||||
|
importPrivateKey,
|
||||||
|
keyStore,
|
||||||
|
} from '@/lib/crypto/user-signing';
|
||||||
|
import { verifyE2EEPublicBundle } from './bundle-proof';
|
||||||
|
import { generateE2EEKeyMaterial } from './client-crypto';
|
||||||
|
import { E2EE_PROTOCOL, type E2EEKeyBundle } from './protocol';
|
||||||
|
|
||||||
|
function pemBody(pem: string): Uint8Array {
|
||||||
|
return Uint8Array.from(Buffer.from(
|
||||||
|
pem
|
||||||
|
.replace(/-----BEGIN PRIVATE KEY-----/g, '')
|
||||||
|
.replace(/-----END PRIVATE KEY-----/g, '')
|
||||||
|
.replace(/\s/g, ''),
|
||||||
|
'base64',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundle(
|
||||||
|
material: Awaited<ReturnType<typeof generateE2EEKeyMaterial>>,
|
||||||
|
): E2EEKeyBundle {
|
||||||
|
return {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
keyId: material.keyId,
|
||||||
|
version: 1,
|
||||||
|
publicKey: material.publicKey,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
recoveryCommitment: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signedBundleResponse(did: string) {
|
||||||
|
const signingKeys = await generatePemKeyPair();
|
||||||
|
const signingPrivateKey = await importPrivateKey(pemBody(signingKeys.privateKey));
|
||||||
|
keyStore.setPrivateKey(signingPrivateKey);
|
||||||
|
|
||||||
|
const encryptionKeys = await generateE2EEKeyMaterial();
|
||||||
|
const publicBundle = bundle(encryptionKeys);
|
||||||
|
const proof = await createSignedAction('e2ee_key_bundle', publicBundle, did, 'alice');
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
bundle: publicBundle,
|
||||||
|
proof,
|
||||||
|
signingPublicKey: signingKeys.publicKey,
|
||||||
|
},
|
||||||
|
signingPublicKey: signingKeys.publicKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => clearUserPrivateKey());
|
||||||
|
|
||||||
|
describe('did:key E2EE bundle binding', () => {
|
||||||
|
it('binds an actual generated PEM key through a canonical DER DID', async () => {
|
||||||
|
const signingKeys = await generatePemKeyPair();
|
||||||
|
const did = generateDID(signingKeys.publicKey);
|
||||||
|
const signingPrivateKey = await importPrivateKey(pemBody(signingKeys.privateKey));
|
||||||
|
keyStore.setPrivateKey(signingPrivateKey);
|
||||||
|
|
||||||
|
const encryptionKeys = await generateE2EEKeyMaterial();
|
||||||
|
const publicBundle = bundle(encryptionKeys);
|
||||||
|
const proof = await createSignedAction('e2ee_key_bundle', publicBundle, did, 'alice');
|
||||||
|
const response = { bundle: publicBundle, proof, signingPublicKey: signingKeys.publicKey };
|
||||||
|
|
||||||
|
const didBytes = base58btc.decode(did.slice('did:key:'.length));
|
||||||
|
expect(didBytes).toHaveLength(91);
|
||||||
|
expect(Buffer.from(didBytes).toString('base64'))
|
||||||
|
.toBe(normalizeSigningPublicKey(signingKeys.publicKey));
|
||||||
|
expect(signingPublicKeyFromDid(did)).toBe(normalizeSigningPublicKey(signingKeys.publicKey));
|
||||||
|
expect(didKeyMatchesPublicKey(did, signingKeys.publicKey)).toBe(true);
|
||||||
|
await expect(verifyE2EEPublicBundle(response, did)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies the exact historical PEM-as-base64 DID without accepting arbitrary prefixes', async () => {
|
||||||
|
const signingKeys = await generatePemKeyPair();
|
||||||
|
const legacyDid = `did:key:${base58btc.encode(
|
||||||
|
new Uint8Array(Buffer.from(signingKeys.publicKey, 'base64')),
|
||||||
|
)}`;
|
||||||
|
const signingPrivateKey = await importPrivateKey(pemBody(signingKeys.privateKey));
|
||||||
|
keyStore.setPrivateKey(signingPrivateKey);
|
||||||
|
|
||||||
|
const encryptionKeys = await generateE2EEKeyMaterial();
|
||||||
|
const publicBundle = bundle(encryptionKeys);
|
||||||
|
const proof = await createSignedAction('e2ee_key_bundle', publicBundle, legacyDid, 'alice');
|
||||||
|
const response = { bundle: publicBundle, proof, signingPublicKey: signingKeys.publicKey };
|
||||||
|
|
||||||
|
expect(signingPublicKeyFromDid(legacyDid)).toBe(normalizeSigningPublicKey(signingKeys.publicKey));
|
||||||
|
expect(didKeyMatchesPublicKey(legacyDid, signingKeys.publicKey)).toBe(true);
|
||||||
|
await expect(verifyE2EEPublicBundle(response, legacyDid)).resolves.toBe(true);
|
||||||
|
|
||||||
|
const legacyBytes = base58btc.decode(legacyDid.slice('did:key:'.length));
|
||||||
|
const wrongPrefix = legacyBytes.slice();
|
||||||
|
wrongPrefix[0] ^= 0x01;
|
||||||
|
const malformedDid = `did:key:${base58btc.encode(wrongPrefix)}`;
|
||||||
|
const malformedProof = await createSignedAction(
|
||||||
|
'e2ee_key_bundle',
|
||||||
|
publicBundle,
|
||||||
|
malformedDid,
|
||||||
|
'alice',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(signingPublicKeyFromDid(malformedDid)).toBeNull();
|
||||||
|
await expect(verifyE2EEPublicBundle({ ...response, proof: malformedProof }, malformedDid))
|
||||||
|
.resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a different signing key even when it signs a proof naming the expected DID', async () => {
|
||||||
|
const owner = await generatePemKeyPair();
|
||||||
|
const ownerDid = generateDID(owner.publicKey);
|
||||||
|
const attacker = await signedBundleResponse(ownerDid);
|
||||||
|
|
||||||
|
await expect(verifyE2EEPublicBundle(attacker.response, ownerDid)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
|
||||||
|
import { canonicalize, verifySignedActionSignature } from '@/lib/crypto/user-signing';
|
||||||
|
import {
|
||||||
|
normalizeSigningPublicKey,
|
||||||
|
signingPublicKeyFromDid,
|
||||||
|
} from '@/lib/crypto/did-key';
|
||||||
|
import {
|
||||||
|
E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
e2eeKeyBundleSchema,
|
||||||
|
signedUserActionSchema,
|
||||||
|
type E2EEPublicBundleResponse,
|
||||||
|
} from './protocol';
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64Url(bytes: Uint8Array): string {
|
||||||
|
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlToBytes(value: string): Uint8Array {
|
||||||
|
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptionKeyIdFromPublicKey(publicKey: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
await sodium.ready;
|
||||||
|
const key = base64UrlToBytes(publicKey);
|
||||||
|
if (key.length !== sodium.crypto_box_PUBLICKEYBYTES) return null;
|
||||||
|
return `k1_${bytesToBase64Url(sodium.crypto_generichash(16, key, null))}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { signingPublicKeyFromDid } from '@/lib/crypto/did-key';
|
||||||
|
|
||||||
|
export async function verifyE2EEPublicBundle(
|
||||||
|
response: E2EEPublicBundleResponse,
|
||||||
|
expectedDid: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const proofResult = signedUserActionSchema.safeParse(response.proof);
|
||||||
|
const bundleResult = e2eeKeyBundleSchema.safeParse(response.bundle);
|
||||||
|
if (!proofResult.success || !bundleResult.success) return false;
|
||||||
|
|
||||||
|
const proof = proofResult.data;
|
||||||
|
if (proof.action !== E2EE_KEY_BUNDLE_ACTION || proof.did !== expectedDid) return false;
|
||||||
|
|
||||||
|
const proofBundle = e2eeKeyBundleSchema.safeParse(proof.data);
|
||||||
|
if (!proofBundle.success || canonicalize(proofBundle.data) !== canonicalize(bundleResult.data)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await encryptionKeyIdFromPublicKey(bundleResult.data.publicKey) !== bundleResult.data.keyId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseSigningPublicKey = normalizeSigningPublicKey(response.signingPublicKey);
|
||||||
|
if (!responseSigningPublicKey) return false;
|
||||||
|
|
||||||
|
const didSigningPublicKey = signingPublicKeyFromDid(expectedDid);
|
||||||
|
if (expectedDid.startsWith('did:key:') && !didSigningPublicKey) return false;
|
||||||
|
if (didSigningPublicKey && didSigningPublicKey !== responseSigningPublicKey) return false;
|
||||||
|
const signingPublicKey = didSigningPublicKey || responseSigningPublicKey;
|
||||||
|
if (!signingPublicKey) return false;
|
||||||
|
return verifySignedActionSignature(proof, signingPublicKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createE2EEVault,
|
||||||
|
decryptE2EEMessage,
|
||||||
|
encryptE2EEMessage,
|
||||||
|
fromBase64Url,
|
||||||
|
generateE2EEKeyMaterial,
|
||||||
|
openE2EEVault,
|
||||||
|
prepareE2EEVaultUnlock,
|
||||||
|
toBase64Url,
|
||||||
|
} from './client-crypto';
|
||||||
|
import {
|
||||||
|
E2EE_PROTOCOL,
|
||||||
|
e2eeMessageEnvelopeSchema,
|
||||||
|
validateMessageBindings,
|
||||||
|
type E2EEKeyBundle,
|
||||||
|
type E2EEMessageEnvelope,
|
||||||
|
} from './protocol';
|
||||||
|
import {
|
||||||
|
createPinVerifierMac,
|
||||||
|
openServerShare,
|
||||||
|
pinVerifierMatches,
|
||||||
|
sealServerShare,
|
||||||
|
} from './server-secrets';
|
||||||
|
|
||||||
|
const aliceDid = 'did:key:alice-test-identity';
|
||||||
|
const bobDid = 'did:key:bob-test-identity';
|
||||||
|
|
||||||
|
function bundle(
|
||||||
|
material: Awaited<ReturnType<typeof generateE2EEKeyMaterial>>,
|
||||||
|
version = 1,
|
||||||
|
): E2EEKeyBundle {
|
||||||
|
return {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
keyId: material.keyId,
|
||||||
|
version,
|
||||||
|
publicKey: material.publicKey,
|
||||||
|
createdAt: 1_700_000_000_000,
|
||||||
|
recoveryCommitment: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flipEncodedByte(value: string): string {
|
||||||
|
const bytes = fromBase64Url(value);
|
||||||
|
bytes[Math.floor(bytes.length / 2)] ^= 1;
|
||||||
|
return toBase64Url(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function messageFixture() {
|
||||||
|
const alice = await generateE2EEKeyMaterial();
|
||||||
|
const bob = await generateE2EEKeyMaterial();
|
||||||
|
const envelope = await encryptE2EEMessage({
|
||||||
|
plaintext: 'the server should never receive this text',
|
||||||
|
senderDid: aliceDid,
|
||||||
|
senderHandle: 'alice',
|
||||||
|
senderBundle: bundle(alice),
|
||||||
|
recipientDid: bobDid,
|
||||||
|
recipientHandle: 'bob',
|
||||||
|
recipientBundle: bundle(bob),
|
||||||
|
});
|
||||||
|
return { alice, bob, envelope };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('E2EE message crypto', () => {
|
||||||
|
it('decrypts the same envelope for both sender and recipient', async () => {
|
||||||
|
const { alice, bob, envelope } = await messageFixture();
|
||||||
|
|
||||||
|
await expect(decryptE2EEMessage(envelope, aliceDid, alice))
|
||||||
|
.resolves.toBe('the server should never receive this text');
|
||||||
|
await expect(decryptE2EEMessage(envelope, bobDid, bob))
|
||||||
|
.resolves.toBe('the server should never receive this text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unrelated private key', async () => {
|
||||||
|
const { envelope } = await messageFixture();
|
||||||
|
const mallory = await generateE2EEKeyMaterial();
|
||||||
|
await expect(decryptE2EEMessage(envelope, bobDid, mallory)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['ciphertext', 'nonce', 'keyCommitment'] as const)(
|
||||||
|
'rejects a modified %s',
|
||||||
|
async (field) => {
|
||||||
|
const { bob, envelope } = await messageFixture();
|
||||||
|
const tampered = { ...envelope, [field]: flipEncodedByte(envelope[field]) };
|
||||||
|
await expect(decryptE2EEMessage(tampered, bobDid, bob)).rejects.toThrow();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects authenticated-header tampering', async () => {
|
||||||
|
const { bob, envelope } = await messageFixture();
|
||||||
|
const tampered = { ...envelope, conversationId: `${envelope.conversationId}x` };
|
||||||
|
await expect(decryptE2EEMessage(tampered, bobDid, bob)).rejects.toThrow(/transplanted/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects sealed-key transplantation between messages', async () => {
|
||||||
|
const { alice, bob, envelope } = await messageFixture();
|
||||||
|
const second = await encryptE2EEMessage({
|
||||||
|
plaintext: 'second message',
|
||||||
|
senderDid: aliceDid,
|
||||||
|
senderHandle: 'alice',
|
||||||
|
senderBundle: bundle(alice),
|
||||||
|
recipientDid: bobDid,
|
||||||
|
recipientHandle: 'bob',
|
||||||
|
recipientBundle: bundle(bob),
|
||||||
|
});
|
||||||
|
const transplanted = { ...second, keyEnvelopes: envelope.keyEnvelopes };
|
||||||
|
await expect(decryptE2EEMessage(transplanted, bobDid, bob)).rejects.toThrow(/transplanted/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('E2EE PIN vault', () => {
|
||||||
|
it('round-trips only with the correct PIN and server share', async () => {
|
||||||
|
const material = await generateE2EEKeyMaterial();
|
||||||
|
const setup = await createE2EEVault('739184', material, aliceDid, 1);
|
||||||
|
const prepared = await prepareE2EEVaultUnlock('739184', setup.vault);
|
||||||
|
|
||||||
|
await expect(openE2EEVault(prepared, setup.vault, setup.serverShare)).resolves.toEqual(material);
|
||||||
|
|
||||||
|
const wrong = await prepareE2EEVaultUnlock('739185', setup.vault);
|
||||||
|
await expect(openE2EEVault(wrong, setup.vault, setup.serverShare)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds a vault to its owner and key metadata', async () => {
|
||||||
|
const material = await generateE2EEKeyMaterial();
|
||||||
|
const setup = await createE2EEVault('739184', material, aliceDid, 1);
|
||||||
|
const prepared = await prepareE2EEVaultUnlock('739184', setup.vault);
|
||||||
|
const transplanted = { ...setup.vault, ownerDid: bobDid };
|
||||||
|
|
||||||
|
await expect(openE2EEVault(prepared, transplanted, setup.serverShare)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('E2EE protocol validation', () => {
|
||||||
|
it('rejects unknown fields and unsupported suites', async () => {
|
||||||
|
const { envelope } = await messageFixture();
|
||||||
|
expect(e2eeMessageEnvelopeSchema.safeParse({ ...envelope, plaintext: 'leak' }).success).toBe(false);
|
||||||
|
expect(e2eeMessageEnvelopeSchema.safeParse({ ...envelope, cipherSuite: 'unknown' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate and mismatched key-envelope bindings', async () => {
|
||||||
|
const { envelope } = await messageFixture();
|
||||||
|
const duplicate: E2EEMessageEnvelope = {
|
||||||
|
...envelope,
|
||||||
|
keyEnvelopes: [envelope.keyEnvelopes[0], envelope.keyEnvelopes[0]],
|
||||||
|
};
|
||||||
|
expect(() => validateMessageBindings(duplicate, {
|
||||||
|
action: 'chat_e2ee',
|
||||||
|
did: envelope.senderDid,
|
||||||
|
handle: envelope.senderHandle,
|
||||||
|
ts: envelope.createdAt,
|
||||||
|
})).toThrow(/duplicate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a stable old envelope to be re-signed for idempotent retry but rejects future dating', async () => {
|
||||||
|
const { envelope } = await messageFixture();
|
||||||
|
const action = {
|
||||||
|
action: 'chat_e2ee',
|
||||||
|
did: envelope.senderDid,
|
||||||
|
handle: envelope.senderHandle,
|
||||||
|
ts: envelope.createdAt + 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
expect(() => validateMessageBindings(envelope, action)).not.toThrow();
|
||||||
|
expect(() => validateMessageBindings({
|
||||||
|
...envelope,
|
||||||
|
createdAt: action.ts + 5 * 60 * 1000 + 1,
|
||||||
|
}, action)).toThrow(/future/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server recovery-secret protection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('E2EE_RECOVERY_SECRET', 'test-only-recovery-secret-with-entropy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seals server shares to account and key context', () => {
|
||||||
|
const sealed = sealServerShare('server-share', 'user-1', 'key-1');
|
||||||
|
expect(openServerShare(sealed, 'user-1', 'key-1')).toBe('server-share');
|
||||||
|
expect(() => openServerShare(sealed, 'user-2', 'key-1')).toThrow();
|
||||||
|
expect(() => openServerShare(sealed, 'user-1', 'key-2')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares PIN verifiers without accepting another context', () => {
|
||||||
|
const mac = createPinVerifierMac('pin-proof', 'user-1', 'key-1');
|
||||||
|
expect(pinVerifierMatches('pin-proof', mac, 'user-1', 'key-1')).toBe(true);
|
||||||
|
expect(pinVerifierMatches('pin-proof', mac, 'user-2', 'key-1')).toBe(false);
|
||||||
|
expect(pinVerifierMatches('other-proof', mac, 'user-1', 'key-1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing, placeholder, or reused deployment secret', () => {
|
||||||
|
vi.stubEnv('E2EE_RECOVERY_SECRET', 'replace-with-a-separate-long-random-secret');
|
||||||
|
expect(() => sealServerShare('share', 'user-1', 'key-1')).toThrow(/high-entropy/);
|
||||||
|
|
||||||
|
vi.stubEnv('E2EE_RECOVERY_SECRET', 'same-secret-that-is-long-enough-for-both-values');
|
||||||
|
vi.stubEnv('AUTH_SECRET', 'same-secret-that-is-long-enough-for-both-values');
|
||||||
|
expect(() => sealServerShare('share', 'user-1', 'key-1')).toThrow(/independent/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import {
|
||||||
|
E2EE_KDF,
|
||||||
|
E2EE_CIPHER_SUITE,
|
||||||
|
E2EE_PROTOCOL,
|
||||||
|
type E2EEKeyBundle,
|
||||||
|
type E2EEKeyMaterial,
|
||||||
|
type E2EEMessageEnvelope,
|
||||||
|
type E2EEVaultRecord,
|
||||||
|
type E2EEVaultSetup,
|
||||||
|
messageAuthenticatedData,
|
||||||
|
validatePin,
|
||||||
|
vaultAuthenticatedData,
|
||||||
|
} from './protocol';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||||
|
const PIN_VERIFIER_CONTEXT = encoder.encode('synapsis-e2ee-pin-verifier-v1');
|
||||||
|
const VAULT_KEY_CONTEXT = encoder.encode('synapsis-e2ee-vault-key-v1');
|
||||||
|
|
||||||
|
async function ready(): Promise<typeof sodium> {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBase64Url(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromBase64Url(value: string): Uint8Array {
|
||||||
|
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
||||||
|
const length = parts.reduce((total, part) => total + part.length, 0);
|
||||||
|
const result = new Uint8Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
result.set(part, offset);
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function derivePinKey(
|
||||||
|
pin: string,
|
||||||
|
salt: Uint8Array,
|
||||||
|
opsLimit: number,
|
||||||
|
memLimit: number,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
validatePin(pin);
|
||||||
|
const crypto = await ready();
|
||||||
|
return crypto.crypto_pwhash(
|
||||||
|
32,
|
||||||
|
pin,
|
||||||
|
salt,
|
||||||
|
opsLimit,
|
||||||
|
memLimit,
|
||||||
|
crypto.crypto_pwhash_ALG_ARGON2ID13,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveVerifier(crypto: typeof sodium, pinKey: Uint8Array): Uint8Array {
|
||||||
|
return crypto.crypto_generichash(32, PIN_VERIFIER_CONTEXT, pinKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveVaultKey(
|
||||||
|
crypto: typeof sodium,
|
||||||
|
pinKey: Uint8Array,
|
||||||
|
serverShare: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
return crypto.crypto_generichash(32, concatBytes(VAULT_KEY_CONTEXT, serverShare), pinKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateE2EEKeyMaterial(): Promise<E2EEKeyMaterial> {
|
||||||
|
const crypto = await ready();
|
||||||
|
const pair = crypto.crypto_box_keypair();
|
||||||
|
const fingerprint = crypto.crypto_generichash(16, pair.publicKey, null);
|
||||||
|
return {
|
||||||
|
keyId: `k1_${toBase64Url(fingerprint)}`,
|
||||||
|
publicKey: toBase64Url(pair.publicKey),
|
||||||
|
privateKey: toBase64Url(pair.privateKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertKeyMaterialMatchesPublicKey(material: E2EEKeyMaterial): Promise<void> {
|
||||||
|
const crypto = await ready();
|
||||||
|
const publicKey = fromBase64Url(material.publicKey);
|
||||||
|
const privateKey = fromBase64Url(material.privateKey);
|
||||||
|
try {
|
||||||
|
if (publicKey.length !== crypto.crypto_box_PUBLICKEYBYTES
|
||||||
|
|| privateKey.length !== crypto.crypto_box_SECRETKEYBYTES) {
|
||||||
|
throw new Error('Encrypted message key material has an invalid length');
|
||||||
|
}
|
||||||
|
const derivedPublicKey = crypto.crypto_scalarmult_base(privateKey);
|
||||||
|
const fingerprint = crypto.crypto_generichash(16, publicKey, null);
|
||||||
|
if (!crypto.memcmp(derivedPublicKey, publicKey)
|
||||||
|
|| material.keyId !== `k1_${toBase64Url(fingerprint)}`) {
|
||||||
|
throw new Error('Encrypted message private key does not match its public key');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
crypto.memzero(privateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createE2EEVault(
|
||||||
|
pin: string,
|
||||||
|
material: E2EEKeyMaterial,
|
||||||
|
ownerDid: string,
|
||||||
|
keyVersion: number,
|
||||||
|
): Promise<E2EEVaultSetup> {
|
||||||
|
validatePin(pin);
|
||||||
|
const crypto = await ready();
|
||||||
|
await assertKeyMaterialMatchesPublicKey(material);
|
||||||
|
const salt = crypto.randombytes_buf(crypto.crypto_pwhash_SALTBYTES);
|
||||||
|
const serverShare = crypto.randombytes_buf(32);
|
||||||
|
const nonce = crypto.randombytes_buf(crypto.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||||
|
let pinKey: Uint8Array | null = null;
|
||||||
|
let vaultKey: Uint8Array | null = null;
|
||||||
|
let plaintext: Uint8Array | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
pinKey = await derivePinKey(pin, salt, E2EE_KDF.opsLimit, E2EE_KDF.memLimit);
|
||||||
|
vaultKey = deriveVaultKey(crypto, pinKey, serverShare);
|
||||||
|
const vaultWithoutCiphertext = {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
ownerDid,
|
||||||
|
keyId: material.keyId,
|
||||||
|
keyVersion,
|
||||||
|
publicKey: material.publicKey,
|
||||||
|
salt: toBase64Url(salt),
|
||||||
|
kdfAlgorithm: E2EE_KDF.algorithm,
|
||||||
|
kdfOpsLimit: E2EE_KDF.opsLimit,
|
||||||
|
kdfMemLimit: E2EE_KDF.memLimit,
|
||||||
|
} as const;
|
||||||
|
plaintext = encoder.encode(JSON.stringify(material));
|
||||||
|
const ciphertext = crypto.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||||
|
plaintext,
|
||||||
|
encoder.encode(vaultAuthenticatedData(vaultWithoutCiphertext)),
|
||||||
|
null,
|
||||||
|
nonce,
|
||||||
|
vaultKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
vault: {
|
||||||
|
...vaultWithoutCiphertext,
|
||||||
|
ciphertext: toBase64Url(ciphertext),
|
||||||
|
nonce: toBase64Url(nonce),
|
||||||
|
},
|
||||||
|
serverShare: toBase64Url(serverShare),
|
||||||
|
pinVerifier: toBase64Url(deriveVerifier(crypto, pinKey)),
|
||||||
|
} satisfies E2EEVaultSetup;
|
||||||
|
} finally {
|
||||||
|
if (pinKey) crypto.memzero(pinKey);
|
||||||
|
if (vaultKey) crypto.memzero(vaultKey);
|
||||||
|
if (plaintext) crypto.memzero(plaintext);
|
||||||
|
crypto.memzero(serverShare);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedVaultUnlock {
|
||||||
|
pinKey: Uint8Array;
|
||||||
|
pinVerifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareE2EEVaultUnlock(
|
||||||
|
pin: string,
|
||||||
|
vault: Pick<E2EEVaultRecord, 'salt' | 'kdfOpsLimit' | 'kdfMemLimit'>,
|
||||||
|
): Promise<PreparedVaultUnlock> {
|
||||||
|
const crypto = await ready();
|
||||||
|
const pinKey = await derivePinKey(pin, fromBase64Url(vault.salt), vault.kdfOpsLimit, vault.kdfMemLimit);
|
||||||
|
return {
|
||||||
|
pinKey,
|
||||||
|
pinVerifier: toBase64Url(deriveVerifier(crypto, pinKey)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openE2EEVault(
|
||||||
|
prepared: PreparedVaultUnlock,
|
||||||
|
vault: E2EEVaultRecord,
|
||||||
|
serverShareValue: string,
|
||||||
|
): Promise<E2EEKeyMaterial> {
|
||||||
|
const crypto = await ready();
|
||||||
|
let serverShare: Uint8Array | null = null;
|
||||||
|
let vaultKey: Uint8Array | null = null;
|
||||||
|
let plaintext: Uint8Array | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
serverShare = fromBase64Url(serverShareValue);
|
||||||
|
vaultKey = deriveVaultKey(crypto, prepared.pinKey, serverShare);
|
||||||
|
plaintext = crypto.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||||
|
null,
|
||||||
|
fromBase64Url(vault.ciphertext),
|
||||||
|
encoder.encode(vaultAuthenticatedData(vault)),
|
||||||
|
fromBase64Url(vault.nonce),
|
||||||
|
vaultKey,
|
||||||
|
);
|
||||||
|
const material = JSON.parse(decoder.decode(plaintext)) as E2EEKeyMaterial;
|
||||||
|
if (material.keyId !== vault.keyId || material.publicKey !== vault.publicKey) {
|
||||||
|
throw new Error('Encrypted message vault does not match its public key');
|
||||||
|
}
|
||||||
|
await assertKeyMaterialMatchesPublicKey(material);
|
||||||
|
return material;
|
||||||
|
} finally {
|
||||||
|
crypto.memzero(prepared.pinKey);
|
||||||
|
if (vaultKey) crypto.memzero(vaultKey);
|
||||||
|
if (serverShare) crypto.memzero(serverShare);
|
||||||
|
if (plaintext) crypto.memzero(plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptE2EEMessage(input: {
|
||||||
|
plaintext: string;
|
||||||
|
senderDid: string;
|
||||||
|
senderHandle: string;
|
||||||
|
senderBundle: E2EEKeyBundle;
|
||||||
|
recipientDid: string;
|
||||||
|
recipientHandle: string;
|
||||||
|
recipientBundle: E2EEKeyBundle;
|
||||||
|
}): Promise<E2EEMessageEnvelope> {
|
||||||
|
const crypto = await ready();
|
||||||
|
const contentKey = crypto.randombytes_buf(crypto.crypto_aead_xchacha20poly1305_ietf_KEYBYTES);
|
||||||
|
const nonce = crypto.randombytes_buf(crypto.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||||
|
const conversationParticipants = [input.senderDid, input.recipientDid].sort();
|
||||||
|
const conversationId = `dm1_${toBase64Url(crypto.crypto_generichash(
|
||||||
|
16,
|
||||||
|
encoder.encode(JSON.stringify(conversationParticipants)),
|
||||||
|
null,
|
||||||
|
))}`;
|
||||||
|
const header = {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
cipherSuite: E2EE_CIPHER_SUITE,
|
||||||
|
messageId: uuidv4(),
|
||||||
|
conversationId,
|
||||||
|
senderDid: input.senderDid,
|
||||||
|
senderHandle: input.senderHandle,
|
||||||
|
recipientDid: input.recipientDid,
|
||||||
|
recipientHandle: input.recipientHandle,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
senderKeyId: input.senderBundle.keyId,
|
||||||
|
senderKeyVersion: input.senderBundle.version,
|
||||||
|
recipientKeyId: input.recipientBundle.keyId,
|
||||||
|
recipientKeyVersion: input.recipientBundle.version,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const recipients = new Map<string, E2EEKeyBundle>();
|
||||||
|
recipients.set(input.senderDid, input.senderBundle);
|
||||||
|
recipients.set(input.recipientDid, input.recipientBundle);
|
||||||
|
let sealedPayload: Uint8Array | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authenticatedData = encoder.encode(messageAuthenticatedData(header));
|
||||||
|
const ciphertext = crypto.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||||
|
encoder.encode(input.plaintext),
|
||||||
|
authenticatedData,
|
||||||
|
null,
|
||||||
|
nonce,
|
||||||
|
contentKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const transcriptHash = crypto.crypto_hash_sha256(authenticatedData);
|
||||||
|
sealedPayload = concatBytes(contentKey, transcriptHash);
|
||||||
|
const keyCommitment = crypto.crypto_generichash(
|
||||||
|
32,
|
||||||
|
concatBytes(authenticatedData, nonce, ciphertext),
|
||||||
|
contentKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...header,
|
||||||
|
nonce: toBase64Url(nonce),
|
||||||
|
ciphertext: toBase64Url(ciphertext),
|
||||||
|
keyCommitment: toBase64Url(keyCommitment),
|
||||||
|
keyEnvelopes: Array.from(recipients.entries()).map(([did, bundle]) => ({
|
||||||
|
did,
|
||||||
|
keyId: bundle.keyId,
|
||||||
|
keyVersion: bundle.version,
|
||||||
|
sealedKey: toBase64Url(
|
||||||
|
crypto.crypto_box_seal(sealedPayload!, fromBase64Url(bundle.publicKey)),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
crypto.memzero(contentKey);
|
||||||
|
if (sealedPayload) crypto.memzero(sealedPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptE2EEMessage(
|
||||||
|
envelope: E2EEMessageEnvelope,
|
||||||
|
userDid: string,
|
||||||
|
material: E2EEKeyMaterial,
|
||||||
|
): Promise<string> {
|
||||||
|
const crypto = await ready();
|
||||||
|
const wrappedKey = envelope.keyEnvelopes.find(
|
||||||
|
(candidate) => candidate.did === userDid && candidate.keyId === material.keyId,
|
||||||
|
);
|
||||||
|
if (!wrappedKey) {
|
||||||
|
throw new Error('Message was encrypted for a different encryption key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sealedPayload = crypto.crypto_box_seal_open(
|
||||||
|
fromBase64Url(wrappedKey.sealedKey),
|
||||||
|
fromBase64Url(material.publicKey),
|
||||||
|
fromBase64Url(material.privateKey),
|
||||||
|
);
|
||||||
|
let contentKey: Uint8Array | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sealedPayload.length !== 64) throw new Error('Encrypted message key payload is invalid');
|
||||||
|
contentKey = sealedPayload.slice(0, 32);
|
||||||
|
const expectedTranscriptHash = sealedPayload.slice(32);
|
||||||
|
const authenticatedData = encoder.encode(messageAuthenticatedData(envelope));
|
||||||
|
const transcriptHash = crypto.crypto_hash_sha256(authenticatedData);
|
||||||
|
if (!crypto.memcmp(transcriptHash, expectedTranscriptHash)) {
|
||||||
|
throw new Error('Encrypted message key was transplanted from another message');
|
||||||
|
}
|
||||||
|
const commitment = crypto.crypto_generichash(
|
||||||
|
32,
|
||||||
|
concatBytes(authenticatedData, fromBase64Url(envelope.nonce), fromBase64Url(envelope.ciphertext)),
|
||||||
|
contentKey,
|
||||||
|
);
|
||||||
|
if (!crypto.memcmp(commitment, fromBase64Url(envelope.keyCommitment))) {
|
||||||
|
throw new Error('Encrypted message key commitment is invalid');
|
||||||
|
}
|
||||||
|
const plaintext = crypto.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||||
|
null,
|
||||||
|
fromBase64Url(envelope.ciphertext),
|
||||||
|
authenticatedData,
|
||||||
|
fromBase64Url(envelope.nonce),
|
||||||
|
contentKey,
|
||||||
|
);
|
||||||
|
return decoder.decode(plaintext);
|
||||||
|
} finally {
|
||||||
|
if (contentKey) crypto.memzero(contentKey);
|
||||||
|
crypto.memzero(sealedPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearUserPrivateKey,
|
||||||
|
createSignedAction,
|
||||||
|
exportPublicKey,
|
||||||
|
generateKeyPair,
|
||||||
|
keyStore,
|
||||||
|
} from '@/lib/crypto/user-signing';
|
||||||
|
import { decryptStoredChatMessage } from './client';
|
||||||
|
import { verifyE2EEPublicBundle } from './bundle-proof';
|
||||||
|
import { encryptE2EEMessage, generateE2EEKeyMaterial } from './client-crypto';
|
||||||
|
import { E2EE_PROTOCOL, type E2EEKeyBundle } from './protocol';
|
||||||
|
|
||||||
|
const senderDid = 'did:synapsis:alice-signature-test';
|
||||||
|
const recipientDid = 'did:synapsis:bob-signature-test';
|
||||||
|
|
||||||
|
function bundle(
|
||||||
|
material: Awaited<ReturnType<typeof generateE2EEKeyMaterial>>,
|
||||||
|
): E2EEKeyBundle {
|
||||||
|
return {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
keyId: material.keyId,
|
||||||
|
version: 1,
|
||||||
|
publicKey: material.publicKey,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
recoveryCommitment: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => clearUserPrivateKey());
|
||||||
|
|
||||||
|
describe('stored encrypted messages', () => {
|
||||||
|
it('verifies the outer account signature before decrypting', async () => {
|
||||||
|
const signingKeys = await generateKeyPair();
|
||||||
|
const signingPublicKey = await exportPublicKey(signingKeys.publicKey);
|
||||||
|
keyStore.setPrivateKey(signingKeys.privateKey);
|
||||||
|
|
||||||
|
const sender = await generateE2EEKeyMaterial();
|
||||||
|
const recipient = await generateE2EEKeyMaterial();
|
||||||
|
const envelope = await encryptE2EEMessage({
|
||||||
|
plaintext: 'signed and encrypted',
|
||||||
|
senderDid,
|
||||||
|
senderHandle: 'alice',
|
||||||
|
senderBundle: bundle(sender),
|
||||||
|
recipientDid,
|
||||||
|
recipientHandle: 'bob',
|
||||||
|
recipientBundle: bundle(recipient),
|
||||||
|
});
|
||||||
|
const signedAction = await createSignedAction(
|
||||||
|
'chat_e2ee',
|
||||||
|
envelope,
|
||||||
|
senderDid,
|
||||||
|
'alice',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(decryptStoredChatMessage({
|
||||||
|
protocolVersion: 1,
|
||||||
|
encryptedEnvelope: envelope,
|
||||||
|
signedAction,
|
||||||
|
senderPublicKey: signingPublicKey,
|
||||||
|
}, recipientDid, recipient)).resolves.toEqual({
|
||||||
|
content: 'signed and encrypted',
|
||||||
|
legacy: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidSignature = {
|
||||||
|
...signedAction,
|
||||||
|
sig: `${signedAction.sig.startsWith('A') ? 'B' : 'A'}${signedAction.sig.slice(1)}`,
|
||||||
|
};
|
||||||
|
await expect(decryptStoredChatMessage({
|
||||||
|
protocolVersion: 1,
|
||||||
|
encryptedEnvelope: envelope,
|
||||||
|
signedAction: invalidSignature,
|
||||||
|
senderPublicKey: signingPublicKey,
|
||||||
|
}, recipientDid, recipient)).rejects.toThrow(/signature/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a stored envelope that differs from the signed envelope', async () => {
|
||||||
|
const signingKeys = await generateKeyPair();
|
||||||
|
const signingPublicKey = await exportPublicKey(signingKeys.publicKey);
|
||||||
|
keyStore.setPrivateKey(signingKeys.privateKey);
|
||||||
|
const sender = await generateE2EEKeyMaterial();
|
||||||
|
const recipient = await generateE2EEKeyMaterial();
|
||||||
|
const envelope = await encryptE2EEMessage({
|
||||||
|
plaintext: 'original',
|
||||||
|
senderDid,
|
||||||
|
senderHandle: 'alice',
|
||||||
|
senderBundle: bundle(sender),
|
||||||
|
recipientDid,
|
||||||
|
recipientHandle: 'bob',
|
||||||
|
recipientBundle: bundle(recipient),
|
||||||
|
});
|
||||||
|
const signedAction = await createSignedAction('chat_e2ee', envelope, senderDid, 'alice');
|
||||||
|
|
||||||
|
await expect(decryptStoredChatMessage({
|
||||||
|
protocolVersion: 1,
|
||||||
|
encryptedEnvelope: { ...envelope, ciphertext: `${envelope.ciphertext}A` },
|
||||||
|
signedAction,
|
||||||
|
senderPublicKey: signingPublicKey,
|
||||||
|
}, recipientDid, recipient)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('signed encryption key bundles', () => {
|
||||||
|
it('accepts the DID-signing-key proof and rejects altered key material', async () => {
|
||||||
|
const signingKeys = await generateKeyPair();
|
||||||
|
const signingPublicKey = await exportPublicKey(signingKeys.publicKey);
|
||||||
|
keyStore.setPrivateKey(signingKeys.privateKey);
|
||||||
|
const material = await generateE2EEKeyMaterial();
|
||||||
|
const publicBundle = bundle(material);
|
||||||
|
const proof = await createSignedAction('e2ee_key_bundle', publicBundle, senderDid, 'alice');
|
||||||
|
const response = { bundle: publicBundle, proof, signingPublicKey };
|
||||||
|
|
||||||
|
await expect(verifyE2EEPublicBundle(response, senderDid)).resolves.toBe(true);
|
||||||
|
await expect(verifyE2EEPublicBundle({
|
||||||
|
...response,
|
||||||
|
bundle: { ...publicBundle, version: 2 },
|
||||||
|
}, senderDid)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { canonicalize, createSignedAction, verifySignedActionSignature } from '@/lib/crypto/user-signing';
|
||||||
|
import { signingPublicKeyFromDid, verifyE2EEPublicBundle } from './bundle-proof';
|
||||||
|
import {
|
||||||
|
createE2EEVault,
|
||||||
|
decryptE2EEMessage,
|
||||||
|
generateE2EEKeyMaterial,
|
||||||
|
openE2EEVault,
|
||||||
|
prepareE2EEVaultUnlock,
|
||||||
|
toBase64Url,
|
||||||
|
} from './client-crypto';
|
||||||
|
import { persistE2EEKeyMaterial } from './local-key-store';
|
||||||
|
import {
|
||||||
|
E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
E2EE_PROTOCOL,
|
||||||
|
e2eeMessageEnvelopeSchema,
|
||||||
|
e2eePublicBundleResponseSchema,
|
||||||
|
e2eeVaultRecordSchema,
|
||||||
|
e2eeVaultStatusSchema,
|
||||||
|
signedUserActionSchema,
|
||||||
|
validateMessageBindings,
|
||||||
|
type E2EEKeyMaterial,
|
||||||
|
type E2EEPublicBundleResponse,
|
||||||
|
type E2EEVaultStatus,
|
||||||
|
} from './protocol';
|
||||||
|
|
||||||
|
export class E2EEClientError extends Error {
|
||||||
|
constructor(message: string, readonly code: string, readonly details?: Record<string, unknown>) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'E2EEClientError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseError(response: Response, fallback: string): Promise<E2EEClientError> {
|
||||||
|
const body = await response.json().catch(() => null) as Record<string, unknown> | null;
|
||||||
|
return new E2EEClientError(
|
||||||
|
typeof body?.error === 'string' ? body.error : fallback,
|
||||||
|
typeof body?.code === 'string' ? body.code : 'E2EE_REQUEST_FAILED',
|
||||||
|
body || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchE2EEVaultStatus(expectedDid: string): Promise<E2EEVaultStatus> {
|
||||||
|
const response = await fetch('/api/e2ee/vault', { cache: 'no-store' });
|
||||||
|
if (!response.ok) throw await responseError(response, 'Encrypted message setup could not be loaded');
|
||||||
|
const status = e2eeVaultStatusSchema.parse(await response.json());
|
||||||
|
if (status.ownerDid !== expectedDid) {
|
||||||
|
throw new E2EEClientError('The active account changed while encrypted messages were loading', 'E2EE_ACCOUNT_CHANGED');
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function provisionE2EEAccount(input: {
|
||||||
|
did: string;
|
||||||
|
handle: string;
|
||||||
|
pin: string;
|
||||||
|
replacesKeyId?: string;
|
||||||
|
currentPassword?: string;
|
||||||
|
}): Promise<E2EEKeyMaterial> {
|
||||||
|
const material = await generateE2EEKeyMaterial();
|
||||||
|
let version = 1;
|
||||||
|
if (input.replacesKeyId) {
|
||||||
|
const status = await fetchE2EEVaultStatus(input.did);
|
||||||
|
const previousKey = status.configured
|
||||||
|
? { keyId: status.keyId, keyVersion: status.keyVersion }
|
||||||
|
: status.previousKey;
|
||||||
|
if (!previousKey || previousKey.keyId !== input.replacesKeyId) {
|
||||||
|
throw new E2EEClientError('The encryption key to replace changed', 'E2EE_KEY_CONFLICT');
|
||||||
|
}
|
||||||
|
version = previousKey.keyVersion + 1;
|
||||||
|
}
|
||||||
|
const recovery = await createE2EEVault(input.pin, material, input.did, version);
|
||||||
|
const recoveryCommitment = toBase64Url(new Uint8Array(await crypto.subtle.digest(
|
||||||
|
'SHA-256',
|
||||||
|
new TextEncoder().encode(canonicalize(recovery)),
|
||||||
|
)));
|
||||||
|
const bundle = {
|
||||||
|
protocol: E2EE_PROTOCOL,
|
||||||
|
keyId: material.keyId,
|
||||||
|
version,
|
||||||
|
publicKey: material.publicKey,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
recoveryCommitment,
|
||||||
|
...(input.replacesKeyId ? { replacesKeyId: input.replacesKeyId } : {}),
|
||||||
|
} as const;
|
||||||
|
const proof = await createSignedAction(
|
||||||
|
E2EE_KEY_BUNDLE_ACTION,
|
||||||
|
bundle,
|
||||||
|
input.did,
|
||||||
|
input.handle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch('/api/e2ee/vault', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
proof,
|
||||||
|
recovery,
|
||||||
|
...(input.currentPassword ? { currentPassword: input.currentPassword } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw await responseError(response, 'Encrypted messages were not set up');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await persistE2EEKeyMaterial(input.did, material);
|
||||||
|
} catch (error) {
|
||||||
|
// IndexedDB can be unavailable in private/restricted browser contexts. The
|
||||||
|
// key remains usable in memory; this device will simply ask for the PIN again.
|
||||||
|
console.warn('[E2EE] Could not remember this device:', error);
|
||||||
|
}
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockE2EEAccount(
|
||||||
|
did: string,
|
||||||
|
pin: string,
|
||||||
|
status: E2EEVaultStatus,
|
||||||
|
): Promise<E2EEKeyMaterial> {
|
||||||
|
if (!status.configured || !status.keyId || !status.keyVersion || !status.publicKey || !status.salt
|
||||||
|
|| !status.kdfAlgorithm || !status.kdfOpsLimit || !status.kdfMemLimit) {
|
||||||
|
throw new E2EEClientError('Encrypted message recovery is incomplete', 'E2EE_VAULT_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepared = await prepareE2EEVaultUnlock(pin, {
|
||||||
|
salt: status.salt,
|
||||||
|
kdfOpsLimit: status.kdfOpsLimit,
|
||||||
|
kdfMemLimit: status.kdfMemLimit,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/e2ee/vault/unlock', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ownerDid: did,
|
||||||
|
keyId: status.keyId,
|
||||||
|
keyVersion: status.keyVersion,
|
||||||
|
pinVerifier: prepared.pinVerifier,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await responseError(response, 'Encrypted messages could not be unlocked');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
const vault = e2eeVaultRecordSchema.parse(body.vault);
|
||||||
|
const material = await openE2EEVault(prepared, vault, body.serverShare);
|
||||||
|
if (material.keyId !== status.keyId || material.publicKey !== status.publicKey) {
|
||||||
|
throw new E2EEClientError('Recovered key does not match this account', 'E2EE_KEY_MISMATCH');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await persistE2EEKeyMaterial(did, material);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[E2EE] Could not remember this device:', error);
|
||||||
|
}
|
||||||
|
return material;
|
||||||
|
} finally {
|
||||||
|
prepared.pinKey.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveE2EEPublicBundle(
|
||||||
|
did: string,
|
||||||
|
handle: string,
|
||||||
|
): Promise<E2EEPublicBundleResponse> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/e2ee/keys/resolve?did=${encodeURIComponent(did)}&handle=${encodeURIComponent(handle)}`,
|
||||||
|
{ cache: 'no-store' },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw await responseError(response, 'Recipient encryption key could not be verified');
|
||||||
|
const result = e2eePublicBundleResponseSchema.parse(await response.json());
|
||||||
|
if (!await verifyE2EEPublicBundle(result, did)) {
|
||||||
|
throw new E2EEClientError('Recipient encryption key proof is invalid', 'E2EE_KEY_PROOF_INVALID');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredChatMessage {
|
||||||
|
protocolVersion: number;
|
||||||
|
content?: string | null;
|
||||||
|
encryptedEnvelope?: unknown;
|
||||||
|
signedAction?: unknown;
|
||||||
|
senderPublicKey?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptStoredChatMessage(
|
||||||
|
message: StoredChatMessage,
|
||||||
|
userDid: string,
|
||||||
|
material: E2EEKeyMaterial,
|
||||||
|
): Promise<{ content: string; legacy: boolean }> {
|
||||||
|
if (message.protocolVersion === 0) {
|
||||||
|
return { content: message.content || '[Empty legacy message]', legacy: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = e2eeMessageEnvelopeSchema.parse(message.encryptedEnvelope);
|
||||||
|
const signedAction = signedUserActionSchema.parse(message.signedAction);
|
||||||
|
validateMessageBindings(envelope, signedAction);
|
||||||
|
if (canonicalize(signedAction.data) !== canonicalize(envelope)) {
|
||||||
|
throw new E2EEClientError('Encrypted message envelope was altered', 'E2EE_MESSAGE_TAMPERED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingPublicKey = signingPublicKeyFromDid(signedAction.did) || message.senderPublicKey;
|
||||||
|
if (!signingPublicKey || !await verifySignedActionSignature(signedAction, signingPublicKey)) {
|
||||||
|
throw new E2EEClientError('Encrypted message signature is invalid', 'E2EE_MESSAGE_SIGNATURE_INVALID');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: await decryptE2EEMessage(envelope, userDid, material),
|
||||||
|
legacy: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { E2EEKeyMaterial } from './protocol';
|
||||||
|
|
||||||
|
const DB_NAME = 'synapsis-e2ee';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_NAME = 'account-keys';
|
||||||
|
|
||||||
|
interface PersistedKeyRecord {
|
||||||
|
keyId: string;
|
||||||
|
publicKey: string;
|
||||||
|
wrappingKey: CryptoKey;
|
||||||
|
ciphertext: ArrayBuffer;
|
||||||
|
iv: ArrayBuffer;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDatabase(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const database = request.result;
|
||||||
|
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
database.createObjectStore(STORE_NAME);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readValue<T>(key: string): Promise<T | null> {
|
||||||
|
const database = await openDatabase();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORE_NAME, 'readonly');
|
||||||
|
const request = transaction.objectStore(STORE_NAME).get(key);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve((request.result as T | undefined) ?? null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeValue(key: string, value: unknown): Promise<void> {
|
||||||
|
const database = await openDatabase();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
transaction.onabort = () => reject(transaction.error);
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.objectStore(STORE_NAME).put(value, key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteValue(key: string): Promise<void> {
|
||||||
|
const database = await openDatabase();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
transaction.onabort = () => reject(transaction.error);
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.objectStore(STORE_NAME).delete(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrappingKeyName(did: string): string {
|
||||||
|
return `wrapping:${did}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialName(did: string): string {
|
||||||
|
return `material:${did}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistE2EEKeyMaterial(did: string, material: E2EEKeyMaterial): Promise<void> {
|
||||||
|
const existingRecord = await readValue<Partial<PersistedKeyRecord>>(materialName(did));
|
||||||
|
const legacyWrappingKey = existingRecord?.wrappingKey
|
||||||
|
? null
|
||||||
|
: await readValue<CryptoKey>(wrappingKeyName(did));
|
||||||
|
const wrappingKey = existingRecord?.wrappingKey
|
||||||
|
|| legacyWrappingKey
|
||||||
|
|| await crypto.subtle.generateKey(
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const encoded = new TextEncoder().encode(JSON.stringify(material));
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(`${did}:${material.keyId}`) },
|
||||||
|
wrappingKey,
|
||||||
|
encoded,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The wrapping key and the payload it encrypts must be committed together.
|
||||||
|
// If two first-time writes race, either complete record can win without
|
||||||
|
// leaving one writer's ciphertext paired with the other writer's key.
|
||||||
|
await writeValue(materialName(did), {
|
||||||
|
keyId: material.keyId,
|
||||||
|
publicKey: material.publicKey,
|
||||||
|
wrappingKey,
|
||||||
|
ciphertext,
|
||||||
|
iv: iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
} satisfies PersistedKeyRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreE2EEKeyMaterial(did: string): Promise<E2EEKeyMaterial | null> {
|
||||||
|
try {
|
||||||
|
const record = await readValue<Partial<PersistedKeyRecord>>(materialName(did));
|
||||||
|
const wrappingKey = record?.wrappingKey
|
||||||
|
|| await readValue<CryptoKey>(wrappingKeyName(did));
|
||||||
|
if (!wrappingKey || !record) return null;
|
||||||
|
if (!record.keyId || !record.publicKey || !record.iv || !record.ciphertext) return null;
|
||||||
|
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: new Uint8Array(record.iv),
|
||||||
|
additionalData: new TextEncoder().encode(`${did}:${record.keyId}`),
|
||||||
|
},
|
||||||
|
wrappingKey,
|
||||||
|
record.ciphertext,
|
||||||
|
);
|
||||||
|
const material = JSON.parse(new TextDecoder().decode(plaintext)) as E2EEKeyMaterial;
|
||||||
|
if (material.keyId !== record.keyId || material.publicKey !== record.publicKey) return null;
|
||||||
|
return material;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearE2EEKeyMaterial(did: string): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
deleteValue(materialName(did)),
|
||||||
|
deleteValue(wrappingKeyName(did)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||||
|
|
||||||
|
export const E2EE_PROTOCOL = 'synapsis-e2ee-v1' as const;
|
||||||
|
export const E2EE_PROTOCOL_VERSION = 1;
|
||||||
|
export const E2EE_CIPHER_SUITE = 'x25519+xchacha20poly1305+blake2b-v1' as const;
|
||||||
|
export const E2EE_KEY_BUNDLE_ACTION = 'e2ee_key_bundle' as const;
|
||||||
|
export const E2EE_CHAT_ACTION = 'chat_e2ee' as const;
|
||||||
|
export const E2EE_PIN_MIN_LENGTH = 6;
|
||||||
|
export const E2EE_PIN_MAX_LENGTH = 12;
|
||||||
|
export const E2EE_MAX_UNLOCK_ATTEMPTS = 10;
|
||||||
|
export const E2EE_LOCKOUT_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const E2EE_KDF = {
|
||||||
|
algorithm: 'argon2id13' as const,
|
||||||
|
opsLimit: 2,
|
||||||
|
memLimit: 64 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64UrlSchema = z.string().min(1).max(24_000).regex(/^[A-Za-z0-9_-]+$/);
|
||||||
|
const didSchema = z.string().min(8).max(2_048).regex(/^did:/);
|
||||||
|
const handleSchema = z.string().min(1).max(320);
|
||||||
|
const keyIdSchema = z.string().min(12).max(96).regex(/^k1_[A-Za-z0-9_-]+$/);
|
||||||
|
|
||||||
|
export const e2eeKeyBundleSchema = z.strictObject({
|
||||||
|
protocol: z.literal(E2EE_PROTOCOL),
|
||||||
|
keyId: keyIdSchema,
|
||||||
|
version: z.number().int().positive().max(1_000_000),
|
||||||
|
publicKey: base64UrlSchema.max(64),
|
||||||
|
createdAt: z.number().int().positive(),
|
||||||
|
recoveryCommitment: base64UrlSchema.max(64),
|
||||||
|
replacesKeyId: keyIdSchema.optional(),
|
||||||
|
}).superRefine((bundle, context) => {
|
||||||
|
if (bundle.version === 1 && bundle.replacesKeyId) {
|
||||||
|
context.addIssue({ code: 'custom', path: ['replacesKeyId'], message: 'Initial encryption keys cannot replace another key' });
|
||||||
|
}
|
||||||
|
if (bundle.version > 1 && !bundle.replacesKeyId) {
|
||||||
|
context.addIssue({ code: 'custom', path: ['replacesKeyId'], message: 'Rotated encryption keys must name their predecessor' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type E2EEKeyBundle = z.infer<typeof e2eeKeyBundleSchema>;
|
||||||
|
|
||||||
|
export const signedUserActionSchema = z.strictObject({
|
||||||
|
action: z.string().min(1).max(64),
|
||||||
|
data: z.unknown(),
|
||||||
|
did: didSchema,
|
||||||
|
handle: handleSchema,
|
||||||
|
ts: z.number().int().positive(),
|
||||||
|
nonce: base64UrlSchema.max(128),
|
||||||
|
sig: base64UrlSchema.max(256),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SignedUserAction = z.infer<typeof signedUserActionSchema>;
|
||||||
|
|
||||||
|
export const e2eeKeyEnvelopeSchema = z.strictObject({
|
||||||
|
did: didSchema,
|
||||||
|
keyId: keyIdSchema,
|
||||||
|
keyVersion: z.number().int().positive().max(1_000_000),
|
||||||
|
sealedKey: base64UrlSchema.max(256),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const e2eeMessageEnvelopeSchema = z.strictObject({
|
||||||
|
protocol: z.literal(E2EE_PROTOCOL),
|
||||||
|
cipherSuite: z.literal(E2EE_CIPHER_SUITE),
|
||||||
|
messageId: z.string().uuid(),
|
||||||
|
conversationId: z.string().min(12).max(96).regex(/^dm1_[A-Za-z0-9_-]+$/),
|
||||||
|
senderDid: didSchema,
|
||||||
|
senderHandle: handleSchema,
|
||||||
|
recipientDid: didSchema,
|
||||||
|
recipientHandle: handleSchema,
|
||||||
|
createdAt: z.number().int().positive(),
|
||||||
|
senderKeyId: keyIdSchema,
|
||||||
|
senderKeyVersion: z.number().int().positive().max(1_000_000),
|
||||||
|
recipientKeyId: keyIdSchema,
|
||||||
|
recipientKeyVersion: z.number().int().positive().max(1_000_000),
|
||||||
|
nonce: base64UrlSchema.max(64),
|
||||||
|
ciphertext: base64UrlSchema.max(16_000),
|
||||||
|
keyCommitment: base64UrlSchema.max(64),
|
||||||
|
keyEnvelopes: z.array(e2eeKeyEnvelopeSchema).min(1).max(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type E2EEMessageEnvelope = z.infer<typeof e2eeMessageEnvelopeSchema>;
|
||||||
|
export type E2EEKeyEnvelope = z.infer<typeof e2eeKeyEnvelopeSchema>;
|
||||||
|
|
||||||
|
export const e2eeVaultRecordSchema = z.strictObject({
|
||||||
|
protocol: z.literal(E2EE_PROTOCOL),
|
||||||
|
ownerDid: didSchema,
|
||||||
|
keyId: keyIdSchema,
|
||||||
|
keyVersion: z.number().int().positive().max(1_000_000),
|
||||||
|
publicKey: base64UrlSchema.max(64),
|
||||||
|
ciphertext: base64UrlSchema.max(4_096),
|
||||||
|
nonce: base64UrlSchema.max(64),
|
||||||
|
salt: base64UrlSchema.max(64),
|
||||||
|
kdfAlgorithm: z.literal(E2EE_KDF.algorithm),
|
||||||
|
kdfOpsLimit: z.number().int().min(1).max(10),
|
||||||
|
kdfMemLimit: z.number().int().min(8 * 1024 * 1024).max(256 * 1024 * 1024),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type E2EEVaultRecord = z.infer<typeof e2eeVaultRecordSchema>;
|
||||||
|
|
||||||
|
export const e2eeVaultSetupSchema = z.strictObject({
|
||||||
|
vault: e2eeVaultRecordSchema,
|
||||||
|
serverShare: base64UrlSchema.max(64),
|
||||||
|
pinVerifier: base64UrlSchema.max(64),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type E2EEVaultSetup = z.infer<typeof e2eeVaultSetupSchema>;
|
||||||
|
|
||||||
|
export interface E2EEPublicBundleResponse {
|
||||||
|
bundle: E2EEKeyBundle;
|
||||||
|
proof: SignedUserAction;
|
||||||
|
signingPublicKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const e2eePublicBundleResponseSchema = z.strictObject({
|
||||||
|
bundle: e2eeKeyBundleSchema,
|
||||||
|
proof: signedUserActionSchema,
|
||||||
|
signingPublicKey: z.string().min(1).max(8_192),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface E2EEKeyMaterial {
|
||||||
|
keyId: string;
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const e2eeVaultStatusSchema = z.discriminatedUnion('configured', [
|
||||||
|
z.strictObject({
|
||||||
|
ownerDid: didSchema,
|
||||||
|
configured: z.literal(false),
|
||||||
|
previousKey: z.strictObject({
|
||||||
|
keyId: keyIdSchema,
|
||||||
|
keyVersion: z.number().int().positive().max(1_000_000),
|
||||||
|
}).optional(),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
ownerDid: didSchema,
|
||||||
|
configured: z.literal(true),
|
||||||
|
keyId: keyIdSchema,
|
||||||
|
keyVersion: z.number().int().positive().max(1_000_000),
|
||||||
|
publicKey: base64UrlSchema.max(64),
|
||||||
|
salt: base64UrlSchema.max(64),
|
||||||
|
kdfAlgorithm: z.literal(E2EE_KDF.algorithm),
|
||||||
|
kdfOpsLimit: z.number().int().min(1).max(10),
|
||||||
|
kdfMemLimit: z.number().int().min(8 * 1024 * 1024).max(256 * 1024 * 1024),
|
||||||
|
failedAttempts: z.number().int().min(0).max(E2EE_MAX_UNLOCK_ATTEMPTS),
|
||||||
|
attemptsRemaining: z.number().int().min(0).max(E2EE_MAX_UNLOCK_ATTEMPTS),
|
||||||
|
lockedUntil: z.string().datetime().nullable(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type E2EEVaultStatus = z.infer<typeof e2eeVaultStatusSchema>;
|
||||||
|
|
||||||
|
export function messageAuthenticatedData(
|
||||||
|
envelope: Pick<
|
||||||
|
E2EEMessageEnvelope,
|
||||||
|
| 'protocol'
|
||||||
|
| 'cipherSuite'
|
||||||
|
| 'messageId'
|
||||||
|
| 'conversationId'
|
||||||
|
| 'senderDid'
|
||||||
|
| 'senderHandle'
|
||||||
|
| 'recipientDid'
|
||||||
|
| 'recipientHandle'
|
||||||
|
| 'createdAt'
|
||||||
|
| 'senderKeyId'
|
||||||
|
| 'senderKeyVersion'
|
||||||
|
| 'recipientKeyId'
|
||||||
|
| 'recipientKeyVersion'
|
||||||
|
>,
|
||||||
|
): string {
|
||||||
|
return canonicalize({
|
||||||
|
protocol: envelope.protocol,
|
||||||
|
cipherSuite: envelope.cipherSuite,
|
||||||
|
messageId: envelope.messageId,
|
||||||
|
conversationId: envelope.conversationId,
|
||||||
|
senderDid: envelope.senderDid,
|
||||||
|
senderHandle: envelope.senderHandle,
|
||||||
|
recipientDid: envelope.recipientDid,
|
||||||
|
recipientHandle: envelope.recipientHandle,
|
||||||
|
createdAt: envelope.createdAt,
|
||||||
|
senderKeyId: envelope.senderKeyId,
|
||||||
|
senderKeyVersion: envelope.senderKeyVersion,
|
||||||
|
recipientKeyId: envelope.recipientKeyId,
|
||||||
|
recipientKeyVersion: envelope.recipientKeyVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vaultAuthenticatedData(
|
||||||
|
record: Pick<
|
||||||
|
E2EEVaultRecord,
|
||||||
|
'protocol' | 'ownerDid' | 'keyId' | 'keyVersion' | 'publicKey' | 'salt' | 'kdfAlgorithm' | 'kdfOpsLimit' | 'kdfMemLimit'
|
||||||
|
>,
|
||||||
|
): string {
|
||||||
|
return canonicalize({
|
||||||
|
protocol: record.protocol,
|
||||||
|
ownerDid: record.ownerDid,
|
||||||
|
keyId: record.keyId,
|
||||||
|
keyVersion: record.keyVersion,
|
||||||
|
publicKey: record.publicKey,
|
||||||
|
salt: record.salt,
|
||||||
|
kdfAlgorithm: record.kdfAlgorithm,
|
||||||
|
kdfOpsLimit: record.kdfOpsLimit,
|
||||||
|
kdfMemLimit: record.kdfMemLimit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMessageBindings(
|
||||||
|
envelope: E2EEMessageEnvelope,
|
||||||
|
signedAction: Pick<SignedUserAction, 'action' | 'did' | 'handle' | 'ts'>,
|
||||||
|
): void {
|
||||||
|
if (signedAction.action !== E2EE_CHAT_ACTION) {
|
||||||
|
throw new Error('Encrypted message action is invalid');
|
||||||
|
}
|
||||||
|
if (envelope.senderDid !== signedAction.did || envelope.senderHandle !== signedAction.handle) {
|
||||||
|
throw new Error('Encrypted message sender does not match its signature');
|
||||||
|
}
|
||||||
|
// A sender may re-sign the same prepared envelope after an ambiguous network
|
||||||
|
// failure so its stable messageId remains idempotent. Old envelope timestamps
|
||||||
|
// are therefore valid; only future-dated envelopes are rejected.
|
||||||
|
if (envelope.createdAt > signedAction.ts + 5 * 60 * 1000) {
|
||||||
|
throw new Error('Encrypted message timestamp is too far in the future');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueRecipients = new Set(envelope.keyEnvelopes.map((item) => item.did));
|
||||||
|
if (uniqueRecipients.size !== envelope.keyEnvelopes.length) {
|
||||||
|
throw new Error('Encrypted message contains duplicate key envelopes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderEnvelope = envelope.keyEnvelopes.find((item) => item.did === envelope.senderDid);
|
||||||
|
const recipientEnvelope = envelope.keyEnvelopes.find((item) => item.did === envelope.recipientDid);
|
||||||
|
if (!senderEnvelope || senderEnvelope.keyId !== envelope.senderKeyId
|
||||||
|
|| senderEnvelope.keyVersion !== envelope.senderKeyVersion) {
|
||||||
|
throw new Error('Encrypted message is missing the sender key envelope');
|
||||||
|
}
|
||||||
|
if (!recipientEnvelope || recipientEnvelope.keyId !== envelope.recipientKeyId
|
||||||
|
|| recipientEnvelope.keyVersion !== envelope.recipientKeyVersion) {
|
||||||
|
throw new Error('Encrypted message is missing the recipient key envelope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePin(pin: string): void {
|
||||||
|
const pinPattern = new RegExp(`^\\d{${E2EE_PIN_MIN_LENGTH},${E2EE_PIN_MAX_LENGTH}}$`);
|
||||||
|
if (!pinPattern.test(pin)) {
|
||||||
|
throw new Error(`PIN must contain ${E2EE_PIN_MIN_LENGTH}-${E2EE_PIN_MAX_LENGTH} digits`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
function recoverySecret(): string {
|
||||||
|
const secret = process.env.E2EE_RECOVERY_SECRET;
|
||||||
|
if (!secret || secret.length < 32 || secret === 'replace-with-a-separate-long-random-secret') {
|
||||||
|
throw new Error('A high-entropy E2EE_RECOVERY_SECRET is required for encrypted message recovery');
|
||||||
|
}
|
||||||
|
if (secret === process.env.AUTH_SECRET) {
|
||||||
|
throw new Error('E2EE_RECOVERY_SECRET must be independent of AUTH_SECRET');
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptionKey(): Buffer {
|
||||||
|
return crypto.createHash('sha256').update(`synapsis:e2ee:server-share:v1:${recoverySecret()}`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifierKey(): Buffer {
|
||||||
|
return crypto.createHash('sha256').update(`synapsis:e2ee:pin-verifier:v1:${recoverySecret()}`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealServerShare(value: string, userId: string, keyId: string): string {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv);
|
||||||
|
cipher.setAAD(Buffer.from(`${userId}:${keyId}`));
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||||
|
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openServerShare(value: string, userId: string, keyId: string): string {
|
||||||
|
const raw = Buffer.from(value, 'base64url');
|
||||||
|
if (raw.length < 29) throw new Error('Invalid encrypted recovery share');
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), raw.subarray(0, 12));
|
||||||
|
decipher.setAAD(Buffer.from(`${userId}:${keyId}`));
|
||||||
|
decipher.setAuthTag(raw.subarray(12, 28));
|
||||||
|
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPinVerifierMac(pinVerifier: string, userId: string, keyId: string): string {
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', verifierKey())
|
||||||
|
.update(`${userId}:${keyId}:`)
|
||||||
|
.update(pinVerifier)
|
||||||
|
.digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinVerifierMatches(
|
||||||
|
candidate: string,
|
||||||
|
expectedMac: string,
|
||||||
|
userId: string,
|
||||||
|
keyId: string,
|
||||||
|
): boolean {
|
||||||
|
const actual = Buffer.from(createPinVerifierMac(candidate, userId, keyId), 'base64url');
|
||||||
|
const expected = Buffer.from(expectedMac, 'base64url');
|
||||||
|
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchE2EEVaultStatus,
|
||||||
|
provisionE2EEAccount,
|
||||||
|
unlockE2EEAccount,
|
||||||
|
type E2EEClientError,
|
||||||
|
} from './client';
|
||||||
|
import { restoreE2EEKeyMaterial } from './local-key-store';
|
||||||
|
import type { E2EEKeyMaterial, E2EEVaultStatus } from './protocol';
|
||||||
|
|
||||||
|
type ConfiguredVaultStatus = Extract<E2EEVaultStatus, { configured: true }>;
|
||||||
|
|
||||||
|
function requireConfiguredVault(status: E2EEVaultStatus): ConfiguredVaultStatus {
|
||||||
|
if (!status.configured) {
|
||||||
|
throw new Error('Encrypted message vault is not configured');
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type E2EEIdentityState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'setup_required'; previousKey?: { keyId: string; keyVersion: number } }
|
||||||
|
| { status: 'locked'; vault: ConfiguredVaultStatus }
|
||||||
|
| { status: 'ready'; material: E2EEKeyMaterial; vault: ConfiguredVaultStatus }
|
||||||
|
| { status: 'error'; message: string };
|
||||||
|
|
||||||
|
export function useE2EEIdentity(did?: string | null, handle?: string | null) {
|
||||||
|
const [state, setState] = useState<E2EEIdentityState>({ status: 'loading' });
|
||||||
|
const [stateOwnerDid, setStateOwnerDid] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const generationRef = useRef(0);
|
||||||
|
const channelSourceRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const bootstrap = useCallback(async () => {
|
||||||
|
const generation = ++generationRef.current;
|
||||||
|
if (!did) {
|
||||||
|
setState({ status: 'loading' });
|
||||||
|
setStateOwnerDid(null);
|
||||||
|
setBusy(false);
|
||||||
|
setActionError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({ status: 'loading' });
|
||||||
|
setStateOwnerDid(null);
|
||||||
|
setBusy(false);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const vault = await fetchE2EEVaultStatus(did);
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
if (!vault.configured) {
|
||||||
|
setStateOwnerDid(did);
|
||||||
|
setState({ status: 'setup_required', previousKey: vault.previousKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const local = await restoreE2EEKeyMaterial(did);
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
if (local && local.keyId === vault.keyId && local.publicKey === vault.publicKey) {
|
||||||
|
setStateOwnerDid(did);
|
||||||
|
setState({ status: 'ready', material: local, vault });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStateOwnerDid(did);
|
||||||
|
setState({ status: 'locked', vault });
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
setStateOwnerDid(did);
|
||||||
|
setState({ status: 'error', message: error instanceof Error ? error.message : 'Encrypted messages could not be loaded' });
|
||||||
|
}
|
||||||
|
}, [did]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void bootstrap();
|
||||||
|
}, [bootstrap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!did || typeof BroadcastChannel === 'undefined') return;
|
||||||
|
channelSourceRef.current ??= crypto.randomUUID();
|
||||||
|
const channel = new BroadcastChannel('synapsis-e2ee');
|
||||||
|
channel.onmessage = (event) => {
|
||||||
|
if (event.data?.did === did && event.data?.type === 'key-updated'
|
||||||
|
&& event.data?.source !== channelSourceRef.current) {
|
||||||
|
void bootstrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => channel.close();
|
||||||
|
}, [did, bootstrap]);
|
||||||
|
|
||||||
|
const broadcastUpdate = useCallback(() => {
|
||||||
|
if (!did || typeof BroadcastChannel === 'undefined') return;
|
||||||
|
const channel = new BroadcastChannel('synapsis-e2ee');
|
||||||
|
channel.postMessage({ type: 'key-updated', did, source: channelSourceRef.current });
|
||||||
|
channel.close();
|
||||||
|
}, [did]);
|
||||||
|
|
||||||
|
const setup = useCallback(async (pin: string) => {
|
||||||
|
if (!did || !handle) return;
|
||||||
|
const generation = ++generationRef.current;
|
||||||
|
setBusy(true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const material = await provisionE2EEAccount({
|
||||||
|
did,
|
||||||
|
handle,
|
||||||
|
pin,
|
||||||
|
...(state.status === 'setup_required' && state.previousKey
|
||||||
|
? { replacesKeyId: state.previousKey.keyId }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
const vault = requireConfiguredVault(await fetchE2EEVaultStatus(did));
|
||||||
|
broadcastUpdate();
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
setStateOwnerDid(did);
|
||||||
|
setState({ status: 'ready', material, vault });
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
setActionError(error instanceof Error ? error.message : 'Encrypted messages were not set up');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (generation === generationRef.current) setBusy(false);
|
||||||
|
}
|
||||||
|
}, [did, handle, state, broadcastUpdate]);
|
||||||
|
|
||||||
|
const unlock = useCallback(async (pin: string) => {
|
||||||
|
if (!did || state.status !== 'locked') return;
|
||||||
|
const generation = ++generationRef.current;
|
||||||
|
setBusy(true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const material = await unlockE2EEAccount(did, pin, state.vault);
|
||||||
|
const vault = requireConfiguredVault(await fetchE2EEVaultStatus(did));
|
||||||
|
broadcastUpdate();
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
setStateOwnerDid(did);
|
||||||
|
setState({ status: 'ready', material, vault });
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
const clientError = error as E2EEClientError;
|
||||||
|
setActionError(clientError.message || 'Encrypted messages could not be unlocked');
|
||||||
|
if (clientError.details?.lockedUntil || clientError.details?.attemptsRemaining !== undefined) {
|
||||||
|
setState({
|
||||||
|
status: 'locked',
|
||||||
|
vault: {
|
||||||
|
...state.vault,
|
||||||
|
lockedUntil: typeof clientError.details.lockedUntil === 'string'
|
||||||
|
? clientError.details.lockedUntil
|
||||||
|
: state.vault.lockedUntil,
|
||||||
|
attemptsRemaining: typeof clientError.details.attemptsRemaining === 'number'
|
||||||
|
? clientError.details.attemptsRemaining
|
||||||
|
: state.vault.attemptsRemaining,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (generation === generationRef.current) setBusy(false);
|
||||||
|
}
|
||||||
|
}, [did, state, broadcastUpdate]);
|
||||||
|
|
||||||
|
const reset = useCallback(async (pin: string, currentPassword: string) => {
|
||||||
|
if (!did || !handle || state.status !== 'locked' || !state.vault.keyId) return;
|
||||||
|
const generation = ++generationRef.current;
|
||||||
|
setBusy(true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const material = await provisionE2EEAccount({
|
||||||
|
did,
|
||||||
|
handle,
|
||||||
|
pin,
|
||||||
|
replacesKeyId: state.vault.keyId,
|
||||||
|
currentPassword,
|
||||||
|
});
|
||||||
|
const vault = requireConfiguredVault(await fetchE2EEVaultStatus(did));
|
||||||
|
broadcastUpdate();
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
setStateOwnerDid(did);
|
||||||
|
setState({ status: 'ready', material, vault });
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
setActionError(error instanceof Error ? error.message : 'Encrypted messages were not reset');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (generation === generationRef.current) setBusy(false);
|
||||||
|
}
|
||||||
|
}, [did, handle, state, broadcastUpdate]);
|
||||||
|
|
||||||
|
const visibleState: E2EEIdentityState = did && stateOwnerDid === did
|
||||||
|
? state
|
||||||
|
: { status: 'loading' };
|
||||||
|
|
||||||
|
return { state: visibleState, busy, actionError, setup, unlock, reset, retry: bootstrap };
|
||||||
|
}
|
||||||
@@ -6,21 +6,16 @@
|
|||||||
* across page refreshes and tabs.
|
* across page refreshes and tabs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
|
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
|
||||||
import {
|
import {
|
||||||
persistUnlockedKey,
|
persistUnlockedKey,
|
||||||
tryRestoreKey,
|
tryRestoreKey,
|
||||||
clearPersistentKey,
|
clearPersistentKey,
|
||||||
hasPersistentKey,
|
|
||||||
} from '@/lib/crypto/key-persistence';
|
} from '@/lib/crypto/key-persistence';
|
||||||
import {
|
import {
|
||||||
keyStore,
|
keyStore,
|
||||||
importPrivateKey,
|
importPrivateKey,
|
||||||
generateKeyPair,
|
|
||||||
exportPrivateKey,
|
|
||||||
exportPublicKey,
|
|
||||||
base64UrlToBase64,
|
|
||||||
createSignedAction
|
createSignedAction
|
||||||
} from '@/lib/crypto/user-signing';
|
} from '@/lib/crypto/user-signing';
|
||||||
|
|
||||||
@@ -35,10 +30,12 @@ export function useUserIdentity() {
|
|||||||
const [identity, setIdentity] = useState<UserIdentity | null>(null);
|
const [identity, setIdentity] = useState<UserIdentity | null>(null);
|
||||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||||
const [isRestoring, setIsRestoring] = useState(true);
|
const [isRestoring, setIsRestoring] = useState(true);
|
||||||
|
const generationRef = useRef(0);
|
||||||
|
|
||||||
// Check status on mount and try to restore from persistence
|
// Check status on mount and try to restore from persistence
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAndRestore = async () => {
|
const checkAndRestore = async () => {
|
||||||
|
const generation = ++generationRef.current;
|
||||||
setIsRestoring(true);
|
setIsRestoring(true);
|
||||||
try {
|
try {
|
||||||
// First check if already in memory (hot reload scenario)
|
// First check if already in memory (hot reload scenario)
|
||||||
@@ -58,9 +55,13 @@ export function useUserIdentity() {
|
|||||||
|
|
||||||
// Try to restore from persistent storage
|
// Try to restore from persistent storage
|
||||||
const restoredKeyBytes = await tryRestoreKey(globalIdentity.did);
|
const restoredKeyBytes = await tryRestoreKey(globalIdentity.did);
|
||||||
|
if (generation !== generationRef.current
|
||||||
|
|| keyStore.getIdentity()?.did !== globalIdentity.did) return;
|
||||||
if (restoredKeyBytes) {
|
if (restoredKeyBytes) {
|
||||||
// Import the restored key as non-extractable
|
// Import the restored key as non-extractable
|
||||||
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
||||||
|
if (generation !== generationRef.current
|
||||||
|
|| keyStore.getIdentity()?.did !== globalIdentity.did) return;
|
||||||
keyStore.setPrivateKey(cryptoKey);
|
keyStore.setPrivateKey(cryptoKey);
|
||||||
setIdentity({ ...globalIdentity, isUnlocked: true });
|
setIdentity({ ...globalIdentity, isUnlocked: true });
|
||||||
setIsUnlocked(true);
|
setIsUnlocked(true);
|
||||||
@@ -73,7 +74,7 @@ export function useUserIdentity() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Identity] Error during restore:', error);
|
console.error('[Identity] Error during restore:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsRestoring(false);
|
if (generation === generationRef.current) setIsRestoring(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ export function useUserIdentity() {
|
|||||||
publicKey: string;
|
publicKey: string;
|
||||||
privateKeyEncrypted?: string;
|
privateKeyEncrypted?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
const generation = ++generationRef.current;
|
||||||
keyStore.clear();
|
keyStore.clear();
|
||||||
|
|
||||||
const coreIdentity = {
|
const coreIdentity = {
|
||||||
@@ -98,17 +100,26 @@ export function useUserIdentity() {
|
|||||||
publicKey: userData.publicKey
|
publicKey: userData.publicKey
|
||||||
};
|
};
|
||||||
keyStore.setIdentity(coreIdentity);
|
keyStore.setIdentity(coreIdentity);
|
||||||
|
setIdentity({ ...coreIdentity, isUnlocked: false });
|
||||||
|
setIsUnlocked(false);
|
||||||
|
setIsRestoring(true);
|
||||||
|
|
||||||
// Try to auto-restore if we have persisted key
|
try {
|
||||||
const restoredKeyBytes = await tryRestoreKey(userData.did);
|
// Try to auto-restore if we have persisted key. Every async boundary is
|
||||||
if (restoredKeyBytes) {
|
// account-bound so an older refresh cannot overwrite a newer switch.
|
||||||
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
const restoredKeyBytes = await tryRestoreKey(userData.did);
|
||||||
keyStore.setPrivateKey(cryptoKey);
|
if (generation !== generationRef.current
|
||||||
setIdentity({ ...coreIdentity, isUnlocked: true });
|
|| keyStore.getIdentity()?.did !== userData.did) return;
|
||||||
setIsUnlocked(true);
|
if (restoredKeyBytes) {
|
||||||
} else {
|
const cryptoKey = await importPrivateKey(restoredKeyBytes);
|
||||||
setIdentity({ ...coreIdentity, isUnlocked: false });
|
if (generation !== generationRef.current
|
||||||
setIsUnlocked(false);
|
|| keyStore.getIdentity()?.did !== userData.did) return;
|
||||||
|
keyStore.setPrivateKey(cryptoKey);
|
||||||
|
setIdentity({ ...coreIdentity, isUnlocked: true });
|
||||||
|
setIsUnlocked(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (generation === generationRef.current) setIsRestoring(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -124,6 +135,8 @@ export function useUserIdentity() {
|
|||||||
userPublicKey?: string
|
userPublicKey?: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
const generation = ++generationRef.current;
|
||||||
|
setIsRestoring(false);
|
||||||
console.log('[Identity] Unlocking with DID:', userDid, 'Handle:', userHandle);
|
console.log('[Identity] Unlocking with DID:', userDid, 'Handle:', userHandle);
|
||||||
|
|
||||||
// Set identity first if provided
|
// Set identity first if provided
|
||||||
@@ -151,6 +164,12 @@ export function useUserIdentity() {
|
|||||||
const binaryDer = Buffer.from(privateKeyBase64, 'base64');
|
const binaryDer = Buffer.from(privateKeyBase64, 'base64');
|
||||||
const cryptoKey = await importPrivateKey(binaryDer);
|
const cryptoKey = await importPrivateKey(binaryDer);
|
||||||
|
|
||||||
|
if (generation !== generationRef.current
|
||||||
|
|| !userDid
|
||||||
|
|| keyStore.getIdentity()?.did !== userDid) {
|
||||||
|
throw new Error('Active account changed while the identity was unlocking');
|
||||||
|
}
|
||||||
|
|
||||||
// Store in memory
|
// Store in memory
|
||||||
keyStore.setPrivateKey(cryptoKey);
|
keyStore.setPrivateKey(cryptoKey);
|
||||||
|
|
||||||
@@ -161,6 +180,8 @@ export function useUserIdentity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await persistUnlockedKey(privateKeyBase64, password, userDid);
|
await persistUnlockedKey(privateKeyBase64, password, userDid);
|
||||||
|
if (generation !== generationRef.current
|
||||||
|
|| keyStore.getIdentity()?.did !== userDid) return;
|
||||||
|
|
||||||
console.log('[Identity] Private key stored in memory and persisted');
|
console.log('[Identity] Private key stored in memory and persisted');
|
||||||
|
|
||||||
@@ -181,28 +202,34 @@ export function useUserIdentity() {
|
|||||||
* Lock the identity (manual lock, keeps identity info)
|
* Lock the identity (manual lock, keeps identity info)
|
||||||
*/
|
*/
|
||||||
const lockIdentity = useCallback(async () => {
|
const lockIdentity = useCallback(async () => {
|
||||||
|
const generation = ++generationRef.current;
|
||||||
const identifier = keyStore.getIdentity()?.did;
|
const identifier = keyStore.getIdentity()?.did;
|
||||||
keyStore.clear();
|
keyStore.clear();
|
||||||
await clearPersistentKey(identifier);
|
|
||||||
setIsUnlocked(false);
|
setIsUnlocked(false);
|
||||||
|
setIsRestoring(false);
|
||||||
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
|
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
|
||||||
|
await clearPersistentKey(identifier);
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the identity (logout)
|
* Clear the identity (logout)
|
||||||
*/
|
*/
|
||||||
const clearIdentity = useCallback(async () => {
|
const clearIdentity = useCallback(async () => {
|
||||||
|
const generation = ++generationRef.current;
|
||||||
const identifier = keyStore.getIdentity()?.did;
|
const identifier = keyStore.getIdentity()?.did;
|
||||||
keyStore.clear();
|
keyStore.clear();
|
||||||
await clearPersistentKey(identifier);
|
|
||||||
setIdentity(null);
|
setIdentity(null);
|
||||||
setIsUnlocked(false);
|
setIsUnlocked(false);
|
||||||
|
setIsRestoring(false);
|
||||||
|
await clearPersistentKey(identifier);
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign a user action
|
* Sign a user action
|
||||||
*/
|
*/
|
||||||
const signUserAction = useCallback(async (action: string, data: any) => {
|
const signUserAction = useCallback(async (action: string, data: unknown) => {
|
||||||
const pk = keyStore.getPrivateKey();
|
const pk = keyStore.getPrivateKey();
|
||||||
const id = keyStore.getIdentity();
|
const id = keyStore.getIdentity();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ const rateLimits = new Map<string, number[]>();
|
|||||||
// Configuration for cleanup
|
// Configuration for cleanup
|
||||||
const CLEANUP_INTERVAL_MS = 60 * 1000; // Run cleanup every minute
|
const CLEANUP_INTERVAL_MS = 60 * 1000; // Run cleanup every minute
|
||||||
const MAX_IDLE_DID_MS = 10 * 60 * 1000; // Remove DIDs with no recent activity after 10 minutes
|
const MAX_IDLE_DID_MS = 10 * 60 * 1000; // Remove DIDs with no recent activity after 10 minutes
|
||||||
|
const MAX_RATE_LIMIT_KEYS = 10_000;
|
||||||
|
|
||||||
|
function evictOldestKey(): void {
|
||||||
|
let oldestKey: string | null = null;
|
||||||
|
let oldestTimestamp = Number.POSITIVE_INFINITY;
|
||||||
|
for (const [key, timestamps] of rateLimits) {
|
||||||
|
const newest = timestamps.at(-1) ?? 0;
|
||||||
|
if (newest < oldestTimestamp) {
|
||||||
|
oldestTimestamp = newest;
|
||||||
|
oldestKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestKey) rateLimits.delete(oldestKey);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a DID is rate limited
|
* Check if a DID is rate limited
|
||||||
@@ -23,6 +37,7 @@ const MAX_IDLE_DID_MS = 10 * 60 * 1000; // Remove DIDs with no recent activity a
|
|||||||
*/
|
*/
|
||||||
export function isRateLimited(did: string, maxRequests = 5, windowMs = 60000): boolean {
|
export function isRateLimited(did: string, maxRequests = 5, windowMs = 60000): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
if (!rateLimits.has(did) && rateLimits.size >= MAX_RATE_LIMIT_KEYS) evictOldestKey();
|
||||||
const timestamps = rateLimits.get(did) || [];
|
const timestamps = rateLimits.get(did) || [];
|
||||||
|
|
||||||
// Filter to only keep timestamps within the window (sliding window)
|
// Filter to only keep timestamps within the window (sliding window)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* Handles node discovery and announcement in the swarm network.
|
* Handles node discovery and announcement in the swarm network.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db, nodes, users, posts } from '@/db';
|
import { db, users, posts } from '@/db';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
|
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
|
||||||
import { getCurrentBuildInfo } from '@/lib/version';
|
import { getCurrentBuildInfo } from '@/lib/version';
|
||||||
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
|
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
|
||||||
@@ -55,7 +55,7 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
|
|||||||
postCount = Number(postResult[0]?.count ?? 0);
|
postCount = Number(postResult[0]?.count ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions'];
|
const capabilities: SwarmCapability[] = ['handles', 'gossip', 'interactions', 'e2ee_dm_v1'];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domain,
|
domain,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
import { getActiveSwarmNodes } from './registry';
|
import { getActiveSwarmNodes } from './registry';
|
||||||
import type { SwarmNodeInfo } from './types';
|
import type { SwarmNodeInfo } from './types';
|
||||||
import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||||
|
import { getPublicSwarmDomain } from './node-domain';
|
||||||
|
import { safeFederationRequest } from './safe-federation-http';
|
||||||
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -390,6 +392,49 @@ export interface SwarmProfileResponse {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEVELOPMENT_LOOPBACK_DOMAIN =
|
||||||
|
/^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?$/i;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSwarmUserPost(value: unknown): value is SwarmUserPost {
|
||||||
|
if (!isRecord(value)) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
typeof value.id === 'string' &&
|
||||||
|
typeof value.content === 'string' &&
|
||||||
|
typeof value.createdAt === 'string' &&
|
||||||
|
typeof value.isNsfw === 'boolean' &&
|
||||||
|
typeof value.likesCount === 'number' &&
|
||||||
|
typeof value.repostsCount === 'number' &&
|
||||||
|
typeof value.repliesCount === 'number'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSwarmProfileResponse(value: unknown): value is SwarmProfileResponse {
|
||||||
|
if (!isRecord(value) || !isRecord(value.profile) || !Array.isArray(value.posts)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = value.profile;
|
||||||
|
return (
|
||||||
|
typeof profile.handle === 'string' &&
|
||||||
|
typeof profile.displayName === 'string' &&
|
||||||
|
typeof profile.followersCount === 'number' &&
|
||||||
|
typeof profile.followingCount === 'number' &&
|
||||||
|
typeof profile.postsCount === 'number' &&
|
||||||
|
typeof profile.createdAt === 'string' &&
|
||||||
|
typeof profile.isNsfw === 'boolean' &&
|
||||||
|
typeof profile.nodeIsNsfw === 'boolean' &&
|
||||||
|
typeof profile.nodeDomain === 'string' &&
|
||||||
|
typeof value.nodeDomain === 'string' &&
|
||||||
|
typeof value.timestamp === 'string' &&
|
||||||
|
value.posts.every(isSwarmUserPost)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a user profile from a swarm node
|
* Fetch a user profile from a swarm node
|
||||||
*/
|
*/
|
||||||
@@ -401,33 +446,51 @@ export async function fetchSwarmUserProfile(
|
|||||||
): Promise<SwarmProfileResponse | null> {
|
): Promise<SwarmProfileResponse | null> {
|
||||||
try {
|
try {
|
||||||
const normalizedDomain = normalizeNodeDomain(domain);
|
const normalizedDomain = normalizeNodeDomain(domain);
|
||||||
if (await isNodeBlocked(normalizedDomain)) {
|
const publicDomain = getPublicSwarmDomain(normalizedDomain);
|
||||||
|
const developmentDomain =
|
||||||
|
process.env.NODE_ENV === 'development' &&
|
||||||
|
DEVELOPMENT_LOOPBACK_DOMAIN.test(normalizedDomain)
|
||||||
|
? normalizedDomain
|
||||||
|
: null;
|
||||||
|
const targetDomain = publicDomain ?? developmentDomain;
|
||||||
|
const cleanHandle = handle.trim().replace(/^@/, '').toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!targetDomain ||
|
||||||
|
!/^[a-z0-9_]{1,64}$/.test(cleanHandle) ||
|
||||||
|
(await isNodeBlocked(targetDomain))
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = domain.startsWith('http')
|
const baseUrl = developmentDomain
|
||||||
? domain
|
? `http://${targetDomain}`
|
||||||
: normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
|
: `https://${targetDomain}`;
|
||||||
? `http://${normalizedDomain}`
|
const url = new URL(`/api/swarm/users/${encodeURIComponent(cleanHandle)}`, baseUrl);
|
||||||
: `https://${normalizedDomain}`;
|
url.searchParams.set(
|
||||||
|
'limit',
|
||||||
|
String(Number.isSafeInteger(postsLimit) ? Math.min(Math.max(postsLimit, 0), 50) : 25)
|
||||||
|
);
|
||||||
|
if (cursor) url.searchParams.set('cursor', cursor.slice(0, 128));
|
||||||
|
|
||||||
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
const response = await safeFederationRequest(url.toString(), {
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
signal: controller.signal,
|
maxResponseBytes: 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeout);
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
const payload = response.json();
|
||||||
|
if (
|
||||||
|
!isSwarmProfileResponse(payload) ||
|
||||||
|
payload.profile.handle.toLowerCase() !== cleanHandle
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Swarm] Failed to fetch profile for ${handle}@${domain}:`, error);
|
console.error(`[Swarm] Failed to fetch profile for ${handle}@${domain}:`, error);
|
||||||
return null;
|
return null;
|
||||||
@@ -452,7 +515,6 @@ export async function cacheSwarmUserPosts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { db, remotePosts } = await import('@/db');
|
const { db, remotePosts } = await import('@/db');
|
||||||
const { eq } = await import('drizzle-orm');
|
|
||||||
|
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return { cached: 0, skipped: 0 };
|
return { cached: 0, skipped: 0 };
|
||||||
@@ -672,8 +734,7 @@ export async function deliverSwarmPost(
|
|||||||
*/
|
*/
|
||||||
export async function getSwarmFollowerDomains(userId: string): Promise<string[]> {
|
export async function getSwarmFollowerDomains(userId: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const { db, remoteFollowers } = await import('@/db');
|
const { db } = await import('@/db');
|
||||||
const { eq } = await import('drizzle-orm');
|
|
||||||
|
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
|
|||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
E2EE_FEDERATION_MAX_REQUEST_BYTES,
|
||||||
SafeFederationError,
|
SafeFederationError,
|
||||||
createSafeFederationRequester,
|
createSafeFederationRequester,
|
||||||
isPublicFederationAddress,
|
isPublicFederationAddress,
|
||||||
@@ -200,6 +201,17 @@ describe('E2EE federation URL and DNS policy', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('E2EE federation HTTP behavior', () => {
|
describe('E2EE federation HTTP behavior', () => {
|
||||||
|
it('rejects an oversized request body before DNS or network activity', async () => {
|
||||||
|
const dnsResolver = vi.fn(async () => [{ address: '8.8.8.8', family: 4 as const }]);
|
||||||
|
const request = createSafeFederationRequester({ development: false, dnsResolver });
|
||||||
|
|
||||||
|
await expect(request('https://node.synapsis.social/api/chat/receive', {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'x'.repeat(E2EE_FEDERATION_MAX_REQUEST_BYTES + 1),
|
||||||
|
})).rejects.toSatisfy(expectCode('REQUEST_TOO_LARGE'));
|
||||||
|
expect(dnsResolver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('supports GET, bounded buffering, UTF-8 text, and JSON parsing', async () => {
|
it('supports GET, bounded buffering, UTF-8 text, and JSON parsing', async () => {
|
||||||
const server = await startServer((_request, response) => {
|
const server = await startServer((_request, response) => {
|
||||||
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
@@ -338,5 +350,11 @@ describe('E2EE federation HTTP behavior', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
request('http://127.0.0.1:1/', { headers: { 'content-length': '999' } })
|
request('http://127.0.0.1:1/', { headers: { 'content-length': '999' } })
|
||||||
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
||||||
|
await expect(
|
||||||
|
request('http://127.0.0.1:1/', { headers: { authorization: 'Bearer secret' } })
|
||||||
|
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
||||||
|
await expect(
|
||||||
|
request('http://127.0.0.1:1/', { headers: { cookie: 'session=secret' } })
|
||||||
|
).rejects.toSatisfy(expectCode('INVALID_HEADER'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,10 +20,14 @@ const EXACT_DEVELOPMENT_LOOPBACK_URL =
|
|||||||
/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?(?:[/?#]|$)/i;
|
/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?(?:[/?#]|$)/i;
|
||||||
const FORBIDDEN_REQUEST_HEADERS = new Set([
|
const FORBIDDEN_REQUEST_HEADERS = new Set([
|
||||||
'accept-encoding',
|
'accept-encoding',
|
||||||
|
'authorization',
|
||||||
'connection',
|
'connection',
|
||||||
'content-length',
|
'content-length',
|
||||||
|
'cookie',
|
||||||
|
'expect',
|
||||||
'host',
|
'host',
|
||||||
'proxy-authorization',
|
'proxy-authorization',
|
||||||
|
'proxy-connection',
|
||||||
'te',
|
'te',
|
||||||
'trailer',
|
'trailer',
|
||||||
'transfer-encoding',
|
'transfer-encoding',
|
||||||
|
|||||||
+91
-29
@@ -10,13 +10,41 @@ import crypto from 'crypto';
|
|||||||
import { db, users } from '@/db';
|
import { db, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { canonicalize } from '@/lib/crypto/user-signing';
|
import { canonicalize } from '@/lib/crypto/user-signing';
|
||||||
import { isNodeBlocked } from './node-blocklist';
|
import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
|
||||||
import { getPublicSwarmDomain } from './node-domain';
|
import { getPublicSwarmDomain } from './node-domain';
|
||||||
|
import { safeFederationRequest } from './safe-federation-http';
|
||||||
|
|
||||||
|
const NODE_PUBLIC_KEY_CACHE_TTL_MS = 60_000;
|
||||||
|
const MAX_NODE_PUBLIC_KEY_CACHE_ENTRIES = 1_000;
|
||||||
|
const nodePublicKeyCache = new Map<string, { publicKey: string; expiresAt: number }>();
|
||||||
|
const pendingNodePublicKeyRequests = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
function cacheNodePublicKey(domain: string, publicKey: string): void {
|
||||||
|
if (!nodePublicKeyCache.has(domain)
|
||||||
|
&& nodePublicKeyCache.size >= MAX_NODE_PUBLIC_KEY_CACHE_ENTRIES) {
|
||||||
|
const oldest = nodePublicKeyCache.keys().next().value as string | undefined;
|
||||||
|
if (oldest) nodePublicKeyCache.delete(oldest);
|
||||||
|
}
|
||||||
|
nodePublicKeyCache.set(domain, {
|
||||||
|
publicKey,
|
||||||
|
expiresAt: Date.now() + NODE_PUBLIC_KEY_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFederationDomain(domain: string): { domain: string; protocol: 'http' | 'https' } | null {
|
||||||
|
const normalized = normalizeNodeDomain(domain);
|
||||||
|
const developmentLoopback = process.env.NODE_ENV === 'development'
|
||||||
|
&& /^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?$/i.test(normalized);
|
||||||
|
if (developmentLoopback) return { domain: normalized, protocol: 'http' };
|
||||||
|
|
||||||
|
const publicDomain = getPublicSwarmDomain(normalized);
|
||||||
|
return publicDomain ? { domain: publicDomain, protocol: 'https' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign a payload with the node's private key
|
* Sign a payload with the node's private key
|
||||||
*/
|
*/
|
||||||
export function signPayload(payload: any, privateKey: string): string {
|
export function signPayload(payload: unknown, privateKey: string): string {
|
||||||
const canonicalPayload = canonicalize(payload);
|
const canonicalPayload = canonicalize(payload);
|
||||||
const sign = crypto.createSign('SHA256');
|
const sign = crypto.createSign('SHA256');
|
||||||
sign.update(canonicalPayload);
|
sign.update(canonicalPayload);
|
||||||
@@ -40,7 +68,7 @@ function normalizePublicKey(publicKey: string): crypto.KeyObject | string {
|
|||||||
/**
|
/**
|
||||||
* Verify a signature using the sender's public key
|
* Verify a signature using the sender's public key
|
||||||
*/
|
*/
|
||||||
export function verifySignature(payload: any, signature: string, publicKey: string): boolean {
|
export function verifySignature(payload: unknown, signature: string, publicKey: string): boolean {
|
||||||
try {
|
try {
|
||||||
const canonicalPayload = canonicalize(payload);
|
const canonicalPayload = canonicalize(payload);
|
||||||
const verify = crypto.createVerify('SHA256');
|
const verify = crypto.createVerify('SHA256');
|
||||||
@@ -58,29 +86,51 @@ export function verifySignature(payload: any, signature: string, publicKey: stri
|
|||||||
*/
|
*/
|
||||||
export async function getNodePublicKey(domain: string): Promise<string | null> {
|
export async function getNodePublicKey(domain: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const normalizedDomain = getPublicSwarmDomain(domain);
|
const target = resolveFederationDomain(domain);
|
||||||
if (!normalizedDomain) {
|
if (!target) {
|
||||||
console.warn(`[Signature] Refusing public key fetch for non-public node ${domain}`);
|
console.warn(`[Signature] Refusing public key fetch for non-public node ${domain}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const normalizedDomain = target.domain;
|
||||||
if (await isNodeBlocked(normalizedDomain)) {
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
|
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a cached node info
|
const cached = nodePublicKeyCache.get(normalizedDomain);
|
||||||
const response = await fetch(`https://${normalizedDomain}/api/node`, {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
headers: { 'Accept': 'application/json' },
|
nodePublicKeyCache.delete(normalizedDomain);
|
||||||
signal: AbortSignal.timeout(5000),
|
nodePublicKeyCache.set(normalizedDomain, cached);
|
||||||
});
|
return cached.publicKey;
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
if (cached) nodePublicKeyCache.delete(normalizedDomain);
|
||||||
|
|
||||||
const data = await response.json();
|
const pending = pendingNodePublicKeyRequests.get(normalizedDomain);
|
||||||
return data.publicKey || null;
|
if (pending) return await pending;
|
||||||
|
|
||||||
|
const keyRequest = (async (): Promise<string | null> => {
|
||||||
|
const response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/node`, {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
maxResponseBytes: 64 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.json() as { publicKey?: unknown };
|
||||||
|
const publicKey = typeof data.publicKey === 'string' ? data.publicKey : null;
|
||||||
|
if (publicKey) cacheNodePublicKey(normalizedDomain, publicKey);
|
||||||
|
return publicKey;
|
||||||
|
})();
|
||||||
|
pendingNodePublicKeyRequests.set(normalizedDomain, keyRequest);
|
||||||
|
try {
|
||||||
|
return await keyRequest;
|
||||||
|
} finally {
|
||||||
|
pendingNodePublicKeyRequests.delete(normalizedDomain);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Signature] Error fetching public key from ${domain}:`, error);
|
console.error(`[Signature] Error fetching public key from ${domain}:`, error);
|
||||||
return null;
|
return null;
|
||||||
@@ -96,15 +146,16 @@ export async function getNodePublicKey(domain: string): Promise<string | null> {
|
|||||||
* @returns true if signature is valid, false otherwise
|
* @returns true if signature is valid, false otherwise
|
||||||
*/
|
*/
|
||||||
export async function verifySwarmRequest(
|
export async function verifySwarmRequest(
|
||||||
payload: any,
|
payload: unknown,
|
||||||
signature: string,
|
signature: string,
|
||||||
senderDomain: string
|
senderDomain: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const normalizedDomain = getPublicSwarmDomain(senderDomain);
|
const target = resolveFederationDomain(senderDomain);
|
||||||
if (!normalizedDomain) {
|
if (!target) {
|
||||||
console.warn(`[Signature] Rejected non-public swarm node ${senderDomain}`);
|
console.warn(`[Signature] Rejected non-public swarm node ${senderDomain}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const normalizedDomain = target.domain;
|
||||||
if (await isNodeBlocked(normalizedDomain)) {
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
|
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -134,17 +185,18 @@ export async function verifySwarmRequest(
|
|||||||
* @returns true if signature is valid, false otherwise
|
* @returns true if signature is valid, false otherwise
|
||||||
*/
|
*/
|
||||||
export async function verifyUserInteraction(
|
export async function verifyUserInteraction(
|
||||||
payload: any,
|
payload: unknown,
|
||||||
signature: string,
|
signature: string,
|
||||||
userHandle: string,
|
userHandle: string,
|
||||||
userDomain: string
|
userDomain: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const normalizedDomain = getPublicSwarmDomain(userDomain);
|
const target = resolveFederationDomain(userDomain);
|
||||||
if (!normalizedDomain) {
|
if (!target) {
|
||||||
console.warn(`[Signature] Rejected user interaction from non-public node ${userDomain}`);
|
console.warn(`[Signature] Rejected user interaction from non-public node ${userDomain}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const normalizedDomain = target.domain;
|
||||||
if (await isNodeBlocked(normalizedDomain)) {
|
if (await isNodeBlocked(normalizedDomain)) {
|
||||||
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
|
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -152,7 +204,7 @@ export async function verifyUserInteraction(
|
|||||||
|
|
||||||
// Try to get cached user
|
// Try to get cached user
|
||||||
const fullHandle = `${userHandle}@${normalizedDomain}`;
|
const fullHandle = `${userHandle}@${normalizedDomain}`;
|
||||||
let user = await db?.query.users.findFirst({
|
const user = await db?.query.users.findFirst({
|
||||||
where: { handle: fullHandle },
|
where: { handle: fullHandle },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,18 +214,28 @@ export async function verifyUserInteraction(
|
|||||||
publicKey = user.publicKey;
|
publicKey = user.publicKey;
|
||||||
} else {
|
} else {
|
||||||
// Fetch from remote node
|
// Fetch from remote node
|
||||||
const response = await fetch(`https://${normalizedDomain}/api/users/${userHandle}`, {
|
const response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/users/${encodeURIComponent(userHandle)}`, {
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
signal: AbortSignal.timeout(5000),
|
timeoutMs: 5_000,
|
||||||
|
maxResponseBytes: 64 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.status < 200 || response.status >= 300) {
|
||||||
console.error(`[Signature] Failed to fetch user ${userHandle}@${normalizedDomain}: ${response.status}`);
|
console.error(`[Signature] Failed to fetch user ${userHandle}@${normalizedDomain}: ${response.status}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userData = await response.json();
|
const userData = response.json() as {
|
||||||
publicKey = userData.user?.publicKey || userData.publicKey;
|
publicKey?: unknown;
|
||||||
|
user?: {
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
did?: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
publicKey?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const resolvedPublicKey = userData.user?.publicKey || userData.publicKey;
|
||||||
|
publicKey = typeof resolvedPublicKey === 'string' ? resolvedPublicKey : null;
|
||||||
|
|
||||||
// Cache the user if we don't have them
|
// Cache the user if we don't have them
|
||||||
if (!user && publicKey && db) {
|
if (!user && publicKey && db) {
|
||||||
@@ -217,7 +279,7 @@ export async function getNodePrivateKey(): Promise<string> {
|
|||||||
/**
|
/**
|
||||||
* Create a signed payload for sending to another node
|
* Create a signed payload for sending to another node
|
||||||
*/
|
*/
|
||||||
export async function createSignedPayload(payload: any): Promise<{ payload: any; signature: string }> {
|
export async function createSignedPayload<T>(payload: T): Promise<{ payload: T; signature: string }> {
|
||||||
const privateKey = await getNodePrivateKey();
|
const privateKey = await getNodePrivateKey();
|
||||||
const signature = signPayload(payload, privateKey);
|
const signature = signPayload(payload, privateKey);
|
||||||
return { payload, signature };
|
return { payload, signature };
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface SwarmNodeInfo {
|
|||||||
lastSeenAt?: string;
|
lastSeenAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search' | 'interactions';
|
export type SwarmCapability = 'handles' | 'gossip' | 'relay' | 'search' | 'interactions' | 'e2ee_dm_v1';
|
||||||
|
|
||||||
export interface SwarmAnnouncement {
|
export interface SwarmAnnouncement {
|
||||||
domain: string;
|
domain: string;
|
||||||
|
|||||||
+34
-10
@@ -1,5 +1,6 @@
|
|||||||
import { db, users } from '@/db';
|
import { db, users } from '@/db';
|
||||||
import { eq, or } from 'drizzle-orm';
|
import { normalizeSigningPublicKey } from '@/lib/crypto/did-key';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
export interface RemoteProfile {
|
export interface RemoteProfile {
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -10,6 +11,14 @@ export interface RemoteProfile {
|
|||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signingKeysEqual(left: string, right: string): boolean {
|
||||||
|
const normalizedLeft = normalizeSigningPublicKey(left);
|
||||||
|
const normalizedRight = normalizeSigningPublicKey(right);
|
||||||
|
return normalizedLeft && normalizedRight
|
||||||
|
? normalizedLeft === normalizedRight
|
||||||
|
: left === right;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert a remote user into the local database for caching/display purposes.
|
* Upsert a remote user into the local database for caching/display purposes.
|
||||||
*
|
*
|
||||||
@@ -19,20 +28,34 @@ export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
|
|||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if user already exists
|
if (!profile.handle.includes('@')) {
|
||||||
const existing = await db.query.users.findFirst({
|
throw new Error('Remote user cache requires a fully qualified handle');
|
||||||
where: { OR: [{ did: profile.did }, { handle: profile.handle }] },
|
}
|
||||||
});
|
|
||||||
|
const [byDid, byHandle] = await Promise.all([
|
||||||
|
db.query.users.findFirst({ where: { did: profile.did } }),
|
||||||
|
db.query.users.findFirst({ where: { handle: profile.handle } }),
|
||||||
|
]);
|
||||||
|
if (byDid && byHandle && byDid.id !== byHandle.id) {
|
||||||
|
throw new Error('Remote user identity conflicts with the existing cache');
|
||||||
|
}
|
||||||
|
const existing = byDid || byHandle;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Update metadata if changed
|
if (!existing.handle.includes('@') && !existing.id.startsWith('swarm:')) {
|
||||||
// Self-healing: Update public key if missing
|
throw new Error('Federation cannot modify a local user');
|
||||||
|
}
|
||||||
|
if (existing.did !== profile.did || existing.handle !== profile.handle) {
|
||||||
|
throw new Error('Remote user DID or handle changed unexpectedly');
|
||||||
|
}
|
||||||
|
if (profile.publicKey && existing.publicKey
|
||||||
|
&& !signingKeysEqual(profile.publicKey, existing.publicKey)) {
|
||||||
|
throw new Error('Remote user signing key changed unexpectedly');
|
||||||
|
}
|
||||||
const shouldUpdateKey = profile.publicKey && !existing.publicKey;
|
const shouldUpdateKey = profile.publicKey && !existing.publicKey;
|
||||||
|
|
||||||
await db.update(users)
|
await db.update(users)
|
||||||
.set({
|
.set({
|
||||||
did: existing.did || profile.did,
|
|
||||||
handle: existing.handle || profile.handle,
|
|
||||||
displayName: profile.displayName || existing.displayName,
|
displayName: profile.displayName || existing.displayName,
|
||||||
avatarUrl: profile.avatarUrl || existing.avatarUrl,
|
avatarUrl: profile.avatarUrl || existing.avatarUrl,
|
||||||
isBot: profile.isBot ?? existing.isBot,
|
isBot: profile.isBot ?? existing.isBot,
|
||||||
@@ -41,6 +64,7 @@ export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, existing.id));
|
.where(eq(users.id, existing.id));
|
||||||
} else {
|
} else {
|
||||||
|
if (!profile.publicKey) throw new Error('Remote user signing key is required');
|
||||||
// Create new placeholder user
|
// Create new placeholder user
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
did: profile.did,
|
did: profile.did,
|
||||||
@@ -48,7 +72,7 @@ export async function upsertRemoteUser(profile: RemoteProfile): Promise<void> {
|
|||||||
displayName: profile.displayName || profile.handle,
|
displayName: profile.displayName || profile.handle,
|
||||||
avatarUrl: profile.avatarUrl || null,
|
avatarUrl: profile.avatarUrl || null,
|
||||||
isBot: profile.isBot || false,
|
isBot: profile.isBot || false,
|
||||||
publicKey: profile.publicKey || '', // Cache provided key or default to empty
|
publicKey: profile.publicKey,
|
||||||
// Note: nodeId is null for remote placeholders unless we specifically link it
|
// Note: nodeId is null for remote placeholders unless we specifically link it
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user