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/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