Secure send: the pages a person actually touches
This commit is contained in:
154
plugins/tools/frontend/views/SecureSendRead.vue
Normal file
154
plugins/tools/frontend/views/SecureSendRead.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="reveal-page">
|
||||
<div class="reveal-card card">
|
||||
<h1>A secret was shared with you</h1>
|
||||
|
||||
<!-- CHECKING, not revealing. The page asks whether the link is still live
|
||||
before anyone clicks, so it can say what will happen rather than
|
||||
spending the only view to find out. -->
|
||||
<p v-if="state === 'checking'" class="muted">Checking the link...</p>
|
||||
|
||||
<template v-else-if="state === 'ready'">
|
||||
<p>
|
||||
Opening this destroys it. Make sure you can save or use the secret now -
|
||||
it cannot be shown twice, and nobody can recover it for you.
|
||||
</p>
|
||||
<!-- An explicit click, never automatic. Mail and chat scanners fetch
|
||||
links to check them; a page that revealed on load would burn the
|
||||
secret before its recipient ever saw it. -->
|
||||
<button class="btn btn-primary" :disabled="busy" @click="reveal">
|
||||
{{ busy ? 'Opening...' : 'Show me the secret' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'revealed'">
|
||||
<p class="muted">Here it is. It is gone from the server now.</p>
|
||||
<pre class="secret">{{ plaintext }}</pre>
|
||||
<button class="btn btn-primary" @click="copySecret">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'nokey'">
|
||||
<p class="problem">This link is missing its key.</p>
|
||||
<p class="muted">
|
||||
The part after the # is what decrypts the secret, and it is not here.
|
||||
Some chat and mail systems trim it when they rewrite links. Ask the
|
||||
sender to share the link again - by a route that keeps it whole, or
|
||||
pasted as plain text - and to create a new secret if this one has since
|
||||
expired.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'undecryptable'">
|
||||
<p class="problem">The secret could not be decrypted.</p>
|
||||
<p class="muted">
|
||||
The link opened, but the key in it does not match. That usually means
|
||||
the link was altered in transit. The secret has now been used up, so
|
||||
ask the sender for a new one.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="problem">This link is not valid.</p>
|
||||
<p class="muted">
|
||||
It may have been opened already, or it may have expired. Secrets are
|
||||
destroyed once read, so a link only works the number of times the
|
||||
sender chose. Ask them for a new one.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toolsApi } from '../api'
|
||||
import { openSecret, cryptoAvailable } from '../securesend'
|
||||
|
||||
const route = useRoute()
|
||||
const state = ref('checking')
|
||||
const plaintext = ref('')
|
||||
const busy = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
const token = route.params.token
|
||||
// The key lives in the fragment, which the router does not parse and the server
|
||||
// never receives. Read it off the raw location.
|
||||
const fragmentKey = (window.location.hash || '').replace(/^#/, '')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!cryptoAvailable()) {
|
||||
state.value = 'undecryptable'
|
||||
return
|
||||
}
|
||||
if (!fragmentKey) {
|
||||
state.value = 'nokey'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await toolsApi.peekSecret(token)
|
||||
state.value = response.data.data.available ? 'ready' : 'gone'
|
||||
} catch (err) {
|
||||
state.value = 'gone'
|
||||
}
|
||||
})
|
||||
|
||||
async function reveal() {
|
||||
busy.value = true
|
||||
try {
|
||||
const response = await toolsApi.redeemSecret(token)
|
||||
const { ciphertext, iv } = response.data.data
|
||||
plaintext.value = await openSecret({ ciphertext, iv, fragmentKey })
|
||||
state.value = 'revealed'
|
||||
} catch (err) {
|
||||
// A failed DECRYPT is different from a missing secret, and the difference
|
||||
// matters to the person reading: one means the link was mangled, the other
|
||||
// means it was already used. The secret is spent either way.
|
||||
state.value = err.response ? 'gone' : 'undecryptable'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plaintext.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch (err) {
|
||||
copied.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Standalone: this page is opened by somebody who may have no account here, so
|
||||
it carries no navigation and nothing to click into. */
|
||||
.reveal-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.reveal-card { max-width: 32rem; width: 100%; padding: 2rem; }
|
||||
.reveal-card h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
||||
.muted { color: var(--text-light); font-size: 0.9rem; }
|
||||
.problem { color: var(--warning); font-weight: 600; margin: 0 0 0.5rem; }
|
||||
|
||||
.secret {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.95rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user