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