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.
191 lines
6.7 KiB
Vue
191 lines
6.7 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h1>Secure Send</h1>
|
|
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
|
</div>
|
|
|
|
<div class="card intro">
|
|
<p>
|
|
Hand somebody a password without leaving it in a chat window. The text is
|
|
encrypted in this browser and the key travels in the link, never to the
|
|
server - so nobody here can read what you send, including an administrator.
|
|
</p>
|
|
<p class="muted">
|
|
The link works once, then the secret is destroyed. For passwords and short
|
|
text only, not documents.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="!supported" class="card control-note">
|
|
This browser cannot encrypt in page (Web Crypto is unavailable, which
|
|
usually means the site is not being served over HTTPS). Secure send needs
|
|
it, because the whole point is that the server never sees your text.
|
|
</div>
|
|
|
|
<template v-else-if="!link">
|
|
<div class="card">
|
|
<label class="field">
|
|
<span class="field-label">Secret</span>
|
|
<textarea
|
|
v-model="plaintext"
|
|
class="form-control"
|
|
rows="4"
|
|
placeholder="Password, connection string, short note..."
|
|
@paste="onPaste"
|
|
></textarea>
|
|
</label>
|
|
<p class="counter" :class="{ over: plaintext.length > MAX_PLAINTEXT }">
|
|
{{ plaintext.length }} / {{ MAX_PLAINTEXT }} characters
|
|
</p>
|
|
<p v-if="refusal" class="control-note">{{ refusal }}</p>
|
|
|
|
<div class="options">
|
|
<label class="field">
|
|
<span class="field-label">Expires after</span>
|
|
<select v-model.number="expiryhours" class="form-control">
|
|
<option :value="1">1 hour</option>
|
|
<option :value="8">8 hours</option>
|
|
<option :value="24">1 day</option>
|
|
<option :value="72">3 days</option>
|
|
<option :value="168">7 days</option>
|
|
</select>
|
|
</label>
|
|
<label class="field">
|
|
<span class="field-label">Can be opened</span>
|
|
<select v-model.number="views" class="form-control">
|
|
<option :value="1">Once</option>
|
|
<option :value="2">Twice</option>
|
|
<option :value="3">3 times</option>
|
|
<option :value="5">5 times</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<button class="btn btn-primary" :disabled="busy || !!refusal" @click="createLink">
|
|
{{ busy ? 'Encrypting...' : 'Create link' }}
|
|
</button>
|
|
<p v-if="error" class="control-note">{{ error }}</p>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="card">
|
|
<h3>Your link</h3>
|
|
<p class="muted">
|
|
Send this to the person who needs it. It opens
|
|
{{ views === 1 ? 'once' : views + ' times' }} and expires {{ expiryLabel }}.
|
|
</p>
|
|
<div class="link-row">
|
|
<input ref="linkInput" class="form-control mono" :value="link" readonly @focus="selectAll" />
|
|
<button class="btn btn-primary" @click="copyLink">{{ copied ? 'Copied' : 'Copy' }}</button>
|
|
</div>
|
|
<p class="control-hint">
|
|
This is the only time the link is shown - it is not stored anywhere you can
|
|
look it up, and the key in it exists nowhere else.
|
|
</p>
|
|
<button class="btn btn-secondary" @click="reset">Send another</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed } from 'vue'
|
|
import { toolsApi } from '../api'
|
|
import {
|
|
sealSecret, refuseReason, pasteCarriesFiles, cryptoAvailable, MAX_PLAINTEXT,
|
|
} from '../securesend'
|
|
|
|
const supported = cryptoAvailable()
|
|
|
|
const plaintext = ref('')
|
|
const expiryhours = ref(24)
|
|
const views = ref(1)
|
|
const busy = ref(false)
|
|
const error = ref('')
|
|
const link = ref('')
|
|
const copied = ref(false)
|
|
const linkInput = ref(null)
|
|
|
|
// Only nag once there is something to nag about: an empty box is not a mistake.
|
|
const refusal = computed(() => (plaintext.value ? refuseReason(plaintext.value) : null))
|
|
|
|
const expiryLabel = computed(() => {
|
|
const hours = expiryhours.value
|
|
if (hours < 24) return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
|
const days = hours / 24
|
|
return `in ${days} day${days === 1 ? '' : 's'}`
|
|
})
|
|
|
|
function onPaste(event) {
|
|
// A pasted FILE is the easiest wrong path into a tool that is not a file
|
|
// transfer. Refuse it here rather than letting its bytes become "text".
|
|
if (pasteCarriesFiles(event)) {
|
|
event.preventDefault()
|
|
error.value = 'Secure send takes text, not files. Paste the password itself.'
|
|
}
|
|
}
|
|
|
|
async function createLink() {
|
|
error.value = ''
|
|
busy.value = true
|
|
try {
|
|
const sealed = await sealSecret(plaintext.value)
|
|
const response = await toolsApi.createSecret({
|
|
ciphertext: sealed.ciphertext,
|
|
iv: sealed.iv,
|
|
expiryhours: expiryhours.value,
|
|
views: views.value,
|
|
})
|
|
const token = response.data.data.token
|
|
// The key rides in the FRAGMENT. Browsers never send it, so it reaches the
|
|
// recipient and nowhere else.
|
|
link.value = `${window.location.origin}/s/${token}#${sealed.fragmentKey}`
|
|
// Drop the plaintext as soon as it is sealed: no reason to keep it in memory
|
|
// behind the link that replaced it.
|
|
plaintext.value = ''
|
|
} catch (err) {
|
|
error.value = err.response?.data?.data?.error?.message || 'Could not create the link.'
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function copyLink() {
|
|
try {
|
|
await navigator.clipboard.writeText(link.value)
|
|
copied.value = true
|
|
setTimeout(() => { copied.value = false }, 2000)
|
|
} catch (err) {
|
|
// Clipboard access is refused in some hardened configurations. Select the
|
|
// text instead so Ctrl+C still works rather than leaving a dead button.
|
|
selectAll()
|
|
}
|
|
}
|
|
|
|
function selectAll() {
|
|
linkInput.value?.select()
|
|
}
|
|
|
|
function reset() {
|
|
link.value = ''
|
|
error.value = ''
|
|
copied.value = false
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.intro p { margin: 0 0 0.5rem; }
|
|
.muted { color: var(--text-light); font-size: 0.9rem; }
|
|
.field { display: block; margin-bottom: 1rem; }
|
|
.field-label { display: block; font-size: 0.8125rem; color: var(--text-light); margin-bottom: 4px; }
|
|
.options { display: flex; gap: 1rem; flex-wrap: wrap; }
|
|
.options .field { flex: 1 1 12rem; }
|
|
.counter { font-size: 0.8125rem; color: var(--text-light); margin: 0 0 0.5rem; }
|
|
.counter.over { color: var(--warning); font-weight: 600; }
|
|
.control-note { color: var(--warning); font-size: 0.875rem; margin: 8px 0; }
|
|
.control-hint { color: var(--text-light); font-size: 0.8125rem; margin: 8px 0 12px; }
|
|
.link-row { display: flex; gap: 8px; align-items: center; margin: 0.5rem 0; }
|
|
.link-row .form-control { flex: 1; }
|
|
.mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.875rem; }
|
|
</style>
|