add password prompt to ZK viewer for wrapped uploads

This commit is contained in:
2026-10-26 20:06:54 +01:00
parent 112aa99f1e
commit dc8e576679
2 changed files with 92 additions and 5 deletions
+33 -2
View File
@@ -4,6 +4,7 @@ import { api } from '@/plugins/axios';
import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult'; import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { toArrayBuffer } from '@/utils/toArrayBuffer'; import { toArrayBuffer } from '@/utils/toArrayBuffer';
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; import { decryptZkUpload } from '@/utils/upload/decryptZkUpload';
export const useViewStore = defineStore('view', () => { export const useViewStore = defineStore('view', () => {
@@ -22,13 +23,43 @@ export const useViewStore = defineStore('view', () => {
return data; return data;
}; };
const loadZk = async (payload: GetZkUploadCiphertextResult, fragmentKey: string): Promise<void> => { const loadZk = async (
payload: GetZkUploadCiphertextResult,
fragmentKey: string,
password?: string
): Promise<void> => {
clearImage(); clearImage();
const iv = base64ToBytes(payload.iv); const iv = base64ToBytes(payload.iv);
const ciphertext = base64ToBytes(payload.ciphertext); const ciphertext = base64ToBytes(payload.ciphertext);
const plaintext = await decryptZkUpload(ciphertext, iv, fragmentKey); let plaintext: Uint8Array;
if (payload.passwordProtected) {
if (!password) {
throw new Error('Password required');
}
if (!payload.wrappedDek || !payload.wrapIv || !payload.salt) {
throw new Error('Incomplete password protection data');
}
const wrappedDek = base64ToBytes(payload.wrappedDek);
const wrapIv = base64ToBytes(payload.wrapIv);
const salt = base64ToBytes(payload.salt);
plaintext = await decryptPasswordProtectedZkUpload(
ciphertext,
iv,
fragmentKey,
password,
wrappedDek,
wrapIv,
salt
);
} else {
plaintext = await decryptZkUpload(ciphertext, iv, fragmentKey);
}
const buffer = toArrayBuffer(plaintext); const buffer = toArrayBuffer(plaintext);
+59 -3
View File
@@ -2,6 +2,29 @@
<main class="flex min-h-screen items-center justify-center p-4"> <main class="flex min-h-screen items-center justify-center p-4">
<p v-if="fetching" class="text-sm text-muted-foreground">Loading image...</p> <p v-if="fetching" class="text-sm text-muted-foreground">Loading image...</p>
<form v-else-if="zkPayload" class="flex w-full max-w-sm flex-col gap-3" @submit.prevent="onUnlockSubmit">
<label class="flex flex-col gap-1.5 text-sm">
<span>Password</span>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
:disabled="unlocking"
/>
</label>
<p v-if="unlockError" class="text-sm text-destructive">{{ unlockError }}</p>
<button
type="submit"
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="unlocking || !password"
>
{{ unlocking ? 'Unlocking...' : 'View image' }}
</button>
</form>
<img v-else-if="imageObjectUrl" :src="imageObjectUrl" class="max-h-[80vh] max-w-full object-contain" /> <img v-else-if="imageObjectUrl" :src="imageObjectUrl" class="max-h-[80vh] max-w-full object-contain" />
</main> </main>
</template> </template>
@@ -11,12 +34,18 @@ import { storeToRefs } from 'pinia';
import { onBeforeMount, onBeforeUnmount, ref } from 'vue'; import { onBeforeMount, onBeforeUnmount, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useViewStore } from '@/stores/view'; import { useViewStore } from '@/stores/view';
import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult';
const route = useRoute(); const route = useRoute();
const viewStore = useViewStore(); const viewStore = useViewStore();
const { imageObjectUrl } = storeToRefs(viewStore); const { imageObjectUrl } = storeToRefs(viewStore);
const fetching = ref(false); const fetching = ref(false);
const unlocking = ref(false);
const zkPayload = ref<GetZkUploadCiphertextResult | null>(null);
const fragmentKey = ref('');
const password = ref('');
const unlockError = ref('');
onBeforeMount(async () => { onBeforeMount(async () => {
const uploadId = route.params.id; const uploadId = route.params.id;
@@ -25,22 +54,24 @@ onBeforeMount(async () => {
return; return;
} }
const fragmentKey = route.hash.slice(1); const key = route.hash.slice(1);
if (!fragmentKey) { if (!key) {
return; return;
} }
fragmentKey.value = key;
fetching.value = true; fetching.value = true;
try { try {
const payload = await viewStore.fetchZkCiphertext(uploadId); const payload = await viewStore.fetchZkCiphertext(uploadId);
if (payload.passwordProtected) { if (payload.passwordProtected) {
zkPayload.value = payload;
return; return;
} }
await viewStore.loadZk(payload, fragmentKey); await viewStore.loadZk(payload, key);
} catch (error) { } catch (error) {
console.error('[viewer] load failed', { uploadId, error }); console.error('[viewer] load failed', { uploadId, error });
} finally { } finally {
@@ -48,6 +79,31 @@ onBeforeMount(async () => {
} }
}); });
const onUnlockSubmit = async (): Promise<void> => {
const payload = zkPayload.value;
const key = fragmentKey.value;
if (!payload || !key) {
return;
}
unlocking.value = true;
unlockError.value = '';
try {
await viewStore.loadZk(payload, key, password.value);
zkPayload.value = null;
password.value = '';
} catch (error) {
console.error('[viewer] unlock failed', { error });
unlockError.value = 'Wrong password';
} finally {
unlocking.value = false;
}
};
onBeforeUnmount(() => { onBeforeUnmount(() => {
viewStore.clearImage(); viewStore.clearImage();
}); });