You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/09/28 18:33:57 UTC
(tooling-trusted-releases) branch sbp updated: Add a module for log sealing using SLH-DSA signatures
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/sbp by this push:
new 36ab484b Add a module for log sealing using SLH-DSA signatures
36ab484b is described below
commit 36ab484b2fef29caf17531b3ade768c70af0b5c1
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Mon Sep 28 19:33:23 2026 +0100
Add a module for log sealing using SLH-DSA signatures
---
atr/sealing.py | 183 +++++++++++++++++++++++++++++++++++++++++++++
tests/unit/test_sealing.py | 136 +++++++++++++++++++++++++++++++++
2 files changed, 319 insertions(+)
diff --git a/atr/sealing.py b/atr/sealing.py
new file mode 100644
index 00000000..1bea53a6
--- /dev/null
+++ b/atr/sealing.py
@@ -0,0 +1,183 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import base64
+import datetime
+import hashlib
+import json
+import os
+import pathlib
+import subprocess
+import tempfile
+from typing import Final
+
+import rfc8785
+
+ALGORITHM: Final = "SLH-DSA-SHAKE-256s"
+PUBLIC_PREFIX: Final = bytes.fromhex("3050300b060960864801650304031e034100")
+SIGNATURE_SIZE: Final = 29792
+VERSION: Final = "org.apache.tooling.atr.seal.v1"
+
+
+def initialise(key_dir: pathlib.Path) -> bytes:
+ key_dir.mkdir(mode=0o700)
+ _sync_directory(key_dir.parent)
+ return _generate(key_dir / "current.pem")
+
+
+def seal(key_dir: pathlib.Path, data: pathlib.Path) -> None:
+ target = data.with_suffix(".seal.json")
+ if target.exists():
+ raise FileExistsError(target)
+ digest = _encode(_digest(data))
+ successor = key_dir / "next.pem"
+ record = {
+ "datetime": datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "next": _encode(_generate(successor)),
+ "sha3_512": digest,
+ "v": VERSION,
+ }
+ current = key_dir / "current.pem"
+ record["signature"] = _encode(_sign(current, rfc8785.dumps(record)))
+ _verify_signature(_public(current), record)
+ _publish(target, rfc8785.dumps(record) + b"\n")
+ os.replace(successor, current)
+ _sync_directory(key_dir)
+
+
+def verify(data: pathlib.Path, public: bytes) -> bytes:
+ record = _read(data.with_suffix(".seal.json"))
+ _verify_signature(public, record)
+ if _encode(_digest(data)) != record["sha3_512"]:
+ raise ValueError(f"Digest mismatch: {data}")
+ return _decode(record["next"], 82)
+
+
+def _decode(value: str, size: int) -> bytes:
+ decoded = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
+ if (len(decoded) != size) or (_encode(decoded) != value):
+ raise ValueError("Invalid base64url encoding or length")
+ return decoded
+
+
+def _digest(path: pathlib.Path) -> bytes:
+ with path.open("rb") as handle:
+ return hashlib.file_digest(handle, "sha3_512").digest()
+
+
+def _encode(value: bytes) -> str:
+ return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
+
+
+def _generate(path: pathlib.Path) -> bytes:
+ private = _openssl("genpkey", "-algorithm", ALGORITHM)
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400)
+ with os.fdopen(descriptor, "wb") as handle:
+ handle.write(private)
+ handle.flush()
+ os.fsync(handle.fileno())
+ _sync_directory(path.parent)
+ return _public(path)
+
+
+def _openssl(*arguments: str) -> bytes:
+ result = subprocess.run(["openssl", *arguments], capture_output=True, check=False)
+ if result.returncode:
+ raise ValueError(result.stderr.decode("utf-8", errors="replace").strip() or "OpenSSL operation failed")
+ return result.stdout
+
+
+def _public(path: pathlib.Path) -> bytes:
+ public = _openssl("pkey", "-in", str(path), "-pubout", "-outform", "DER")
+ _validate_public(public)
+ return public
+
+
+def _publish(path: pathlib.Path, content: bytes) -> None:
+ descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
+ try:
+ with os.fdopen(descriptor, "wb") as handle:
+ handle.write(content)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.link(name, path)
+ finally:
+ os.unlink(name)
+ _sync_directory(path.parent)
+
+
+def _read(path: pathlib.Path) -> dict[str, str]:
+ content = path.read_bytes()
+ record = json.loads(content)
+ if (not isinstance(record, dict)) or (set(record) != {"datetime", "next", "sha3_512", "signature", "v"}):
+ raise ValueError(f"Invalid seal fields: {path}")
+ if not all(isinstance(value, str) for value in record.values()):
+ raise ValueError(f"Seal fields must be strings: {path}")
+ if (record["v"] != VERSION) or (rfc8785.dumps(record) + b"\n" != content):
+ raise ValueError(f"Unsupported or noncanonical seal: {path}")
+ instant = datetime.datetime.fromisoformat(record["datetime"])
+ if (instant.tzinfo != datetime.UTC) or (
+ instant.isoformat(timespec="seconds").replace("+00:00", "Z") != record["datetime"]
+ ):
+ raise ValueError(f"Invalid seal datetime: {path}")
+ _validate_public(_decode(record["next"], 82))
+ _decode(record["sha3_512"], 64)
+ return record
+
+
+def _sign(private: pathlib.Path, message: bytes) -> bytes:
+ with tempfile.NamedTemporaryFile() as handle:
+ handle.write(message)
+ handle.flush()
+ return _openssl("pkeyutl", "-sign", "-rawin", "-inkey", str(private), "-in", handle.name)
+
+
+def _sync_directory(path: pathlib.Path) -> None:
+ descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ os.fsync(descriptor)
+ finally:
+ os.close(descriptor)
+
+
+def _validate_public(public: bytes) -> None:
+ if (len(public) != 82) or (not public.startswith(PUBLIC_PREFIX)):
+ raise ValueError(f"Expected a DER {ALGORITHM} public key")
+
+
+def _verify_signature(public: bytes, record: dict[str, str]) -> None:
+ _validate_public(public)
+ message = {key: value for key, value in record.items() if key != "signature"}
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory)
+ (path / "public.der").write_bytes(public)
+ (path / "signature").write_bytes(_decode(record["signature"], SIGNATURE_SIZE))
+ (path / "message").write_bytes(rfc8785.dumps(message))
+ _openssl(
+ "pkeyutl",
+ "-verify",
+ "-rawin",
+ "-pubin",
+ "-keyform",
+ "DER",
+ "-inkey",
+ str(path / "public.der"),
+ "-sigfile",
+ str(path / "signature"),
+ "-in",
+ str(path / "message"),
+ )
diff --git a/tests/unit/test_sealing.py b/tests/unit/test_sealing.py
new file mode 100644
index 00000000..5a3a02a8
--- /dev/null
+++ b/tests/unit/test_sealing.py
@@ -0,0 +1,136 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+import pathlib
+import shutil
+import stat
+import subprocess
+import unittest.mock as mock
+
+import pytest
+import rfc8785
+
+import atr.sealing as sealing
+
+pytestmark = pytest.mark.skipif(
+ (shutil.which("openssl") is None)
+ or (
+ sealing.ALGORITHM.encode()
+ not in subprocess.run(["openssl", "list", "-signature-algorithms"], capture_output=True, check=False).stdout
+ ),
+ reason="OpenSSL with SLH-DSA-SHAKE-256s is required",
+)
+
+
+def test_failed_signature_refuses(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ keys = tmp_path / "keys"
+ root = sealing.initialise(keys)
+ data = tmp_path / "1.jsonl"
+ data.write_bytes(b"{}\n")
+ monkeypatch.setattr(sealing, "_sign", mock.Mock(return_value=bytes(sealing.SIGNATURE_SIZE)))
+ with pytest.raises(ValueError):
+ sealing.seal(keys, data)
+ assert not data.with_suffix(".seal.json").exists()
+ assert sealing._public(keys / "current.pem") == root
+ with pytest.raises(FileExistsError):
+ sealing.seal(keys, data)
+
+
+@pytest.mark.parametrize("operation", ["link", "replace"])
+def test_interrupted_round_refuses(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, operation: str) -> None:
+ keys = tmp_path / "keys"
+ sealing.initialise(keys)
+ first, second = tmp_path / "1.jsonl", tmp_path / "2.jsonl"
+ first.write_bytes(b"{}\n")
+ second.write_bytes(b"{}\n")
+ with monkeypatch.context() as failure:
+ failure.setattr(sealing.os, operation, mock.Mock(side_effect=OSError("interrupted")))
+ with pytest.raises(OSError, match="interrupted"):
+ sealing.seal(keys, first)
+ expected = {first, second}
+ if operation == "replace":
+ expected.add(first.with_suffix(".seal.json"))
+ assert set(tmp_path.glob("*.json*")) == expected
+ with pytest.raises(FileExistsError):
+ sealing.seal(keys, second)
+ assert not second.with_suffix(".seal.json").exists()
+
+
+def test_openssl_error_details() -> None:
+ with pytest.raises(ValueError, match="atr-sealing-invalid-command"):
+ sealing._openssl("atr-sealing-invalid-command")
+
+
+def test_seal_chain(tmp_path: pathlib.Path) -> None:
+ keys = tmp_path / "keys"
+ root = sealing.initialise(keys)
+ first, second = tmp_path / "1.jsonl", tmp_path / "2.jsonl"
+ for data in (first, second):
+ data.write_bytes(b"{}\n")
+ sealing.seal(keys, data)
+ assert {path.name for path in keys.iterdir()} == {"current.pem"}
+ assert stat.S_IMODE((keys / "current.pem").stat().st_mode) == 0o400
+ public = sealing.verify(first, root)
+ sealing.verify(second, public)
+ with pytest.raises(ValueError):
+ sealing.verify(second, root)
+ second.write_bytes(b"changed\n")
+ with pytest.raises(ValueError, match="Digest mismatch"):
+ sealing.verify(second, public)
+ path = first.with_suffix(".seal.json")
+ record = json.loads(path.read_bytes())
+ record["next"] = sealing._encode(public[:-1] + bytes([public[-1] ^ 1]))
+ path.write_bytes(rfc8785.dumps(record) + b"\n")
+ with pytest.raises(ValueError):
+ sealing.verify(first, root)
+
+
+def test_verify_requires_canonical_encoding(tmp_path: pathlib.Path) -> None:
+ root = sealing.initialise(tmp_path / "keys")
+ data = tmp_path / "1.jsonl"
+ data.write_bytes(b"{}\n")
+ sealing.seal(tmp_path / "keys", data)
+ path = data.with_suffix(".seal.json")
+ content = path.read_bytes()
+ record = json.loads(content)
+ alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
+ signature = record["signature"]
+ pad_bits = signature[:-1] + alphabet[alphabet.index(signature[-1]) + 1]
+ for change, error in [
+ ({"v": sealing.VERSION + ".unknown"}, "Unsupported"),
+ ({"extra": "field"}, "Invalid seal fields"),
+ ({"datetime": 0}, "Seal fields must be strings"),
+ ({"datetime": "2026-09-28T12:34:56+00:00"}, "Invalid seal datetime"),
+ ({"datetime": "2026-09-28T12:34:56.000Z"}, "Invalid seal datetime"),
+ ({"next": sealing._encode(bytes(82))}, "Expected a DER"),
+ ({"sha3_512": sealing._encode(bytes(63))}, "Invalid base64url"),
+ ({"signature": signature + "="}, "Invalid base64url"),
+ ({"signature": pad_bits}, "Invalid base64url"),
+ ({"signature": "!" + signature}, "Only base64 data"),
+ ]:
+ path.write_bytes(rfc8785.dumps(record | change) + b"\n")
+ with pytest.raises(ValueError, match=error):
+ sealing.verify(data, root)
+ for altered in (
+ b" " + content,
+ content.rstrip(b"\n"),
+ b'{"v":' + json.dumps(record["v"]).encode() + b"," + content[1:],
+ ):
+ path.write_bytes(altered)
+ with pytest.raises(ValueError, match="noncanonical"):
+ sealing.verify(data, root)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org