diff --git a/deploy/site-profile-universal.json b/deploy/site-profile-universal.json index 64b7539e..ce96650e 100644 --- a/deploy/site-profile-universal.json +++ b/deploy/site-profile-universal.json @@ -12,6 +12,7 @@ "notifications", "printedparts", "printers", + "securesend", "slides", "tools", "usb", diff --git a/frontend/src/router/routes/securesend.js b/frontend/src/router/routes/securesend.js new file mode 100644 index 00000000..a3ba0354 --- /dev/null +++ b/frontend/src/router/routes/securesend.js @@ -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' } + } +] diff --git a/frontend/src/views/securesend/SecuresendDetail.vue b/frontend/src/views/securesend/SecuresendDetail.vue new file mode 100644 index 00000000..28604f38 --- /dev/null +++ b/frontend/src/views/securesend/SecuresendDetail.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/views/securesend/SecuresendForm.vue b/frontend/src/views/securesend/SecuresendForm.vue new file mode 100644 index 00000000..0c149029 --- /dev/null +++ b/frontend/src/views/securesend/SecuresendForm.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/frontend/src/views/securesend/SecuresendList.vue b/frontend/src/views/securesend/SecuresendList.vue new file mode 100644 index 00000000..7c65dfae --- /dev/null +++ b/frontend/src/views/securesend/SecuresendList.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/plugins/securesend/README.md b/plugins/securesend/README.md new file mode 100644 index 00000000..2ff7652e --- /dev/null +++ b/plugins/securesend/README.md @@ -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/` | none | The recipient usually has no account here. The unguessable token IS the authorisation | +| `GET /api/securesend/` | 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. diff --git a/plugins/securesend/__init__.py b/plugins/securesend/__init__.py new file mode 100644 index 00000000..2e45a470 --- /dev/null +++ b/plugins/securesend/__init__.py @@ -0,0 +1,5 @@ +"""Securesend plugin package.""" + +from .plugin import SecuresendPlugin + +__all__ = ['SecuresendPlugin'] diff --git a/plugins/securesend/api/__init__.py b/plugins/securesend/api/__init__.py new file mode 100644 index 00000000..b254547a --- /dev/null +++ b/plugins/securesend/api/__init__.py @@ -0,0 +1,5 @@ +"""Securesend plugin API package.""" + +from .routes import securesend_bp + +__all__ = ['securesend_bp'] diff --git a/plugins/securesend/api/routes.py b/plugins/securesend/api/routes.py new file mode 100644 index 00000000..15889a92 --- /dev/null +++ b/plugins/securesend/api/routes.py @@ -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('/', 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('/', 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}) diff --git a/plugins/securesend/manifest.json b/plugins/securesend/manifest.json new file mode 100644 index 00000000..e80ad9ef --- /dev/null +++ b/plugins/securesend/manifest.json @@ -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" +} diff --git a/plugins/securesend/migrations/env.py b/plugins/securesend/migrations/env.py new file mode 100644 index 00000000..4ea69a3d --- /dev/null +++ b/plugins/securesend/migrations/env.py @@ -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() diff --git a/plugins/securesend/migrations/script.py.mako b/plugins/securesend/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/securesend/migrations/script.py.mako @@ -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"} diff --git a/plugins/securesend/migrations/versions/0001_securesend_baseline.py b/plugins/securesend/migrations/versions/0001_securesend_baseline.py new file mode 100644 index 00000000..e03c8307 --- /dev/null +++ b/plugins/securesend/migrations/versions/0001_securesend_baseline.py @@ -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') diff --git a/plugins/securesend/models/__init__.py b/plugins/securesend/models/__init__.py new file mode 100644 index 00000000..474af6c3 --- /dev/null +++ b/plugins/securesend/models/__init__.py @@ -0,0 +1,5 @@ +"""Securesend plugin models.""" + +from .securesend import SecureSecret + +__all__ = ['SecureSecret'] diff --git a/plugins/securesend/models/securesend.py b/plugins/securesend/models/securesend.py new file mode 100644 index 00000000..4b738ac5 --- /dev/null +++ b/plugins/securesend/models/securesend.py @@ -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, + } diff --git a/plugins/securesend/plugin.py b/plugins/securesend/plugin.py new file mode 100644 index 00000000..4619967b --- /dev/null +++ b/plugins/securesend/plugin.py @@ -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') diff --git a/plugins/securesend/schemas/__init__.py b/plugins/securesend/schemas/__init__.py new file mode 100644 index 00000000..5a10bb2c --- /dev/null +++ b/plugins/securesend/schemas/__init__.py @@ -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. +""" diff --git a/plugins/securesend/tests/__init__.py b/plugins/securesend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/securesend/tests/test_plugin.py b/plugins/securesend/tests/test_plugin.py new file mode 100644 index 00000000..6a70b743 --- /dev/null +++ b/plugins/securesend/tests/test_plugin.py @@ -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__') diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 45485530..702763d6 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -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'), diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py index 04a8feec..22f5be48 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -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) diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 23597eee..8ed6670b 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -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' diff --git a/tests/test_plugins/test_securesend.py b/tests/test_plugins/test_securesend.py new file mode 100644 index 00000000..3a72887a --- /dev/null +++ b/tests/test_plugins/test_securesend.py @@ -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'])