heptagon7 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- heptagon7-1.0.0/LICENSE +23 -0
- heptagon7-1.0.0/PKG-INFO +124 -0
- heptagon7-1.0.0/README.md +104 -0
- heptagon7-1.0.0/heptagon/__init__.py +44 -0
- heptagon7-1.0.0/heptagon/__main__.py +7 -0
- heptagon7-1.0.0/heptagon/audit/__init__.py +0 -0
- heptagon7-1.0.0/heptagon/audit/log.py +78 -0
- heptagon7-1.0.0/heptagon/audit/signing.py +127 -0
- heptagon7-1.0.0/heptagon/cirp/__init__.py +0 -0
- heptagon7-1.0.0/heptagon/cirp/model.py +974 -0
- heptagon7-1.0.0/heptagon/cirp/yaml_loader.py +148 -0
- heptagon7-1.0.0/heptagon/cli/__init__.py +0 -0
- heptagon7-1.0.0/heptagon/cli/main.py +395 -0
- heptagon7-1.0.0/heptagon/core/__init__.py +0 -0
- heptagon7-1.0.0/heptagon/core/symbolic.py +659 -0
- heptagon7-1.0.0/heptagon/simulation/__init__.py +1 -0
- heptagon7-1.0.0/heptagon/simulation/internal_simulator.py +134 -0
- heptagon7-1.0.0/heptagon/simulation/stuxnet_test.py +117 -0
- heptagon7-1.0.0/heptagon7.egg-info/PKG-INFO +124 -0
- heptagon7-1.0.0/heptagon7.egg-info/SOURCES.txt +31 -0
- heptagon7-1.0.0/heptagon7.egg-info/dependency_links.txt +1 -0
- heptagon7-1.0.0/heptagon7.egg-info/entry_points.txt +2 -0
- heptagon7-1.0.0/heptagon7.egg-info/requires.txt +8 -0
- heptagon7-1.0.0/heptagon7.egg-info/top_level.txt +1 -0
- heptagon7-1.0.0/pyproject.toml +39 -0
- heptagon7-1.0.0/setup.cfg +4 -0
- heptagon7-1.0.0/tests/test_cirp_checks.py +384 -0
- heptagon7-1.0.0/tests/test_cli.py +107 -0
- heptagon7-1.0.0/tests/test_log_and_audit.py +176 -0
- heptagon7-1.0.0/tests/test_signing.py +170 -0
- heptagon7-1.0.0/tests/test_simulation.py +76 -0
- heptagon7-1.0.0/tests/test_symbolic.py +311 -0
- heptagon7-1.0.0/tests/test_yaml_loader.py +109 -0
heptagon7-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammad Ali
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
|
20
|
+
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
21
|
+
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
22
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
heptagon7-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: heptagon7
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Seven coordinates. Every incident. Verified.
|
|
5
|
+
Author: Muhammad Ali
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/claythe3ed/heptagon
|
|
8
|
+
Project-URL: Repository, https://github.com/claythe3ed/heptagon
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: numpy>=2.0
|
|
13
|
+
Requires-Dist: PyYAML>=6.0
|
|
14
|
+
Requires-Dist: jsonschema>=4.0
|
|
15
|
+
Requires-Dist: PyNaCl>=1.5
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov>=6.0; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# Heptagon
|
|
22
|
+
|
|
23
|
+
**Seven coordinates. Every incident. Verified.**
|
|
24
|
+
|
|
25
|
+
A symbolic, formal, and executable framework for cybersecurity
|
|
26
|
+
incident response planning and management.
|
|
27
|
+
|
|
28
|
+
## What it does
|
|
29
|
+
|
|
30
|
+
Heptagon treats every incident record as a seven-coordinate object.
|
|
31
|
+
An incident without a timestamp, rationale, effect, or lineage is
|
|
32
|
+
not a complete record — and an incomplete record cannot be certified
|
|
33
|
+
as truthful.
|
|
34
|
+
|
|
35
|
+
The framework:
|
|
36
|
+
|
|
37
|
+
1. **Declares** a Cyber Incident Response Plan in code
|
|
38
|
+
2. **Verifies** plan completeness against six compliance gates
|
|
39
|
+
3. **Simulates** scenario escalations through the reporting DAG
|
|
40
|
+
4. **Logs** incidents with tamper-evident evidence and decisions
|
|
41
|
+
5. **Audits** every record for 7D coordinate coverage
|
|
42
|
+
6. **Rejects** incomplete records with specific, actionable reasons
|
|
43
|
+
|
|
44
|
+
## The seven coordinates
|
|
45
|
+
|
|
46
|
+
| Coordinate | Name | Meaning |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| **B** | Spacetime | Timestamp + origin |
|
|
49
|
+
| **F5** | Intention | Why the actor acted |
|
|
50
|
+
| **F6** | Effect | What changed downstream |
|
|
51
|
+
| **F7** | Lineage | Position in the reporting chain |
|
|
52
|
+
|
|
53
|
+
A complete record populates all seven. The `check_7d_completeness()`
|
|
54
|
+
gate enforces this on every logged evidence and decision.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
pip install heptagon7
|
|
59
|
+
|
|
60
|
+
The PyPI project is named **heptagon7** — the name `heptagon` was
|
|
61
|
+
already taken on PyPI. After installing, the Python import and the
|
|
62
|
+
CLI command are both `heptagon`:
|
|
63
|
+
|
|
64
|
+
import heptagon # library
|
|
65
|
+
heptagon --help # CLI
|
|
66
|
+
|
|
67
|
+
For local development:
|
|
68
|
+
|
|
69
|
+
python3 -m venv .venv
|
|
70
|
+
source .venv/bin/activate
|
|
71
|
+
pip install -e .
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
python -m heptagon.cirp.model # reference CIRP demo
|
|
76
|
+
python -m heptagon.core.symbolic # symbolic layer demo
|
|
77
|
+
python -m heptagon.simulation.stuxnet_test # historical validation
|
|
78
|
+
python -m pytest tests/ -v # full test suite
|
|
79
|
+
|
|
80
|
+
## The six compliance gates
|
|
81
|
+
|
|
82
|
+
`CIRPModel.verify_all()` runs:
|
|
83
|
+
|
|
84
|
+
1. `check_teams_complete` — all 5 R-C-C-P-T roles present and complete
|
|
85
|
+
2. `check_reporting_chain` — every reporter can reach an executive
|
|
86
|
+
3. `check_evidence_completeness`— unique hashes, no dangling refs, chronology valid
|
|
87
|
+
4. `check_no_hidden_thing` — every source's slice reconciles with the global log
|
|
88
|
+
5. `check_break_glass_logged` — every emergency override is marked AND evidenced
|
|
89
|
+
6. `check_7d_completeness` — every record populates all seven coordinates
|
|
90
|
+
|
|
91
|
+
## Architecture
|
|
92
|
+
|
|
93
|
+
heptagon/
|
|
94
|
+
core/ Symbolic layer (bundle, sheaf, lineage, operators)
|
|
95
|
+
cirp/ CIRP primitives + compliance checks + tabletop
|
|
96
|
+
simulation/ Deterministic agent-based simulator
|
|
97
|
+
audit/ (future) signature verification, reporting
|
|
98
|
+
|
|
99
|
+
## Honest limits
|
|
100
|
+
|
|
101
|
+
- **Assumes honest actors.** This tool catches accidental omission,
|
|
102
|
+
not adversarial falsification. Signatures are content hashes, not
|
|
103
|
+
PKI signatures. A v1 threat model will address the malicious case.
|
|
104
|
+
- **Does not replace human judgment.** It verifies that decisions are
|
|
105
|
+
logged, not that they are wise.
|
|
106
|
+
- **Does not certify compliance.** It reports whether a plan is
|
|
107
|
+
internally consistent, not whether it meets any specific regulation.
|
|
108
|
+
- **Not production-ready before v1.0.** The API may change.
|
|
109
|
+
|
|
110
|
+
## History
|
|
111
|
+
|
|
112
|
+
The framework's design traces to a symbolic model of comprehensive
|
|
113
|
+
presentation (al-'ard fi fada' al-shumul). The mathematics is a
|
|
114
|
+
language for meaning, not a claim about physics. See `docs/PHILOSOPHY.md`
|
|
115
|
+
(future) for the full derivation.
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT. See `LICENSE`.
|
|
120
|
+
|
|
121
|
+
## Status
|
|
122
|
+
|
|
123
|
+
**v0.1.0** — Audit-closed. All twelve originally identified issues
|
|
124
|
+
resolved or retracted. 58 tests passing.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Heptagon
|
|
2
|
+
|
|
3
|
+
**Seven coordinates. Every incident. Verified.**
|
|
4
|
+
|
|
5
|
+
A symbolic, formal, and executable framework for cybersecurity
|
|
6
|
+
incident response planning and management.
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
Heptagon treats every incident record as a seven-coordinate object.
|
|
11
|
+
An incident without a timestamp, rationale, effect, or lineage is
|
|
12
|
+
not a complete record — and an incomplete record cannot be certified
|
|
13
|
+
as truthful.
|
|
14
|
+
|
|
15
|
+
The framework:
|
|
16
|
+
|
|
17
|
+
1. **Declares** a Cyber Incident Response Plan in code
|
|
18
|
+
2. **Verifies** plan completeness against six compliance gates
|
|
19
|
+
3. **Simulates** scenario escalations through the reporting DAG
|
|
20
|
+
4. **Logs** incidents with tamper-evident evidence and decisions
|
|
21
|
+
5. **Audits** every record for 7D coordinate coverage
|
|
22
|
+
6. **Rejects** incomplete records with specific, actionable reasons
|
|
23
|
+
|
|
24
|
+
## The seven coordinates
|
|
25
|
+
|
|
26
|
+
| Coordinate | Name | Meaning |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| **B** | Spacetime | Timestamp + origin |
|
|
29
|
+
| **F5** | Intention | Why the actor acted |
|
|
30
|
+
| **F6** | Effect | What changed downstream |
|
|
31
|
+
| **F7** | Lineage | Position in the reporting chain |
|
|
32
|
+
|
|
33
|
+
A complete record populates all seven. The `check_7d_completeness()`
|
|
34
|
+
gate enforces this on every logged evidence and decision.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
pip install heptagon7
|
|
39
|
+
|
|
40
|
+
The PyPI project is named **heptagon7** — the name `heptagon` was
|
|
41
|
+
already taken on PyPI. After installing, the Python import and the
|
|
42
|
+
CLI command are both `heptagon`:
|
|
43
|
+
|
|
44
|
+
import heptagon # library
|
|
45
|
+
heptagon --help # CLI
|
|
46
|
+
|
|
47
|
+
For local development:
|
|
48
|
+
|
|
49
|
+
python3 -m venv .venv
|
|
50
|
+
source .venv/bin/activate
|
|
51
|
+
pip install -e .
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
python -m heptagon.cirp.model # reference CIRP demo
|
|
56
|
+
python -m heptagon.core.symbolic # symbolic layer demo
|
|
57
|
+
python -m heptagon.simulation.stuxnet_test # historical validation
|
|
58
|
+
python -m pytest tests/ -v # full test suite
|
|
59
|
+
|
|
60
|
+
## The six compliance gates
|
|
61
|
+
|
|
62
|
+
`CIRPModel.verify_all()` runs:
|
|
63
|
+
|
|
64
|
+
1. `check_teams_complete` — all 5 R-C-C-P-T roles present and complete
|
|
65
|
+
2. `check_reporting_chain` — every reporter can reach an executive
|
|
66
|
+
3. `check_evidence_completeness`— unique hashes, no dangling refs, chronology valid
|
|
67
|
+
4. `check_no_hidden_thing` — every source's slice reconciles with the global log
|
|
68
|
+
5. `check_break_glass_logged` — every emergency override is marked AND evidenced
|
|
69
|
+
6. `check_7d_completeness` — every record populates all seven coordinates
|
|
70
|
+
|
|
71
|
+
## Architecture
|
|
72
|
+
|
|
73
|
+
heptagon/
|
|
74
|
+
core/ Symbolic layer (bundle, sheaf, lineage, operators)
|
|
75
|
+
cirp/ CIRP primitives + compliance checks + tabletop
|
|
76
|
+
simulation/ Deterministic agent-based simulator
|
|
77
|
+
audit/ (future) signature verification, reporting
|
|
78
|
+
|
|
79
|
+
## Honest limits
|
|
80
|
+
|
|
81
|
+
- **Assumes honest actors.** This tool catches accidental omission,
|
|
82
|
+
not adversarial falsification. Signatures are content hashes, not
|
|
83
|
+
PKI signatures. A v1 threat model will address the malicious case.
|
|
84
|
+
- **Does not replace human judgment.** It verifies that decisions are
|
|
85
|
+
logged, not that they are wise.
|
|
86
|
+
- **Does not certify compliance.** It reports whether a plan is
|
|
87
|
+
internally consistent, not whether it meets any specific regulation.
|
|
88
|
+
- **Not production-ready before v1.0.** The API may change.
|
|
89
|
+
|
|
90
|
+
## History
|
|
91
|
+
|
|
92
|
+
The framework's design traces to a symbolic model of comprehensive
|
|
93
|
+
presentation (al-'ard fi fada' al-shumul). The mathematics is a
|
|
94
|
+
language for meaning, not a claim about physics. See `docs/PHILOSOPHY.md`
|
|
95
|
+
(future) for the full derivation.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT. See `LICENSE`.
|
|
100
|
+
|
|
101
|
+
## Status
|
|
102
|
+
|
|
103
|
+
**v0.1.0** — Audit-closed. All twelve originally identified issues
|
|
104
|
+
resolved or retracted. 58 tests passing.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Heptagon — Seven coordinates. Every incident. Verified.
|
|
3
|
+
|
|
4
|
+
A symbolic, formal, and executable framework for cybersecurity
|
|
5
|
+
incident response planning and management.
|
|
6
|
+
"""
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
|
|
9
|
+
from heptagon.core.symbolic import (
|
|
10
|
+
FiberBundle7D,
|
|
11
|
+
MoralSheaf,
|
|
12
|
+
Lineage,
|
|
13
|
+
PresentationOperator,
|
|
14
|
+
TestimonyOperator,
|
|
15
|
+
MoralJacobians,
|
|
16
|
+
UnifiedEvaluation,
|
|
17
|
+
IncidentResponsePipeline,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from heptagon.cirp.model import (
|
|
21
|
+
CIRPModel,
|
|
22
|
+
Team,
|
|
23
|
+
Stakeholder,
|
|
24
|
+
Scenario,
|
|
25
|
+
Evidence,
|
|
26
|
+
Decision,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"FiberBundle7D",
|
|
31
|
+
"MoralSheaf",
|
|
32
|
+
"Lineage",
|
|
33
|
+
"PresentationOperator",
|
|
34
|
+
"TestimonyOperator",
|
|
35
|
+
"MoralJacobians",
|
|
36
|
+
"UnifiedEvaluation",
|
|
37
|
+
"IncidentResponsePipeline",
|
|
38
|
+
"CIRPModel",
|
|
39
|
+
"Team",
|
|
40
|
+
"Stakeholder",
|
|
41
|
+
"Scenario",
|
|
42
|
+
"Evidence",
|
|
43
|
+
"Decision",
|
|
44
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
log.py
|
|
3
|
+
======
|
|
4
|
+
|
|
5
|
+
Read and write incident logs.
|
|
6
|
+
|
|
7
|
+
An incident log is a JSONL file. Each line is one JSON object of
|
|
8
|
+
the form::
|
|
9
|
+
|
|
10
|
+
{"type": "evidence", "data": { ... Evidence fields ... }}
|
|
11
|
+
{"type": "decision", "data": { ... Decision fields ... }}
|
|
12
|
+
|
|
13
|
+
Records are stored in insertion order. Append-only semantics are
|
|
14
|
+
recommended: never rewrite a log once written, only append to it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import List, Union
|
|
23
|
+
|
|
24
|
+
from heptagon.cirp.model import Decision, Evidence
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class IncidentLog:
|
|
29
|
+
"""An ordered collection of evidence and decisions."""
|
|
30
|
+
evidence: List[Evidence] = field(default_factory=list)
|
|
31
|
+
decisions: List[Decision] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
def append_evidence(self, ev: Evidence) -> None:
|
|
34
|
+
self.evidence.append(ev)
|
|
35
|
+
|
|
36
|
+
def append_decision(self, d: Decision) -> None:
|
|
37
|
+
self.decisions.append(d)
|
|
38
|
+
|
|
39
|
+
def to_jsonl(self) -> str:
|
|
40
|
+
lines: List[str] = []
|
|
41
|
+
for ev in self.evidence:
|
|
42
|
+
lines.append(json.dumps({"type": "evidence", "data": ev.to_dict()}))
|
|
43
|
+
for d in self.decisions:
|
|
44
|
+
lines.append(json.dumps({"type": "decision", "data": d.to_dict()}))
|
|
45
|
+
return "\n".join(lines) + "\n"
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_jsonl(cls, text: str) -> "IncidentLog":
|
|
49
|
+
log = cls()
|
|
50
|
+
for lineno, raw in enumerate(text.splitlines(), start=1):
|
|
51
|
+
stripped = raw.strip()
|
|
52
|
+
if not stripped:
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
record = json.loads(stripped)
|
|
56
|
+
except json.JSONDecodeError as e:
|
|
57
|
+
raise ValueError(f"line {lineno}: invalid JSON: {e}") from e
|
|
58
|
+
rtype = record.get("type")
|
|
59
|
+
data = record.get("data", {})
|
|
60
|
+
if rtype == "evidence":
|
|
61
|
+
log.evidence.append(Evidence.from_dict(data))
|
|
62
|
+
elif rtype == "decision":
|
|
63
|
+
log.decisions.append(Decision.from_dict(data))
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"line {lineno}: unknown record type {rtype!r}"
|
|
67
|
+
)
|
|
68
|
+
return log
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_log(log: IncidentLog, path: Union[str, Path]) -> None:
|
|
72
|
+
"""Write an IncidentLog to a JSONL file."""
|
|
73
|
+
Path(path).write_text(log.to_jsonl(), encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_log(path: Union[str, Path]) -> IncidentLog:
|
|
77
|
+
"""Read an IncidentLog from a JSONL file."""
|
|
78
|
+
return IncidentLog.from_jsonl(Path(path).read_text(encoding="utf-8"))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
signing.py
|
|
3
|
+
==========
|
|
4
|
+
|
|
5
|
+
Ed25519 signing for incident-response records.
|
|
6
|
+
|
|
7
|
+
Provides a ``Signer`` class that wraps a private/public keypair and
|
|
8
|
+
signs canonical byte representations of Decision records.
|
|
9
|
+
|
|
10
|
+
Key storage format
|
|
11
|
+
------------------
|
|
12
|
+
A signer is serialized as JSON:
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"algorithm": "ed25519",
|
|
16
|
+
"private_key_hex": "...",
|
|
17
|
+
"public_key_hex": "...",
|
|
18
|
+
"created_utc": "2026-10-10T20:35:00+00:00"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
The private key should be stored with strict filesystem permissions
|
|
22
|
+
(0600). The public key can be shared freely — it is the identifier
|
|
23
|
+
used to verify signed records.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional, Union
|
|
34
|
+
|
|
35
|
+
from nacl import signing as ed25519
|
|
36
|
+
from nacl.exceptions import BadSignatureError
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ALGORITHM_NAME = "ed25519"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Signer:
|
|
44
|
+
"""An Ed25519 keypair capable of signing and verifying records."""
|
|
45
|
+
private_key_bytes: bytes
|
|
46
|
+
public_key_bytes: bytes
|
|
47
|
+
created_utc: str = ""
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Construction
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def generate(cls) -> "Signer":
|
|
55
|
+
"""Generate a fresh random keypair."""
|
|
56
|
+
vk = ed25519.SigningKey.generate()
|
|
57
|
+
sk_bytes = bytes(vk)
|
|
58
|
+
pk_bytes = bytes(vk.verify_key)
|
|
59
|
+
return cls(
|
|
60
|
+
private_key_bytes=sk_bytes,
|
|
61
|
+
public_key_bytes=pk_bytes,
|
|
62
|
+
created_utc=datetime.now(timezone.utc).isoformat(),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Persistence
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def save(self, path: Union[str, Path]) -> None:
|
|
70
|
+
"""Write the keypair to a JSON file with 0600 permissions."""
|
|
71
|
+
p = Path(path)
|
|
72
|
+
payload = {
|
|
73
|
+
"algorithm": ALGORITHM_NAME,
|
|
74
|
+
"private_key_hex": self.private_key_bytes.hex(),
|
|
75
|
+
"public_key_hex": self.public_key_bytes.hex(),
|
|
76
|
+
"created_utc": self.created_utc,
|
|
77
|
+
}
|
|
78
|
+
p.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
79
|
+
# Lock permissions down
|
|
80
|
+
try:
|
|
81
|
+
os.chmod(p, 0o600)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def load(cls, path: Union[str, Path]) -> "Signer":
|
|
87
|
+
"""Load a keypair from a JSON file."""
|
|
88
|
+
p = Path(path)
|
|
89
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
90
|
+
if data.get("algorithm") != ALGORITHM_NAME:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"unsupported algorithm: {data.get('algorithm')}"
|
|
93
|
+
)
|
|
94
|
+
return cls(
|
|
95
|
+
private_key_bytes=bytes.fromhex(data["private_key_hex"]),
|
|
96
|
+
public_key_bytes=bytes.fromhex(data["public_key_hex"]),
|
|
97
|
+
created_utc=data.get("created_utc", ""),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Sign / verify
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def public_key_hex(self) -> str:
|
|
106
|
+
return self.public_key_bytes.hex()
|
|
107
|
+
|
|
108
|
+
def sign(self, payload: bytes) -> bytes:
|
|
109
|
+
"""Sign raw bytes. Returns the 64-byte signature."""
|
|
110
|
+
vk = ed25519.SigningKey(self.private_key_bytes)
|
|
111
|
+
# PyNaCl's SigningKey.sign() returns a SignedMessage container
|
|
112
|
+
# (signature + original message). Extract only the signature.
|
|
113
|
+
return vk.sign(payload).signature
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def verify(
|
|
117
|
+
public_key_bytes: bytes,
|
|
118
|
+
payload: bytes,
|
|
119
|
+
signature_bytes: bytes,
|
|
120
|
+
) -> bool:
|
|
121
|
+
"""Verify a signature against a payload and public key."""
|
|
122
|
+
try:
|
|
123
|
+
vk = ed25519.VerifyKey(public_key_bytes)
|
|
124
|
+
vk.verify(payload, signature_bytes)
|
|
125
|
+
return True
|
|
126
|
+
except (BadSignatureError, Exception):
|
|
127
|
+
return False
|
|
File without changes
|