Fix Gashboard encrypted chat on HTTP and open the native signer on launch
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user