aics-cli 0.1.0__py3-none-any.whl
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.
- aics_cli/__init__.py +8 -0
- aics_cli/__main__.py +3 -0
- aics_cli/bundle_build.py +216 -0
- aics_cli/card.py +933 -0
- aics_cli/checkpoints.py +498 -0
- aics_cli/cli.py +298 -0
- aics_cli/commands.py +1656 -0
- aics_cli/consistency.py +280 -0
- aics_cli/dnstxt.py +319 -0
- aics_cli/encoding.py +125 -0
- aics_cli/errors.py +57 -0
- aics_cli/evidence.py +88 -0
- aics_cli/home.py +112 -0
- aics_cli/keystore.py +189 -0
- aics_cli/message.py +875 -0
- aics_cli/net.py +117 -0
- aics_cli/policy.py +618 -0
- aics_cli/published_keys.py +34 -0
- aics_cli/tiles_http.py +59 -0
- aics_cli/trust.py +243 -0
- aics_cli-0.1.0.dist-info/METADATA +420 -0
- aics_cli-0.1.0.dist-info/RECORD +25 -0
- aics_cli-0.1.0.dist-info/WHEEL +4 -0
- aics_cli-0.1.0.dist-info/entry_points.txt +3 -0
- aics_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
aics_cli/encoding.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Base64 helpers.
|
|
2
|
+
|
|
3
|
+
* ``b64e`` / ``b64d_strict``: standard alphabet with padding, canonical -
|
|
4
|
+
the form used by signed notes, checkpoints and the text protocol's
|
|
5
|
+
``pub`` / ``sig`` lines.
|
|
6
|
+
* ``b64d_lenient``: what the gate accepts from an agent - standard or
|
|
7
|
+
URL-safe alphabet, padding optional, ASCII whitespace ignored.
|
|
8
|
+
* ``b64u_e`` / ``b64u_d``: base64url without padding (JWS, RFC 7515 §2).
|
|
9
|
+
|
|
10
|
+
It also holds :func:`escape_untrusted`, which every text-mode output uses for
|
|
11
|
+
strings that come from a server (card free text, error details): they must
|
|
12
|
+
never add lines or terminal escape sequences to the verifier's own verdict.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import binascii
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
_WS = str.maketrans("", "", " \t\r\n\v\f")
|
|
22
|
+
_B64URL_RE = re.compile(r"[A-Za-z0-9_-]*")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def b64e(data: bytes) -> str:
|
|
26
|
+
return base64.b64encode(data).decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def b64d_strict(text: str) -> bytes:
|
|
30
|
+
"""Canonical standard base64 with padding; anything else raises ValueError."""
|
|
31
|
+
if not isinstance(text, str) or not text or len(text) % 4:
|
|
32
|
+
raise ValueError("not canonical base64")
|
|
33
|
+
try:
|
|
34
|
+
raw = base64.b64decode(text, validate=True)
|
|
35
|
+
except (binascii.Error, ValueError) as exc:
|
|
36
|
+
raise ValueError(f"invalid base64: {exc}") from None
|
|
37
|
+
if base64.b64encode(raw).decode("ascii") != text:
|
|
38
|
+
raise ValueError("not canonical base64")
|
|
39
|
+
return raw
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def b64d_lenient(text: str) -> bytes:
|
|
43
|
+
"""Standard or URL-safe alphabet, padding optional, whitespace ignored."""
|
|
44
|
+
if isinstance(text, bytes):
|
|
45
|
+
text = text.decode("ascii")
|
|
46
|
+
compact = text.translate(_WS)
|
|
47
|
+
if "-" in compact or "_" in compact:
|
|
48
|
+
if "+" in compact or "/" in compact:
|
|
49
|
+
raise ValueError("base64 mixes the standard and URL-safe alphabets")
|
|
50
|
+
compact = compact.replace("-", "+").replace("_", "/")
|
|
51
|
+
stripped = compact.rstrip("=")
|
|
52
|
+
if len(compact) - len(stripped) > 2 or len(stripped) % 4 == 1 or not stripped:
|
|
53
|
+
raise ValueError("invalid base64 length or padding")
|
|
54
|
+
try:
|
|
55
|
+
return base64.b64decode(stripped + "=" * (-len(stripped) % 4), validate=True)
|
|
56
|
+
except (binascii.Error, ValueError) as exc:
|
|
57
|
+
raise ValueError(f"invalid base64: {exc}") from None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def b64u_e(data: bytes) -> str:
|
|
61
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def b64u_d(text: str) -> bytes:
|
|
65
|
+
"""base64url without padding (RFC 7515); rejects characters outside the alphabet."""
|
|
66
|
+
if not isinstance(text, str):
|
|
67
|
+
raise ValueError("base64url value must be a string")
|
|
68
|
+
if not _B64URL_RE.fullmatch(text) or len(text) % 4 == 1:
|
|
69
|
+
raise ValueError("invalid base64url")
|
|
70
|
+
try:
|
|
71
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
72
|
+
except (binascii.Error, ValueError) as exc:
|
|
73
|
+
raise ValueError(f"invalid base64url: {exc}") from None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def escape_untrusted(text: str) -> str:
|
|
77
|
+
"""``text`` with every non-printable character (control, format, line and
|
|
78
|
+
paragraph separators, ...) written as ``\\uXXXX`` / ``\\UXXXXXXXX``, so an
|
|
79
|
+
untrusted string prints as exactly one line with no escape sequences."""
|
|
80
|
+
if not isinstance(text, str):
|
|
81
|
+
text = str(text)
|
|
82
|
+
out = []
|
|
83
|
+
for c in text:
|
|
84
|
+
if c.isprintable() and c != "\\":
|
|
85
|
+
out.append(c)
|
|
86
|
+
elif c == "\\":
|
|
87
|
+
out.append("\\\\")
|
|
88
|
+
elif ord(c) <= 0xFFFF:
|
|
89
|
+
out.append(f"\\u{ord(c):04x}")
|
|
90
|
+
else:
|
|
91
|
+
out.append(f"\\U{ord(c):08x}")
|
|
92
|
+
return "".join(out)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def printable_line(line: str) -> str:
|
|
96
|
+
"""The last pass over every text-mode output line: non-printable
|
|
97
|
+
characters become ``\\uXXXX`` (backslashes are left alone, so JSON
|
|
98
|
+
printed as text stays valid JSON)."""
|
|
99
|
+
if line.isprintable():
|
|
100
|
+
return line
|
|
101
|
+
return "".join(c if c.isprintable() else (f"\\u{ord(c):04x}" if ord(c) <= 0xFFFF else f"\\U{ord(c):08x}")
|
|
102
|
+
for c in line)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_hash_lines(text: str, *, max_hashes: int = 256) -> list[bytes]:
|
|
106
|
+
"""One 32-byte hash per line, base64 or 64 hex characters (the
|
|
107
|
+
``aics-tlog`` proof-file format). Blank lines are ignored."""
|
|
108
|
+
out: list[bytes] = []
|
|
109
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
110
|
+
line = line.strip()
|
|
111
|
+
if not line:
|
|
112
|
+
continue
|
|
113
|
+
if len(line) == 64 and all(c in "0123456789abcdefABCDEF" for c in line):
|
|
114
|
+
h = bytes.fromhex(line)
|
|
115
|
+
else:
|
|
116
|
+
try:
|
|
117
|
+
h = b64d_lenient(line)
|
|
118
|
+
except ValueError as exc:
|
|
119
|
+
raise ValueError(f"proof line {lineno}: {exc}") from None
|
|
120
|
+
if len(h) != 32:
|
|
121
|
+
raise ValueError(f"proof line {lineno}: hash must be 32 bytes, got {len(h)}")
|
|
122
|
+
out.append(h)
|
|
123
|
+
if len(out) > max_hashes:
|
|
124
|
+
raise ValueError("proof too long")
|
|
125
|
+
return out
|
aics_cli/errors.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Exceptions and exit codes shared by every command.
|
|
2
|
+
|
|
3
|
+
Exit codes (the contract every ``aics`` command keeps):
|
|
4
|
+
|
|
5
|
+
* 0 - verified;
|
|
6
|
+
* 1 - error: bad input, missing policy, network failure, or a check that
|
|
7
|
+
could not be completed (inconclusive), a stale head, an older head kept as
|
|
8
|
+
historical, or an invalid note from a source other than the log origin;
|
|
9
|
+
* 2 - verification failed and the output carries the evidence;
|
|
10
|
+
* 3 - the command needs a server feature that does not exist yet.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
EXIT_OK = 0
|
|
18
|
+
EXIT_ERROR = 1
|
|
19
|
+
EXIT_EVIDENCE = 2
|
|
20
|
+
EXIT_UNAVAILABLE = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AicsError(Exception):
|
|
24
|
+
"""An error that ends a command with exit code 1."""
|
|
25
|
+
|
|
26
|
+
exit_code = EXIT_ERROR
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, **details: Any) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message = message
|
|
31
|
+
self.details = details
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UsageError(AicsError):
|
|
35
|
+
"""Bad command line (argparse would exit 2, which means evidence here)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PolicyError(AicsError):
|
|
39
|
+
"""The pinned policy is missing, malformed or refused."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FetchError(AicsError):
|
|
43
|
+
"""A network fetch failed at the transport level (no HTTP answer)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DnsError(AicsError):
|
|
47
|
+
"""A DNS TXT lookup failed (not "no such record": that returns [])."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VerificationFailed(AicsError):
|
|
51
|
+
"""Verification failed; ``evidence`` is printed so others can check it."""
|
|
52
|
+
|
|
53
|
+
exit_code = EXIT_EVIDENCE
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, evidence: dict[str, Any] | None = None, **details: Any) -> None:
|
|
56
|
+
super().__init__(message, **details)
|
|
57
|
+
self.evidence = evidence or {}
|
aics_cli/evidence.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Split-view evidence bundles (docs/DESIGN.md §3 G5, docs/spec/AICS-1.md §14.6).
|
|
2
|
+
|
|
3
|
+
A bundle is self-contained: the pinned log key and witness policy, every
|
|
4
|
+
log-signed note involved (full text, so signatures and cosignatures can be
|
|
5
|
+
re-checked), the authenticated proof material, and the ``aics-tlog``
|
|
6
|
+
commands a third party runs to check it without this tool.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .checkpoints import Cp
|
|
16
|
+
from .consistency import Attempt, Relation
|
|
17
|
+
from .encoding import b64e
|
|
18
|
+
from .policy import Policy
|
|
19
|
+
|
|
20
|
+
BUNDLE_TYPE = "aics-split-view-evidence/v1"
|
|
21
|
+
|
|
22
|
+
REASONS = {
|
|
23
|
+
"equal-size-different-root": "two log-signed checkpoints with the same origin and size but different roots",
|
|
24
|
+
"not-a-prefix": "a log-signed checkpoint is not a prefix of a newer log-signed checkpoint: its root differs "
|
|
25
|
+
"from the newer tree's authenticated root at that size",
|
|
26
|
+
"rollback": "the log now serves a smaller tree than one it signed earlier; an append-only log never shrinks, "
|
|
27
|
+
"so the smaller head is not accepted as current",
|
|
28
|
+
"bad-empty-root": "a log-signed checkpoint of size 0 whose root is not SHA-256(\"\"); no append-only log can "
|
|
29
|
+
"produce it (AICS-1 §14.2 step 1)",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_commands(policy: Policy, old: Cp, new: Cp, att: Attempt | None) -> list[str]:
|
|
34
|
+
cmds = [
|
|
35
|
+
f"aics-tlog verify-checkpoint --note note-{i}.txt --log-key '{cp.log_vkey or policy.log_vkey}' "
|
|
36
|
+
f"--origin '{policy.origin}'"
|
|
37
|
+
for i, cp in ((1, old), (2, new))
|
|
38
|
+
]
|
|
39
|
+
if att is not None and att.proof is not None and att.prefix_root is not None:
|
|
40
|
+
cmds.append(
|
|
41
|
+
f"aics-tlog verify-consistency --old-size {old.size} --new-size {new.size} "
|
|
42
|
+
f"--old-root '{b64e(att.prefix_root)}' --new-root '{new.root_b64}' --proof proof.b64"
|
|
43
|
+
f" # succeeds; compare --old-root with note 1's root {old.root_b64}"
|
|
44
|
+
)
|
|
45
|
+
return cmds
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def split_bundle(policy: Policy, rel: Relation, *, now: int, context: str,
|
|
49
|
+
witness_status: dict[str, bool] | None = None) -> dict[str, Any]:
|
|
50
|
+
"""Evidence for every inconsistent pair found while relating two notes.
|
|
51
|
+
|
|
52
|
+
``witness_status`` maps note SHA-256 to whether that note met the
|
|
53
|
+
witness policy; a conflict between notes that did not all meet it is
|
|
54
|
+
labelled ``log-equivocation`` rather than ``split-proven`` (the log key
|
|
55
|
+
signed both, but clients following the policy would not have accepted
|
|
56
|
+
both). Notes saved under an earlier pinned log key carry that key."""
|
|
57
|
+
pairs = []
|
|
58
|
+
met_all = True
|
|
59
|
+
for old, new, att in rel.split_pairs:
|
|
60
|
+
notes = [old.evidence(), new.evidence()]
|
|
61
|
+
if witness_status is not None:
|
|
62
|
+
for n in notes:
|
|
63
|
+
n["witness_policy_met"] = witness_status.get(n["sha256"])
|
|
64
|
+
if n["witness_policy_met"] is False:
|
|
65
|
+
met_all = False
|
|
66
|
+
entry: dict[str, Any] = {"notes": notes, "check": _check_commands(policy, old, new, att)}
|
|
67
|
+
if att is not None:
|
|
68
|
+
entry["proof"] = att.report()
|
|
69
|
+
pairs.append(entry)
|
|
70
|
+
reason = rel.reason or "not-a-prefix"
|
|
71
|
+
classification = "split-proven" if met_all else "log-equivocation"
|
|
72
|
+
if reason == "rollback":
|
|
73
|
+
classification = "rollback"
|
|
74
|
+
return {
|
|
75
|
+
"type": BUNDLE_TYPE,
|
|
76
|
+
"classification": classification,
|
|
77
|
+
"reason": reason,
|
|
78
|
+
"explanation": REASONS.get(reason, reason),
|
|
79
|
+
"context": context,
|
|
80
|
+
"origin": policy.origin,
|
|
81
|
+
"log_vkey": policy.log_vkey,
|
|
82
|
+
"witness_policy": {"quorum": policy.quorum, "witnesses": policy.witness_vkeys,
|
|
83
|
+
"max_checkpoint_age": policy.max_checkpoint_age},
|
|
84
|
+
"pairs": pairs,
|
|
85
|
+
"attempts": [a.report() for a in rel.attempts],
|
|
86
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
|
|
87
|
+
"generator": f"aics {__version__}",
|
|
88
|
+
}
|
aics_cli/home.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Layout of the agent's local state directory (default ``~/.aics``).
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
~/.aics/ 0700
|
|
6
|
+
aics.key Ed25519 private key, PKCS#8 PEM (what
|
|
7
|
+
``openssl genpkey -algorithm ed25519`` writes), 0600
|
|
8
|
+
aics-x.key X25519 private key, PKCS#8 PEM (what
|
|
9
|
+
``openssl genpkey -algorithm X25519`` writes), 0600
|
|
10
|
+
identity.json public keys only
|
|
11
|
+
policy.toml pinned keys and URLs (``aics policy init``)
|
|
12
|
+
policy-history/ policies replaced with ``--force``
|
|
13
|
+
checkpoints/ append-only: verified checkpoint notes
|
|
14
|
+
index.jsonl one line per save, with its role (head/evidence/observed)
|
|
15
|
+
and log key; ``reset`` lines (--reset-history)
|
|
16
|
+
<origin>/<size>-<sha256>.note
|
|
17
|
+
cards/ append-only: verified assurance cards
|
|
18
|
+
index.jsonl
|
|
19
|
+
<policy key>/<card hash>.json
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
ED25519_KEY = "aics.key"
|
|
31
|
+
X25519_KEY = "aics-x.key"
|
|
32
|
+
IDENTITY = "identity.json"
|
|
33
|
+
POLICY = "policy.toml"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_home() -> Path:
|
|
37
|
+
env = os.environ.get("AICS_HOME")
|
|
38
|
+
if env:
|
|
39
|
+
return Path(env).expanduser()
|
|
40
|
+
return Path("~/.aics").expanduser()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ensure_dir(path: Path, mode: int = 0o700) -> Path:
|
|
44
|
+
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def slug(text: str) -> str:
|
|
49
|
+
"""A filesystem-safe, collision-resistant directory name for an origin or host."""
|
|
50
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("._") or "_"
|
|
51
|
+
if safe != text:
|
|
52
|
+
import hashlib
|
|
53
|
+
|
|
54
|
+
safe = f"{safe[:80]}-{hashlib.sha256(text.encode('utf-8')).hexdigest()[:8]}"
|
|
55
|
+
return safe
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_new(path: Path, data: bytes, mode: int = 0o644) -> bool:
|
|
59
|
+
"""Create ``path`` with ``data``; never replace an existing file.
|
|
60
|
+
|
|
61
|
+
Returns True if created, False if the file already holds exactly
|
|
62
|
+
``data``. Raises FileExistsError if it holds something else.
|
|
63
|
+
"""
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
try:
|
|
66
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
67
|
+
except FileExistsError:
|
|
68
|
+
if path.read_bytes() == data:
|
|
69
|
+
return False
|
|
70
|
+
raise
|
|
71
|
+
try:
|
|
72
|
+
with os.fdopen(fd, "wb") as fh:
|
|
73
|
+
fh.write(data)
|
|
74
|
+
fh.flush()
|
|
75
|
+
os.fsync(fh.fileno())
|
|
76
|
+
except BaseException:
|
|
77
|
+
try:
|
|
78
|
+
path.unlink()
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
raise
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def append_jsonl(path: Path, record: dict[str, Any]) -> None:
|
|
86
|
+
"""Append one JSON line (O_APPEND; never rewrites earlier lines)."""
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
line = (json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
89
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
90
|
+
try:
|
|
91
|
+
os.write(fd, line)
|
|
92
|
+
finally:
|
|
93
|
+
os.close(fd)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
97
|
+
out: list[dict[str, Any]] = []
|
|
98
|
+
try:
|
|
99
|
+
text = path.read_text("utf-8")
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
return out
|
|
102
|
+
for line in text.splitlines():
|
|
103
|
+
line = line.strip()
|
|
104
|
+
if not line:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
obj = json.loads(line)
|
|
108
|
+
except ValueError:
|
|
109
|
+
continue # a torn final line from a crash; earlier lines stay valid
|
|
110
|
+
if isinstance(obj, dict):
|
|
111
|
+
out.append(obj)
|
|
112
|
+
return out
|
aics_cli/keystore.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The agent's own keys: ``aics keys new`` / ``aics keys show``.
|
|
2
|
+
|
|
3
|
+
Ed25519 (identity, signing) and X25519 (end-to-end encryption) private
|
|
4
|
+
keys are generated locally and written as PKCS#8 PEM with mode 0600, the
|
|
5
|
+
same file format ``openssl genpkey -algorithm ed25519`` produces, so the
|
|
6
|
+
agent can also sign with ``openssl pkeyutl`` as docs/PROTOCOL-TEXT.md §3
|
|
7
|
+
describes. An OpenSSH private key (``ssh-keygen -t ed25519``) is accepted
|
|
8
|
+
for signing as well.
|
|
9
|
+
|
|
10
|
+
Public forms printed are the gate's replies (docs/spec/AICS-1.md §11.4,
|
|
11
|
+
PROTOCOL-TEXT.md §3): ``pub <base64 of the raw 32 bytes>`` for the Ed25519
|
|
12
|
+
key and ``enc <base64 of 32 bytes>`` for the X25519 key, then the signing
|
|
13
|
+
``did:key`` (the agent's ``kid``) and the encryption DID.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import stat
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from aics_identity import keys as idkeys
|
|
27
|
+
from cryptography.hazmat.primitives import serialization
|
|
28
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
29
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
|
30
|
+
|
|
31
|
+
from . import home as _home
|
|
32
|
+
from .encoding import b64e
|
|
33
|
+
from .errors import AicsError
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PublicIdentity:
|
|
38
|
+
ed25519: bytes
|
|
39
|
+
x25519: bytes | None
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
pub = idkeys.public_key_from_bytes(self.ed25519)
|
|
43
|
+
d: dict[str, Any] = {
|
|
44
|
+
"pub": f"pub {b64e(self.ed25519)}",
|
|
45
|
+
"ed25519": b64e(self.ed25519),
|
|
46
|
+
"kid": idkeys.did_key(pub),
|
|
47
|
+
"enc": None,
|
|
48
|
+
"x25519": None,
|
|
49
|
+
"x25519_did": None,
|
|
50
|
+
}
|
|
51
|
+
if self.x25519 is not None:
|
|
52
|
+
from aics_identity.sealed import x25519_did_key
|
|
53
|
+
|
|
54
|
+
d.update(enc=f"enc {b64e(self.x25519)}", x25519=b64e(self.x25519),
|
|
55
|
+
x25519_did=x25519_did_key(self.x25519))
|
|
56
|
+
return d
|
|
57
|
+
|
|
58
|
+
def lines(self) -> list[str]:
|
|
59
|
+
d = self.to_dict()
|
|
60
|
+
out = [d["pub"]]
|
|
61
|
+
if d["enc"]:
|
|
62
|
+
out.append(d["enc"])
|
|
63
|
+
out.append(d["kid"])
|
|
64
|
+
if d["x25519_did"]:
|
|
65
|
+
out.append(d["x25519_did"])
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _pem(key: Ed25519PrivateKey | X25519PrivateKey) -> bytes:
|
|
70
|
+
return key.private_bytes(
|
|
71
|
+
encoding=serialization.Encoding.PEM,
|
|
72
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
73
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _write_private(path: Path, data: bytes) -> None:
|
|
78
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
79
|
+
with os.fdopen(fd, "wb") as fh:
|
|
80
|
+
os.fchmod(fh.fileno(), 0o600)
|
|
81
|
+
fh.write(data)
|
|
82
|
+
fh.flush()
|
|
83
|
+
os.fsync(fh.fileno())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def new_keys(directory: Path) -> tuple[PublicIdentity, list[Path]]:
|
|
87
|
+
"""Generate Ed25519 + X25519 keys in ``directory``; refuses to overwrite."""
|
|
88
|
+
_home.ensure_dir(directory)
|
|
89
|
+
try:
|
|
90
|
+
os.chmod(directory, 0o700)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
ed_path = directory / _home.ED25519_KEY
|
|
94
|
+
x_path = directory / _home.X25519_KEY
|
|
95
|
+
existing = [p for p in (ed_path, x_path) if p.exists()]
|
|
96
|
+
if existing:
|
|
97
|
+
raise AicsError(
|
|
98
|
+
"refusing to overwrite existing keys (losing them loses the identity); "
|
|
99
|
+
"move them away or use another --dir",
|
|
100
|
+
existing=[str(p) for p in existing],
|
|
101
|
+
)
|
|
102
|
+
ed_key = idkeys.generate_keypair()
|
|
103
|
+
x_key = X25519PrivateKey.generate()
|
|
104
|
+
_write_private(ed_path, _pem(ed_key))
|
|
105
|
+
_write_private(x_path, _pem(x_key))
|
|
106
|
+
ident = PublicIdentity(
|
|
107
|
+
ed25519=idkeys.public_key_bytes(ed_key.public_key()),
|
|
108
|
+
x25519=x_key.public_key().public_bytes_raw(),
|
|
109
|
+
)
|
|
110
|
+
info = ident.to_dict()
|
|
111
|
+
info["created"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
112
|
+
id_path = directory / _home.IDENTITY
|
|
113
|
+
tmp = id_path.with_suffix(".json.tmp")
|
|
114
|
+
tmp.write_text(json.dumps(info, indent=2, sort_keys=True) + "\n", "utf-8")
|
|
115
|
+
os.replace(tmp, id_path)
|
|
116
|
+
return ident, [ed_path, x_path, id_path]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_ed25519_private(path: Path) -> Ed25519PrivateKey:
|
|
120
|
+
"""Load an Ed25519 private key: PKCS#8 PEM/DER (openssl) or OpenSSH."""
|
|
121
|
+
try:
|
|
122
|
+
data = path.read_bytes()
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
raise AicsError(f"no key at {path}; run: aics keys new") from None
|
|
125
|
+
try:
|
|
126
|
+
if b"BEGIN OPENSSH PRIVATE KEY" in data:
|
|
127
|
+
key = serialization.load_ssh_private_key(data, password=None)
|
|
128
|
+
elif data.lstrip().startswith(b"-----BEGIN"):
|
|
129
|
+
key = serialization.load_pem_private_key(data, password=None)
|
|
130
|
+
else:
|
|
131
|
+
key = serialization.load_der_private_key(data, password=None)
|
|
132
|
+
except (ValueError, TypeError) as exc:
|
|
133
|
+
raise AicsError(f"cannot read private key {path}: {exc} (encrypted keys are not supported)") from None
|
|
134
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
135
|
+
raise AicsError(f"{path} is not an Ed25519 private key")
|
|
136
|
+
return key
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_x25519_private(path: Path) -> X25519PrivateKey | None:
|
|
140
|
+
try:
|
|
141
|
+
data = path.read_bytes()
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
key = serialization.load_pem_private_key(data, password=None)
|
|
146
|
+
except (ValueError, TypeError) as exc:
|
|
147
|
+
raise AicsError(f"cannot read X25519 key {path}: {exc}") from None
|
|
148
|
+
if not isinstance(key, X25519PrivateKey):
|
|
149
|
+
raise AicsError(f"{path} is not an X25519 private key")
|
|
150
|
+
return key
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def permission_warning(path: Path) -> str | None:
|
|
154
|
+
try:
|
|
155
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
156
|
+
except OSError:
|
|
157
|
+
return None
|
|
158
|
+
if mode & 0o077:
|
|
159
|
+
return f"{path} is readable by others (mode {mode:o}); chmod 600 it"
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def show_keys(directory: Path) -> tuple[PublicIdentity, list[str]]:
|
|
164
|
+
ed = load_ed25519_private(directory / _home.ED25519_KEY)
|
|
165
|
+
x = load_x25519_private(directory / _home.X25519_KEY)
|
|
166
|
+
warnings = [w for w in (permission_warning(directory / _home.ED25519_KEY),
|
|
167
|
+
permission_warning(directory / _home.X25519_KEY)) if w]
|
|
168
|
+
ident = PublicIdentity(
|
|
169
|
+
ed25519=idkeys.public_key_bytes(ed.public_key()),
|
|
170
|
+
x25519=x.public_key().public_bytes_raw() if x is not None else None,
|
|
171
|
+
)
|
|
172
|
+
return ident, warnings
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_public_key_text(text: str) -> Ed25519PublicKey:
|
|
176
|
+
"""The public-key forms an agent hands the gate (PROTOCOL-TEXT.md §3):
|
|
177
|
+
``pub <base64>``, ``pub ssh-ed25519 <base64> [comment]``, bare base64
|
|
178
|
+
(either alphabet, padding optional) or ``did:key:z...``. Small-order and
|
|
179
|
+
non-canonical keys are refused (aics_identity)."""
|
|
180
|
+
import re
|
|
181
|
+
|
|
182
|
+
t = text.strip()
|
|
183
|
+
m = re.match(r"pub(?::\s*|\s+)(.*)$", t, re.IGNORECASE | re.DOTALL)
|
|
184
|
+
if m:
|
|
185
|
+
t = m.group(1).strip()
|
|
186
|
+
try:
|
|
187
|
+
return idkeys.parse_public_key_text(t)
|
|
188
|
+
except (ValueError, TypeError) as exc:
|
|
189
|
+
raise AicsError(f"bad public key: {exc}") from None
|