Files
shopdb-flask/plugins/tools/frontend/views/SecureSendRead.vue
cproudlock 7fc109d91a
Some checks failed
CI / backend (push) Failing after 7m16s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
Give secure send a card, a form, and a page for the person receiving it
The backend has been there since d4b49b9 with nothing to click. This is the
half a person actually touches.

A card under a new "Secrets & Access" heading on Tech Tools, which is where
somebody looks for a utility - the whole reason the plugin moved there.

THE CREATE PAGE encrypts before anything leaves the browser and shows the link
once. The plaintext is dropped from memory as soon as it is sealed, and the page
says plainly that the key is in the link and exists nowhere else, because
somebody who does not know that will paste the link somewhere durable.

THE READ PAGE IS PUBLIC, at /s/<token>, outside the app shell: the recipient
usually has no account here, so it carries no navigation and asks for no login.
Short path because the whole link gets pasted into a chat message.

IT NEVER REVEALS ON LOAD. It asks whether the link is still live, then says
"opening this destroys it" and waits for a click. A page that revealed
automatically would be burned by the first scanner that fetched it - which is
the same reason redeeming is a POST.

A MISSING FRAGMENT GETS ITS OWN MESSAGE. If a link rewriter strips the part
after the #, the recipient is told the link is missing its key and what to ask
for, rather than being shown a generic failure. That is the one failure mode
this design cannot recover from, so it is worth naming to the person looking at
it.

The client-side guards live in securesend.js and are advisory by nature - the
server cannot see plaintext, so its size limit is what enforces. They refuse a
pasted FILE outright, which removes the easiest wrong path without anyone
reading a rule, and they warn on something document-shaped: over the length a
credential needs, more than twenty lines, or starting with a PDF/zip/image
signature.

Web Crypto only, no dependency, AES-GCM so a tampered ciphertext fails to
decrypt rather than producing plausible rubbish. The key is base64url for the
fragment, which survives copy-paste and link rewriting better than the standard
alphabet.

VERIFIED THAT THE PAGES RENDER, headlessly, against the dev server: the card
appears under Secrets & Access and the create form draws with its counter and
options. NOT VERIFIED END TO END - no secret has been created, sent, opened or
decrypted, because that needs the dev API restarted and the table migrated.
2026-08-21 15:10:52 -04:00

155 lines
5.0 KiB
Vue

<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>