Replaces LICENSE (GPLv3 -> AGPLv3) and prepends `SPDX-License-Identifier: AGPL-3.0-or-later` to every source file across decnet/, decnet_web/, tests/, scripts/, and tools/. Rationale: closes the GPLv3 ASP loophole so any party operating a modified DECNET as a network service must offer their modified source. Personal copyright (Samuel Paschuan) + inbound=outbound contributions make a future unilateral relicense infeasible. - LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt) - COPYRIGHT: project copyright notice - tools/add_spdx_headers.py: idempotent header injector (shebang- and PEP 263-aware) Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh). No behavior change; comments only.
125 lines
4.4 KiB
Python
125 lines
4.4 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Tests for QUIC v1 Initial packet key derivation (RFC 9001 Appendix A vectors)."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from decnet.sniffer.fingerprint import (
|
|
_hkdf_extract,
|
|
_hkdf_expand_label,
|
|
_quic_initial_keys,
|
|
_QUIC_V1_INITIAL_SALT,
|
|
_ja4_quic,
|
|
_parse_quic_initial,
|
|
)
|
|
|
|
|
|
# RFC 9001 Appendix A.1 key derivation test vectors
|
|
_RFC9001_DCID = bytes.fromhex("8394c8f03e515708")
|
|
_RFC9001_CLIENT_KEY = bytes.fromhex("1f369613dd76d5467730efcbe3b1a22d")
|
|
_RFC9001_CLIENT_IV = bytes.fromhex("fa044b2f42a3fd3b46fb255c")
|
|
_RFC9001_CLIENT_HP = bytes.fromhex("9f50449e04a0e810283a1e9933adedd2")
|
|
|
|
|
|
class TestHKDF:
|
|
def test_extract_sha256(self):
|
|
# HKDF-Extract is HMAC-SHA256(salt, IKM). Cross-check with a known value.
|
|
result = _hkdf_extract(b"salt", b"ikm")
|
|
import hmac, hashlib
|
|
expected = hmac.new(b"salt", b"ikm", hashlib.sha256).digest()
|
|
assert result == expected
|
|
|
|
def test_expand_label_length(self):
|
|
secret = _hkdf_extract(_QUIC_V1_INITIAL_SALT, _RFC9001_DCID)
|
|
# "client in" expand should be 32 bytes
|
|
client_secret = _hkdf_expand_label(secret, "client in", b"", 32)
|
|
assert len(client_secret) == 32
|
|
|
|
def test_expand_label_key_length(self):
|
|
secret = _hkdf_extract(_QUIC_V1_INITIAL_SALT, _RFC9001_DCID)
|
|
client_secret = _hkdf_expand_label(secret, "client in", b"", 32)
|
|
key = _hkdf_expand_label(client_secret, "quic key", b"", 16)
|
|
assert len(key) == 16
|
|
|
|
def test_expand_label_iv_length(self):
|
|
secret = _hkdf_extract(_QUIC_V1_INITIAL_SALT, _RFC9001_DCID)
|
|
client_secret = _hkdf_expand_label(secret, "client in", b"", 32)
|
|
iv = _hkdf_expand_label(client_secret, "quic iv", b"", 12)
|
|
assert len(iv) == 12
|
|
|
|
|
|
class TestQuicInitialKeys:
|
|
def test_rfc9001_appendix_a_vectors(self):
|
|
"""Key derivation must match RFC 9001 Appendix A.1 test vectors exactly."""
|
|
key, iv, hp = _quic_initial_keys(_RFC9001_DCID)
|
|
assert key == _RFC9001_CLIENT_KEY, f"key mismatch: {key.hex()}"
|
|
assert iv == _RFC9001_CLIENT_IV, f"iv mismatch: {iv.hex()}"
|
|
assert hp == _RFC9001_CLIENT_HP, f"hp mismatch: {hp.hex()}"
|
|
|
|
|
|
class TestJA4Quic:
|
|
def test_proto_prefix_is_q(self):
|
|
ch = {
|
|
"cipher_suites": [0x1301, 0x1302],
|
|
"extensions": [0x000a, 0x000d, 0x002b],
|
|
"signature_algorithms": [0x0403, 0x0804],
|
|
"supported_versions": [0x0304],
|
|
"sni": "example.com",
|
|
"alpn": ["h3"],
|
|
"tls_version": 0x0303,
|
|
}
|
|
result = _ja4_quic(ch)
|
|
assert result.startswith("q"), f"expected 'q' prefix: {result}"
|
|
|
|
def test_structure(self):
|
|
ch = {
|
|
"cipher_suites": [0x1301],
|
|
"extensions": [0x000a],
|
|
"signature_algorithms": [],
|
|
"supported_versions": [0x0304],
|
|
"sni": "",
|
|
"alpn": [],
|
|
"tls_version": 0x0303,
|
|
}
|
|
result = _ja4_quic(ch)
|
|
parts = result.split("_")
|
|
assert len(parts) == 3
|
|
|
|
def test_deterministic(self):
|
|
ch = {
|
|
"cipher_suites": [0x1301, 0x1302, 0x1303],
|
|
"extensions": [0x000a, 0x000d],
|
|
"signature_algorithms": [0x0403],
|
|
"supported_versions": [0x0304],
|
|
"sni": "host.example",
|
|
"alpn": ["h3"],
|
|
"tls_version": 0x0303,
|
|
}
|
|
assert _ja4_quic(ch) == _ja4_quic(ch)
|
|
|
|
|
|
class TestParseQuicInitial:
|
|
def test_short_header_rejected(self):
|
|
# Short header: bit 7 clear
|
|
assert _parse_quic_initial(b"\x40" + b"\x00" * 20) is None
|
|
|
|
def test_wrong_version_rejected(self):
|
|
# Long header, Initial type, version = 0x00000002
|
|
pkt = bytearray(30)
|
|
pkt[0] = 0xC0 # long header + Initial
|
|
pkt[1:5] = b"\x00\x00\x00\x02" # version 2
|
|
assert _parse_quic_initial(bytes(pkt)) is None
|
|
|
|
def test_non_initial_type_rejected(self):
|
|
# Long header, Handshake type (0x20 set)
|
|
pkt = bytearray(30)
|
|
pkt[0] = 0xE0 # long header + Handshake
|
|
pkt[1:5] = b"\x00\x00\x00\x01"
|
|
assert _parse_quic_initial(bytes(pkt)) is None
|
|
|
|
def test_garbage_returns_none(self):
|
|
assert _parse_quic_initial(b"garbage bytes that are not QUIC") is None
|
|
|
|
def test_too_short_returns_none(self):
|
|
assert _parse_quic_initial(b"\xc0\x00") is None
|