Stored credentials are encrypted at rest (#5927)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m40s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 38s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s

#5927 "Adopt the family idea secret.Box": the SMTP password and the backup
bucket's secret key were kept as plain text in the settings table, so a copy
of the database alone could read them.

- New sealing.py: AES-GCM under an HKDF-derived key, each value bound to its
  setting's name. The key is a random file, /var/inkwell/sealing.key (0600),
  made on first use. It lives on the data volume, not in the database, which
  is where the signing secret sits by default.
- set_settings seals a secret before storing it; get_setting opens it. A value
  that no longer opens (key file lost) reads as not set, so Settings asks
  for it again.
- At startup, credentials stored before this are sealed in place, so nothing
  has to be re-entered. A data folder that can't be written never stops the
  server; saving a password then answers with the reason.
- New dependency: cryptography (approved by the operator).

Also #1919 "Deprecate / remove the PWA": kept. Its service worker caches
only the offline page, so an installed copy always runs the live build. One
stale comment in sw.js is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 14:18:41 -04:00
co-authored by Claude Opus 5.5
parent edcb5c4c11
commit c04e949c40
10 changed files with 290 additions and 10 deletions
+31 -2
View File
@@ -26,7 +26,7 @@ import pytest
import pytest_asyncio
from sqlalchemy import delete, func, select, text, update
from inkwell import audit, mailer, ratelimit
from inkwell import audit, mailer, ratelimit, sealing
from inkwell.app import create_app
from inkwell.config import Config
from inkwell.db import dispose_engine, session_scope
@@ -40,7 +40,7 @@ from inkwell.models.note import Note
from inkwell.models.note_attachment import NoteAttachment
from inkwell.models.user import User
from inkwell.notes.tags import _lift_and_reconcile_tags
from inkwell.settings import get_setting, live, refresh_live, reset_live, set_settings
from inkwell.settings import get_setting, live, refresh_live, reset_live, seal_stored_secrets, set_settings
from inkwell.notes.checklist import parse_items, set_item_checked
from inkwell.notes.helpers import derive_display_title
from inkwell.models.note_link_preview import NoteLinkPreview
@@ -2004,11 +2004,40 @@ async def test_the_smtp_password_never_leaves_the_server(app_client, db):
assert (await app_client.patch("/api/settings", json={"smtp_password": ""})).status_code == 200
async with session_scope() as fresh:
assert await get_setting(fresh, "smtp_password") == "hunter2"
# And the row itself holds it sealed, not as text (#5927).
row = await fresh.get(Setting, "smtp_password")
assert "hunter2" not in row.value and sealing.PREFIX in row.value
assert (await app_client.patch("/api/settings", json={"smtp_security": "ssl3"})).status_code == 400
assert (await app_client.patch("/api/settings", json={"public_url": "notes.example.test"})).status_code == 400
async def test_a_credential_stored_before_sealing_is_sealed_and_a_lost_key_asks_again(app_client, db):
await _mail_off()
await _admin_with_invite(app_client)
# As an instance from before #5927 left it: the password as plain JSON.
async with session_scope() as fresh:
fresh.add(Setting(key="smtp_password", value='"hunter2"'))
await fresh.commit()
async with session_scope() as fresh:
assert await get_setting(fresh, "smtp_password") == "hunter2"
assert await seal_stored_secrets(fresh) == 1
await fresh.commit()
async with session_scope() as fresh:
assert "hunter2" not in (await fresh.get(Setting, "smtp_password")).value
assert await get_setting(fresh, "smtp_password") == "hunter2"
assert await seal_stored_secrets(fresh) == 0 # once only
# The key file lost and a new one made: the value no longer opens, and reads as
# not set, so Settings asks for it again.
sealing.key_path().unlink()
sealing.seal("anything", "other")
async with session_scope() as fresh:
assert await get_setting(fresh, "smtp_password") == ""
rows = {r["key"]: r for r in (await (await app_client.get("/api/settings")).get_json())["settings"]}
assert rows["smtp_password"]["is_set"] is False
async def test_a_forgotten_password_is_reset_from_an_emailed_link(app_client, db, monkeypatch):
await _mail_off()
outbox = _outbox(monkeypatch)