+
+
+
+
+
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'])