Secure send: the pages a person actually touches
This commit is contained in:
23
plugins/tools/frontend/api.js
Normal file
23
plugins/tools/frontend/api.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Tech Tools API client.
|
||||||
|
//
|
||||||
|
// Only secure send needs one - every other tool runs entirely in the browser
|
||||||
|
// and talks to nothing.
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
export const toolsApi = {
|
||||||
|
createSecret(payload) {
|
||||||
|
return api.post('/tools/secrets', payload)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Is the link still live? Spends nothing, so it is safe for the read page to
|
||||||
|
// ask before anyone clicks.
|
||||||
|
peekSecret(token) {
|
||||||
|
return api.get(`/tools/secrets/${token}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// POST, not GET, and that is deliberate: chat and mail clients prefetch GET
|
||||||
|
// urls to build previews, and a preview must not consume a one-view secret.
|
||||||
|
redeemSecret(token) {
|
||||||
|
return api.post(`/tools/secrets/${token}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -23,4 +23,26 @@ export default [
|
|||||||
component: () => import('./views/CodeGenerator.vue'),
|
component: () => import('./views/CodeGenerator.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'tools' }
|
meta: { requiresAuth: true, plugin: 'tools' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tools/secure-send',
|
||||||
|
name: 'tools-secure-send',
|
||||||
|
component: () => import('./views/SecureSendCreate.vue'),
|
||||||
|
// Creating a secret needs a login AND tools.secrets. The route guard covers
|
||||||
|
// the login; the server enforces the permission, and the page surfaces its
|
||||||
|
// refusal rather than pretending the form worked.
|
||||||
|
meta: { requiresAuth: true, plugin: 'tools' }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// PUBLIC and OUTSIDE AppLayout. The recipient of a secret usually has no account
|
||||||
|
// here, so this page carries no navigation and asks for no login - the
|
||||||
|
// unguessable token in the path, and the key in the fragment, are what let them
|
||||||
|
// read it. Short path because the whole link gets pasted into a chat message.
|
||||||
|
export const toplevel = [
|
||||||
|
{
|
||||||
|
path: '/s/:token',
|
||||||
|
name: 'secure-send-read',
|
||||||
|
component: () => import('./views/SecureSendRead.vue'),
|
||||||
|
meta: { plugin: 'tools' }
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
136
plugins/tools/frontend/securesend.js
Normal file
136
plugins/tools/frontend/securesend.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// Client-side crypto for secure send.
|
||||||
|
//
|
||||||
|
// THE KEY NEVER LEAVES THIS FILE'S PROCESS. It is generated here, put in the
|
||||||
|
// URL fragment, and read back out of the fragment on the other side. Browsers
|
||||||
|
// do not transmit fragments, so it reaches the recipient's browser and nowhere
|
||||||
|
// else - not the server, not its logs, not a proxy in between. That is what
|
||||||
|
// makes the stored ciphertext useless to anyone who reads the database.
|
||||||
|
//
|
||||||
|
// AES-GCM because it authenticates as well as encrypts: a tampered ciphertext
|
||||||
|
// fails to decrypt rather than producing plausible rubbish.
|
||||||
|
//
|
||||||
|
// Web Crypto only, no dependency. It is available on every browser the fleet
|
||||||
|
// runs and requires a secure context, which is the right constraint anyway -
|
||||||
|
// the page and its scripts still cross the network even though the key does not.
|
||||||
|
|
||||||
|
const ALGORITHM = 'AES-GCM'
|
||||||
|
const KEY_BITS = 256
|
||||||
|
const IV_BYTES = 12
|
||||||
|
|
||||||
|
function toBase64(bytes) {
|
||||||
|
let binary = ''
|
||||||
|
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64(text) {
|
||||||
|
const binary = atob(text)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64url for the fragment: '+' and '/' are legal in a fragment but survive
|
||||||
|
// copy-paste and link rewriting far less reliably than the url-safe alphabet.
|
||||||
|
function toBase64Url(bytes) {
|
||||||
|
return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64Url(text) {
|
||||||
|
const padded = text.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
return fromBase64(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cryptoAvailable() {
|
||||||
|
return typeof crypto !== 'undefined' && !!crypto.subtle
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt text. Returns the payload for the server AND the key for the link. */
|
||||||
|
export async function sealSecret(plaintext) {
|
||||||
|
const key = await crypto.subtle.generateKey(
|
||||||
|
{ name: ALGORITHM, length: KEY_BITS }, true, ['encrypt', 'decrypt'])
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES))
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: ALGORITHM, iv }, key, new TextEncoder().encode(plaintext))
|
||||||
|
const raw = await crypto.subtle.exportKey('raw', key)
|
||||||
|
return {
|
||||||
|
ciphertext: toBase64(ciphertext),
|
||||||
|
iv: toBase64(iv),
|
||||||
|
// Goes in the fragment. Never sent anywhere.
|
||||||
|
fragmentKey: toBase64Url(raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt what the server handed back, using the key from the fragment. */
|
||||||
|
export async function openSecret({ ciphertext, iv, fragmentKey }) {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw', fromBase64Url(fragmentKey),
|
||||||
|
{ name: ALGORITHM, length: KEY_BITS }, false, ['decrypt'])
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{ name: ALGORITHM, iv: fromBase64(iv) }, key, fromBase64(ciphertext))
|
||||||
|
return new TextDecoder().decode(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Keeping this from becoming a document transfer ---------------------------
|
||||||
|
//
|
||||||
|
// The server can only bound SIZE, because it cannot read what it holds. These
|
||||||
|
// run here, where the plaintext is still visible, and are advisory: they guide
|
||||||
|
// somebody who is about to do the wrong thing without realising. A determined
|
||||||
|
// person can POST directly, which is why the size limit on the server is the
|
||||||
|
// control that actually enforces.
|
||||||
|
|
||||||
|
// Matches MAX_CIPHERTEXT server-side, minus room for base64 expansion and the
|
||||||
|
// GCM tag, so the browser refuses before a round trip rather than after one.
|
||||||
|
export const MAX_PLAINTEXT = 2800
|
||||||
|
|
||||||
|
const MAX_LINES = 20
|
||||||
|
|
||||||
|
// A pasted file, recognised by how it starts. Cheap, high signal.
|
||||||
|
const FILE_SIGNATURES = [
|
||||||
|
['%PDF', 'a PDF'],
|
||||||
|
['PK\x03\x04', 'a zip or Office file'],
|
||||||
|
['{\\rtf', 'an RTF document'],
|
||||||
|
['\x89PNG', 'a PNG image'],
|
||||||
|
// JPEG magic bytes as escapes rather than literals: the naming gate bans
|
||||||
|
// non-ASCII source, and these are bytes rather than text anyway.
|
||||||
|
['\xff\xd8\xff', 'a JPEG image'],
|
||||||
|
['data:', 'a data URI'],
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Why this text should not be sent, or null when it is fine. */
|
||||||
|
export function refuseReason(text) {
|
||||||
|
const value = text || ''
|
||||||
|
if (!value.trim()) return 'Nothing to send yet.'
|
||||||
|
|
||||||
|
if (value.length > MAX_PLAINTEXT) {
|
||||||
|
return `That is ${value.length} characters. This is for passwords and short `
|
||||||
|
+ `text, not documents - the limit is ${MAX_PLAINTEXT}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [signature, description] of FILE_SIGNATURES) {
|
||||||
|
if (value.startsWith(signature)) {
|
||||||
|
return `That looks like ${description} pasted as text. Secure send is for `
|
||||||
|
+ 'credentials, not file transfer.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = value.split('\n').length
|
||||||
|
if (lines > MAX_LINES) {
|
||||||
|
return `That is ${lines} lines. A password is one - this looks like a `
|
||||||
|
+ 'document, and secure send is not a file transfer.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a paste that carries FILES rather than text.
|
||||||
|
*
|
||||||
|
* Removes the easiest wrong path - dragging a PDF in, or Ctrl+V on a copied
|
||||||
|
* file - without anyone having to read a rule about it.
|
||||||
|
*/
|
||||||
|
export function pasteCarriesFiles(event) {
|
||||||
|
const items = event?.clipboardData?.items
|
||||||
|
if (!items) return false
|
||||||
|
return Array.from(items).some(item => item.kind === 'file')
|
||||||
|
}
|
||||||
@@ -24,11 +24,21 @@ export const TOOLS = [
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
keywords: ['qr', 'barcode', 'code128', 'label', 'zebra', 'sticker', 'csv', 'print'],
|
keywords: ['qr', 'barcode', 'code128', 'label', 'zebra', 'sticker', 'csv', 'print'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'secure-send',
|
||||||
|
name: 'Secure Send',
|
||||||
|
description: 'Hand somebody a password by a link that works once and then destroys it.',
|
||||||
|
category: 'security',
|
||||||
|
route: '/tools/secure-send',
|
||||||
|
keywords: ['password', 'secret', 'credential', 'share', 'link', 'expire',
|
||||||
|
'one time', 'burn', 'pusher'],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const CATEGORY_LABELS = {
|
export const CATEGORY_LABELS = {
|
||||||
labels: 'Labels & Printing',
|
labels: 'Labels & Printing',
|
||||||
convert: 'Conversion',
|
convert: 'Conversion',
|
||||||
|
security: 'Secrets & Access',
|
||||||
network: 'Network',
|
network: 'Network',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
190
plugins/tools/frontend/views/SecureSendCreate.vue
Normal file
190
plugins/tools/frontend/views/SecureSendCreate.vue
Normal 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>
|
||||||
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