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>
56 lines
1.9 KiB
Python
56 lines
1.9 KiB
Python
import stat
|
|
|
|
import pytest
|
|
|
|
from inkwell import sealing
|
|
from inkwell.config import Config
|
|
|
|
|
|
def test_a_sealed_value_opens_for_its_own_setting_only():
|
|
stored = sealing.seal("hunter2", "smtp_password")
|
|
assert stored.startswith(sealing.PREFIX) and "hunter2" not in stored
|
|
assert sealing.unseal(stored, "smtp_password") == "hunter2"
|
|
# Copied onto another setting's row, it does not open there.
|
|
assert sealing.unseal(stored, "backup_s3_secret_key") is None
|
|
|
|
|
|
def test_sealing_the_same_value_twice_gives_different_text():
|
|
assert sealing.seal("x", "k") != sealing.seal("x", "k")
|
|
|
|
|
|
def test_the_key_file_is_made_once_readable_only_by_the_server():
|
|
sealing.seal("x", "k")
|
|
path = sealing.key_path()
|
|
assert path.parent == Config.data_root()
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
first = path.read_bytes()
|
|
sealing.seal("y", "k")
|
|
assert path.read_bytes() == first
|
|
|
|
|
|
def test_a_value_from_before_sealing_reads_as_it_is():
|
|
assert sealing.unseal("hunter2", "smtp_password") == "hunter2"
|
|
assert sealing.unseal("v1.looks-versioned", "smtp_password") == "v1.looks-versioned"
|
|
|
|
|
|
def test_reading_never_creates_a_key():
|
|
assert sealing.unseal(sealing.PREFIX + "AAAA", "k") is None
|
|
assert not sealing.key_path().exists()
|
|
|
|
|
|
def test_a_replaced_key_or_a_damaged_value_reads_as_unreadable():
|
|
stored = sealing.seal("hunter2", "k")
|
|
assert sealing.unseal(stored[:-4] + "AAAA", "k") is None
|
|
assert sealing.unseal(sealing.PREFIX + "not base64!", "k") is None
|
|
sealing.key_path().unlink()
|
|
sealing.seal("other", "k") # a new key
|
|
assert sealing.unseal(stored, "k") is None
|
|
|
|
|
|
def test_a_data_folder_that_cannot_be_written_refuses_to_seal(tmp_path, monkeypatch):
|
|
blocked = tmp_path / "file-not-dir"
|
|
blocked.write_text("")
|
|
monkeypatch.setattr(Config, "DATA_DIR", str(blocked))
|
|
with pytest.raises(sealing.SealError):
|
|
sealing.seal("hunter2", "k")
|