Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ on:
required: true
type: string
workflow_dispatch:
inputs:
version:
required: false
type: string

permissions:
contents: write
Expand Down
22 changes: 14 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.result }}
actions: write

steps:
- name: Extract version from labels
Expand Down Expand Up @@ -50,9 +49,16 @@ jobs:

core.info(`Created release ${version}`);

docker:
needs: release
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.release.outputs.version }}
secrets: inherit
- name: Trigger Docker image build
uses: actions/github-script@v7
with:
script: |
const version = '${{ steps.version.outputs.result }}';
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'docker.yml',
ref: 'main',
inputs: { version },
});
core.info(`Dispatched docker.yml build for ${version}`);
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ PBKDF2 key derivation → ECDH key exchange (P-256) → AES-GCM message encrypti
- **File naming**: Backend `camelCase.ts`, components `PascalCase.tsx`, hooks `useXxx.ts`, models `camelCase.ts`, tests `__tests__/name.test.ts`
- **Backend barrels**: `mod.ts`; **Frontend**: no barrels (direct imports)
- **Import order**: external libs → types → core/models → components (atoms→molecules→organisms) → utils
- **No code comments**: do not add explanatory comments; code should be self-documenting through clear naming
- Never mention AI usage in code or documentation
- Do not add AI co-author lines to commits

