Hand somebody a password without leaving it in a chat window
The habit this replaces is pasting a credential into Teams, where it stays until someone deletes the conversation. Tabled on 2026-08-21 pending a decision about scope; the decision was text only, so this is the half that survives it. BACKEND ONLY. There is no page yet, so nothing here is reachable by a person. TEXT, NOT DOCUMENTS, and that shapes everything. Dropping files removed the export-control question entirely - a password is not technical data - along with blob storage, size and MIME handling, and the need to authenticate the recipient so a document fetch could be pinned to a name. TWO DOORS. Creating needs a login AND securesend.create: 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. Reading needs neither, because the recipient usually has no account here and the link is handed over on a call - the unguessable token IS the authorisation, which is why it is 128 bits of randomness and why the row is spent on first read. REDEEMING IS A POST, and that is the detail that would have broken this in practice. Teams, Outlook and Safe Links prefetch GET urls to build previews and scan for malware, so a GET that redeemed would let a preview window consume a secret before its recipient ever saw it. The GET route that remains reports only whether a link is still live, so the read page can say "opening this will destroy it" without spending the only view to find out. THE SIZE LIMIT IS THE WHOLE ENFORCEMENT. The key lives in the URL fragment, which browsers never transmit, so the server holds ciphertext it cannot read and cannot tell a password from a page of a drawing. MAX_CIPHERTEXT is therefore not a tuning knob: raising it quietly turns this into the document transfer it was scoped not to be. The refusal says what the limit is FOR, because somebody hitting it is usually trying to send a file and the answer is a different tool. Line counts, file signatures and refusing a pasted file belong in the browser, guide an honest person, and are bypassable by anyone willing to POST directly. NOT ENUMERABLE. Missing, expired and spent all answer identically - telling them apart would confirm to anyone holding a guessed token whether it ever named anything. A spent or expired row is deleted on the way past rather than waiting for a sweeper, because it has no reason to keep its ciphertext. Its own plugin rather than a Tech Tool: that plugin is browser-only by design, and this needs storage, expiry and a view counter. Not an asset extension either, so no assetid and no asset type - the scaffold assumes one and it was removed. FOUND BY ITS OWN TESTS: expiryhours=0 returned 201. `int(x or DEFAULT)` treats an explicit zero as absent, so "expire immediately" silently became "expire in a day". A caller who sends a number is now told when it is not allowed rather than quietly given a different one. BEFORE ANYONE RELIES ON THIS, test that your link rewriter preserves URL fragments: create a secret, send the link to yourself in Teams, click it from the message, and check the key is still after the #. If Safe Links strips it the secret is unrecoverable and the server cannot help, which is the point of the design. The fallback is passphrase mode, worth building only if that test says so. Three product gates caught what a new plugin has to declare, and each was right to: the route-parity gate wanted the endpoints described in api-inventory.json, the lean-build guard wanted the plugin listed in the universal profile the released installer builds from, and the authz sweep wanted the public redeem route named in EXEMPT_ENDPOINTS with a reason. That last one matters most - a mutating route that answers an unauthenticated caller is a decision somebody has to write down, not a default, and reading a secret is a mutation because it spends it.
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"notifications",
|
"notifications",
|
||||||
"printedparts",
|
"printedparts",
|
||||||
"printers",
|
"printers",
|
||||||
|
"securesend",
|
||||||
"slides",
|
"slides",
|
||||||
"tools",
|
"tools",
|
||||||
"usb",
|
"usb",
|
||||||
|
|||||||
@@ -3260,6 +3260,35 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"surface": "plugin-securesend",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/securesend",
|
||||||
|
"auth": "permission:securesend.create",
|
||||||
|
"params": "body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)",
|
||||||
|
"purpose": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never reaches the server, so what is held here is ciphertext nobody on this side can read - which is why SIZE is the only limit the server can enforce, and why this is for passwords and short text rather than documents",
|
||||||
|
"example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{\"ciphertext\":\"...\",\"iv\":\"...\"}' http://localhost:5001/api/securesend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/securesend/<token>",
|
||||||
|
"auth": "public",
|
||||||
|
"params": "none",
|
||||||
|
"purpose": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather than GET because chat and mail clients prefetch GET urls to build previews, and a preview must not consume a one-view secret. Missing, expired and spent answer identically so a guessed token learns nothing",
|
||||||
|
"example": "curl -X POST http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/securesend/<token>",
|
||||||
|
"auth": "public",
|
||||||
|
"params": "none",
|
||||||
|
"purpose": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will destroy it\" instead of consuming the only view to find out, and gives link scanners something harmless to fetch",
|
||||||
|
"example": "curl http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"surface": "core-docs",
|
"surface": "core-docs",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
|||||||
@@ -171,6 +171,9 @@
|
|||||||
{
|
{
|
||||||
"name": "plugin-backups"
|
"name": "plugin-backups"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "plugin-securesend"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "core-docs"
|
"name": "core-docs"
|
||||||
},
|
},
|
||||||
@@ -18820,6 +18823,148 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/securesend": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-securesend"
|
||||||
|
],
|
||||||
|
"summary": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never...",
|
||||||
|
"description": "Store an already-encrypted secret and return its one-view link token. The AES key lives in the URL fragment and never reaches the server, so what is held here is ciphertext nobody on this side can read - which is why SIZE is the only limit the server can enforce, and why this is for passwords and short text rather than documents\n\n**Auth:** permission:securesend.create\n\n**Params:** body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{\"ciphertext\":\"...\",\"iv\":\"...\"}' http://localhost:5001/api/securesend\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"description": "body: ciphertext (base64, max 4096 chars), iv, expiryhours (1-168, default 24), views (1-5, default 1)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/securesend/{token}": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-securesend"
|
||||||
|
],
|
||||||
|
"summary": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather...",
|
||||||
|
"description": "Redeem a secret: returns the ciphertext once and spends a view, deleting the row when the last view goes. POST rather than GET because chat and mail clients prefetch GET urls to build previews, and a preview must not consume a one-view secret. Missing, expired and spent answer identically so a guessed token learns nothing\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
|
||||||
|
"security": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such record."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"description": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-securesend"
|
||||||
|
],
|
||||||
|
"summary": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will...",
|
||||||
|
"description": "Whether a link is still live, WITHOUT spending it. Lets the read page say \"a secret is waiting and opening it will destroy it\" instead of consuming the only view to find out, and gives link scanners something harmless to fetch\n\n**Auth:** public\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/securesend/9f8e7d6c5b4a39281706f5e4d3c2b1a0\n```",
|
||||||
|
"security": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such record."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/docs/": {
|
"/api/docs/": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
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 docs/PLUGIN-HOOKS.md 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'),
|
'printeditemfiles'),
|
||||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
||||||
'printersupplyalerts', 'printerobservedqueues'),
|
'printersupplyalerts', 'printerobservedqueues'),
|
||||||
|
'securesend': ('securesendsecrets',),
|
||||||
'slides': ('tvslides',),
|
'slides': ('tvslides',),
|
||||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||||
'warranty': ('warranties', 'warrantyassets'),
|
'warranty': ('warranties', 'warrantyassets'),
|
||||||
|
|||||||
@@ -50,7 +50,19 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
|||||||
# badge-attributed server-side. Decision record in
|
# badge-attributed server-side. Decision record in
|
||||||
# docs/proposals/printedparts-plugin.md; justification in
|
# docs/proposals/printedparts-plugin.md; justification in
|
||||||
# the plugin README.
|
# 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)
|
@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
|
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||||
# the per-transaction revision column.
|
# the per-transaction revision column.
|
||||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
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
|
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||||
# the shared board category.
|
# the shared board category.
|
||||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
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