Skip to content

Commit 8fe9f2c

Browse files
gijzelaerrclaude
andcommitted
session_auth: add derive_key_id (8-byte SHA-256 fingerprint)
Second slice of the HarpoS7 port (refs #717). Adds the small SHA-256 fingerprinting function that computes the symmetric/public key id fields embedded in the SecurityKeyEncryptedKey blob. The function is just `SHA-256(key[:24] + b"DERIVE")[:8]` — well-defined in HarpoS7's `KeyExtensions.DeriveKeyId`. We verify byte-correctness in two ways: 1. The three test vectors HarpoS7 ships in `KeyExtensionsTests.cs` (a 64-byte PlcSim key plus two repeating-byte cases). 2. A self-consistency check: every bundled public key, when run through `derive_key_id`, must produce the LE byte-reversal of the BE-display hex it's keyed on. This catches both algorithm bugs and key-byte transcription errors in #b44792d. @xBiggs's PLC fingerprint `01:BD426B091F08731A` round-trips correctly, so once the next slices land we can compute exactly the publickeychecksum / symmetrickeychecksum the PLC expects to see. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b44792d commit 8fe9f2c

4 files changed

Lines changed: 128 additions & 6 deletions

File tree

s7/session_auth/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@
3030
get_public_key,
3131
parse_fingerprint,
3232
)
33+
from .utils import KEY_ID_LENGTH, derive_key_id
3334

3435
__all__ = [
3536
"KeyFamily",
37+
"KEY_ID_LENGTH",
3638
"PUBLIC_KEY_LENGTH_REAL_PLC",
3739
"PUBLIC_KEY_LENGTH_PLCSIM",
3840
"UnknownPublicKeyError",
41+
"derive_key_id",
3942
"get_public_key",
4043
"parse_fingerprint",
4144
]

s7/session_auth/utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Shared low-level helpers used across the session-auth subpackage.
2+
3+
Ported from HarpoS7 (MIT) — specifically ``HarpoS7.Utilities.Extensions.KeyExtensions``.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import hashlib
9+
10+
# The fingerprint Siemens uses to identify a public or symmetric key.
11+
# Length matches the publickeychecksum / symmetrickeychecksum fields in
12+
# the SecurityKeyEncryptedKey blob (see Wireshark s7comm-plus dissector).
13+
KEY_ID_LENGTH = 8
14+
15+
_KEY_PART_LENGTH = 24
16+
_DERIVE_KEY_ID_MAGIC = b"DERIVE"
17+
18+
19+
def derive_key_id(key: bytes) -> bytes:
20+
"""Compute the 8-byte key fingerprint used in the encrypted key blob.
21+
22+
HarpoS7 calls this ``DeriveKeyId``. It is SHA-256 over the first 24
23+
bytes of the key concatenated with the literal ASCII string
24+
``"DERIVE"``, truncated to the first 8 bytes of the digest.
25+
26+
Args:
27+
key: At least 24 bytes of key material — a public key or a
28+
session symmetric key. Only the leading 24 bytes are used;
29+
longer keys are silently truncated, matching upstream.
30+
31+
Returns:
32+
8 bytes. Read as a little-endian uint64, this equals the value
33+
the PLC advertises in its ``ObjectVariableTypeName`` attribute
34+
(id 233) for that key.
35+
36+
Raises:
37+
ValueError: If the key is shorter than 24 bytes.
38+
"""
39+
if len(key) < _KEY_PART_LENGTH:
40+
raise ValueError(f"key must be at least {_KEY_PART_LENGTH} bytes, got {len(key)}")
41+
digest = hashlib.sha256(key[:_KEY_PART_LENGTH] + _DERIVE_KEY_ID_MAGIC).digest()
42+
return digest[:KEY_ID_LENGTH]

tests/test_session_auth.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,12 @@ def test_xbiggs_s7_1200(self) -> None:
5959
# The fingerprint @xBiggs's PLC advertises in #710. Verify the
6060
# bytes match HarpoS7's BD426B091F08731A.bin verbatim.
6161
key = get_public_key("01:BD426B091F08731A")
62-
assert key == bytes.fromhex(
63-
"e0e1f04a5ca3f90148178689bd0c930ab9db867b4f0ab109623959aa32316b7880ed1b4f9a9b189f"
64-
)
62+
assert key == bytes.fromhex("e0e1f04a5ca3f90148178689bd0c930ab9db867b4f0ab109623959aa32316b7880ed1b4f9a9b189f")
6563
assert len(key) == PUBLIC_KEY_LENGTH_REAL_PLC
6664

