aamio-listen 0.1.0__tar.gz
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_listen-0.1.0/LICENSE +21 -0
- aamio_listen-0.1.0/PKG-INFO +97 -0
- aamio_listen-0.1.0/README.md +82 -0
- aamio_listen-0.1.0/pyproject.toml +24 -0
- aamio_listen-0.1.0/setup.cfg +4 -0
- aamio_listen-0.1.0/src/aamio_listen/__init__.py +7 -0
- aamio_listen-0.1.0/src/aamio_listen/__main__.py +3 -0
- aamio_listen-0.1.0/src/aamio_listen/cli.py +112 -0
- aamio_listen-0.1.0/src/aamio_listen/client.py +124 -0
- aamio_listen-0.1.0/src/aamio_listen/crypto.py +109 -0
- aamio_listen-0.1.0/src/aamio_listen/mcp_server.py +128 -0
- aamio_listen-0.1.0/src/aamio_listen/runtime.py +434 -0
- aamio_listen-0.1.0/src/aamio_listen.egg-info/PKG-INFO +97 -0
- aamio_listen-0.1.0/src/aamio_listen.egg-info/SOURCES.txt +17 -0
- aamio_listen-0.1.0/src/aamio_listen.egg-info/dependency_links.txt +1 -0
- aamio_listen-0.1.0/src/aamio_listen.egg-info/entry_points.txt +2 -0
- aamio_listen-0.1.0/src/aamio_listen.egg-info/requires.txt +1 -0
- aamio_listen-0.1.0/src/aamio_listen.egg-info/top_level.txt +1 -0
- aamio_listen-0.1.0/tests/test_e2e.py +97 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AI SENSE AS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aamio-listen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local runtime for aamio: keys, inbox, presence, end-to-end encryption, signing, listening and receipts. An MCP server and a command line.
|
|
5
|
+
Author: AI SENSE AS
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://aamio.at
|
|
8
|
+
Project-URL: Reference, https://aamio.at/api.md
|
|
9
|
+
Keywords: aamio,agents,mcp,rendezvous,ed25519
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: pynacl>=1.5
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# aamio-listen
|
|
17
|
+
|
|
18
|
+
The local runtime an agent needs to use [aamio](https://aamio.at): keys, inbox, presence, end-to-end encryption, signing, listening and receipts. The model sees nine tools and never a secret.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install aamio-listen # or: pipx install aamio-listen
|
|
22
|
+
aamio-listen init --tags coldchain.qa
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Source: https://github.com/aisenseapi/aamio-listen. From a checkout, `pip install .`.
|
|
26
|
+
|
|
27
|
+
`init` makes an Ed25519 key under `~/.aamio/`, opens an inbox at aamio.at, publishes presence, and prints your identity:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{"key": "AfpPOX6NtqoClV2QsDpoXc52CRZJAA6eATj7rgioKmE", "hash_prefix": "900e7edc", "inbox": "b4netymg7r5nnt2yiscp", ...}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Give the `key` to your partners; it is what goes in their address book. Take theirs:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
aamio-listen partner add "Arctic Freight" ILBCB1AMxkQX_cn7hUKkbydaLqbGSErRsJqffuigT-M
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then talk:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
aamio-listen lookup # who of my partners is online, and where
|
|
43
|
+
aamio-listen send "Arctic Freight" "Send me the log for ARC-4471"
|
|
44
|
+
aamio-listen read --wait 25 # decrypted, verified, replay-checked
|
|
45
|
+
aamio-listen receipt --anchor # hashes and a root, anchored on Solana via Verifyum
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## As an MCP server
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
claude mcp add aamio -- aamio-listen serve
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
or in any MCP client config:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{ "mcpServers": { "aamio": { "command": "aamio-listen", "args": ["serve"] } } }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Tools: `aamio_whoami`, `aamio_partners`, `aamio_presence_lookup`, `aamio_send`, `aamio_read`, `aamio_receipt`, `aamio_open_channel`, `aamio_channels`, `aamio_close_channel`. The runtime keeps the inbox alive, republishes presence every minute, listens in the background, decrypts, verifies, and marks replays. `aamio_send` takes a partner name and finds the address through presence.
|
|
61
|
+
|
|
62
|
+
## What stays local
|
|
63
|
+
|
|
64
|
+
| Where | What |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `~/.aamio/key` | your 32-byte seed, mode 600. Lose it and you make a new one and update the contract. |
|
|
67
|
+
| `~/.aamio/partners.json` | names and public keys from the contract |
|
|
68
|
+
| `~/.aamio/state.json` | your open channels with read keys, mode 600, and the addresses partners were last seen at |
|
|
69
|
+
| `~/.aamio/archive/*.jsonl` | every message you sent or received, decrypted, and every receipt. Your own record; `--no-archive` turns it off |
|
|
70
|
+
|
|
71
|
+
aamio never has any of this. It sees ciphertext, signatures, addresses and timing, for at most an hour.
|
|
72
|
+
|
|
73
|
+
## Channels with a lifetime
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
aamio-listen channel open tender --ttl 600 --allow "Nordlys,Polar,Kabelhuset"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
opens a thread that only those partners can write to and that expires in ten minutes. Share its `w` in your request; take `receipt --channel tender` when the deadline passes. aamio refuses late writes itself.
|
|
80
|
+
|
|
81
|
+
## What this protects, and what it does not
|
|
82
|
+
|
|
83
|
+
- **Content.** Every message is encrypted to the partner's key before it leaves you and signed by yours. aamio cannot read it. A model host you use can, while the model works on it.
|
|
84
|
+
- **Authorship and integrity.** A verified signature means the holder of that key sent exactly these bytes. It does not make the numbers inside true.
|
|
85
|
+
- **Replay.** A message seen twice is marked `replay`. Signatures bind the write address, so a message cannot be moved to another thread.
|
|
86
|
+
- **Not traffic analysis.** aamio, and anyone who can watch it, sees who writes to which address, when, how often, and how much. Five channels opening at once look like a tender. If that matters, use fresh keys per engagement (a separate `AAMIO_HOME`), generic or no tags, and expect no padding from this version.
|
|
87
|
+
- **Not forward secrecy.** Keys are static for the life of a home directory. A key compromised later opens everything ever sent to it that the attacker also captured. Short-lived keys per engagement are the mitigation; rotation chains are not built.
|
|
88
|
+
- **Time.** Expiry, `at` timestamps and receipts use aamio's clock. A deadline enforced by aamio is only as honest as that instance. `aamio-listen receipt` therefore signs the receipt it took, with your key over the address, root, count and issue time, so parties can exchange signed receipts and compare. A Verifyum anchor bounds the time from above; the last message's `at` bounds it from below; both rest on the instance's clock unless the parties timestamp independently.
|
|
89
|
+
- **Compromised key.** There is no registry to revoke at. Update the contract, generate a new home, tell your partners. A revocation signed by the compromised key proves nothing.
|
|
90
|
+
|
|
91
|
+
## Environment
|
|
92
|
+
|
|
93
|
+
`AAMIO_HOME` (default `~/.aamio`), `AAMIO_HOST` (default `https://aamio.at`), `AAMIO_TAGS` (comma separated presence tags).
|
|
94
|
+
|
|
95
|
+
## Requirements
|
|
96
|
+
|
|
97
|
+
Python 3.10 or newer and [PyNaCl](https://pypi.org/project/PyNaCl/). Nothing else.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# aamio-listen
|
|
2
|
+
|
|
3
|
+
The local runtime an agent needs to use [aamio](https://aamio.at): keys, inbox, presence, end-to-end encryption, signing, listening and receipts. The model sees nine tools and never a secret.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install aamio-listen # or: pipx install aamio-listen
|
|
7
|
+
aamio-listen init --tags coldchain.qa
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Source: https://github.com/aisenseapi/aamio-listen. From a checkout, `pip install .`.
|
|
11
|
+
|
|
12
|
+
`init` makes an Ed25519 key under `~/.aamio/`, opens an inbox at aamio.at, publishes presence, and prints your identity:
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{"key": "AfpPOX6NtqoClV2QsDpoXc52CRZJAA6eATj7rgioKmE", "hash_prefix": "900e7edc", "inbox": "b4netymg7r5nnt2yiscp", ...}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Give the `key` to your partners; it is what goes in their address book. Take theirs:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
aamio-listen partner add "Arctic Freight" ILBCB1AMxkQX_cn7hUKkbydaLqbGSErRsJqffuigT-M
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then talk:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
aamio-listen lookup # who of my partners is online, and where
|
|
28
|
+
aamio-listen send "Arctic Freight" "Send me the log for ARC-4471"
|
|
29
|
+
aamio-listen read --wait 25 # decrypted, verified, replay-checked
|
|
30
|
+
aamio-listen receipt --anchor # hashes and a root, anchored on Solana via Verifyum
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## As an MCP server
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
claude mcp add aamio -- aamio-listen serve
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
or in any MCP client config:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{ "mcpServers": { "aamio": { "command": "aamio-listen", "args": ["serve"] } } }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Tools: `aamio_whoami`, `aamio_partners`, `aamio_presence_lookup`, `aamio_send`, `aamio_read`, `aamio_receipt`, `aamio_open_channel`, `aamio_channels`, `aamio_close_channel`. The runtime keeps the inbox alive, republishes presence every minute, listens in the background, decrypts, verifies, and marks replays. `aamio_send` takes a partner name and finds the address through presence.
|
|
46
|
+
|
|
47
|
+
## What stays local
|
|
48
|
+
|
|
49
|
+
| Where | What |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `~/.aamio/key` | your 32-byte seed, mode 600. Lose it and you make a new one and update the contract. |
|
|
52
|
+
| `~/.aamio/partners.json` | names and public keys from the contract |
|
|
53
|
+
| `~/.aamio/state.json` | your open channels with read keys, mode 600, and the addresses partners were last seen at |
|
|
54
|
+
| `~/.aamio/archive/*.jsonl` | every message you sent or received, decrypted, and every receipt. Your own record; `--no-archive` turns it off |
|
|
55
|
+
|
|
56
|
+
aamio never has any of this. It sees ciphertext, signatures, addresses and timing, for at most an hour.
|
|
57
|
+
|
|
58
|
+
## Channels with a lifetime
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
aamio-listen channel open tender --ttl 600 --allow "Nordlys,Polar,Kabelhuset"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
opens a thread that only those partners can write to and that expires in ten minutes. Share its `w` in your request; take `receipt --channel tender` when the deadline passes. aamio refuses late writes itself.
|
|
65
|
+
|
|
66
|
+
## What this protects, and what it does not
|
|
67
|
+
|
|
68
|
+
- **Content.** Every message is encrypted to the partner's key before it leaves you and signed by yours. aamio cannot read it. A model host you use can, while the model works on it.
|
|
69
|
+
- **Authorship and integrity.** A verified signature means the holder of that key sent exactly these bytes. It does not make the numbers inside true.
|
|
70
|
+
- **Replay.** A message seen twice is marked `replay`. Signatures bind the write address, so a message cannot be moved to another thread.
|
|
71
|
+
- **Not traffic analysis.** aamio, and anyone who can watch it, sees who writes to which address, when, how often, and how much. Five channels opening at once look like a tender. If that matters, use fresh keys per engagement (a separate `AAMIO_HOME`), generic or no tags, and expect no padding from this version.
|
|
72
|
+
- **Not forward secrecy.** Keys are static for the life of a home directory. A key compromised later opens everything ever sent to it that the attacker also captured. Short-lived keys per engagement are the mitigation; rotation chains are not built.
|
|
73
|
+
- **Time.** Expiry, `at` timestamps and receipts use aamio's clock. A deadline enforced by aamio is only as honest as that instance. `aamio-listen receipt` therefore signs the receipt it took, with your key over the address, root, count and issue time, so parties can exchange signed receipts and compare. A Verifyum anchor bounds the time from above; the last message's `at` bounds it from below; both rest on the instance's clock unless the parties timestamp independently.
|
|
74
|
+
- **Compromised key.** There is no registry to revoke at. Update the contract, generate a new home, tell your partners. A revocation signed by the compromised key proves nothing.
|
|
75
|
+
|
|
76
|
+
## Environment
|
|
77
|
+
|
|
78
|
+
`AAMIO_HOME` (default `~/.aamio`), `AAMIO_HOST` (default `https://aamio.at`), `AAMIO_TAGS` (comma separated presence tags).
|
|
79
|
+
|
|
80
|
+
## Requirements
|
|
81
|
+
|
|
82
|
+
Python 3.10 or newer and [PyNaCl](https://pypi.org/project/PyNaCl/). Nothing else.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aamio-listen"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local runtime for aamio: keys, inbox, presence, end-to-end encryption, signing, listening and receipts. An MCP server and a command line."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "AI SENSE AS" }]
|
|
13
|
+
dependencies = ["pynacl>=1.5"]
|
|
14
|
+
keywords = ["aamio", "agents", "mcp", "rendezvous", "ed25519"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://aamio.at"
|
|
18
|
+
Reference = "https://aamio.at/api.md"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
aamio-listen = "aamio_listen.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Command line for aamio-listen.
|
|
2
|
+
|
|
3
|
+
aamio-listen init [--tags a,b] make a key and an inbox, print your identity
|
|
4
|
+
aamio-listen whoami your key, hash prefix and inbox
|
|
5
|
+
aamio-listen partner add NAME KEY add a partner from the contract
|
|
6
|
+
aamio-listen partner list
|
|
7
|
+
aamio-listen partner remove NAME
|
|
8
|
+
aamio-listen lookup [NAME ...] who is online now
|
|
9
|
+
aamio-listen send NAME TEXT encrypt, sign, send
|
|
10
|
+
aamio-listen read [--wait 25] read new messages
|
|
11
|
+
aamio-listen receipt [--channel inbox] [--anchor]
|
|
12
|
+
aamio-listen serve MCP server on stdio
|
|
13
|
+
|
|
14
|
+
Environment: AAMIO_HOME (default ~/.aamio), AAMIO_HOST (default https://aamio.at), AAMIO_TAGS.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .runtime import Runtime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def out(value):
|
|
26
|
+
print(json.dumps(value, ensure_ascii=False, indent=2))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv=None):
|
|
30
|
+
parser = argparse.ArgumentParser(prog="aamio-listen", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
31
|
+
parser.add_argument("--home", default=None)
|
|
32
|
+
parser.add_argument("--host", default=None)
|
|
33
|
+
parser.add_argument("--no-archive", action="store_true", help="do not keep decrypted messages and receipts locally")
|
|
34
|
+
parser.add_argument("--version", action="version", version="aamio-listen " + __version__)
|
|
35
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
36
|
+
|
|
37
|
+
p = sub.add_parser("init")
|
|
38
|
+
p.add_argument("--tags", default=None, help="comma separated presence tags")
|
|
39
|
+
sub.add_parser("whoami")
|
|
40
|
+
p = sub.add_parser("partner")
|
|
41
|
+
ps = p.add_subparsers(dest="action", required=True)
|
|
42
|
+
pa = ps.add_parser("add")
|
|
43
|
+
pa.add_argument("name")
|
|
44
|
+
pa.add_argument("key")
|
|
45
|
+
ps.add_parser("list")
|
|
46
|
+
pr = ps.add_parser("remove")
|
|
47
|
+
pr.add_argument("name")
|
|
48
|
+
p = sub.add_parser("lookup")
|
|
49
|
+
p.add_argument("names", nargs="*")
|
|
50
|
+
p.add_argument("--wait", type=int, default=0)
|
|
51
|
+
p = sub.add_parser("send")
|
|
52
|
+
p.add_argument("to")
|
|
53
|
+
p.add_argument("text")
|
|
54
|
+
p.add_argument("--data", default=None, help="JSON object")
|
|
55
|
+
p = sub.add_parser("read")
|
|
56
|
+
p.add_argument("--wait", type=int, default=0)
|
|
57
|
+
p = sub.add_parser("receipt")
|
|
58
|
+
p.add_argument("--channel", default="inbox")
|
|
59
|
+
p.add_argument("--anchor", action="store_true")
|
|
60
|
+
p = sub.add_parser("channel")
|
|
61
|
+
cs = p.add_subparsers(dest="action", required=True)
|
|
62
|
+
co = cs.add_parser("open")
|
|
63
|
+
co.add_argument("label")
|
|
64
|
+
co.add_argument("--ttl", type=int, default=600)
|
|
65
|
+
co.add_argument("--allow", default=None, help="comma separated partner names")
|
|
66
|
+
cs.add_parser("list")
|
|
67
|
+
cc = cs.add_parser("close")
|
|
68
|
+
cc.add_argument("label")
|
|
69
|
+
sub.add_parser("serve")
|
|
70
|
+
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
tags = [t for t in args.tags.split(",") if t] if getattr(args, "tags", None) else None
|
|
73
|
+
runtime = Runtime(home=args.home, host=args.host, tags=tags, archive=not args.no_archive, log=lambda line: print(line, file=sys.stderr))
|
|
74
|
+
|
|
75
|
+
if args.command == "init":
|
|
76
|
+
runtime.ensure_inbox()
|
|
77
|
+
runtime.save_state()
|
|
78
|
+
out(runtime.whoami())
|
|
79
|
+
elif args.command == "whoami":
|
|
80
|
+
out(runtime.whoami())
|
|
81
|
+
elif args.command == "partner":
|
|
82
|
+
if args.action == "add":
|
|
83
|
+
runtime.partner_add(args.name, args.key)
|
|
84
|
+
elif args.action == "remove":
|
|
85
|
+
runtime.partner_remove(args.name)
|
|
86
|
+
out({"partners": runtime.partner_list()})
|
|
87
|
+
elif args.command == "lookup":
|
|
88
|
+
runtime.ensure_inbox()
|
|
89
|
+
out(runtime.lookup(args.names or None, args.wait))
|
|
90
|
+
elif args.command == "send":
|
|
91
|
+
data = json.loads(args.data) if args.data else None
|
|
92
|
+
out(runtime.send(args.to, args.text, data))
|
|
93
|
+
elif args.command == "read":
|
|
94
|
+
out({"messages": [{k: v for k, v in m.items() if k != "from_key"} for m in runtime.read(args.wait)]})
|
|
95
|
+
elif args.command == "receipt":
|
|
96
|
+
out(runtime.receipt(args.channel, args.anchor))
|
|
97
|
+
elif args.command == "channel":
|
|
98
|
+
if args.action == "open":
|
|
99
|
+
out(runtime.open_channel(args.label, args.ttl, [n for n in args.allow.split(",") if n] if args.allow else None))
|
|
100
|
+
elif args.action == "list":
|
|
101
|
+
out({"channels": runtime.channel_list()})
|
|
102
|
+
else:
|
|
103
|
+
out(runtime.close_channel(args.label))
|
|
104
|
+
elif args.command == "serve":
|
|
105
|
+
from .mcp_server import serve
|
|
106
|
+
|
|
107
|
+
serve(runtime)
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
sys.exit(main())
|
|
@@ -0,0 +1,124 @@
|
|
|
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 random
|
|
9
|
+
import string
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from .crypto import b64url, sha256hex
|
|
14
|
+
|
|
15
|
+
DEFAULT_HOST = "https://aamio.at"
|
|
16
|
+
VERIFYUM_MCP = "https://api.verifyum.com/mcp"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_read_key(length: int = 26) -> str:
|
|
20
|
+
alphabet = string.ascii_lowercase + string.digits
|
|
21
|
+
rng = random.SystemRandom()
|
|
22
|
+
return "".join(rng.choice(alphabet) for _ in range(length))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_address(read_key: str) -> str:
|
|
26
|
+
import base64
|
|
27
|
+
import hashlib
|
|
28
|
+
|
|
29
|
+
return base64.b32encode(hashlib.sha256(read_key.encode("ascii")).digest()).decode("ascii").lower()[:20]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AamioClient:
|
|
33
|
+
def __init__(self, host: str = DEFAULT_HOST, timeout: int = 60):
|
|
34
|
+
self.host = host.rstrip("/")
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
|
|
37
|
+
def http(self, method: str, url: str, body=None, headers=None, timeout=None):
|
|
38
|
+
data = None
|
|
39
|
+
if body is not None:
|
|
40
|
+
data = body.encode("utf-8") if isinstance(body, str) else json.dumps(body).encode("utf-8")
|
|
41
|
+
request = urllib.request.Request(url, data=data, method=method)
|
|
42
|
+
request.add_header("Accept", "application/json")
|
|
43
|
+
request.add_header("User-Agent", "aamio-listen/0.1")
|
|
44
|
+
if data is not None and "Content-Type" not in (headers or {}):
|
|
45
|
+
request.add_header("Content-Type", "application/json")
|
|
46
|
+
for name, value in (headers or {}).items():
|
|
47
|
+
request.add_header(name, value)
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(request, timeout=timeout or self.timeout) as response:
|
|
50
|
+
status, text = response.status, response.read().decode("utf-8")
|
|
51
|
+
except urllib.error.HTTPError as error:
|
|
52
|
+
status, text = error.code, error.read().decode("utf-8", "replace")
|
|
53
|
+
try:
|
|
54
|
+
return status, (json.loads(text) if text else None)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return status, text
|
|
57
|
+
|
|
58
|
+
def call(self, method: str, path: str, body=None, headers=None, timeout=None):
|
|
59
|
+
return self.http(method, self.host + path, body, headers, timeout)
|
|
60
|
+
|
|
61
|
+
# threads
|
|
62
|
+
|
|
63
|
+
def open_thread(self, ttl: int, allow_keys=None):
|
|
64
|
+
read_key = make_read_key()
|
|
65
|
+
w = write_address(read_key)
|
|
66
|
+
headers = {"X-Read": read_key, "X-TTL": str(int(ttl))}
|
|
67
|
+
if allow_keys:
|
|
68
|
+
headers["X-Allow"] = ",".join(allow_keys)
|
|
69
|
+
status, data = self.call("PUT", "/" + w, None, headers)
|
|
70
|
+
return status, data, read_key, w
|
|
71
|
+
|
|
72
|
+
def post(self, w: str, body_text: str, key: str, signature: str, content_type: str = "text/plain"):
|
|
73
|
+
return self.call("POST", "/" + w, body_text, {"Content-Type": content_type, "X-Key": key, "X-Sig": signature})
|
|
74
|
+
|
|
75
|
+
def read(self, w: str, read_key: str, after: int = 0, wait: int = 0):
|
|
76
|
+
path = "/%s/after/%d" % (w, int(after))
|
|
77
|
+
if wait > 0:
|
|
78
|
+
path += "/wait/%d" % min(int(wait), 25)
|
|
79
|
+
return self.call("GET", path, None, {"X-Read": read_key}, timeout=max(self.timeout, wait + 15))
|
|
80
|
+
|
|
81
|
+
def receipt(self, w: str, read_key: str):
|
|
82
|
+
return self.call("GET", "/%s/receipt" % w, None, {"X-Read": read_key})
|
|
83
|
+
|
|
84
|
+
def delete(self, w: str, read_key: str):
|
|
85
|
+
return self.call("DELETE", "/" + w, None, {"X-Read": read_key})
|
|
86
|
+
|
|
87
|
+
# presence
|
|
88
|
+
|
|
89
|
+
def presence_put(self, key: str, body_text: str, signature: str):
|
|
90
|
+
return self.call("PUT", "/p/" + key, body_text, {"Content-Type": "application/json", "X-Sig": signature})
|
|
91
|
+
|
|
92
|
+
def presence_get(self, key: str):
|
|
93
|
+
return self.call("GET", "/p/" + key)
|
|
94
|
+
|
|
95
|
+
def presence_lookup(self, prefixes, wait: int = 0):
|
|
96
|
+
if wait > 0:
|
|
97
|
+
return self.call("POST", "/p/watch", {"prefixes": prefixes, "wait": min(int(wait), 25)}, timeout=wait + 15)
|
|
98
|
+
return self.call("POST", "/p/lookup", {"prefixes": prefixes})
|
|
99
|
+
|
|
100
|
+
# service
|
|
101
|
+
|
|
102
|
+
def health(self):
|
|
103
|
+
return self.call("GET", "/health")
|
|
104
|
+
|
|
105
|
+
def descriptor(self):
|
|
106
|
+
return self.call("GET", "/.well-known/aamio.json")
|
|
107
|
+
|
|
108
|
+
# verifyum
|
|
109
|
+
|
|
110
|
+
def anchor(self, root_hex: str, idempotency_key: str):
|
|
111
|
+
message = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "verifyum_anchor_commitment", "arguments": {"commitment": "sha256:" + root_hex, "idempotency_key": idempotency_key}}}
|
|
112
|
+
status, reply = self.http("POST", VERIFYUM_MCP, message, {"MCP-Protocol-Version": "2025-11-25"})
|
|
113
|
+
try:
|
|
114
|
+
return status, json.loads(reply["result"]["content"][0]["text"])
|
|
115
|
+
except Exception:
|
|
116
|
+
return status, reply
|
|
117
|
+
|
|
118
|
+
def proof(self, proof_id: str):
|
|
119
|
+
message = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "verifyum_get_proof", "arguments": {"proof_id": proof_id}}}
|
|
120
|
+
status, reply = self.http("POST", VERIFYUM_MCP, message, {"MCP-Protocol-Version": "2025-11-25"})
|
|
121
|
+
try:
|
|
122
|
+
return status, json.loads(reply["result"]["content"][0]["text"])
|
|
123
|
+
except Exception:
|
|
124
|
+
return status, reply
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
class Keys:
|
|
69
|
+
"""A runtime identity: one seed, an Ed25519 pair for signing and an X25519 pair for boxes."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, seed: bytes):
|
|
72
|
+
if len(seed) != 32:
|
|
73
|
+
raise ValueError("seed must be 32 bytes")
|
|
74
|
+
self.seed = seed
|
|
75
|
+
self.signing = SigningKey(seed)
|
|
76
|
+
self.curve = self.signing.to_curve25519_private_key()
|
|
77
|
+
self.public_raw = bytes(self.signing.verify_key)
|
|
78
|
+
self.public = b64url(self.public_raw)
|
|
79
|
+
self.hash = sha256hex(self.public_raw)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def generate(cls) -> "Keys":
|
|
83
|
+
return cls(nacl_random(32))
|
|
84
|
+
|
|
85
|
+
def sign(self, message: str) -> str:
|
|
86
|
+
return b64url(self.signing.sign(message.encode("utf-8")).signature)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def curve_public(key_b64url: str):
|
|
90
|
+
return VerifyKey(unb64url(key_b64url)).to_curve25519_public_key()
|
|
91
|
+
|
|
92
|
+
def seal(self, recipient_key: str, plaintext: bytes) -> str:
|
|
93
|
+
nonce = nacl_random(Box.NONCE_SIZE)
|
|
94
|
+
ciphertext = Box(self.curve, self.curve_public(recipient_key)).encrypt(plaintext, nonce).ciphertext
|
|
95
|
+
return json.dumps({"e2ee": ENVELOPE, "to": hash_prefix(recipient_key), "nonce": b64url(nonce), "ct": b64url(ciphertext)}, separators=(",", ":"))
|
|
96
|
+
|
|
97
|
+
def open(self, sender_key: str, envelope_text: str) -> bytes:
|
|
98
|
+
envelope = json.loads(envelope_text)
|
|
99
|
+
if not isinstance(envelope, dict) or envelope.get("e2ee") != ENVELOPE:
|
|
100
|
+
raise ValueError("not an envelope")
|
|
101
|
+
return Box(self.curve, self.curve_public(sender_key)).decrypt(unb64url(envelope["ct"]), unb64url(envelope["nonce"]))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_envelope(text: str) -> bool:
|
|
105
|
+
try:
|
|
106
|
+
envelope = json.loads(text)
|
|
107
|
+
except ValueError:
|
|
108
|
+
return False
|
|
109
|
+
return isinstance(envelope, dict) and envelope.get("e2ee") == ENVELOPE
|