Expand Down
3 changes: 1 addition & 2 deletions app/src/js/core/models/channel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* global JsonWebKey */
import { flow, makeAutoObservable } from "mobx";
import { Channel } from "../../types.ts";
import type { AppModel } from "./app.ts";
Expand All @@ -17,7 +16,7 @@ export class ChannelModel {
channelType: "DIRECT" | "PRIVATE" | "PUBLIC";
root: AppModel;

channelKey: JsonWebKey | null = null;
channelKey: CryptoKey | null = null;

constructor(value: Channel, root: AppModel) {
makeAutoObservable(this, { root: false });
Expand Down
5 changes: 2 additions & 3 deletions app/src/js/core/tools/messageEncryption.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* global JsonWebKey */
import * as enc from "@quack/encryption";
import { BaseMessage, FullMessage, Message, MessageData } from "../client.ts";
import { EncryptedData, EncryptedMessage } from "../../types.ts";
Expand All @@ -8,7 +7,7 @@ type Messages = Message | Message[];
export class MessageEncryption {
static decrypt = async (
msg: Messages,
encryptionKey?: JsonWebKey | null,
encryptionKey?: CryptoKey | null,
): Promise<FullMessage[]> => {
try {
if (!encryptionKey) {
Expand Down Expand Up @@ -40,7 +39,7 @@ export class MessageEncryption {

static encrypt = async (
msg: FullMessage,
encryptionKey: JsonWebKey,
encryptionKey: CryptoKey,
): Promise<Partial<Message>> => {
const { clientId, channelId, parentId, ...data } = msg;
if (!encryptionKey) {
Expand Down
3 changes: 2 additions & 1 deletion deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
"test": "DATABASE_URL='mongodb://chat:chat@localhost:27017/tests?authSource=admin' deno test -A",
"ssl": "deno run -A deno/tools/generate-ssl.ts",
"check": "deno fmt && deno lint && deno task test",
"mobile:config": "deno run -A deno/tools/export-mobile-config.ts"
"mobile:config": "deno run -A deno/tools/export-mobile-config.ts",
"auth:status": "deno run -A deno/tools/auth-migration-status.ts"
},
"compilerOptions": {
"jsx": "react",
Expand Down
76 changes: 47 additions & 29 deletions deno/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import type {
} from "./types.ts";
import * as enc from "@quack/encryption";
import type API from "./mod.ts";
import {
clearSessionKeys,
loadSessionKeys,
saveSessionKeys,
type SessionKeys,
} from "./cryptoStore.ts";

export class ApiError extends Error {
payload: Record<string, unknown>;
Expand Down Expand Up @@ -54,7 +60,6 @@ class AuthAPI extends EventTarget {
{ email, password }: { email: string; password: string },
): Promise<Result<UserSession, LoginError>> {
const credentials = await enc.prepareCredentials(email, password);
localStorage.setItem("key", credentials.key);
const ret = await this.api.fetchWithCredentials("/api/auth/session", {
method: "POST",
body: JSON.stringify(credentials.login),
Expand All @@ -64,53 +69,66 @@ class AuthAPI extends EventTarget {
return { status: "error", ...error };
}
const session: UserSession = await ret.json();
await this.validateSession(session);
await this.activateSession(session, credentials.encryptionKey);
return session;
}

async restoreSession(): Promise<Result<UserSession>> {
const key = localStorage.getItem("key");
if (!key) return { status: "error" };
const keys = await loadSessionKeys();
if (!keys) return { status: "error" };
const ret = await this.api.fetchWithCredentials("/api/auth/session");
const session = await ret.json();
if (!await this.validateSession(session)) {
localStorage.removeItem("token");
localStorage.removeItem("userId");
localStorage.removeItem("key");
if (session.status !== "ok") {
await this.clear();
return session;
}
this.applyKeys(session, keys);
return session;
}

async validateSession(session: UserSession): Promise<boolean> {
async activateSession(
session: UserSession,
encryptionKey: JsonWebKey,
): Promise<boolean> {
try {
const key = localStorage.getItem("key");
if (!key) return false;
if (session.status === "ok") {
localStorage.setItem("userId", session.userId);
this.api.token = session.token;
localStorage.setItem("token", session.token);
const encryptionKey = enc.joinJSON<JsonWebKey>([key, session.key]);
const secrets: UserSessionSecrets = await enc.decrypt(
session.secrets,
encryptionKey,
);
if (secrets.sanityCheck !== "valid") return false;
this.api.userEncryptionKey = secrets.encryptionKey;
this.api.privateKey = secrets.privateKey;
this.api.publicKey = session.publicKey;
return true;
if (session.status !== "ok") return false;
const secrets: UserSessionSecrets = await enc.decrypt(
session.secrets,
encryptionKey,
);
if (secrets.sanityCheck !== "valid") return false;
const keys: SessionKeys = {
privateKey: await enc.importPrivateKey(secrets.privateKey),
};
this.applyKeys(session, keys);
try {
await saveSessionKeys(keys);
} catch (e) {
console.warn("Could not persist session keys", e);
}
return false;
return true;
} catch (e) {
console.error("Error validating session", e);
console.error("Error activating session", e);
return false;
}
}

async logout() {
localStorage.removeItem("key");
applyKeys(session: UserSession, keys: SessionKeys) {
localStorage.setItem("userId", session.userId);
this.api.token = session.token;
localStorage.setItem("token", session.token);
this.api.privateKey = keys.privateKey;
this.api.publicKey = session.publicKey;
}

async clear() {
localStorage.removeItem("token");
localStorage.removeItem("userId");
await clearSessionKeys();
}

async logout() {
await this.clear();
const ret = await this.api.fetchWithCredentials("/api/auth/session", {
method: "DELETE",
body: "{}",
Expand Down
89 changes: 89 additions & 0 deletions deno/api/cryptoStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// deno-lint-ignore-file no-explicit-any

const DB_NAME = "quack";
const STORE = "keys";
const RECORD_ID = "session";

export type SessionKeys = {
privateKey: CryptoKey;
};

function hasIndexedDB(): boolean {
return !!(globalThis as any).indexedDB;
}

function openDb(): Promise<any> {
return new Promise((resolve, reject) => {
const idb = (globalThis as any).indexedDB;
if (!idb) {
reject(new Error("IndexedDB not available"));
return;
}
const req = idb.open(DB_NAME, 1);
req.onupgradeneeded = (e: any) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE);
}
};
req.onsuccess = (e: any) => resolve(e.target.result);
req.onerror = (e: any) => reject(e.target.error);
});
}

export async function saveSessionKeys(keys: SessionKeys): Promise<void> {
if (!hasIndexedDB()) return;
const db = await openDb();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(keys, RECORD_ID);
tx.oncomplete = () => resolve();
tx.onerror = (e: any) => reject(e.target.error);
});
} finally {
db.close();
}
}

export async function loadSessionKeys(): Promise<SessionKeys | null> {
let db: any;
try {
db = await openDb();
} catch {
return null;
}
try {
const result = await new Promise<any>((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).get(RECORD_ID);
req.onsuccess = (e: any) => resolve(e.target.result ?? null);
req.onerror = (e: any) => reject(e.target.error);
});
if (result && result.privateKey instanceof CryptoKey) {
return result as SessionKeys;
}
return null;
} finally {
db.close();
}
}

export async function clearSessionKeys(): Promise<void> {
let db: any;
try {
db = await openDb();
} catch {
return;
}
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).delete(RECORD_ID);
tx.oncomplete = () => resolve();
tx.onerror = (e: any) => reject(e.target.error);
});
} finally {
db.close();
}
}
4 changes: 1 addition & 3 deletions deno/api/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,7 @@ class API extends EventTarget {

abortController: AbortController;

userEncryptionKey: JsonWebKey | null = null;

privateKey: JsonWebKey | null = null;
privateKey: CryptoKey | null = null;

publicKey: JsonWebKey | null = null;

Expand Down
1 change: 0 additions & 1 deletion deno/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ export type UserSession = {
userId: string;
publicKey: JsonWebKey;
secrets: EncryptedData;
key: string;
};

export type LoginError = {
Expand Down
Loading
Loading