Move secure send into Tech Tools, where somebody would look for it
Some checks failed
CI / backend (push) Failing after 7m10s
CI / naming (push) Failing after 7m14s
CI / frontend (push) Failing after 7m10s
CI / migrations-mysql (push) Failing after 7m10s

9845867 gave it its own plugin because Tech Tools is browser-only and this needs
storage. That was the right reading of the code and the wrong answer for the
person using it: Tech Tools is where you go for a utility, and a tool nobody can
find is a tool nobody uses.

So Tech Tools now has a blueprint, a table and a migration chain where it had
none. Its docstring and manifest say why, because "every tool here runs in the
browser" was load-bearing documentation and is no longer true.

WHAT MOVED
  /api/securesend       -> /api/tools/secrets
  securesend.create     -> tools.secrets
  securesend0001baseline -> tools0001securesend
  PLUGIN_TABLE_OWNERS, EXPECTED_HEAD_REVISION, EXEMPT_ENDPOINTS, the universal
  profile and the api-inventory surface all follow it.

THE OLD "NO BACKEND" TEST IS NARROWED, NOT DELETED. It asserted
get_blueprint() is None, which is the sort of guard that gets removed when it
becomes inconvenient. It now asserts the blueprint serves EXACTLY the three
secret routes, with their methods - so a future tool that quietly grows an
endpoint or a table has to edit this test, and therefore make the decision on
purpose rather than by drift.

Asserting methods rather than paths also pins the detail this design turns on:
redeeming is POST-ONLY. Chat and mail clients prefetch GET urls to build
previews, and a preview that could redeem would consume a one-view secret before
its recipient opened the link. Previously that was a comment; now it fails a
test.

UNINSTALLING THE OLD PLUGIN COULD NOT BE DONE WITH THE PLUGIN TOOLING. `flask
plugin uninstall securesend` loads a plugin before uninstalling it, so a plugin
whose directory has been deleted cannot be removed by the command that manages
plugins - the registry entry had to be edited by hand. Worth fixing separately;
it is the same registry-versus-code divergence the device work hit from the
other side.

MIGRATING A CHAIN THAT HAS ALREADY RUN needs the old table and its version row
dropped first, because the relocated chain has no stamp and tries to create a
table that exists. Only relevant to anyone who installed securesend during the
hour it existed - it did reach the publication repo in 4242028 - and the fix is
DROP TABLE securesendsecrets plus DROP TABLE alembic_version_securesend before
running plugin upgrade-all.

1829 tests green. Still backend only: there are no pages yet, so nothing here is
reachable by a person.
This commit is contained in:
cproudlock
2026-08-21 14:17:57 -04:00
parent 98458672eb
commit d4b49b91b4
26 changed files with 156 additions and 281 deletions

View File

@@ -0,0 +1,5 @@
"""Tech Tools API package."""
from .routes import tools_bp
__all__ = ['tools_bp']

176
plugins/tools/api/routes.py Normal file
View File

@@ -0,0 +1,176 @@
"""Secure send: create a one-view link for a credential, and redeem it.
Lives in Tech Tools because that is where somebody looks for a utility. It is
the one tool here that is NOT purely browser-side: a link that expires and dies
on first read needs somewhere to keep the ciphertext until then.
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
tools_bp = Blueprint('tools', __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)
@tools_bp.route('/secrets', methods=['POST'])
@jwt_required()
@require_permission('tools.secrets')
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)
@tools_bp.route('/secrets/<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)
@tools_bp.route('/secrets/<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})

View File

@@ -1,11 +1,15 @@
{
"name": "tools",
"version": "1.0.0",
"description": "Tech Tools: shop-floor utilities that run entirely in the browser (barcode/QR label generator, with room for more)",
"description": "Tech Tools: technician utilities, mostly browser-side (barcode/QR labels) plus secure send for handing somebody a credential by one-view link",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.16.0,<1.0.0",
"provides": {
"features": ["code_generator"]
}
"features": [
"code_generator",
"secure_send"
]
},
"api_prefix": "/api/tools"
}

View File

@@ -0,0 +1,16 @@
"""Alembic environment for the tools 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_tools. 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'] = 'tools'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View 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"}

View File

@@ -0,0 +1,63 @@
"""tools plugin baseline: the secure send table.
Creates the one table this plugin owns. Tech Tools was schemaless until secure
send arrived: a link that expires and dies on first read has to keep the
ciphertext somewhere until it is claimed. 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 = 'tools0001securesend'
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')

View File

@@ -0,0 +1,10 @@
"""Tech Tools models.
Most tools run entirely in the browser and own nothing. Secure send is the
exception: handing somebody a credential needs storage, an expiry and a view
counter, so this plugin does carry one table.
"""
from .securesend import SecureSecret
__all__ = ['SecureSecret']

View 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,
}

View File

@@ -1,13 +1,19 @@
"""Tech Tools plugin.
A section for small technician utilities. Every tool here runs entirely in the
browser: no API, no tables, no state. That is deliberate - these are the things
someone reaches for at a bench with no network guarantee, and an air-gapped
site gets them for free.
A section for small technician utilities. MOST tools here run entirely in the
browser - no API, no tables, no state - which is deliberate: these are the
things someone reaches for at a bench with no network guarantee, and an
air-gapped site gets them for free.
The plugin exists so the section can carry a nav entry and be included in or
left out of a per-site build (ADR-013). The tools themselves are frontend-only,
which is why get_blueprint and get_models are both empty.
SECURE SEND IS THE EXCEPTION, and it is why this plugin now has a blueprint, a
table and a migration chain where it once had none. A link that expires and dies
on first read cannot be browser-only: something has to hold the ciphertext until
it is claimed, and forget it afterwards. The secret itself is still encrypted in
the browser - the key travels in the URL fragment and never reaches this server -
so what is stored is ciphertext nobody on this side can read.
The plugin also exists so the section can carry a nav entry and be included in or
left out of a per-site build (ADR-013).
"""
import logging
@@ -17,30 +23,51 @@ from flask import Blueprint, Flask
from shopdb.plugins.base import BasePlugin, PluginMeta
from .models import SecureSecret
from .api import tools_bp
logger = logging.getLogger(__name__)
class ToolsPlugin(BasePlugin):
"""Registers the Tech Tools section. All tools are client-side."""
"""Registers the Tech Tools section and the one tool that needs a server."""
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name='tools',
version='1.0.0',
description='Tech Tools: browser-side technician utilities',
description=('Tech Tools: technician utilities, mostly browser-side '
'(barcode/QR labels) plus secure send for handing '
'somebody a credential by one-view link'),
author='ShopDB Team',
dependencies=[],
core_version='>=0.16.0,<1.0.0',
api_prefix='/api/tools',
)
def get_blueprint(self) -> Optional[Blueprint]:
# No API surface. The tools never leave the browser.
return None
# Secure send only. Every other tool is still browser-side.
return tools_bp
def get_models(self) -> List[Type]:
# No tables. Nothing here is persisted.
return []
return [SecureSecret]
def get_permissions(self) -> List:
"""One permission, on CREATING a secret only.
There is deliberately no read permission: the recipient usually has no
account here, and the unguessable token is what authorises the read.
Gating that side would make the tool useless for the case it exists for -
handing somebody a credential 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 record of a secret that had not been read yet.
"""
return [
('tools.secrets', 'Create secure send links', 'tools'),
]
def get_navigation_items(self) -> List[dict]:
return [