Secure send: hand somebody a password without leaving it in a chat window
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"notifications",
|
||||
"printedparts",
|
||||
"printers",
|
||||
"securesend",
|
||||
"slides",
|
||||
"tools",
|
||||
"usb",
|
||||
|
||||
35
frontend/src/router/routes/securesend.js
Normal file
35
frontend/src/router/routes/securesend.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Securesend plugin routes.
|
||||
*
|
||||
* Auto-discovered by the router via import.meta.glob, so no registration
|
||||
* edit is needed. Every route carries meta.plugin 'securesend' so the ADR-009
|
||||
* guard redirects to the dashboard when the securesend backend plugin is
|
||||
* disabled. Form routes add requiresAuth so anonymous users cannot reach
|
||||
* create or edit.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'securesend',
|
||||
name: 'securesend',
|
||||
component: () => import('../../views/securesend/SecuresendList.vue'),
|
||||
meta: { plugin: 'securesend' }
|
||||
},
|
||||
{
|
||||
path: 'securesend/new',
|
||||
name: 'securesend-new',
|
||||
component: () => import('../../views/securesend/SecuresendForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'securesend' }
|
||||
},
|
||||
{
|
||||
path: 'securesend/:id',
|
||||
name: 'securesend-detail',
|
||||
component: () => import('../../views/securesend/SecuresendDetail.vue'),
|
||||
meta: { plugin: 'securesend' }
|
||||
},
|
||||
{
|
||||
path: 'securesend/:id/edit',
|
||||
name: 'securesend-edit',
|
||||
component: () => import('../../views/securesend/SecuresendForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'securesend' }
|
||||
}
|
||||
]
|
||||
149
frontend/src/views/securesend/SecuresendDetail.vue
Normal file
149
frontend/src/views/securesend/SecuresendDetail.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="detail-page" v-if="item">
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title-row">
|
||||
<h1 class="hero-title">{{ item.name || item.assetnumber || 'Securesend' }}</h1>
|
||||
<router-link
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="`/securesend/${itemId}/edit`"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="detail-item" v-if="item.assetnumber">
|
||||
<span class="label">Asset #</span>
|
||||
<span class="value">{{ item.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="item.serialnumber">
|
||||
<span class="label">Serial</span>
|
||||
<span class="value mono">{{ item.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Securesend Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Example Field</span>
|
||||
<span class="info-value">{{ item.examplefield || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Asset Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ item.assetnumber || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ item.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar" v-if="authStore.isAuthenticated">
|
||||
<router-link :to="`/securesend/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="loading-container">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-container">
|
||||
<p>Record not found</p>
|
||||
<router-link to="/securesend" class="btn btn-secondary">Back to Securesend</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/securesend/frontend-api-snippet.js) then swap for:
|
||||
// import { securesendApi } from '../../api'
|
||||
const securesendApi = {
|
||||
list(params = {}) { return api.get('/securesend', { params }) },
|
||||
get(itemId) { return api.get(`/securesend/${itemId}`) },
|
||||
create(data) { return api.post('/securesend', data) },
|
||||
update(itemId, data) { return api.put(`/securesend/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/securesend/${itemId}`) }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const itemId = route.params.id
|
||||
const item = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(loadItem)
|
||||
|
||||
async function loadItem() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await securesendApi.get(itemId)
|
||||
item.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading securesend:', error)
|
||||
item.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (confirm('Delete this record?')) {
|
||||
try {
|
||||
await securesendApi.remove(itemId)
|
||||
router.push('/securesend')
|
||||
} catch (error) {
|
||||
console.error('Error deleting securesend:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
228
frontend/src/views/securesend/SecuresendForm.vue
Normal file
228
frontend/src/views/securesend/SecuresendForm.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Securesend' : 'Add Securesend' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<fieldset>
|
||||
<legend>Asset Information</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number *</label>
|
||||
<input
|
||||
id="assetnumber"
|
||||
v-model="form.assetnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Securesend Details</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="examplefield">Example Field</label>
|
||||
<input
|
||||
id="examplefield"
|
||||
v-model="form.examplefield"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/securesend/frontend-api-snippet.js) then swap for:
|
||||
// import { securesendApi } from '../../api'
|
||||
const securesendApi = {
|
||||
list(params = {}) { return api.get('/securesend', { params }) },
|
||||
get(itemId) { return api.get(`/securesend/${itemId}`) },
|
||||
create(data) { return api.post('/securesend', data) },
|
||||
update(itemId, data) { return api.put(`/securesend/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/securesend/${itemId}`) }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const itemId = route.params.id
|
||||
const isEdit = computed(() => !!itemId)
|
||||
|
||||
const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
serialnumber: '',
|
||||
examplefield: ''
|
||||
})
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
await loadItem()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
try {
|
||||
const response = await securesendApi.get(itemId)
|
||||
const data = response.data.data
|
||||
form.value.assetnumber = data.assetnumber || ''
|
||||
form.value.name = data.name || ''
|
||||
form.value.serialnumber = data.serialnumber || ''
|
||||
form.value.examplefield = data.examplefield || ''
|
||||
} catch (loadError) {
|
||||
console.error('Error loading securesend:', loadError)
|
||||
error.value = 'Failed to load record'
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
examplefield: form.value.examplefield || null
|
||||
}
|
||||
|
||||
let redirectId = itemId
|
||||
if (isEdit.value) {
|
||||
await securesendApi.update(itemId, payload)
|
||||
} else {
|
||||
const response = await securesendApi.create(payload)
|
||||
redirectId = response.data.data?.assetid
|
||||
}
|
||||
|
||||
router.push(redirectId ? `/securesend/${redirectId}` : '/securesend')
|
||||
} catch (submitError) {
|
||||
console.error('Error saving securesend:', submitError)
|
||||
error.value = 'Failed to save record'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (isEdit.value) {
|
||||
router.push(`/securesend/${itemId}`)
|
||||
} else {
|
||||
router.push('/securesend')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: 600;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
margin-bottom: 0.375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
142
frontend/src/views/securesend/SecuresendList.vue
Normal file
142
frontend/src/views/securesend/SecuresendList.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Securesend</h2>
|
||||
<router-link to="/securesend/new" class="btn btn-primary">Add Securesend</router-link>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Name</th>
|
||||
<th>Example Field</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.assetid">
|
||||
<td>{{ item.assetnumber || '-' }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td>{{ item.examplefield || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/securesend/${item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No securesend records found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/securesend/frontend-api-snippet.js) then swap for:
|
||||
// import { securesendApi } from '../../api'
|
||||
const securesendApi = {
|
||||
list(params = {}) { return api.get('/securesend', { params }) },
|
||||
get(itemId) { return api.get(`/securesend/${itemId}`) },
|
||||
create(data) { return api.post('/securesend', data) },
|
||||
update(itemId, data) { return api.put(`/securesend/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/securesend/${itemId}`) }
|
||||
}
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(25)
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
const response = await securesendApi.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading securesend:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(target) {
|
||||
if (target >= 1 && target <= totalPages.value) {
|
||||
page.value = target
|
||||
loadItems()
|
||||
}
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters .form-control {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
71
plugins/securesend/README.md
Normal file
71
plugins/securesend/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Securesend plugin
|
||||
|
||||
One-view expiring links for handing someone a credential. The secret is
|
||||
encrypted in the browser and the key never reaches this server.
|
||||
|
||||
Replaces the habit it exists to kill: pasting a password into a chat message,
|
||||
where it stays until somebody deletes the conversation.
|
||||
|
||||
## What it is not
|
||||
|
||||
**Not a document transfer.** Text only, with a hard size limit. See "The size
|
||||
limit is the whole enforcement" below - that is a deliberate scope, not a
|
||||
missing feature.
|
||||
|
||||
**Not a Tech Tools page.** Tools is browser-only by design (`get_blueprint` and
|
||||
`get_models` are both empty there); this needs storage, expiry and a view
|
||||
counter, so it is its own plugin.
|
||||
|
||||
**Not an asset.** A shared credential is a short-lived event, not something the
|
||||
site inventories, so there is no assetid and no asset type.
|
||||
|
||||
## Two doors
|
||||
|
||||
| Route | Auth | Why |
|
||||
|---|---|---|
|
||||
| `POST /api/securesend` | login + `securesend.create` | Anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what |
|
||||
| `POST /api/securesend/<token>` | none | The recipient usually has no account here. The unguessable token IS the authorisation |
|
||||
| `GET /api/securesend/<token>` | none | Reports whether a link is still live. Spends nothing |
|
||||
|
||||
## Redeeming is a POST, and that is not an accident
|
||||
|
||||
Teams, Outlook and Defender Safe Links prefetch GET urls to build previews and
|
||||
scan for malware. If a GET redeemed the secret, the preview would consume it and
|
||||
the recipient would open the link to find it already used.
|
||||
|
||||
The GET route exists so the read page can say "a secret is waiting, and opening
|
||||
it will destroy it" without spending the only view to find out.
|
||||
|
||||
## The size limit is the whole enforcement
|
||||
|
||||
The AES key lives in the URL fragment, which browsers never transmit, so the
|
||||
server holds ciphertext it cannot read. It therefore cannot tell a password from
|
||||
a page of a drawing, and `MAX_CIPHERTEXT` is not a tuning knob - it is the only
|
||||
control a zero-knowledge design leaves on the server side.
|
||||
|
||||
Everything else (line counts, file signatures, refusing a pasted file) belongs in
|
||||
the browser, guides an honest person, and is bypassable by anyone willing to POST
|
||||
directly. Raising the limit quietly turns this into the document transfer it was
|
||||
scoped not to be.
|
||||
|
||||
## What the audit trail can and cannot say
|
||||
|
||||
It can say who created a secret, how big it was, when it was read and how often.
|
||||
It cannot say what was in it - by design. That is the right trade for a
|
||||
credential and the wrong one for a controlled document, which is the other reason
|
||||
files are out of scope.
|
||||
|
||||
## Not enumerable
|
||||
|
||||
The token is 128 bits of randomness rendered as hex, never a sequence. Missing,
|
||||
expired and spent all answer identically, so a guessed token learns nothing about
|
||||
whether it ever named anything.
|
||||
|
||||
## Before rolling it out
|
||||
|
||||
**Test that your link rewriter preserves URL fragments.** Create a secret, send
|
||||
the link to yourself in Teams, and click it from the message. If Safe Links
|
||||
strips the `#`, the recipient gets a link with no key and the secret is
|
||||
unrecoverable - the server cannot help, which is the point of the design. The
|
||||
fallback if that happens is passphrase mode, where the key is derived from a
|
||||
passphrase shared out of band.
|
||||
5
plugins/securesend/__init__.py
Normal file
5
plugins/securesend/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Securesend plugin package."""
|
||||
|
||||
from .plugin import SecuresendPlugin
|
||||
|
||||
__all__ = ['SecuresendPlugin']
|
||||
5
plugins/securesend/api/__init__.py
Normal file
5
plugins/securesend/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Securesend plugin API package."""
|
||||
|
||||
from .routes import securesend_bp
|
||||
|
||||
__all__ = ['securesend_bp']
|
||||
172
plugins/securesend/api/routes.py
Normal file
172
plugins/securesend/api/routes.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Create a one-view link, and redeem it.
|
||||
|
||||
TWO DIFFERENT DOORS, on purpose.
|
||||
|
||||
CREATE needs a login and a permission. Anonymous creation would be an
|
||||
untraceable way to move data out of a system that otherwise records who did
|
||||
what, and the creator is the only person these rows can ever name.
|
||||
|
||||
READ needs neither, because the recipient usually has no account here and the
|
||||
link is normally handed over in a call or a chat. The unguessable token IS the
|
||||
authorisation - which is why it is 128 bits of randomness and why the row is
|
||||
spent on the first successful read.
|
||||
|
||||
WHAT KEEPS THIS FROM BECOMING A DOCUMENT TRANSFER. The key never reaches the
|
||||
server, so the server cannot look at what it holds and cannot tell a password
|
||||
from a page of a drawing. The only lever it has is SIZE, and MAX_CIPHERTEXT is
|
||||
therefore not a tuning knob - it is the whole enforcement. Everything else (line
|
||||
counts, file signatures, refusing a pasted file) runs in the browser, guides an
|
||||
honest person, and is bypassable by anyone willing to POST directly.
|
||||
"""
|
||||
|
||||
import secrets as pysecrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
from ..models import SecureSecret
|
||||
|
||||
securesend_bp = Blueprint('securesend', __name__)
|
||||
|
||||
# Base64 of an encrypted credential. A password is well under a hundred
|
||||
# characters; this is generous for a connection string and useless for a
|
||||
# document. See the module docstring: this limit is the only server-side control
|
||||
# that a zero-knowledge design leaves available.
|
||||
MAX_CIPHERTEXT = 4096
|
||||
|
||||
# Hours. The default is short because the normal case is "read it while we are
|
||||
# on this call"; the maximum exists so nothing lingers for a week.
|
||||
DEFAULT_EXPIRY_HOURS = 24
|
||||
MAX_EXPIRY_HOURS = 168
|
||||
|
||||
MAX_VIEWS = 5
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
@securesend_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('securesend.create')
|
||||
def create_secret():
|
||||
"""Store an already-encrypted secret and return its token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
ciphertext = (data.get('ciphertext') or '').strip()
|
||||
iv = (data.get('iv') or '').strip()
|
||||
|
||||
if not ciphertext or not iv:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'ciphertext and iv are required')
|
||||
|
||||
if len(ciphertext) > MAX_CIPHERTEXT:
|
||||
# Say what the limit is FOR, not just that it exists. Somebody hitting
|
||||
# it is usually trying to send a file, and the answer to that is a
|
||||
# different tool rather than a bigger number here.
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Secret is too large ({} of {} characters). This is for passwords '
|
||||
'and short text, not documents - the server cannot read what it '
|
||||
'holds, so size is the only limit it can enforce.'.format(
|
||||
len(ciphertext), MAX_CIPHERTEXT))
|
||||
|
||||
# `is None`, not `or`: an explicit 0 is falsy, so `or DEFAULT` silently
|
||||
# turned "expire immediately" into "expire in a day" instead of refusing it.
|
||||
# A caller who sends a number should be told when it is not allowed, not
|
||||
# quietly given a different one.
|
||||
rawhours = data.get('expiryhours')
|
||||
rawviews = data.get('views')
|
||||
try:
|
||||
hours = int(DEFAULT_EXPIRY_HOURS if rawhours is None else rawhours)
|
||||
views = int(1 if rawviews is None else rawviews)
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'expiryhours and views must be whole numbers')
|
||||
|
||||
if not 1 <= hours <= MAX_EXPIRY_HOURS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'expiryhours must be between 1 and {}'.format(MAX_EXPIRY_HOURS))
|
||||
if not 1 <= views <= MAX_VIEWS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'views must be between 1 and {}'.format(MAX_VIEWS))
|
||||
|
||||
try:
|
||||
userid = int(get_jwt_identity())
|
||||
except (TypeError, ValueError):
|
||||
userid = None
|
||||
|
||||
secret = SecureSecret(
|
||||
token=pysecrets.token_hex(16), # 128 bits; the link is the credential
|
||||
ciphertext=ciphertext,
|
||||
iv=iv,
|
||||
expiresat=_utcnow() + timedelta(hours=hours),
|
||||
viewsremaining=views,
|
||||
createdbyuserid=userid,
|
||||
)
|
||||
db.session.add(secret)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(secret.to_dict(), http_code=201)
|
||||
|
||||
|
||||
@securesend_bp.route('/<token>', methods=['POST'])
|
||||
def redeem_secret(token):
|
||||
"""Hand over the ciphertext once, then spend the view.
|
||||
|
||||
POST rather than GET on purpose. A GET is prefetched by link previews in
|
||||
chat and mail clients, and a one-view secret consumed by a preview window is
|
||||
a secret the recipient never gets to see.
|
||||
|
||||
A missing, expired and spent secret all answer the SAME way. Distinguishing
|
||||
them would confirm to anyone holding a guessed token whether it ever named
|
||||
anything.
|
||||
"""
|
||||
gone = error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'This link is not valid. It may have been opened already, or expired.',
|
||||
http_code=404)
|
||||
|
||||
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
|
||||
if secret is None:
|
||||
return gone
|
||||
|
||||
now = _utcnow()
|
||||
if secret.is_expired(now) or secret.is_spent():
|
||||
# Tidy up on the way past rather than waiting for a sweeper: a spent
|
||||
# secret has no reason to keep its ciphertext.
|
||||
db.session.delete(secret)
|
||||
db.session.commit()
|
||||
return gone
|
||||
|
||||
payload = {'ciphertext': secret.ciphertext, 'iv': secret.iv}
|
||||
|
||||
secret.viewsremaining -= 1
|
||||
secret.viewcount += 1
|
||||
secret.lastviewedat = now
|
||||
if secret.viewsremaining <= 0:
|
||||
db.session.delete(secret)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@securesend_bp.route('/<token>', methods=['GET'])
|
||||
def peek_secret(token):
|
||||
"""Whether a link is still live, WITHOUT spending it.
|
||||
|
||||
The read page asks this first so it can render "this secret is waiting, and
|
||||
opening it will destroy it" rather than consuming the only view to find out
|
||||
there was something there.
|
||||
"""
|
||||
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
|
||||
live = bool(secret and not secret.is_expired() and not secret.is_spent())
|
||||
return success_response({'available': live})
|
||||
9
plugins/securesend/manifest.json
Normal file
9
plugins/securesend/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "securesend",
|
||||
"version": "0.1.0",
|
||||
"description": "One-view expiring links for handing someone a credential, encrypted in the browser",
|
||||
"author": "",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/securesend"
|
||||
}
|
||||
16
plugins/securesend/migrations/env.py
Normal file
16
plugins/securesend/migrations/env.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Alembic environment for the securesend plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
|
||||
the metadata to this plugin's tables and drives Alembic against the per-plugin
|
||||
version table alembic_version_securesend. See ADR-008 for the ownership
|
||||
model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin
|
||||
is NEW: its 0001 baseline really CREATES its tables, because the core chain
|
||||
never built them.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'securesend'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/securesend/migrations/script.py.mako
Normal file
24
plugins/securesend/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""securesend plugin baseline.
|
||||
|
||||
Creates the one table this plugin owns. Not an asset extension: a shared
|
||||
credential is a short-lived event rather than a thing the site inventories, so
|
||||
there is no assetid and no asset type.
|
||||
|
||||
Explicit Alembic ops rather than the create_plugin_tables helper, matching the
|
||||
backups baseline: the helper builds a per-plugin MetaData filtered to this
|
||||
plugin's tables, so the foreign key to the core users table cannot resolve at
|
||||
CreateTable-compile time.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'securesend0001baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'securesendsecrets',
|
||||
sa.Column('secretid', sa.Integer(), nullable=False),
|
||||
# 128 bits of randomness as hex. With no login on the read side this is
|
||||
# the credential, so it must never be a sequence.
|
||||
sa.Column('token', sa.String(length=64), nullable=False),
|
||||
sa.Column('ciphertext', sa.Text(), nullable=False),
|
||||
sa.Column('iv', sa.String(length=64), nullable=False),
|
||||
sa.Column('expiresat', sa.DateTime(), nullable=False),
|
||||
sa.Column('viewsremaining', sa.Integer(), nullable=False,
|
||||
server_default='1'),
|
||||
sa.Column('createdbyuserid', sa.Integer(), nullable=True),
|
||||
sa.Column('lastviewedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('viewcount', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.ForeignKeyConstraint(['createdbyuserid'], ['users.userid'],
|
||||
ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('secretid'),
|
||||
)
|
||||
# Unique, because the token is looked up on every read and two rows sharing
|
||||
# one would make a link ambiguous.
|
||||
op.create_index('ixsecuresendsecretstoken', 'securesendsecrets',
|
||||
['token'], unique=True)
|
||||
# Expiry sweeps scan on this.
|
||||
op.create_index('ixsecuresendsecretsexpiresat', 'securesendsecrets',
|
||||
['expiresat'])
|
||||
op.create_index('ixsecuresendsecretscreatedby', 'securesendsecrets',
|
||||
['createdbyuserid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ixsecuresendsecretscreatedby', table_name='securesendsecrets')
|
||||
op.drop_index('ixsecuresendsecretsexpiresat', table_name='securesendsecrets')
|
||||
op.drop_index('ixsecuresendsecretstoken', table_name='securesendsecrets')
|
||||
op.drop_table('securesendsecrets')
|
||||
5
plugins/securesend/models/__init__.py
Normal file
5
plugins/securesend/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Securesend plugin models."""
|
||||
|
||||
from .securesend import SecureSecret
|
||||
|
||||
__all__ = ['SecureSecret']
|
||||
77
plugins/securesend/models/securesend.py
Normal file
77
plugins/securesend/models/securesend.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""A secret handed over by link, encrypted before it ever reaches here.
|
||||
|
||||
NOT AN ASSET, so this is a standalone table rather than an extension keyed by
|
||||
assetid: a shared credential is an event with a short life, not a thing the site
|
||||
owns and inventories.
|
||||
|
||||
WHAT THIS TABLE CAN AND CANNOT SEE. The AES key lives in the URL fragment, which
|
||||
browsers never send to a server, so what is stored is ciphertext and an IV and
|
||||
nothing else. Compromising this table yields no secrets. It also means the
|
||||
server cannot inspect what it is holding - which is the right trade for a
|
||||
credential, and the reason the SIZE limit in the routes carries the whole weight
|
||||
of "this is not a document transfer".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class SecureSecret(BaseModel):
|
||||
"""One shared secret: opaque id, ciphertext, and the rules for its life."""
|
||||
|
||||
__tablename__ = 'securesendsecrets'
|
||||
|
||||
secretid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# The unguessable half of the link. With no login on the read side this IS
|
||||
# the credential, so it is 128 bits of randomness rendered as hex - never a
|
||||
# sequence, which would be enumerable by anyone who received one link.
|
||||
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
|
||||
# Base64 of the AES-GCM ciphertext, and the IV it was sealed with. Opaque
|
||||
# here by design.
|
||||
ciphertext = db.Column(db.Text, nullable=False)
|
||||
iv = db.Column(db.String(64), nullable=False)
|
||||
|
||||
# Both limits apply, whichever comes first.
|
||||
expiresat = db.Column(db.DateTime, nullable=False, index=True)
|
||||
viewsremaining = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
# Who created it. The reader is anonymous by design, so this is the only
|
||||
# person the row can name - and the reason creation requires a login.
|
||||
createdbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Recorded for the audit trail; the content of what was read is not
|
||||
# recoverable, but that it was read, and when, is.
|
||||
lastviewedat = db.Column(db.DateTime, nullable=True)
|
||||
viewcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
def is_expired(self, now=None):
|
||||
return (now or _utcnow()) >= self.expiresat
|
||||
|
||||
def is_spent(self):
|
||||
return self.viewsremaining <= 0
|
||||
|
||||
def to_dict(self):
|
||||
"""NEVER includes the ciphertext.
|
||||
|
||||
This is the shape the CREATOR sees back - a receipt. Handing the payload
|
||||
back on create would put the secret in a second place for no reason.
|
||||
"""
|
||||
return {
|
||||
'token': self.token,
|
||||
'expiresat': self.expiresat.isoformat() if self.expiresat else None,
|
||||
'viewsremaining': self.viewsremaining,
|
||||
'viewcount': self.viewcount,
|
||||
'createddate': self.createddate.isoformat() if self.createddate else None,
|
||||
}
|
||||
76
plugins/securesend/plugin.py
Normal file
76
plugins/securesend/plugin.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Securesend plugin main class.
|
||||
|
||||
One-view expiring links for handing someone a credential, encrypted in the browser
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
from .models import SecureSecret
|
||||
from .api import securesend_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SecuresendPlugin(BasePlugin):
|
||||
"""Securesend plugin.
|
||||
|
||||
One-view expiring links for handing someone a credential, encrypted in the
|
||||
browser. The key travels in the URL fragment and never reaches the server,
|
||||
so what is stored here is ciphertext nobody on this side can read.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
with open(manifest_path) as f:
|
||||
self._manifest = json.load(f)
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest['name'],
|
||||
version=self._manifest['version'],
|
||||
description=self._manifest['description'],
|
||||
author=self._manifest.get('author', ''),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.1.0'),
|
||||
api_prefix=self._manifest.get('api_prefix'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return securesend_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [SecureSecret]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""One permission, on CREATION only.
|
||||
|
||||
There is deliberately no read permission: the recipient of a secret
|
||||
usually has no account here, and the unguessable token is what
|
||||
authorises the read. Adding a permission to that side would make the
|
||||
feature useless for the case it exists for - handing a credential to
|
||||
somebody while you are on a call with them.
|
||||
|
||||
No delete permission either. A secret is spent by being read or by
|
||||
expiring, and both remove the row; a hand-delete route would only add a
|
||||
way to destroy the audit trail of a secret that had not been read yet.
|
||||
"""
|
||||
return [
|
||||
('securesend.create', 'Create shared secret links', 'securesend'),
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f'Securesend plugin initialized (v{self.meta.version})')
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
# NO ASSET TYPE. The scaffold assumes a plugin extends Asset, and this
|
||||
# one does not: a shared credential is a short-lived event, not a thing
|
||||
# the site owns and inventories.
|
||||
logger.info('Securesend plugin installed')
|
||||
6
plugins/securesend/schemas/__init__.py
Normal file
6
plugins/securesend/schemas/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Securesend plugin schemas (marshmallow).
|
||||
|
||||
Add schema classes here when you need request/response validation
|
||||
beyond the simple to_dict() output. The framework wires marshmallow
|
||||
into the response helpers; see the PLUGIN-HOOKS wiki page for details.
|
||||
"""
|
||||
0
plugins/securesend/tests/__init__.py
Normal file
0
plugins/securesend/tests/__init__.py
Normal file
30
plugins/securesend/tests/test_plugin.py
Normal file
30
plugins/securesend/tests/test_plugin.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Securesend plugin smoke tests.
|
||||
|
||||
Asserts the plugin loads cleanly and satisfies the framework contract.
|
||||
Replace and extend with domain tests as you build the plugin out.
|
||||
"""
|
||||
|
||||
from plugins.securesend.plugin import SecuresendPlugin
|
||||
|
||||
|
||||
def test_securesend_plugin_meta_is_valid():
|
||||
"""SecuresendPlugin.meta returns a PluginMeta with the expected name."""
|
||||
plugin = SecuresendPlugin()
|
||||
assert plugin.meta.name == 'securesend'
|
||||
assert plugin.meta.api_prefix == '/api/securesend'
|
||||
|
||||
|
||||
def test_securesend_plugin_get_blueprint_returns_blueprint():
|
||||
"""get_blueprint returns a Flask Blueprint, not None."""
|
||||
from flask import Blueprint
|
||||
plugin = SecuresendPlugin()
|
||||
assert isinstance(plugin.get_blueprint(), Blueprint)
|
||||
|
||||
|
||||
def test_securesend_plugin_get_models_returns_a_model():
|
||||
"""get_models returns a list with at least one SQLAlchemy model."""
|
||||
plugin = SecuresendPlugin()
|
||||
models = plugin.get_models()
|
||||
assert len(models) >= 1
|
||||
for model in models:
|
||||
assert hasattr(model, '__tablename__')
|
||||
@@ -67,6 +67,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'printeditemfiles'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
||||
'printersupplyalerts', 'printerobservedqueues'),
|
||||
'securesend': ('securesendsecrets',),
|
||||
'slides': ('tvslides',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
'warranty': ('warranties', 'warrantyassets'),
|
||||
|
||||
@@ -50,7 +50,19 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
||||
# badge-attributed server-side. Decision record in
|
||||
# docs/proposals/printedparts-plugin.md; justification in
|
||||
# the plugin README.
|
||||
'printedparts.kiosk_take'}
|
||||
'printedparts.kiosk_take',
|
||||
# Redeeming a shared secret. The recipient usually has no
|
||||
# account here, so the unguessable 128-bit token IS the
|
||||
# authorisation - gating this on a role would make the
|
||||
# feature useless for the only case it exists for, handing
|
||||
# somebody a credential while you are on a call with them.
|
||||
#
|
||||
# A mutation because reading SPENDS the secret, and a POST
|
||||
# rather than a GET because chat and mail clients prefetch
|
||||
# GET urls and a preview must not consume a one-view link.
|
||||
# CREATING one is permission-gated and IS swept.
|
||||
# Justification in plugins/securesend/README.md.
|
||||
'securesend.redeem_secret'}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -71,6 +71,9 @@ EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'
|
||||
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||
# the per-transaction revision column.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||
# Built after the ADR-008 cutover, so its 0001 genuinely creates the table
|
||||
# rather than stamping an anchor.
|
||||
EXPECTED_HEAD_REVISION['securesend'] = 'securesend0001baseline'
|
||||
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||
# the shared board category.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
||||
|
||||
161
tests/test_plugins/test_securesend.py
Normal file
161
tests/test_plugins/test_securesend.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Handing over a credential by link: who may create, who may read, what dies.
|
||||
|
||||
The interesting tests here are the refusals. A secret that leaks, outlives its
|
||||
window, or is consumed by a link preview before its recipient ever sees it, all
|
||||
fail in ways nobody notices until somebody needs the password.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.securesend.models import SecureSecret
|
||||
from plugins.securesend.api.routes import MAX_CIPHERTEXT
|
||||
|
||||
CIPHER = 'ZmFrZS1jaXBoZXJ0ZXh0LWZvci10ZXN0cw=='
|
||||
IV = 'MTIzNDU2Nzg5MDEy'
|
||||
|
||||
|
||||
def _create(client, headers, **overrides):
|
||||
body = {'ciphertext': CIPHER, 'iv': IV}
|
||||
body.update(overrides)
|
||||
return client.post('/api/securesend', json=body, headers=headers)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- creating
|
||||
|
||||
def test_creating_requires_a_login(client):
|
||||
"""Anonymous creation would be an untraceable way to move data out of a
|
||||
system that otherwise records who did what."""
|
||||
resp = client.post('/api/securesend', json={'ciphertext': CIPHER, 'iv': IV})
|
||||
assert resp.status_code in (401, 403, 422)
|
||||
|
||||
|
||||
def test_a_logged_in_user_with_the_permission_can_create(client, auth_headers):
|
||||
resp = _create(client, auth_headers)
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
|
||||
data = resp.get_json()['data']
|
||||
assert data['token']
|
||||
assert len(data['token']) == 32 # 128 bits of randomness, as hex
|
||||
# The receipt must NOT hand the payload back: that would put the secret in a
|
||||
# second place for no reason.
|
||||
assert 'ciphertext' not in data
|
||||
|
||||
|
||||
def test_two_secrets_never_share_a_token(client, auth_headers):
|
||||
first = _create(client, auth_headers).get_json()['data']['token']
|
||||
second = _create(client, auth_headers).get_json()['data']['token']
|
||||
assert first != second
|
||||
|
||||
|
||||
# -------------------------------------------------------------- size limiting
|
||||
|
||||
def test_something_document_sized_is_refused(client, auth_headers):
|
||||
"""The ONLY server-side control a zero-knowledge design leaves available.
|
||||
|
||||
The key never reaches the server, so it cannot tell a password from a page
|
||||
of a drawing - it can only bound how much it will hold.
|
||||
"""
|
||||
resp = _create(client, auth_headers, ciphertext='A' * (MAX_CIPHERTEXT + 1))
|
||||
assert resp.status_code == 400
|
||||
body = resp.get_data(as_text=True)
|
||||
assert 'too large' in body.lower()
|
||||
# The message has to explain what the limit is FOR: somebody hitting it is
|
||||
# usually trying to send a file, and the answer is a different tool.
|
||||
assert 'document' in body.lower()
|
||||
|
||||
|
||||
def test_a_credential_sized_secret_is_fine(client, auth_headers):
|
||||
assert _create(client, auth_headers,
|
||||
ciphertext='A' * 200).status_code in (200, 201)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- reading once
|
||||
|
||||
def test_anyone_can_read_it_without_logging_in(client, auth_headers):
|
||||
"""The recipient usually has no account here. The unguessable token is the
|
||||
authorisation."""
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
resp = client.post('/api/securesend/%s' % token)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['ciphertext'] == CIPHER
|
||||
|
||||
|
||||
def test_the_second_read_finds_nothing(client, auth_headers):
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 200
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 404
|
||||
|
||||
|
||||
def test_a_spent_secret_is_deleted_not_just_marked(client, db, auth_headers):
|
||||
"""A secret that has been read has no reason to keep its ciphertext."""
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
client.post('/api/securesend/%s' % token)
|
||||
assert SecureSecret.query.filter_by(token=token).first() is None
|
||||
|
||||
|
||||
def test_more_than_one_view_can_be_allowed(client, auth_headers):
|
||||
token = _create(client, auth_headers, views=2).get_json()['data']['token']
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 200
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 200
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------- link previews (Teams)
|
||||
|
||||
def test_a_get_does_not_spend_the_secret(client, auth_headers):
|
||||
"""THE case that would break this in practice.
|
||||
|
||||
Teams, Outlook and Safe Links prefetch GET urls to build previews and scan
|
||||
for malware. If a GET redeemed the secret, the preview would consume it and
|
||||
the recipient would open the link to find it already used. Redeeming is a
|
||||
POST for exactly this reason.
|
||||
"""
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
|
||||
peek = client.get('/api/securesend/%s' % token)
|
||||
assert peek.status_code == 200
|
||||
assert peek.get_json()['data']['available'] is True
|
||||
assert 'ciphertext' not in peek.get_json()['data']
|
||||
|
||||
# Still there for the person it was meant for.
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 200
|
||||
|
||||
|
||||
def test_peeking_at_a_spent_link_says_so_without_leaking(client, auth_headers):
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
client.post('/api/securesend/%s' % token)
|
||||
peek = client.get('/api/securesend/%s' % token)
|
||||
assert peek.get_json()['data']['available'] is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- expiry
|
||||
|
||||
def test_an_expired_secret_is_gone(client, db, auth_headers):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
secret = SecureSecret.query.filter_by(token=token).first()
|
||||
secret.expiresat = (datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- timedelta(minutes=1))
|
||||
db.session.commit()
|
||||
|
||||
assert client.post('/api/securesend/%s' % token).status_code == 404
|
||||
assert SecureSecret.query.filter_by(token=token).first() is None
|
||||
|
||||
|
||||
def test_an_absurd_expiry_is_refused(client, auth_headers):
|
||||
assert _create(client, auth_headers, expiryhours=10000).status_code == 400
|
||||
assert _create(client, auth_headers, expiryhours=0).status_code == 400
|
||||
|
||||
|
||||
# ------------------------------------------------------------ not enumerable
|
||||
|
||||
def test_an_unknown_token_answers_the_same_as_a_spent_one(client, auth_headers):
|
||||
"""Distinguishing them would confirm to anyone holding a guessed token
|
||||
whether it ever named anything."""
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
client.post('/api/securesend/%s' % token)
|
||||
|
||||
spent = client.post('/api/securesend/%s' % token)
|
||||
never = client.post('/api/securesend/%s' % ('f' * 32))
|
||||
assert spent.status_code == never.status_code == 404
|
||||
assert (spent.get_json()['data']['error']['message']
|
||||
== never.get_json()['data']['error']['message'])
|
||||
Reference in New Issue
Block a user