Secure send: the pages a person actually touches

This commit is contained in:
2026-08-21 15:11:04 -04:00
parent a01f5c1a83
commit 94fd70391a
6 changed files with 535 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
<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>

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