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 +7 -0
- aamio/__main__.py +3 -0
- aamio/cli.py +180 -0
- aamio/client.py +149 -0
- aamio/crypto.py +117 -0
- aamio/mcp_server.py +201 -0
- aamio/runtime.py +1149 -0
- aamio-0.4.0.dist-info/METADATA +147 -0
- aamio-0.4.0.dist-info/RECORD +13 -0
- aamio-0.4.0.dist-info/WHEEL +5 -0
- aamio-0.4.0.dist-info/entry_points.txt +3 -0
- aamio-0.4.0.dist-info/licenses/LICENSE +21 -0
- aamio-0.4.0.dist-info/top_level.txt +1 -0
aamio/__init__.py
ADDED
aamio/__main__.py
ADDED
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
|