6765
def test_known_s7_1500_key(self) -> None:
6866
key = get_public_key("00:181B7B0847D11694")
69-
assert key == bytes.fromhex(
70-
"8456a26996122216c921c571ff11e0befafdb1d70b5d4bc8390f5b0cc273ec142a03f2a04e6f1593"
71-
)
67+
assert key == bytes.fromhex("8456a26996122216c921c571ff11e0befafdb1d70b5d4bc8390f5b0cc273ec142a03f2a04e6f1593")
7268

7369
def test_known_plcsim_key(self) -> None:
7470
key = get_public_key("03:09013727CCBFBF3C")

tests/test_session_auth_utils.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Tests for the session-auth low-level helpers.
2+
3+
The ``derive_key_id`` test vectors are reproduced verbatim from
4+
HarpoS7's ``HarpoS7.Utilities.Tests/Extensions/KeyExtensionsTests.cs``,
5+
which in turn was generated from the proprietary algorithm in
6+
Siemens' ``OMSp_core_managed.dll``. Matching them gives us strong
7+
ground-truth coverage that our port is byte-correct.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import pytest
13+
14+
from s7.session_auth import KEY_ID_LENGTH, derive_key_id, get_public_key, parse_fingerprint
15+
from s7.session_auth.keys import _PUBLIC_KEYS
16+
17+
18+
class TestDeriveKeyId:
19+
def test_harpos7_vector_64_byte_key(self) -> None:
20+
# First TestCase from HarpoS7 KeyExtensionsTests — a 64-byte
21+
# PlcSim key (5A9B6B015F48D284 in family 3).
22+
key = bytes.fromhex(
23+
"eca6d799ddf03eaadd16b5d7245331e426c9e6ba8997877a"
24+
"7394f3286532a6b053e4229818085223432483fba4d5c43b"
25+
"d6c354c10febc903908ed271697f39e9"
26+
)
27+
assert derive_key_id(key) == bytes.fromhex("84d2485f016b9b5a")
28+
29+
def test_harpos7_vector_repeating_11(self) -> None:
30+
# Second TestCase from HarpoS7 — 24 bytes of 0x11.
31+
key = b"\x11" * 24
32+
assert derive_key_id(key) == bytes.fromhex("06ddcee4adaec77a")
33+
34+
def test_harpos7_vector_repeating_44(self) -> None:
35+
# Third TestCase from HarpoS7 — 24 bytes of 0x44.
36+
key = b"\x44" * 24
37+
assert derive_key_id(key) == bytes.fromhex("06d0ef4b10626822")
38+
39+
def test_returns_eight_bytes(self) -> None:
40+
assert len(derive_key_id(b"\x00" * 32)) == KEY_ID_LENGTH
41+
42+
def test_only_first_24_bytes_used(self) -> None:
43+
# Bytes past offset 24 must not affect the output.
44+
base = b"A" * 24
45+
assert derive_key_id(base + b"X" * 100) == derive_key_id(base + b"Y" * 100)
46+
47+
def test_short_key_rejected(self) -> None:
48+
with pytest.raises(ValueError, match="at least 24 bytes"):
49+
derive_key_id(b"\x00" * 23)
50+
51+
52+
class TestKeyIdMatchesAdvertisedFingerprint:
53+
"""Self-verifies: every bundled public key, when run through
54+
``derive_key_id``, must produce the fingerprint it's keyed on.
55+
56+
The fingerprint string is the big-endian hex display of the same
57+
8 bytes ``derive_key_id`` produces (in little-endian byte order).
58+
"""
59+
60+
def test_xbiggs_s7_1200_key(self) -> None:
61+
# The headline case: the PLC fingerprint @xBiggs reports in
62+
# #710 must round-trip through our key store + derive_key_id.
63+
key = get_public_key("01:BD426B091F08731A")
64+
assert derive_key_id(key) == bytes.fromhex("1A73081F096B42BD")
65+
66+
def test_all_bundled_keys_round_trip(self) -> None:
67+
for (family, key_id), key in _PUBLIC_KEYS.items():
68+
expected_id_bytes = bytes.fromhex(key_id)[::-1] # BE display → LE bytes
69+
assert derive_key_id(key) == expected_id_bytes, f"derive_key_id mismatch for {family.name}/{key_id}"
70+
71+
def test_full_fingerprint_string_round_trip(self) -> None:
72+
# End-to-end: parse the fingerprint string, look up the key,
73+
# derive its id, and confirm the id reconstructs the original
74+
# post-colon hex.
75+
fp = "01:BD426B091F08731A"
76+
family, key_id = parse_fingerprint(fp)
77+
del family # not asserted here
78+
key = get_public_key(fp)
79+
derived_le = derive_key_id(key)
80+
derived_be_hex = derived_le[::-1].hex().upper()
81+
assert derived_be_hex == key_id

0 commit comments

Comments
 (0)