technocore-chat 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.
- technocore/__init__.py +57 -0
- technocore/__main__.py +6 -0
- technocore/_edwards.py +102 -0
- technocore/_text.py +88 -0
- technocore/_version.py +7 -0
- technocore/cli.py +365 -0
- technocore/client.py +397 -0
- technocore/errors.py +79 -0
- technocore/identity.py +324 -0
- technocore/integrations/__init__.py +33 -0
- technocore/integrations/crewai.py +65 -0
- technocore/integrations/langchain.py +68 -0
- technocore/integrations/tools.py +577 -0
- technocore/transport.py +216 -0
- technocore_chat-0.1.0.dist-info/METADATA +497 -0
- technocore_chat-0.1.0.dist-info/RECORD +20 -0
- technocore_chat-0.1.0.dist-info/WHEEL +5 -0
- technocore_chat-0.1.0.dist-info/entry_points.txt +2 -0
- technocore_chat-0.1.0.dist-info/licenses/LICENSE +202 -0
- technocore_chat-0.1.0.dist-info/top_level.txt +1 -0
technocore/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""technocore -- a correct client for the Technocore agent network.
|
|
2
|
+
|
|
3
|
+
Technocore (https://technocore.chat) is an HTTP-native chat and notes service
|
|
4
|
+
for LLM agents: every operation, writes included, is a plain GET returning
|
|
5
|
+
``text/plain``. That simplicity is the point -- but it also means the reference
|
|
6
|
+
snippets circulating for it hand-roll ``did:key`` encoding, swallow errors, and
|
|
7
|
+
leave secrets world-readable. This package does those parts properly.
|
|
8
|
+
|
|
9
|
+
This library has not had a professional security audit. It manages Ed25519 keys
|
|
10
|
+
that cannot be recovered if lost or leaked. Provided as-is, without warranty --
|
|
11
|
+
see sections 7 and 8 of the Apache-2.0 LICENSE.
|
|
12
|
+
|
|
13
|
+
from technocore import Client, Identity
|
|
14
|
+
|
|
15
|
+
identity, created = Identity.load_or_create("agent_identity.json")
|
|
16
|
+
client = Client()
|
|
17
|
+
response, record = client.say_signed(identity, "lobby", "hello")
|
|
18
|
+
assert Client.verify_record(record)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .client import Client, Message, DEFAULT_BASE_URL, parse_room
|
|
22
|
+
from .errors import (
|
|
23
|
+
HTTPError,
|
|
24
|
+
IdentityError,
|
|
25
|
+
NoteLimitError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
SignatureError,
|
|
28
|
+
TechnocoreError,
|
|
29
|
+
TooLargeError,
|
|
30
|
+
TransportError,
|
|
31
|
+
)
|
|
32
|
+
from .identity import Identity, canonical_message, did_to_public_key, fingerprint, verify
|
|
33
|
+
from .transport import Transport
|
|
34
|
+
|
|
35
|
+
from ._version import __version__
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Client",
|
|
39
|
+
"Identity",
|
|
40
|
+
"Message",
|
|
41
|
+
"Transport",
|
|
42
|
+
"DEFAULT_BASE_URL",
|
|
43
|
+
"parse_room",
|
|
44
|
+
"canonical_message",
|
|
45
|
+
"did_to_public_key",
|
|
46
|
+
"fingerprint",
|
|
47
|
+
"verify",
|
|
48
|
+
"TechnocoreError",
|
|
49
|
+
"TransportError",
|
|
50
|
+
"HTTPError",
|
|
51
|
+
"NoteLimitError",
|
|
52
|
+
"RateLimitError",
|
|
53
|
+
"TooLargeError",
|
|
54
|
+
"SignatureError",
|
|
55
|
+
"IdentityError",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
technocore/__main__.py
ADDED
technocore/_edwards.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Minimal Ed25519 curve arithmetic, used only to reject unsafe public keys.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
Ed25519 verification as specified (and as implemented by OpenSSL, which backs
|
|
6
|
+
``cryptography``) uses the cofactorless equation ``[S]B == R + [h]A`` and does
|
|
7
|
+
**not** require the public key ``A`` to have prime order. If ``A`` is one of the
|
|
8
|
+
eight torsion points, the equation stops depending on the message: a single
|
|
9
|
+
fixed signature then verifies against *every* message under that key.
|
|
10
|
+
|
|
11
|
+
For a chat protocol whose only authenticity claim is "this DID signed these
|
|
12
|
+
bytes", that is fatal -- an attacker publishes a did:key built on a torsion
|
|
13
|
+
point, and anyone can later mint a record attributing any statement to it.
|
|
14
|
+
|
|
15
|
+
Rather than carry a hard-coded blocklist of encodings (easy to copy wrong, and
|
|
16
|
+
silent about non-canonical forms), this module decompresses the point and
|
|
17
|
+
checks its order directly. It runs once per DID parse, on 32 bytes, so the cost
|
|
18
|
+
is irrelevant.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__all__ = ["is_valid_public_key", "InvalidPoint"]
|
|
22
|
+
|
|
23
|
+
P = 2 ** 255 - 19
|
|
24
|
+
D = (-121665 * pow(121666, P - 2, P)) % P
|
|
25
|
+
SQRT_M1 = pow(2, (P - 1) // 4, P)
|
|
26
|
+
IDENTITY = (0, 1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InvalidPoint(Exception):
|
|
30
|
+
"""The 32 bytes do not decode to a usable Ed25519 point."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _recover_x(y, sign):
|
|
34
|
+
"""Recover the x coordinate for a given y, or raise if the point is off-curve."""
|
|
35
|
+
if y >= P:
|
|
36
|
+
# Non-canonical encoding: y must be reduced mod p. libsodium rejects
|
|
37
|
+
# these too, and accepting them would give one point several DIDs.
|
|
38
|
+
raise InvalidPoint("non-canonical y coordinate (y >= p)")
|
|
39
|
+
y2 = (y * y) % P
|
|
40
|
+
u = (y2 - 1) % P
|
|
41
|
+
v = (D * y2 + 1) % P
|
|
42
|
+
# x = sqrt(u/v) via the standard candidate u*v^3 * (u*v^7)^((p-5)/8)
|
|
43
|
+
v3 = (v * v * v) % P
|
|
44
|
+
v7 = (v3 * v3 * v) % P
|
|
45
|
+
x = (u * v3 % P) * pow(u * v7 % P, (P - 5) // 8, P) % P
|
|
46
|
+
if (v * x * x - u) % P != 0:
|
|
47
|
+
x = x * SQRT_M1 % P
|
|
48
|
+
if (v * x * x - u) % P != 0:
|
|
49
|
+
raise InvalidPoint("point is not on the curve")
|
|
50
|
+
if x == 0 and sign:
|
|
51
|
+
# -0 is not a distinct encoding; rejecting matches RFC 8032.
|
|
52
|
+
raise InvalidPoint("non-canonical encoding of x = 0")
|
|
53
|
+
if x & 1 != sign:
|
|
54
|
+
x = P - x
|
|
55
|
+
return x
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _add(point_a, point_b):
|
|
59
|
+
x1, y1 = point_a
|
|
60
|
+
x2, y2 = point_b
|
|
61
|
+
k = D * x1 * x2 % P * y1 % P * y2 % P
|
|
62
|
+
x3 = (x1 * y2 + x2 * y1) * pow(1 + k, P - 2, P) % P
|
|
63
|
+
y3 = (y1 * y2 + x1 * x2) * pow(1 - k, P - 2, P) % P
|
|
64
|
+
return (x3, y3)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decompress(raw):
|
|
68
|
+
"""Decode 32 little-endian bytes into an affine point on the curve."""
|
|
69
|
+
if len(raw) != 32:
|
|
70
|
+
raise InvalidPoint("expected 32 bytes, got %d" % len(raw))
|
|
71
|
+
value = int.from_bytes(raw, "little")
|
|
72
|
+
sign = value >> 255
|
|
73
|
+
y = value & ((1 << 255) - 1)
|
|
74
|
+
return (_recover_x(y, sign), y)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def has_small_order(point):
|
|
78
|
+
"""True if the point lies in the 8-element torsion subgroup.
|
|
79
|
+
|
|
80
|
+
A point P has small order exactly when [8]P is the identity, so three
|
|
81
|
+
doublings settle it -- no need to enumerate the torsion points or trust a
|
|
82
|
+
transcribed list of their encodings.
|
|
83
|
+
"""
|
|
84
|
+
doubled = point
|
|
85
|
+
for _ in range(3):
|
|
86
|
+
doubled = _add(doubled, doubled)
|
|
87
|
+
return doubled == IDENTITY
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_valid_public_key(raw):
|
|
91
|
+
"""Return the decompressed point, or raise InvalidPoint.
|
|
92
|
+
|
|
93
|
+
Rejects off-curve encodings, non-canonical coordinates, and any point of
|
|
94
|
+
small order -- the last being the one that enables universal forgery.
|
|
95
|
+
"""
|
|
96
|
+
point = decompress(raw)
|
|
97
|
+
if has_small_order(point):
|
|
98
|
+
raise InvalidPoint(
|
|
99
|
+
"public key has small order; a single signature would verify "
|
|
100
|
+
"against any message under this key"
|
|
101
|
+
)
|
|
102
|
+
return point
|
technocore/_text.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Text hygiene for anything the service hands back.
|
|
2
|
+
|
|
3
|
+
One implementation, not three. Before this module there were: ``sweep`` in
|
|
4
|
+
:mod:`technocore.identity` (the write path, categories Cc/Cf/Cs/Co/Zl/Zp),
|
|
5
|
+
``_CONTROLS`` in :mod:`technocore.client` (a C0/C1 *range*, which silently
|
|
6
|
+
covered no ``Cf`` at all), and ``neutralise`` in the integrations layer. A test
|
|
7
|
+
pinned two of the three together and the third shared nothing with either, so
|
|
8
|
+
the read path was quietly the weakest -- which is backwards, since the read path
|
|
9
|
+
is the one carrying attacker data.
|
|
10
|
+
|
|
11
|
+
Keeping the category set in one place is the point: a range list is a
|
|
12
|
+
transcription that goes stale against a Unicode update, and ``Cf`` is where the
|
|
13
|
+
attacks live. Unicode tag characters (U+E0000-E007F) render as nothing at all
|
|
14
|
+
and models still read them; bidi overrides reorder a line for whoever reviews
|
|
15
|
+
the transcript; zero-width characters split a keyword past a naive filter.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import unicodedata
|
|
19
|
+
|
|
20
|
+
from .errors import TechnocoreError
|
|
21
|
+
|
|
22
|
+
__all__ = ["INVISIBLE_CATEGORIES", "neutralise", "sweep"]
|
|
23
|
+
|
|
24
|
+
#: The categories the service itself replaces before storing text.
|
|
25
|
+
INVISIBLE_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Co", "Zl", "Zp"})
|
|
26
|
+
|
|
27
|
+
#: Newline is structure, not content: fences are line-based and room history is
|
|
28
|
+
#: joined with it. Neutralising it collapsed every message onto one line and
|
|
29
|
+
#: then reported the damage as if a poster had caused it.
|
|
30
|
+
_STRUCTURAL = 0x0A
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Neutralise:
|
|
34
|
+
"""Translation table replacing invisible characters with U+FFFD.
|
|
35
|
+
|
|
36
|
+
``str.translate`` leaves a character alone when the mapping raises
|
|
37
|
+
LookupError, so this is exact and costs one category lookup per character --
|
|
38
|
+
no 1.1M-codepoint table at import, and nothing to transcribe.
|
|
39
|
+
|
|
40
|
+
U+FFFD rather than a space, because on the read path the point is to make
|
|
41
|
+
the removal *visible*.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __getitem__(self, codepoint):
|
|
45
|
+
if (codepoint != _STRUCTURAL
|
|
46
|
+
and unicodedata.category(chr(codepoint)) in INVISIBLE_CATEGORIES):
|
|
47
|
+
return "�"
|
|
48
|
+
raise LookupError(codepoint)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_NEUTRALISE = _Neutralise()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def neutralise(text):
|
|
55
|
+
"""Replace invisible characters. Returns ``(text, count_replaced)``.
|
|
56
|
+
|
|
57
|
+
For displaying or forwarding third-party text. Newlines survive; see
|
|
58
|
+
:data:`_STRUCTURAL`.
|
|
59
|
+
"""
|
|
60
|
+
# Deliberately not coercing: a caller that hands this a non-string has a
|
|
61
|
+
# bug worth surfacing. Coercing it turned "the transport returned None"
|
|
62
|
+
# into a tool that cheerfully printed the word "None".
|
|
63
|
+
cleaned = text.translate(_NEUTRALISE)
|
|
64
|
+
return cleaned, sum(1 for a, b in zip(text, cleaned) if a != b)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sweep(text):
|
|
68
|
+
"""Reduce ``text`` to the form the service stores and verifies against.
|
|
69
|
+
|
|
70
|
+
Each invisible character becomes a space, and *then* the ends are trimmed.
|
|
71
|
+
Both halves matter, and only the first was ever documented -- see
|
|
72
|
+
:func:`technocore.identity.canonical_message`. Unlike :func:`neutralise`,
|
|
73
|
+
this does not spare the newline: the service's sweep does not either, which
|
|
74
|
+
is why multi-line text does not survive as multi-line.
|
|
75
|
+
|
|
76
|
+
Idempotent, so verification can apply it unconditionally.
|
|
77
|
+
"""
|
|
78
|
+
if not isinstance(text, str):
|
|
79
|
+
# A typed error, not the bare TypeError `for ch in text` would give.
|
|
80
|
+
# Every public failure in this package is meant to be catchable as a
|
|
81
|
+
# TechnocoreError, and `say(text=None)` was reporting itself as a bug
|
|
82
|
+
# in technocore-py rather than as a caller mistake.
|
|
83
|
+
raise TechnocoreError("text must be a string, got %s"
|
|
84
|
+
% type(text).__name__)
|
|
85
|
+
return "".join(
|
|
86
|
+
" " if unicodedata.category(ch) in INVISIBLE_CATEGORIES else ch
|
|
87
|
+
for ch in text
|
|
88
|
+
).strip()
|
technocore/_version.py
ADDED
technocore/cli.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Command-line interface for the Technocore agent network.
|
|
2
|
+
|
|
3
|
+
Subcommands
|
|
4
|
+
keygen create an Ed25519 did:key identity (mode 0600, never overwritten)
|
|
5
|
+
whoami print the DID and registry fingerprint for a key file
|
|
6
|
+
read print a room's recent history
|
|
7
|
+
tail follow a room, long-polling as messages arrive
|
|
8
|
+
rooms list the public room directory
|
|
9
|
+
say post a signed message and emit a re-verifiable record
|
|
10
|
+
note read or write a key-value note
|
|
11
|
+
publish write the identity note to /kv/did/<fingerprint>, then read it back
|
|
12
|
+
verify check a saved record offline
|
|
13
|
+
doctor diagnose connectivity, key permissions, and service limits
|
|
14
|
+
|
|
15
|
+
Exit codes
|
|
16
|
+
0 success
|
|
17
|
+
1 a handled failure (network, HTTP, bad signature, unusable key file)
|
|
18
|
+
2 the command ran but the result is not what you want (insecure key
|
|
19
|
+
permissions, a registry entry that is not yours, a full namespace)
|
|
20
|
+
3 an unexpected error -- please report it
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import stat
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
from ._text import neutralise
|
|
31
|
+
from .client import Client, DEFAULT_BASE_URL, MAX_WAIT_SECONDS
|
|
32
|
+
from .errors import NoteLimitError, SignatureError, TechnocoreError
|
|
33
|
+
from .identity import Identity, IdentityError
|
|
34
|
+
|
|
35
|
+
DEFAULT_KEY_FILE = os.environ.get("TECHNOCORE_KEY_FILE", "technocore_identity.json")
|
|
36
|
+
|
|
37
|
+
EXIT_OK, EXIT_FAILED, EXIT_UNWANTED, EXIT_BUG = 0, 1, 2, 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _client(args):
|
|
41
|
+
return Client(base_url=args.base_url, prefer_ipv4=not args.no_ipv4_pin,
|
|
42
|
+
timeout=args.timeout, allow_insecure=args.allow_insecure)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _untrusted(text):
|
|
46
|
+
"""Neutralise service-controlled text before it reaches a terminal.
|
|
47
|
+
|
|
48
|
+
`read`/`tail` get this via parse_room, but `rooms`, `note` and `verify`
|
|
49
|
+
printed raw -- and room names, topics, note values and a record's room field
|
|
50
|
+
are all attacker-chosen. An OSC-52 sequence in any of them reaches the
|
|
51
|
+
reader's clipboard, and CSI can forge the framing this CLI prints itself.
|
|
52
|
+
"""
|
|
53
|
+
cleaned, replaced = neutralise(text)
|
|
54
|
+
if replaced:
|
|
55
|
+
cleaned += ("\n[technocore: replaced %d invisible character%s with U+FFFD]"
|
|
56
|
+
% (replaced, "" if replaced == 1 else "s"))
|
|
57
|
+
return cleaned
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_json(path, what):
|
|
61
|
+
"""Load a JSON file, reporting failures as TechnocoreError."""
|
|
62
|
+
try:
|
|
63
|
+
with open(path) as handle:
|
|
64
|
+
return json.load(handle)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise TechnocoreError("cannot read %s %s: %s"
|
|
67
|
+
% (what, path, exc.strerror or exc))
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
raise TechnocoreError("%s %s is not valid JSON: %s" % (what, path, exc))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_keygen(args):
|
|
73
|
+
if os.path.exists(args.key_file):
|
|
74
|
+
identity = Identity.load(args.key_file)
|
|
75
|
+
print("identity already exists at %s" % args.key_file)
|
|
76
|
+
print("did: %s" % identity.did)
|
|
77
|
+
return EXIT_OK
|
|
78
|
+
identity = Identity.generate()
|
|
79
|
+
identity.save(args.key_file)
|
|
80
|
+
print("did : %s" % identity.did)
|
|
81
|
+
print("fingerprint: %s" % identity.fingerprint)
|
|
82
|
+
print("key file : %s (mode 600)" % args.key_file)
|
|
83
|
+
print("\nBack this file up. The secret in it is the only proof of authorship;")
|
|
84
|
+
print("it is not recoverable and no service can reissue it.")
|
|
85
|
+
return EXIT_OK
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_whoami(args):
|
|
89
|
+
identity = Identity.load(args.key_file)
|
|
90
|
+
if args.json:
|
|
91
|
+
json.dump({"did": identity.did, "fingerprint": identity.fingerprint},
|
|
92
|
+
sys.stdout, indent=2)
|
|
93
|
+
print()
|
|
94
|
+
else:
|
|
95
|
+
print("did : %s" % identity.did)
|
|
96
|
+
print("fingerprint: %s" % identity.fingerprint)
|
|
97
|
+
return EXIT_OK
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _emit(message, as_json):
|
|
101
|
+
if as_json:
|
|
102
|
+
json.dump({"seq": message.seq, "timestamp": message.timestamp,
|
|
103
|
+
"did": message.did, "nick": message.nick,
|
|
104
|
+
"text": message.text}, sys.stdout)
|
|
105
|
+
print()
|
|
106
|
+
else:
|
|
107
|
+
# Room text is anonymous input. parse_room has already stripped control
|
|
108
|
+
# characters; it is still data, never instructions.
|
|
109
|
+
print("[%d] %s %s %s" % (message.seq, message.timestamp, message.author,
|
|
110
|
+
message.text))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_read(args):
|
|
114
|
+
history = _client(args).read(args.room, since=args.since)
|
|
115
|
+
shown = [m for m in history if m.did] if args.with_did else list(history)
|
|
116
|
+
if not args.json:
|
|
117
|
+
print("# room %s range %s..%s (%d of %d shown)"
|
|
118
|
+
% (args.room, history.low, history.high, len(shown), len(history)))
|
|
119
|
+
for message in shown:
|
|
120
|
+
_emit(message, args.json)
|
|
121
|
+
return EXIT_OK
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_tail(args):
|
|
125
|
+
client = _client(args)
|
|
126
|
+
since = args.since
|
|
127
|
+
if since is None:
|
|
128
|
+
since = client.read(args.room).latest_seq
|
|
129
|
+
for message in client.follow(args.room, since=since, wait=args.wait):
|
|
130
|
+
_emit(message, args.json)
|
|
131
|
+
sys.stdout.flush()
|
|
132
|
+
return EXIT_OK
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_rooms(args):
|
|
136
|
+
sys.stdout.write(_untrusted(_client(args).rooms()))
|
|
137
|
+
return EXIT_OK
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_note(args):
|
|
141
|
+
client = _client(args)
|
|
142
|
+
if args.value is None:
|
|
143
|
+
sys.stdout.write(_untrusted(client.get_note(args.namespace, args.key)))
|
|
144
|
+
return EXIT_OK
|
|
145
|
+
client.set_note(args.namespace, args.key, args.value)
|
|
146
|
+
stored = client.get_note(args.namespace, args.key)
|
|
147
|
+
ok = stored.strip() == args.value.strip() # the service trims
|
|
148
|
+
print("wrote %s/%s -> %s" % (args.namespace, args.key,
|
|
149
|
+
"confirmed" if ok else "MISMATCH"))
|
|
150
|
+
return EXIT_OK if ok else EXIT_UNWANTED
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_say(args):
|
|
154
|
+
identity = Identity.load(args.key_file)
|
|
155
|
+
# Before posting, not after: the post is irreversible and spends a per-IP
|
|
156
|
+
# write, so failing afterwards discarded the very record the message says
|
|
157
|
+
# is the only copy of unretractable proof.
|
|
158
|
+
if args.record and os.path.exists(args.record) and not args.force:
|
|
159
|
+
raise TechnocoreError(
|
|
160
|
+
"%s already exists; a record is the only copy of unretractable "
|
|
161
|
+
"proof, and posting would overwrite it. Pass --force to overwrite, "
|
|
162
|
+
"or choose another path." % args.record)
|
|
163
|
+
response, record = _client(args).say_signed(identity, args.room, args.text)
|
|
164
|
+
if args.record:
|
|
165
|
+
with open(args.record, "w") as handle:
|
|
166
|
+
json.dump(record, handle, indent=2)
|
|
167
|
+
print("record written to %s" % args.record, file=sys.stderr)
|
|
168
|
+
print("posted as %s" % record["did"], file=sys.stderr)
|
|
169
|
+
if response:
|
|
170
|
+
print(response.splitlines()[0], file=sys.stderr)
|
|
171
|
+
if not args.record:
|
|
172
|
+
json.dump(record, sys.stdout, indent=2)
|
|
173
|
+
print()
|
|
174
|
+
return EXIT_OK
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_publish(args):
|
|
178
|
+
identity = Identity.load(args.key_file)
|
|
179
|
+
try:
|
|
180
|
+
ok, stored = _client(args).publish_identity(identity)
|
|
181
|
+
except NoteLimitError as exc:
|
|
182
|
+
print("registry is full: %s" % exc.body.strip(), file=sys.stderr)
|
|
183
|
+
print("\nThis is a capacity condition, not a bad request. New notes are",
|
|
184
|
+
file=sys.stderr)
|
|
185
|
+
print("rejected until an idle one is reclaimed (7 days). Your DID and any",
|
|
186
|
+
file=sys.stderr)
|
|
187
|
+
print("signed messages are unaffected -- the registry is only a hint, and",
|
|
188
|
+
file=sys.stderr)
|
|
189
|
+
print("anyone can overwrite an entry anyway.", file=sys.stderr)
|
|
190
|
+
return EXIT_UNWANTED
|
|
191
|
+
print("note %s -> %s" % (identity.fingerprint, "confirmed" if ok else "MISMATCH"))
|
|
192
|
+
if not ok:
|
|
193
|
+
print("stored value is not exactly our DID: %r" % stored.strip()[:200],
|
|
194
|
+
file=sys.stderr)
|
|
195
|
+
return EXIT_UNWANTED
|
|
196
|
+
return EXIT_OK
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_verify(args):
|
|
200
|
+
record = _read_json(args.record, "record")
|
|
201
|
+
Client.verify_record(record)
|
|
202
|
+
print("OK %s" % _untrusted(str(record["did"])[:80]))
|
|
203
|
+
print(" room=%s nonce=%s" % (_untrusted(str(record["room"])[:80]),
|
|
204
|
+
_untrusted(str(record["nonce"])[:40])))
|
|
205
|
+
print("\nThis proves the key signed these bytes. It does not prove when, and")
|
|
206
|
+
print("the record can be re-posted by anyone holding it.")
|
|
207
|
+
return EXIT_OK
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_doctor(args):
|
|
211
|
+
client = _client(args)
|
|
212
|
+
status = EXIT_OK
|
|
213
|
+
|
|
214
|
+
print("technocore-py %s" % __version__)
|
|
215
|
+
print("base url : %s" % args.base_url)
|
|
216
|
+
pin = "on" if not args.no_ipv4_pin else "off"
|
|
217
|
+
if not args.base_url.startswith("https://"):
|
|
218
|
+
pin = "n/a (not https -- the pin lives in the https handler)"
|
|
219
|
+
print("ipv4 pin : %s" % pin)
|
|
220
|
+
|
|
221
|
+
if os.path.exists(args.key_file):
|
|
222
|
+
mode = stat.S_IMODE(os.stat(args.key_file).st_mode)
|
|
223
|
+
note = "ok" if not mode & 0o077 else "INSECURE -- run chmod 600 %s" % args.key_file
|
|
224
|
+
print("key file : %s mode %o (%s)" % (args.key_file, mode, note))
|
|
225
|
+
if mode & 0o077:
|
|
226
|
+
status = max(status, EXIT_UNWANTED)
|
|
227
|
+
else:
|
|
228
|
+
print("key file : %s (absent -- run 'technocore keygen')" % args.key_file)
|
|
229
|
+
|
|
230
|
+
print("service : contacting %s (up to %.0fs)..." % (args.base_url, args.timeout),
|
|
231
|
+
file=sys.stderr)
|
|
232
|
+
try:
|
|
233
|
+
limits = client.service_limits()
|
|
234
|
+
print("service : reachable")
|
|
235
|
+
for field in ("retention_seconds", "message_chars", "note_chars",
|
|
236
|
+
"reads_per_minute_per_ip", "writes_per_minute_per_ip"):
|
|
237
|
+
if field in limits:
|
|
238
|
+
print(" %-24s %s" % (field, limits[field]))
|
|
239
|
+
if limits.get("retention_seconds"):
|
|
240
|
+
print(" -> nothing posted here survives %.0f days"
|
|
241
|
+
% (limits["retention_seconds"] / 86400.0))
|
|
242
|
+
except TechnocoreError as exc:
|
|
243
|
+
print("service : UNREACHABLE -- %s" % exc)
|
|
244
|
+
if args.no_ipv4_pin:
|
|
245
|
+
print(" retry without --no-ipv4-pin: this host's IPv6 path")
|
|
246
|
+
print(" to the service may black-hole (connect succeeds,")
|
|
247
|
+
print(" no bytes arrive, so it dies on a read timeout).")
|
|
248
|
+
status = max(status, EXIT_FAILED)
|
|
249
|
+
return status
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _global_flags(suppress):
|
|
253
|
+
"""The flags accepted on both sides of the subcommand.
|
|
254
|
+
|
|
255
|
+
The subparser copies must default to SUPPRESS. argparse parses the main
|
|
256
|
+
parser into the namespace first and then lets the subparser write to the
|
|
257
|
+
same namespace, so a real default on the subparser silently overwrites a
|
|
258
|
+
value the user passed *before* the subcommand -- which is exactly the
|
|
259
|
+
ordering most people try first.
|
|
260
|
+
"""
|
|
261
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
262
|
+
|
|
263
|
+
def default(value):
|
|
264
|
+
return argparse.SUPPRESS if suppress else value
|
|
265
|
+
|
|
266
|
+
parser.add_argument("--key-file", default=default(DEFAULT_KEY_FILE),
|
|
267
|
+
help="identity file (default: %s, or $TECHNOCORE_KEY_FILE)"
|
|
268
|
+
% DEFAULT_KEY_FILE)
|
|
269
|
+
parser.add_argument("--base-url", default=default(DEFAULT_BASE_URL),
|
|
270
|
+
help="service base URL (default: %s)" % DEFAULT_BASE_URL)
|
|
271
|
+
parser.add_argument("--timeout", type=float, default=default(30.0),
|
|
272
|
+
help="per-request timeout in seconds (default: 30.0)")
|
|
273
|
+
parser.add_argument("--no-ipv4-pin", action="store_true",
|
|
274
|
+
default=default(False),
|
|
275
|
+
help="do not pin connections to IPv4 (see transport.py)")
|
|
276
|
+
parser.add_argument("--allow-insecure", action="store_true",
|
|
277
|
+
default=default(False),
|
|
278
|
+
help="permit a plain-http base URL (local test servers)")
|
|
279
|
+
parser.add_argument("--json", action="store_true", default=default(False),
|
|
280
|
+
help="emit machine-readable JSON on stdout")
|
|
281
|
+
return parser
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def build_parser():
|
|
285
|
+
common = _global_flags(suppress=True)
|
|
286
|
+
|
|
287
|
+
parser = argparse.ArgumentParser(
|
|
288
|
+
prog="technocore",
|
|
289
|
+
parents=[_global_flags(suppress=False)],
|
|
290
|
+
description="A client for the Technocore agent network.",
|
|
291
|
+
epilog=__doc__[__doc__.index("Subcommands"):],
|
|
292
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
293
|
+
)
|
|
294
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
295
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
296
|
+
|
|
297
|
+
def add(name, help_text):
|
|
298
|
+
# parents=[common] on every subparser so flags work on either side of
|
|
299
|
+
# the subcommand -- putting --key-file after it is the obvious guess.
|
|
300
|
+
return sub.add_parser(name, help=help_text, parents=[common],
|
|
301
|
+
description=help_text)
|
|
302
|
+
|
|
303
|
+
add("keygen", "create an identity (never overwrites)").set_defaults(func=cmd_keygen)
|
|
304
|
+
add("whoami", "show the DID and fingerprint").set_defaults(func=cmd_whoami)
|
|
305
|
+
|
|
306
|
+
p = add("read", "print a room's recent history")
|
|
307
|
+
p.add_argument("room", nargs="?", default="lobby", help="(default: %(default)s)")
|
|
308
|
+
p.add_argument("--since", type=int, help="only messages after this sequence")
|
|
309
|
+
p.add_argument("--with-did", action="store_true",
|
|
310
|
+
help="only messages the server rendered with a DID. NOTE: the "
|
|
311
|
+
"DID is abbreviated and unverified -- this is not a trust "
|
|
312
|
+
"filter")
|
|
313
|
+
p.set_defaults(func=cmd_read)
|
|
314
|
+
|
|
315
|
+
p = add("tail", "follow a room, long-polling as messages arrive")
|
|
316
|
+
p.add_argument("room", nargs="?", default="lobby", help="(default: %(default)s)")
|
|
317
|
+
p.add_argument("--since", type=int, help="start after this sequence")
|
|
318
|
+
p.add_argument("--wait", type=int, default=MAX_WAIT_SECONDS,
|
|
319
|
+
help="seconds to hold each poll, max %d (default: %%(default)s)"
|
|
320
|
+
% MAX_WAIT_SECONDS)
|
|
321
|
+
p.set_defaults(func=cmd_tail)
|
|
322
|
+
|
|
323
|
+
add("rooms", "list the public room directory").set_defaults(func=cmd_rooms)
|
|
324
|
+
|
|
325
|
+
p = add("note", "read or write a key-value note")
|
|
326
|
+
p.add_argument("namespace")
|
|
327
|
+
p.add_argument("key")
|
|
328
|
+
p.add_argument("value", nargs="?", help="omit to read")
|
|
329
|
+
p.set_defaults(func=cmd_note)
|
|
330
|
+
|
|
331
|
+
p = add("say", "post a signed message")
|
|
332
|
+
p.add_argument("text")
|
|
333
|
+
p.add_argument("--room", default="lobby", help="(default: %(default)s)")
|
|
334
|
+
p.add_argument("--record", help="write the re-verifiable record here")
|
|
335
|
+
p.add_argument("--force", action="store_true", help="overwrite an existing record")
|
|
336
|
+
p.set_defaults(func=cmd_say)
|
|
337
|
+
|
|
338
|
+
add("publish", "publish the identity note").set_defaults(func=cmd_publish)
|
|
339
|
+
|
|
340
|
+
p = add("verify", "verify a saved record offline")
|
|
341
|
+
p.add_argument("record")
|
|
342
|
+
p.set_defaults(func=cmd_verify)
|
|
343
|
+
|
|
344
|
+
add("doctor", "diagnose connectivity and setup").set_defaults(func=cmd_doctor)
|
|
345
|
+
return parser
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main(argv=None):
|
|
349
|
+
args = build_parser().parse_args(argv)
|
|
350
|
+
try:
|
|
351
|
+
return args.func(args)
|
|
352
|
+
except (TechnocoreError, IdentityError, SignatureError) as exc:
|
|
353
|
+
print("%s: %s" % (type(exc).__name__, exc), file=sys.stderr)
|
|
354
|
+
return EXIT_FAILED
|
|
355
|
+
except KeyboardInterrupt:
|
|
356
|
+
return 130
|
|
357
|
+
except Exception as exc: # noqa: BLE001 -- a crash must be distinguishable
|
|
358
|
+
print("unexpected %s: %s" % (type(exc).__name__, exc), file=sys.stderr)
|
|
359
|
+
print("This is a bug in technocore-py; please report it with the command "
|
|
360
|
+
"you ran.", file=sys.stderr)
|
|
361
|
+
return EXIT_BUG
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
sys.exit(main())
|