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.
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'),
|
||||
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,
|
||||
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 = {
|
||||
labels: 'Labels & Printing',
|
||||
convert: 'Conversion',
|
||||
security: 'Secrets & Access',
|
||||
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