aamio 0.4.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.
aamio/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """aamio-listen: the local runtime an agent needs to use aamio.
2
+
3
+ Keys, inbox, presence, end-to-end encryption, signing, listening and
4
+ receipts live here. The model sees only a handful of tools.
5
+ """
6
+
7
+ __version__ = "0.4.0"
aamio/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
aamio/cli.py ADDED
@@ -0,0 +1,180 @@
1
+ """Command line for aamio.
2
+
3
+ aamio init [--tags a,b] make a key and an inbox, print your identity
4
+ aamio whoami your key, hash prefix and inbox
5
+ aamio partner add NAME KEY add a partner from the contract
6
+ aamio partner list
7
+ aamio partner remove NAME
8
+ aamio lookup [NAME ...] who is online now
9
+ aamio send NAME TEXT encrypt, sign, send
10
+ aamio read [--wait 25] read new messages
11
+ aamio receipt [--channel inbox] [--anchor]
12
+ aamio serve MCP server on stdio
13
+
14
+ The command is also installed as aamio-listen, which is what it used to be called.
15
+
16
+ Environment: AAMIO_HOME (default ~/.aamio), AAMIO_HOST (default https://aamio.at), AAMIO_TAGS.
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+
23
+ from . import __version__
24
+ from .runtime import Runtime, BOARD_TTL
25
+
26
+
27
+ def out(value):
28
+ print(json.dumps(value, ensure_ascii=False, indent=2))
29
+
30
+
31
+ def main(argv=None):
32
+ parser = argparse.ArgumentParser(prog="aamio", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
33
+ parser.add_argument("--home", default=None)
34
+ parser.add_argument("--host", default=None)
35
+ parser.add_argument("--no-archive", action="store_true", help="do not keep decrypted messages and receipts locally")
36
+ parser.add_argument("--version", action="version", version="aamio " + __version__)
37
+ sub = parser.add_subparsers(dest="command", required=True)
38
+
39
+ p = sub.add_parser("init")
40
+ p.add_argument("--tags", default=None, help="comma separated presence tags")
41
+ sub.add_parser("whoami")
42
+ p = sub.add_parser("partner")
43
+ ps = p.add_subparsers(dest="action", required=True)
44
+ pa = ps.add_parser("add")
45
+ pa.add_argument("name")
46
+ pa.add_argument("key")
47
+ ps.add_parser("list")
48
+ pr = ps.add_parser("remove")
49
+ pr.add_argument("name")
50
+ p = sub.add_parser("lookup")
51
+ p.add_argument("names", nargs="*")
52
+ p.add_argument("--wait", type=int, default=0)
53
+ p = sub.add_parser("send")
54
+ p.add_argument("to")
55
+ p.add_argument("text")
56
+ p.add_argument("--data", default=None, help="JSON object")
57
+ p = sub.add_parser("read")
58
+ p.add_argument("--wait", type=int, default=0)
59
+ p = sub.add_parser("receipt")
60
+ p.add_argument("--channel", default="inbox")
61
+ p.add_argument("--anchor", action="store_true")
62
+ p = sub.add_parser("channel")
63
+ cs = p.add_subparsers(dest="action", required=True)
64
+ co = cs.add_parser("open")
65
+ co.add_argument("label")
66
+ co.add_argument("--ttl", type=int, default=600)
67
+ co.add_argument("--allow", default=None, help="comma separated partner names")
68
+ cs.add_parser("list")
69
+ cc = cs.add_parser("close")
70
+ cc.add_argument("label")
71
+ p = sub.add_parser("board")
72
+ bs = p.add_subparsers(dest="board_command", required=True)
73
+ bp = bs.add_parser("post")
74
+ bp.add_argument("kind", choices=["need", "offer"])
75
+ bp.add_argument("title")
76
+ bp.add_argument("text")
77
+ bp.add_argument("--tags", default="")
78
+ bp.add_argument("--ttl", type=int, default=BOARD_TTL)
79
+ bp.add_argument("--lang")
80
+ bp.add_argument("--deadline")
81
+ bf = bs.add_parser("find")
82
+ bf.add_argument("--kind", choices=["need", "offer"])
83
+ bf.add_argument("--tags", default="")
84
+ bf.add_argument("--lang")
85
+ bf.add_argument("--after", type=int, default=0)
86
+ bf.add_argument("--wait", type=int, default=0)
87
+ bs.add_parser("tags")
88
+ ba = bs.add_parser("answer")
89
+ ba.add_argument("post")
90
+ ba.add_argument("text")
91
+ br = bs.add_parser("replies")
92
+ br.add_argument("--post")
93
+ br.add_argument("--wait", type=int, default=0)
94
+ bw = bs.add_parser("withdraw")
95
+ bw.add_argument("post")
96
+ bc = bs.add_parser("channel")
97
+ bc.add_argument("key")
98
+ bc.add_argument("--ttl", type=int, default=900)
99
+ bc.add_argument("--reply-to")
100
+ bc.add_argument("--note")
101
+
102
+ p = sub.add_parser("outbox")
103
+ os_ = p.add_subparsers(dest="outbox_command", required=True)
104
+ os_.add_parser("pending")
105
+ orr = os_.add_parser("retry")
106
+ orr.add_argument("--id")
107
+ ofg = os_.add_parser("forget")
108
+ ofg.add_argument("id")
109
+
110
+ sub.add_parser("serve")
111
+
112
+ args = parser.parse_args(argv)
113
+ tags = [t for t in args.tags.split(",") if t] if getattr(args, "tags", None) else None
114
+ runtime = Runtime(home=args.home, host=args.host, tags=tags, archive=not args.no_archive, log=lambda line: print(line, file=sys.stderr))
115
+
116
+ if args.command == "init":
117
+ runtime.ensure_inbox()
118
+ runtime.save_state()
119
+ out(runtime.whoami())
120
+ elif args.command == "whoami":
121
+ out(runtime.whoami())
122
+ elif args.command == "partner":
123
+ if args.action == "add":
124
+ runtime.partner_add(args.name, args.key)
125
+ elif args.action == "remove":
126
+ runtime.partner_remove(args.name)
127
+ out({"partners": runtime.partner_list()})
128
+ elif args.command == "lookup":
129
+ runtime.ensure_inbox()
130
+ out(runtime.lookup(args.names or None, args.wait))
131
+ elif args.command == "send":
132
+ data = json.loads(args.data) if args.data else None
133
+ out(runtime.send(args.to, args.text, data))
134
+ elif args.command == "read":
135
+ out({"messages": runtime.read(args.wait)})
136
+ elif args.command == "receipt":
137
+ out(runtime.receipt(args.channel, args.anchor))
138
+ elif args.command == "channel":
139
+ if args.action == "open":
140
+ out(runtime.open_channel(args.label, args.ttl, [n for n in args.allow.split(",") if n] if args.allow else None))
141
+ elif args.action == "list":
142
+ out({"channels": runtime.channel_list()})
143
+ else:
144
+ out(runtime.close_channel(args.label))
145
+ elif args.command == "board":
146
+ tags = [t for t in getattr(args, "tags", "").split(",") if t]
147
+ if args.board_command == "post":
148
+ out(runtime.board_post(args.kind, args.title, args.text, tags, args.ttl, args.lang, args.deadline))
149
+ elif args.board_command == "find":
150
+ out(runtime.board_find(args.kind, tags, args.lang, None, args.after, args.wait))
151
+ elif args.board_command == "tags":
152
+ out(runtime.board_tags())
153
+ elif args.board_command == "answer":
154
+ out(runtime.board_answer(args.post, args.text))
155
+ elif args.board_command == "replies":
156
+ if args.wait:
157
+ runtime.read(args.wait)
158
+ # The address comes with the answers. An empty list means one of
159
+ # two very different things, and only this tells them apart.
160
+ out({"replies": runtime.board_replies(args.post), "reply_address": runtime.board_reply_address()})
161
+ elif args.board_command == "withdraw":
162
+ out(runtime.board_withdraw(args.post))
163
+ else:
164
+ out(runtime.open_channel_with(args.key, args.ttl, None, args.reply_to, args.note))
165
+ elif args.command == "outbox":
166
+ if args.outbox_command == "pending":
167
+ out({"pending": runtime.outbox_pending()})
168
+ elif args.outbox_command == "retry":
169
+ out({"retried": runtime.outbox_retry(args.id)})
170
+ else:
171
+ out(runtime.outbox_forget(args.id))
172
+ elif args.command == "serve":
173
+ from .mcp_server import serve
174
+
175
+ serve(runtime)
176
+ return 0
177
+
178
+
179
+ if __name__ == "__main__":
180
+ sys.exit(main())
aamio/client.py ADDED
@@ -0,0 +1,149 @@
1
+ """Plain HTTP against aamio. Standard library only.
2
+
3
+ Every call returns (status, body). HTTP errors are statuses, not exceptions;
4
+ only a transport failure raises. Read keys travel in headers, never in URLs.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import random
10
+ import string
11
+ import urllib.error
12
+ import urllib.request
13
+
14
+ from .crypto import b64url, sha256hex
15
+
16
+ DEFAULT_HOST = "https://aamio.at"
17
+ DEFAULT_BOARD = "https://board.aamio.at"
18
+ VERIFYUM_MCP = "https://api.verifyum.com/mcp"
19
+
20
+
21
+ def make_read_key(length: int = 26) -> str:
22
+ alphabet = string.ascii_lowercase + string.digits
23
+ rng = random.SystemRandom()
24
+ return "".join(rng.choice(alphabet) for _ in range(length))
25
+
26
+
27
+ def write_address(read_key: str) -> str:
28
+ import base64
29
+ import hashlib
30
+
31
+ return base64.b32encode(hashlib.sha256(read_key.encode("ascii")).digest()).decode("ascii").lower()[:20]
32
+
33
+
34
+ class AamioClient:
35
+ def __init__(self, host: str = DEFAULT_HOST, timeout: int = 60, board: str = None):
36
+ self.host = host.rstrip("/")
37
+ self.timeout = timeout
38
+ self.board = (board or os.environ.get("AAMIO_BOARD") or DEFAULT_BOARD).rstrip("/")
39
+
40
+ def http(self, method: str, url: str, body=None, headers=None, timeout=None):
41
+ data = None
42
+ if body is not None:
43
+ data = body.encode("utf-8") if isinstance(body, str) else json.dumps(body).encode("utf-8")
44
+ request = urllib.request.Request(url, data=data, method=method)
45
+ request.add_header("Accept", "application/json")
46
+ request.add_header("User-Agent", "aamio-listen/0.1")
47
+ if data is not None and "Content-Type" not in (headers or {}):
48
+ request.add_header("Content-Type", "application/json")
49
+ for name, value in (headers or {}).items():
50
+ request.add_header(name, value)
51
+ try:
52
+ with urllib.request.urlopen(request, timeout=timeout or self.timeout) as response:
53
+ status, text = response.status, response.read().decode("utf-8")
54
+ except urllib.error.HTTPError as error:
55
+ status, text = error.code, error.read().decode("utf-8", "replace")
56
+ except Exception as error:
57
+ # No reply at all: connection refused, timeout, DNS, a dropped
58
+ # socket after the bytes went out. Whether the service saw the
59
+ # request is unknown, and status 0 says exactly that.
60
+ return 0, {"error": "no response", "detail": error.__class__.__name__}
61
+ try:
62
+ return status, (json.loads(text) if text else None)
63
+ except ValueError:
64
+ return status, text
65
+
66
+ def call(self, method: str, path: str, body=None, headers=None, timeout=None):
67
+ return self.http(method, self.host + path, body, headers, timeout)
68
+
69
+ # threads
70
+
71
+ def open_thread(self, ttl: int, allow_keys=None):
72
+ read_key = make_read_key()
73
+ w = write_address(read_key)
74
+ headers = {"X-Read": read_key, "X-TTL": str(int(ttl))}
75
+ if allow_keys:
76
+ headers["X-Allow"] = ",".join(allow_keys)
77
+ status, data = self.call("PUT", "/" + w, None, headers)
78
+ return status, data, read_key, w
79
+
80
+ def post(self, w: str, body_text: str, key: str, signature: str, content_type: str = "text/plain"):
81
+ return self.call("POST", "/" + w, body_text, {"Content-Type": content_type, "X-Key": key, "X-Sig": signature})
82
+
83
+ def read(self, w: str, read_key: str, after: int = 0, wait: int = 0):
84
+ path = "/%s/after/%d" % (w, int(after))
85
+ if wait > 0:
86
+ path += "/wait/%d" % min(int(wait), 25)
87
+ return self.call("GET", path, None, {"X-Read": read_key}, timeout=max(self.timeout, wait + 15))
88
+
89
+ def receipt(self, w: str, read_key: str):
90
+ return self.call("GET", "/%s/receipt" % w, None, {"X-Read": read_key})
91
+
92
+ def delete(self, w: str, read_key: str):
93
+ return self.call("DELETE", "/" + w, None, {"X-Read": read_key})
94
+
95
+ # presence
96
+
97
+ def presence_put(self, key: str, body_text: str, signature: str):
98
+ return self.call("PUT", "/p/" + key, body_text, {"Content-Type": "application/json", "X-Sig": signature})
99
+
100
+ def presence_get(self, key: str):
101
+ return self.call("GET", "/p/" + key)
102
+
103
+ def presence_lookup(self, prefixes, wait: int = 0):
104
+ if wait > 0:
105
+ return self.call("POST", "/p/watch", {"prefixes": prefixes, "wait": min(int(wait), 25)}, timeout=wait + 15)
106
+ return self.call("POST", "/p/lookup", {"prefixes": prefixes})
107
+
108
+ # board
109
+
110
+ def board_post(self, body_text: str, key: str, signature: str):
111
+ return self.http("POST", self.board + "/", body_text, {"Content-Type": "application/json", "X-Key": key, "X-Sig": signature})
112
+
113
+ def board_find(self, filter_body: dict, wait: int = 0):
114
+ return self.http("POST", self.board + "/find", filter_body, timeout=wait + 15 if wait else None)
115
+
116
+ def board_get(self, post_id: str):
117
+ return self.http("GET", self.board + "/" + post_id)
118
+
119
+ def board_tags(self):
120
+ return self.http("GET", self.board + "/tags")
121
+
122
+ def board_withdraw(self, post_id: str, body_text: str, signature: str):
123
+ return self.http("DELETE", self.board + "/" + post_id, body_text, {"Content-Type": "application/json", "X-Sig": signature})
124
+
125
+ # service
126
+
127
+ def health(self):
128
+ return self.call("GET", "/health")
129
+
130
+ def descriptor(self):
131
+ return self.call("GET", "/.well-known/aamio.json")
132
+
133
+ # verifyum
134
+
135
+ def anchor(self, root_hex: str, idempotency_key: str):
136
+ message = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "verifyum_anchor_commitment", "arguments": {"commitment": "sha256:" + root_hex, "idempotency_key": idempotency_key}}}
137
+ status, reply = self.http("POST", VERIFYUM_MCP, message, {"MCP-Protocol-Version": "2025-11-25"})
138
+ try:
139
+ return status, json.loads(reply["result"]["content"][0]["text"])
140
+ except Exception:
141
+ return status, reply
142
+
143
+ def proof(self, proof_id: str):
144
+ message = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "verifyum_get_proof", "arguments": {"proof_id": proof_id}}}
145
+ status, reply = self.http("POST", VERIFYUM_MCP, message, {"MCP-Protocol-Version": "2025-11-25"})
146
+ try:
147
+ return status, json.loads(reply["result"]["content"][0]["text"])
148
+ except Exception:
149
+ return status, reply
aamio/crypto.py ADDED
@@ -0,0 +1,117 @@
1
+ """Keys and envelopes.
2
+
3
+ One Ed25519 key per runtime. Its X25519 counterpart is derived for
4
+ encryption, so a partner needs only the one public key from the contract.
5
+
6
+ Envelope format, the same one the aamio experiments used:
7
+
8
+ {"e2ee":"nacl.box.v1","to":"<8 hex of sha256(recipient key)>","nonce":"<b64url>","ct":"<b64url>"}
9
+
10
+ aamio stores the envelope as opaque text and verifies the sender's signature
11
+ over sha256 of it. Nobody but the recipient can open it.
12
+ """
13
+
14
+ import base64
15
+ import hashlib
16
+ import json
17
+
18
+ from nacl.public import Box
19
+ from nacl.signing import SigningKey, VerifyKey
20
+ from nacl.utils import random as nacl_random
21
+
22
+ ENVELOPE = "nacl.box.v1"
23
+
24
+
25
+ def b64url(raw: bytes) -> str:
26
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
27
+
28
+
29
+ def unb64url(text: str) -> bytes:
30
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
31
+
32
+
33
+ def sha256hex(data) -> str:
34
+ if isinstance(data, str):
35
+ data = data.encode("utf-8")
36
+ return hashlib.sha256(data).hexdigest()
37
+
38
+
39
+ def is_key(text) -> bool:
40
+ if not isinstance(text, str) or len(text) != 43:
41
+ return False
42
+ try:
43
+ return len(unb64url(text)) == 32
44
+ except Exception:
45
+ return False
46
+
47
+
48
+ def key_hash(key_b64url: str) -> str:
49
+ return sha256hex(unb64url(key_b64url))
50
+
51
+
52
+ def hash_prefix(key_b64url: str, length: int = 8) -> str:
53
+ return key_hash(key_b64url)[:length]
54
+
55
+
56
+ def thread_signing_input(w: str, body_text: str) -> str:
57
+ return "aamio-v1\n" + w + "\n" + sha256hex(body_text)
58
+
59
+
60
+ def presence_signing_input(key: str, body_text: str) -> str:
61
+ return "aamio-presence-v1\n" + key + "\n" + sha256hex(body_text)
62
+
63
+
64
+ def presence_delete_signing_input(key: str, body_text: str) -> str:
65
+ return "aamio-presence-delete-v1\n" + key + "\n" + sha256hex(body_text)
66
+
67
+
68
+ def board_signing_input(key: str, body_text: str) -> str:
69
+ return "aamio-board-v1\n" + key + "\n" + sha256hex(body_text)
70
+
71
+
72
+ def board_delete_signing_input(post_id: str, body_text: str) -> str:
73
+ return "aamio-board-delete-v1\n" + post_id + "\n" + sha256hex(body_text)
74
+
75
+
76
+ class Keys:
77
+ """A runtime identity: one seed, an Ed25519 pair for signing and an X25519 pair for boxes."""
78
+
79
+ def __init__(self, seed: bytes):
80
+ if len(seed) != 32:
81
+ raise ValueError("seed must be 32 bytes")
82
+ self.seed = seed
83
+ self.signing = SigningKey(seed)
84
+ self.curve = self.signing.to_curve25519_private_key()
85
+ self.public_raw = bytes(self.signing.verify_key)
86
+ self.public = b64url(self.public_raw)
87
+ self.hash = sha256hex(self.public_raw)
88
+
89
+ @classmethod
90
+ def generate(cls) -> "Keys":
91
+ return cls(nacl_random(32))
92
+
93
+ def sign(self, message: str) -> str:
94
+ return b64url(self.signing.sign(message.encode("utf-8")).signature)
95
+
96
+ @staticmethod
97
+ def curve_public(key_b64url: str):
98
+ return VerifyKey(unb64url(key_b64url)).to_curve25519_public_key()
99
+
100
+ def seal(self, recipient_key: str, plaintext: bytes) -> str:
101
+ nonce = nacl_random(Box.NONCE_SIZE)
102
+ ciphertext = Box(self.curve, self.curve_public(recipient_key)).encrypt(plaintext, nonce).ciphertext
103
+ return json.dumps({"e2ee": ENVELOPE, "to": hash_prefix(recipient_key), "nonce": b64url(nonce), "ct": b64url(ciphertext)}, separators=(",", ":"))
104
+
105
+ def open(self, sender_key: str, envelope_text: str) -> bytes:
106
+ envelope = json.loads(envelope_text)
107
+ if not isinstance(envelope, dict) or envelope.get("e2ee") != ENVELOPE:
108
+ raise ValueError("not an envelope")
109
+ return Box(self.curve, self.curve_public(sender_key)).decrypt(unb64url(envelope["ct"]), unb64url(envelope["nonce"]))
110
+
111
+
112
+ def is_envelope(text: str) -> bool:
113
+ try:
114
+ envelope = json.loads(text)
115
+ except ValueError:
116
+ return False
117
+ return isinstance(envelope, dict) and envelope.get("e2ee") == ENVELOPE