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