Fix Gashboard encrypted chat on HTTP and open the native signer on launch

This commit is contained in:
yaya
2026-10-06 07:58:36 +01:00
parent 6faebff8ba
commit 6650ae2ca2
8 changed files with 71 additions and 33 deletions
+1
View File
@@ -10,6 +10,7 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@noble/ciphers": "0.5.3",
"applesauce-accounts": "5.1.0",
"applesauce-signers": "5.1.0",
"nostr-tools": "2.10.4",
+7 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import { useAuthStore } from "./stores/auth";
import { useStatsStore } from "./stores/stats";
import ChatDrawer from "./components/ChatDrawer.vue";
@@ -9,6 +9,11 @@ import ReleaseNotesDrawer from "./components/ReleaseNotesDrawer.vue";
const auth = useAuthStore();
const stats = useStatsStore();
const route = useRoute();
const router = useRouter();
function logout(): void {
auth.logout();
void router.push({ name: "login" });
}
const crt = ref(false);
const chatOpen = ref(false);
const releaseOpen = ref(false);
@@ -63,7 +68,7 @@ watch(
<template v-if="auth.isLoggedIn">
<button class="thin" @click="chatOpen = !chatOpen">chat</button>
<span class="muted">{{ auth.npub ? shortNpub(auth.npub) : "" }}</span>
<button class="thin" @click="auth.logout()">logout</button>
<button class="thin" @click="logout">logout</button>
</template>
</div>
</header>
@@ -26,7 +26,7 @@ const mediaStickers = computed(() => CHAT_STICKERS.filter((sticker) => sticker.i
const textStickers = computed(() => CHAT_STICKERS.filter((sticker) => !sticker.image));
onMounted(() => {
if (auth.isLoggedIn) void start();
if (props.open && auth.isLoggedIn) void start();
});
onUnmounted(() => {
@@ -36,10 +36,12 @@ onUnmounted(() => {
watch(
() => auth.isLoggedIn,
(loggedIn) => {
if (loggedIn) void start();
else {
if (loggedIn) {
if (props.open) void start();
} else {
sub?.close();
sub = null;
chat.clearChatSession();
messages.value = [];
profiles.value = {};
reactions.value = {};
@@ -50,16 +52,17 @@ watch(
watch(
() => props.open,
(open) => {
async (open) => {
if (open) {
if (!canSend.value) void auth.restoreSavedSigner();
if (!canSend.value) await auth.restoreSavedSigner();
if (auth.isLoggedIn) await start();
void scrollBottom();
}
},
);
async function start(): Promise<void> {
if (sub) return;
if (sub || loading.value) return;
loading.value = true;
error.value = "";
try {
+21 -17
View File
@@ -1,3 +1,4 @@
import { gcm } from "@noble/ciphers/aes";
import type { Event as NostrEvent } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { SimplePool } from "nostr-tools/pool";
@@ -34,9 +35,17 @@ export type ChatProfile = {
let reactionCache: ChatReaction[] = [];
let cachedRecipients: string[] | null = null;
let cachedRoomKey: CryptoKey | null = null;
let cachedRoomKey: Uint8Array | null = null;
let cachedRoomKeyRaw = "";
export function clearChatSession(): void {
cachedRoomKey?.fill(0);
cachedRoomKey = null;
cachedRoomKeyRaw = "";
cachedRecipients = null;
reactionCache = [];
}
export function canSendChat(): boolean {
return hasActiveSigner();
}
@@ -164,11 +173,9 @@ async function encryptPayload(plaintext: string): Promise<string> {
const key = await ensureRoomKey();
try {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(plaintext),
);
// WebCrypto is unavailable on LAN HTTP. Noble preserves the same AES-GCM
// wire format (ciphertext followed by the 128-bit authentication tag).
const ciphertext = gcm(key, iv).encrypt(new TextEncoder().encode(plaintext));
return JSON.stringify({
v: 1,
alg: "aes-gcm",
@@ -192,11 +199,9 @@ async function decryptPayload(value: string): Promise<string> {
return "[encrypted with unknown chat format]";
}
const key = await ensureRoomKey();
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: unbase64(envelope.iv) },
key,
unbase64(envelope.ciphertext),
);
const iv = unbase64(envelope.iv);
if (iv.length !== 12) throw new Error("Invalid chat nonce");
const decrypted = gcm(key, iv).decrypt(unbase64(envelope.ciphertext));
return new TextDecoder().decode(decrypted);
} catch {
return "[encrypted - decrypt failed]";
@@ -218,7 +223,7 @@ async function getRecipients(): Promise<string[]> {
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
}
async function ensureRoomKey(snapshot?: ChatSnapshot): Promise<CryptoKey> {
async function ensureRoomKey(snapshot?: ChatSnapshot): Promise<Uint8Array> {
if (cachedRoomKey) return cachedRoomKey;
const signer = getActiveSigner();
if (!signer) throw new Error("Reconnect your signer to unlock private chat");
@@ -250,12 +255,11 @@ async function publishRoomKey(raw: string): Promise<void> {
await postChatKeyWraps({ wraps });
}
async function importRoomKey(raw: string): Promise<CryptoKey> {
async function importRoomKey(raw: string): Promise<Uint8Array> {
if (cachedRoomKey && cachedRoomKeyRaw === raw) return cachedRoomKey;
cachedRoomKey = await crypto.subtle.importKey("raw", unbase64(raw), { name: "AES-GCM" }, false, [
"encrypt",
"decrypt",
]);
const key = unbase64(raw);
if (key.length !== 32) throw new Error("Invalid private chat room key");
cachedRoomKey = key;
cachedRoomKeyRaw = raw;
return cachedRoomKey;
}
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(stored?.token ?? null);
const error = ref<string | null>(null);
const busy = ref(false);
const nativeLoginPending = ref(!stored?.token);
const restoringSigner = ref(false);
const isLoggedIn = computed(() => !!token.value);
@@ -107,6 +108,7 @@ export const useAuthStore = defineStore("auth", () => {
}
function logout(): void {
nativeLoginPending.value = false;
api.clearToken();
signer.clearSigner();
token.value = null;
@@ -133,6 +135,7 @@ export const useAuthStore = defineStore("auth", () => {
error,
busy,
restoringSigner,
nativeLoginPending,
isLoggedIn,
hasActiveSigner,
loginExtension,
+4
View File
@@ -120,6 +120,10 @@ export type ApiError = { error: { code: string; message: string } };
declare global {
interface Window {
archipelagoNostr?: {
selectIdentity(): Promise<unknown>;
getSelectedIdentity(): { nostr_pubkey: string } | null;
};
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: {
@@ -9,13 +9,23 @@ const route = useRoute();
const bunkerUri = ref("");
const showBunkerInput = ref(false);
const waitingRemote = ref(false);
const choosingIdentity = ref(false);
async function loginExt(): Promise<void> {
async function loginExt(initialLaunch = false): Promise<void> {
if (auth.busy || choosingIdentity.value) return;
choosingIdentity.value = true;
auth.error = null;
try {
const native = window.archipelagoNostr;
if (native && (!initialLaunch || !native.getSelectedIdentity())) {
await native.selectIdentity();
}
await auth.loginExtension();
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
} catch (error) {
auth.error = error instanceof Error ? error.message : "Sign-in cancelled";
} finally {
choosingIdentity.value = false;
}
}
@@ -58,7 +68,12 @@ async function resumeRemoteApp(): Promise<void> {
}
onMounted(() => {
if (route.query.remote === "return") void resumeRemoteApp();
if (route.query.remote === "return") {
void resumeRemoteApp();
} else if (window.archipelagoNostr && auth.nativeLoginPending && !auth.isLoggedIn) {
auth.nativeLoginPending = false;
void loginExt(true);
}
});
</script>
@@ -73,14 +88,14 @@ onMounted(() => {
</div>
<div class="options">
<button class="primary big" :disabled="auth.busy" @click="loginExt">
<button class="primary big" :disabled="auth.busy || choosingIdentity" @click="loginExt(false)">
{{ auth.busy ? "..." : "sign in with Nostr" }}
<small>Archipelago identity or browser extension</small>
</button>
<button
class="primary big"
:disabled="auth.busy"
:disabled="auth.busy || choosingIdentity"
@click="loginRemoteApp"
>
{{ waitingRemote ? "waiting for signer..." : "open signer app" }}
@@ -89,7 +104,7 @@ onMounted(() => {
<button
class="big"
:disabled="auth.busy"
:disabled="auth.busy || choosingIdentity"
@click="showBunkerInput = !showBunkerInput"
>
bunker URI
@@ -104,7 +119,7 @@ onMounted(() => {
spellcheck="false"
autocomplete="off"
/>
<button class="primary" :disabled="auth.busy" @click="loginBunker">
<button class="primary" :disabled="auth.busy || choosingIdentity" @click="loginBunker">
{{ auth.busy ? "connecting…" : "connect" }}
</button>
</div>
+3
View File
@@ -65,6 +65,9 @@ importers:
apps/web:
dependencies:
'@noble/ciphers':
specifier: 0.5.3
version: 0.5.3
applesauce-accounts:
specifier: 5.1.0
version: 5.1.0(typescript@5.7.2)