attested-memory 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.
@@ -0,0 +1,3 @@
1
+ dist/
2
+ __pycache__/
3
+ *.egg-info/
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Attested Memory Hub contributors
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: attested-memory
3
+ Version: 0.1.0
4
+ Summary: Client and CLI for Attested Memory: write, search and read verifiable AI-agent memory under a signed Ed25519 actor identity.
5
+ Project-URL: Homepage, https://attestedmemory.net/
6
+ Project-URL: Source, https://github.com/alexar76/attested-memory
7
+ Author: AIMarket
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: agent-memory,ai-agents,ed25519,memory-poisoning,provenance,verifiable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Security :: Cryptography
15
+ Requires-Python: >=3.9
16
+ Requires-Dist: cryptography>=41
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # attested-memory
22
+
23
+ Python client and CLI for [Attested Memory](https://attestedmemory.net/) — verifiable memory
24
+ for AI agents. Every call is signed by your own Ed25519 **actor** key; the private key never
25
+ leaves your machine. A memory you read is checked here, on the client, against the
26
+ `content_hash` the hub recorded, so a changed text is refused instead of trusted.
27
+
28
+ ```bash
29
+ pip install attested-memory
30
+ attested-memory init # your actor key → ~/.config/attested-memory/actor.json (0600)
31
+ attested-memory trial # 7-day personal key, bound to that actor
32
+ attested-memory write "Supplier terms" "Net 30, EUR, signed 2026-09-01" --tag finance --source contract-17
33
+ attested-memory search supplier
34
+ attested-memory read mem_…
35
+ ```
36
+
37
+ ```python
38
+ from attested_memory import Actor, Client
39
+
40
+ actor = Actor.generate() # or Actor.load("actor.json")
41
+ client = Client(actor)
42
+ client.start_trial() # or Client(actor, api_key="ask_…")
43
+ unit = client.write("Supplier terms", "Net 30", tags=["finance"])
44
+ client.read(unit["id"]) # raises IntegrityError if the text does not match its hash
45
+ ```
46
+
47
+ What is checked where:
48
+
49
+ | | where |
50
+ |---|---|
51
+ | your identity (`did:actor:` + SHA-256 of your public key) and a one-time signed proof per request | client signs, server verifies |
52
+ | the text of a memory matches its `content_hash` | **this client** |
53
+ | `truth` (supported / contested / rejected) and `provenance` (attested receipt) | reported by the hub |
54
+
55
+ The proof is a signature over `<actor id>\n<unix seconds>\n<nonce>`: good for five minutes and
56
+ one request, so a captured header set replays nowhere.
57
+
58
+ Environment for the CLI: `ATTESTED_MEMORY_HOME`, `ATTESTED_MEMORY_URL`, `ATTESTED_MEMORY_API_KEY`.
59
+
60
+ Apache-2.0. Source: [alexar76/attested-memory](https://github.com/alexar76/attested-memory) (`clients/python`).
@@ -0,0 +1,40 @@
1
+ # attested-memory
2
+
3
+ Python client and CLI for [Attested Memory](https://attestedmemory.net/) — verifiable memory
4
+ for AI agents. Every call is signed by your own Ed25519 **actor** key; the private key never
5
+ leaves your machine. A memory you read is checked here, on the client, against the
6
+ `content_hash` the hub recorded, so a changed text is refused instead of trusted.
7
+
8
+ ```bash
9
+ pip install attested-memory
10
+ attested-memory init # your actor key → ~/.config/attested-memory/actor.json (0600)
11
+ attested-memory trial # 7-day personal key, bound to that actor
12
+ attested-memory write "Supplier terms" "Net 30, EUR, signed 2026-09-01" --tag finance --source contract-17
13
+ attested-memory search supplier
14
+ attested-memory read mem_…
15
+ ```
16
+
17
+ ```python
18
+ from attested_memory import Actor, Client
19
+
20
+ actor = Actor.generate() # or Actor.load("actor.json")
21
+ client = Client(actor)
22
+ client.start_trial() # or Client(actor, api_key="ask_…")
23
+ unit = client.write("Supplier terms", "Net 30", tags=["finance"])
24
+ client.read(unit["id"]) # raises IntegrityError if the text does not match its hash
25
+ ```
26
+
27
+ What is checked where:
28
+
29
+ | | where |
30
+ |---|---|
31
+ | your identity (`did:actor:` + SHA-256 of your public key) and a one-time signed proof per request | client signs, server verifies |
32
+ | the text of a memory matches its `content_hash` | **this client** |
33
+ | `truth` (supported / contested / rejected) and `provenance` (attested receipt) | reported by the hub |
34
+
35
+ The proof is a signature over `<actor id>\n<unix seconds>\n<nonce>`: good for five minutes and
36
+ one request, so a captured header set replays nowhere.
37
+
38
+ Environment for the CLI: `ATTESTED_MEMORY_HOME`, `ATTESTED_MEMORY_URL`, `ATTESTED_MEMORY_API_KEY`.
39
+
40
+ Apache-2.0. Source: [alexar76/attested-memory](https://github.com/alexar76/attested-memory) (`clients/python`).
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "attested-memory"
7
+ version = "0.1.0"
8
+ description = "Client and CLI for Attested Memory: write, search and read verifiable AI-agent memory under a signed Ed25519 actor identity."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "AIMarket" }]
13
+ keywords = ["ai-agents", "agent-memory", "provenance", "verifiable", "ed25519", "memory-poisoning"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Topic :: Security :: Cryptography",
18
+ "Intended Audience :: Developers",
19
+ ]
20
+ dependencies = ["cryptography>=41"]
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=8"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://attestedmemory.net/"
27
+ Source = "https://github.com/alexar76/attested-memory"
28
+
29
+ [project.scripts]
30
+ attested-memory = "attested_memory.cli:main"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/attested_memory"]
34
+
35
+ # An explicit file set (and the package's own .gitignore): without it hatchling walks up to the
36
+ # monorepo's .gitignore and ships it in the sdist, which the publish gate refuses.
37
+ [tool.hatch.build.targets.sdist]
38
+ include = ["/src/attested_memory", "/tests", "/README.md", "/LICENSE", "/pyproject.toml", "/.gitignore"]
@@ -0,0 +1,9 @@
1
+ """Python client for Attested Memory — verifiable memory for AI agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .actor import Actor # noqa: E402
6
+ from .client import AttestedMemoryError, Client, IntegrityError, check_integrity, content_hash # noqa: E402
7
+
8
+ __all__ = ["Actor", "AttestedMemoryError", "Client", "IntegrityError", "check_integrity", "content_hash",
9
+ "__version__"]
@@ -0,0 +1,96 @@
1
+ """The signed actor identity every protected Attested Memory call carries.
2
+
3
+ Wire format (the same in the Memory Market, the SaaS gateway and the product shells):
4
+
5
+ * ``X-Actor-ID`` ``did:actor:`` + hex SHA-256 of the raw Ed25519 public key (74 chars)
6
+ * ``X-Actor-Public-Key`` the raw 32-byte public key, base64url
7
+ * ``X-Actor-Timestamp`` unix seconds; the server accepts five minutes of skew
8
+ * ``X-Actor-Nonce`` 16-64 url-safe characters, good for exactly one request
9
+ * ``X-Actor-Signature`` Ed25519 over ``<actor id>\\n<timestamp>\\n<nonce>``, base64url
10
+
11
+ A captured header set therefore replays nowhere: it is bound to one nonce and expires.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import secrets
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from cryptography.hazmat.primitives import serialization
25
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
26
+
27
+
28
+ def _b64url(raw: bytes) -> str:
29
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
30
+
31
+
32
+ def _from_b64url(text: str) -> bytes:
33
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
34
+
35
+
36
+ class Actor:
37
+ """An Ed25519 identity. The private key never leaves this object or its key file."""
38
+
39
+ def __init__(self, private_key: Ed25519PrivateKey) -> None:
40
+ self._key = private_key
41
+ self._public = private_key.public_key().public_bytes(
42
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
43
+ )
44
+
45
+ @classmethod
46
+ def generate(cls) -> "Actor":
47
+ return cls(Ed25519PrivateKey.generate())
48
+
49
+ @classmethod
50
+ def from_seed(cls, seed_b64url: str) -> "Actor":
51
+ seed = _from_b64url(seed_b64url)
52
+ if len(seed) != 32:
53
+ raise ValueError("an actor seed is 32 bytes")
54
+ return cls(Ed25519PrivateKey.from_private_bytes(seed))
55
+
56
+ @classmethod
57
+ def load(cls, path: str | os.PathLike[str]) -> "Actor":
58
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
59
+ actor = cls.from_seed(data["seed"])
60
+ if data.get("actor_id") and data["actor_id"] != actor.actor_id:
61
+ raise ValueError(f"{path}: actor_id does not match the stored key")
62
+ return actor
63
+
64
+ def save(self, path: str | os.PathLike[str]) -> Path:
65
+ """Write the key file with mode 0600; refuses to overwrite an existing one."""
66
+ target = Path(path)
67
+ target.parent.mkdir(parents=True, exist_ok=True)
68
+ seed = self._key.private_bytes(
69
+ serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()
70
+ )
71
+ body = json.dumps({"actor_id": self.actor_id, "seed": _b64url(seed)}, indent=2) + "\n"
72
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
73
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
74
+ stream.write(body)
75
+ return target
76
+
77
+ @property
78
+ def public_key(self) -> str:
79
+ return _b64url(self._public)
80
+
81
+ @property
82
+ def actor_id(self) -> str:
83
+ return "did:actor:" + hashlib.sha256(self._public).hexdigest()
84
+
85
+ def proof_headers(self, *, now: float | None = None, nonce: str | None = None) -> dict[str, str]:
86
+ """Fresh headers for ONE request (a new nonce each call)."""
87
+ timestamp = str(int(time.time() if now is None else now))
88
+ nonce = nonce or secrets.token_urlsafe(24)
89
+ message = f"{self.actor_id}\n{timestamp}\n{nonce}".encode()
90
+ return {
91
+ "X-Actor-ID": self.actor_id,
92
+ "X-Actor-Public-Key": self.public_key,
93
+ "X-Actor-Timestamp": timestamp,
94
+ "X-Actor-Nonce": nonce,
95
+ "X-Actor-Signature": _b64url(self._key.sign(message)),
96
+ }
@@ -0,0 +1,152 @@
1
+ """attested-memory — command line for the Attested Memory API.
2
+
3
+ attested-memory init # create your actor key (~/.config/attested-memory)
4
+ attested-memory trial # claim the 7-day personal key for that actor
5
+ attested-memory write "Title" "Text" # or: --file notes.md
6
+ attested-memory search "supplier"
7
+ attested-memory read mem_…
8
+
9
+ Environment: ATTESTED_MEMORY_HOME (config dir), ATTESTED_MEMORY_URL (default
10
+ https://attestedmemory.net), ATTESTED_MEMORY_API_KEY (instead of the stored trial key).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from . import __version__
23
+ from .actor import Actor
24
+ from .client import DEFAULT_BASE_URL, AttestedMemoryError, Client
25
+
26
+
27
+ def _home() -> Path:
28
+ return Path(os.environ.get("ATTESTED_MEMORY_HOME") or Path.home() / ".config" / "attested-memory")
29
+
30
+
31
+ def _actor_path() -> Path:
32
+ return _home() / "actor.json"
33
+
34
+
35
+ def _key_path() -> Path:
36
+ return _home() / "key.json"
37
+
38
+
39
+ def _load_actor() -> Actor:
40
+ path = _actor_path()
41
+ if not path.exists():
42
+ raise SystemExit(f"no actor key at {path} — run: attested-memory init")
43
+ return Actor.load(path)
44
+
45
+
46
+ def _write_private(path: Path, payload: dict[str, Any]) -> None:
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
49
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
50
+ json.dump(payload, stream, indent=2)
51
+ stream.write("\n")
52
+
53
+
54
+ def _client(args: argparse.Namespace, *, need_key: bool = True) -> Client:
55
+ api_key = os.environ.get("ATTESTED_MEMORY_API_KEY")
56
+ if not api_key and _key_path().exists():
57
+ api_key = json.loads(_key_path().read_text(encoding="utf-8")).get("api_key")
58
+ if need_key and not api_key:
59
+ raise SystemExit("no API key — run: attested-memory trial (or set ATTESTED_MEMORY_API_KEY)")
60
+ return Client(_load_actor(), api_key, base_url=args.url)
61
+
62
+
63
+ def _print(value: Any) -> None:
64
+ print(json.dumps(value, indent=2, ensure_ascii=False))
65
+
66
+
67
+ def cmd_init(args: argparse.Namespace) -> int:
68
+ path = _actor_path()
69
+ if path.exists():
70
+ print(f"actor already exists: {Actor.load(path).actor_id} ({path})")
71
+ return 0
72
+ actor = Actor.generate()
73
+ actor.save(path)
74
+ print(f"created {actor.actor_id}\nkey file: {path} (mode 0600 — keep it; it is your identity)")
75
+ return 0
76
+
77
+
78
+ def cmd_whoami(args: argparse.Namespace) -> int:
79
+ actor = _load_actor()
80
+ print(actor.actor_id)
81
+ if _key_path().exists():
82
+ stored = json.loads(_key_path().read_text(encoding="utf-8"))
83
+ print(f"key: {stored.get('product')} {stored.get('plan')} until {stored.get('expires_at')}")
84
+ return 0
85
+
86
+
87
+ def cmd_trial(args: argparse.Namespace) -> int:
88
+ client = _client(args, need_key=False)
89
+ result = client.start_trial(args.product)
90
+ _write_private(_key_path(), {k: result.get(k) for k in ("api_key", "product", "plan", "expires_at", "key_id")})
91
+ print(f"{result.get('plan')} until {result.get('expires_at')} — key saved to {_key_path()}")
92
+ return 0
93
+
94
+
95
+ def cmd_write(args: argparse.Namespace) -> int:
96
+ content = Path(args.file).read_text(encoding="utf-8") if args.file else args.content
97
+ if not content:
98
+ raise SystemExit("give the text as an argument or with --file")
99
+ _print(_client(args).write(args.title, content, tags=args.tag or (), visibility=args.visibility,
100
+ source_refs=args.source or ()))
101
+ return 0
102
+
103
+
104
+ def cmd_read(args: argparse.Namespace) -> int:
105
+ _print(_client(args).read(args.memory_id))
106
+ return 0
107
+
108
+
109
+ def cmd_search(args: argparse.Namespace) -> int:
110
+ _print(_client(args).search(args.query, args.limit))
111
+ return 0
112
+
113
+
114
+ def build_parser() -> argparse.ArgumentParser:
115
+ parser = argparse.ArgumentParser(prog="attested-memory", description="Verifiable memory for AI agents.")
116
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
117
+ parser.add_argument("--url", default=os.environ.get("ATTESTED_MEMORY_URL", DEFAULT_BASE_URL))
118
+ sub = parser.add_subparsers(dest="command", required=True)
119
+ sub.add_parser("init", help="create your actor key").set_defaults(func=cmd_init)
120
+ sub.add_parser("whoami", help="show your actor id and key").set_defaults(func=cmd_whoami)
121
+ trial = sub.add_parser("trial", help="claim the free trial key for your actor")
122
+ trial.add_argument("--product", default="personal", choices=["personal", "team", "expert-market"])
123
+ trial.set_defaults(func=cmd_trial)
124
+ write = sub.add_parser("write", help="store a memory")
125
+ write.add_argument("title")
126
+ write.add_argument("content", nargs="?")
127
+ write.add_argument("--file")
128
+ write.add_argument("--tag", action="append")
129
+ write.add_argument("--source", action="append", help="a source reference (URL, doc id)")
130
+ write.add_argument("--visibility", default="private", choices=["private", "shared", "public", "paid"])
131
+ write.set_defaults(func=cmd_write)
132
+ read = sub.add_parser("read", help="read a memory (content checked against its hash)")
133
+ read.add_argument("memory_id")
134
+ read.set_defaults(func=cmd_read)
135
+ search = sub.add_parser("search", help="search memories you can see")
136
+ search.add_argument("query", nargs="?", default="")
137
+ search.add_argument("--limit", type=int, default=30)
138
+ search.set_defaults(func=cmd_search)
139
+ return parser
140
+
141
+
142
+ def main(argv: list[str] | None = None) -> int:
143
+ args = build_parser().parse_args(argv)
144
+ try:
145
+ return args.func(args)
146
+ except AttestedMemoryError as error:
147
+ print(f"attested-memory: {error}", file=sys.stderr)
148
+ return 1
149
+
150
+
151
+ if __name__ == "__main__":
152
+ raise SystemExit(main())
@@ -0,0 +1,119 @@
1
+ """HTTP client for the public Attested Memory API (Personal Memory product).
2
+
3
+ actor = Actor.generate()
4
+ client = Client(actor)
5
+ client.start_trial() # 7-day personal key, bound to this actor
6
+ unit = client.write("Supplier terms", "Net 30, EUR, signed 2026-09-01", tags=["finance"])
7
+ client.read(unit["id"]) # content checked against its content_hash
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from typing import Any, Iterable
18
+
19
+ from . import __version__
20
+ from .actor import Actor
21
+
22
+ DEFAULT_BASE_URL = "https://attestedmemory.net"
23
+
24
+
25
+ class AttestedMemoryError(Exception):
26
+ """The service refused or failed a request; ``status`` is the HTTP code (0 = network)."""
27
+
28
+ def __init__(self, status: int, detail: str) -> None:
29
+ super().__init__(f"HTTP {status}: {detail}" if status else detail)
30
+ self.status = status
31
+ self.detail = detail
32
+
33
+
34
+ class IntegrityError(AttestedMemoryError):
35
+ """A memory's content does not hash to the content_hash the service reported."""
36
+
37
+
38
+ def content_hash(content: str) -> str:
39
+ """The Memory Market's hash of a memory's text: ``sha256:`` + hex."""
40
+ return "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
41
+
42
+
43
+ def check_integrity(unit: dict[str, Any]) -> dict[str, Any]:
44
+ """Raise IntegrityError unless ``unit['content']`` hashes to ``unit['content_hash']``.
45
+
46
+ This is checked here, on the client, not taken from the service. The truth and
47
+ provenance states in the same object are what the hub reports.
48
+ """
49
+ if "content" in unit and unit.get("content_hash") != content_hash(unit["content"]):
50
+ raise IntegrityError(0, f"memory {unit.get('id')}: content does not match its content_hash")
51
+ return unit
52
+
53
+
54
+ class Client:
55
+ def __init__(self, actor: Actor, api_key: str | None = None, *,
56
+ base_url: str = DEFAULT_BASE_URL, timeout: float = 20.0) -> None:
57
+ self.actor = actor
58
+ self.api_key = api_key
59
+ self.base_url = base_url.rstrip("/")
60
+ self.timeout = timeout
61
+
62
+ # --- account -------------------------------------------------------------------------
63
+ def start_trial(self, product: str = "personal") -> dict[str, Any]:
64
+ """Claim the free trial key for this actor (one per actor and product) and keep it."""
65
+ result = self._request("POST", "/api/v1/trials", {"product": product}, need_key=False)
66
+ self.api_key = result.get("api_key") or self.api_key
67
+ return result
68
+
69
+ def key_status(self, product: str = "personal") -> dict[str, Any]:
70
+ return self._request("GET", "/api/v1/keys/me?" + urllib.parse.urlencode({"product": product}))
71
+
72
+ # --- memory --------------------------------------------------------------------------
73
+ def write(self, title: str, content: str, *, tags: Iterable[str] = (), visibility: str = "private",
74
+ source_refs: Iterable[str] = (), parent_memory_ids: Iterable[str] = (),
75
+ price_usdc: str | None = None) -> dict[str, Any]:
76
+ body = {"title": title, "content": content, "tags": list(tags), "visibility": visibility,
77
+ "source_refs": list(source_refs), "parent_memory_ids": list(parent_memory_ids),
78
+ "price_usdc": price_usdc}
79
+ return check_integrity(self._request("POST", "/memory/api/memories", body))
80
+
81
+ def read(self, memory_id: str) -> dict[str, Any]:
82
+ path = "/memory/api/memories/" + urllib.parse.quote(memory_id, safe="")
83
+ return check_integrity(self._request("GET", path))
84
+
85
+ def search(self, query: str = "", limit: int = 30) -> dict[str, Any]:
86
+ params = urllib.parse.urlencode({"q": query, "limit": max(1, min(int(limit), 100))})
87
+ return self._request("GET", "/memory/api/search?" + params)
88
+
89
+ # --- transport -----------------------------------------------------------------------
90
+ def _request(self, method: str, path: str, body: dict[str, Any] | None = None, *,
91
+ need_key: bool = True) -> Any:
92
+ headers = {"Accept": "application/json", "User-Agent": f"attested-memory-python/{__version__}",
93
+ **self.actor.proof_headers()}
94
+ if need_key:
95
+ if not self.api_key:
96
+ raise AttestedMemoryError(0, "no API key: call start_trial() or pass api_key")
97
+ headers["X-SaaS-Key"] = self.api_key
98
+ data = None
99
+ if body is not None:
100
+ data = json.dumps(body).encode("utf-8")
101
+ headers["Content-Type"] = "application/json"
102
+ request = urllib.request.Request(self.base_url + path, data=data, method=method, headers=headers)
103
+ try:
104
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
105
+ raw = response.read()
106
+ except urllib.error.HTTPError as error:
107
+ raise AttestedMemoryError(error.code, _detail(error.read())) from None
108
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
109
+ raise AttestedMemoryError(0, f"cannot reach {self.base_url}: {error}") from None
110
+ return json.loads(raw) if raw else {}
111
+
112
+
113
+ def _detail(raw: bytes) -> str:
114
+ try:
115
+ payload = json.loads(raw)
116
+ except ValueError:
117
+ return raw.decode("utf-8", "replace")[:300]
118
+ detail = payload.get("detail") if isinstance(payload, dict) else None
119
+ return str(detail if detail is not None else payload)[:300]
@@ -0,0 +1,57 @@
1
+ """The client's actor proof against the Memory Market's own verifier, not a copy of it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+
9
+ import pytest
10
+
11
+ HUB_SRC = Path(__file__).resolve().parents[3] / "memory-market" / "src"
12
+ sys.path.insert(0, str(HUB_SRC))
13
+ pytest.importorskip("fastapi")
14
+ app = pytest.importorskip("memory_market.app")
15
+ from fastapi import HTTPException # noqa: E402
16
+
17
+ from attested_memory import Actor # noqa: E402
18
+
19
+ SETTINGS = SimpleNamespace(api_key="test-key")
20
+
21
+
22
+ def verify(headers: dict[str, str]) -> str:
23
+ return app._production_actor(
24
+ headers["X-Actor-ID"], "Bearer test-key", headers["X-Actor-Signature"],
25
+ headers["X-Actor-Public-Key"], SETTINGS, headers["X-Actor-Timestamp"], headers["X-Actor-Nonce"],
26
+ )
27
+
28
+
29
+ def test_the_server_accepts_the_clients_proof():
30
+ actor = Actor.generate()
31
+ assert verify(actor.proof_headers()) == actor.actor_id
32
+ assert len(actor.actor_id) == 74 and actor.actor_id.startswith("did:actor:")
33
+
34
+
35
+ def test_a_proof_is_good_for_one_request():
36
+ headers = Actor.generate().proof_headers()
37
+ verify(headers)
38
+ with pytest.raises(HTTPException, match="already used"):
39
+ verify(headers)
40
+
41
+
42
+ def test_every_request_gets_a_fresh_nonce():
43
+ actor = Actor.generate()
44
+ assert actor.proof_headers()["X-Actor-Nonce"] != actor.proof_headers()["X-Actor-Nonce"]
45
+
46
+
47
+ def test_someone_elses_id_with_my_key_is_refused():
48
+ mine, theirs = Actor.generate(), Actor.generate()
49
+ headers = {**mine.proof_headers(), "X-Actor-ID": theirs.actor_id}
50
+ with pytest.raises(HTTPException):
51
+ verify(headers)
52
+
53
+
54
+ def test_a_stale_proof_is_refused():
55
+ import time
56
+ with pytest.raises(HTTPException, match="stale"):
57
+ verify(Actor.generate().proof_headers(now=time.time() - 3600))
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+
9
+ import pytest
10
+
11
+ from attested_memory import Actor, AttestedMemoryError, Client, IntegrityError, content_hash
12
+ from attested_memory.cli import main
13
+
14
+
15
+ class Fake(BaseHTTPRequestHandler):
16
+ """A stand-in for attestedmemory.net: records requests, answers like the real routes."""
17
+
18
+ seen: list[dict] = []
19
+ tamper = False
20
+
21
+ def log_message(self, *args): # silence
22
+ pass
23
+
24
+ def _reply(self, code: int, body: dict) -> None:
25
+ raw = json.dumps(body).encode()
26
+ self.send_response(code)
27
+ self.send_header("Content-Type", "application/json")
28
+ self.send_header("Content-Length", str(len(raw)))
29
+ self.end_headers()
30
+ self.wfile.write(raw)
31
+
32
+ def _record(self) -> dict:
33
+ length = int(self.headers.get("Content-Length") or 0)
34
+ body = json.loads(self.rfile.read(length)) if length else None
35
+ entry = {"method": self.command, "path": self.path,
36
+ "headers": {k.lower(): v for k, v in self.headers.items()}, "body": body} # HTTP is case-blind
37
+ Fake.seen.append(entry)
38
+ return entry
39
+
40
+ def do_POST(self):
41
+ entry = self._record()
42
+ if self.path == "/api/v1/trials":
43
+ return self._reply(201, {"api_key": "ask_trial", "plan": "personal.trial.7d", "product": "personal",
44
+ "expires_at": "2026-10-16T00:00:00Z", "key_id": "key_1"})
45
+ if self.path == "/memory/api/memories":
46
+ if entry["headers"].get("x-saas-key") != "ask_trial":
47
+ return self._reply(401, {"detail": "SaaS API key is invalid or expired"})
48
+ content = entry["body"]["content"]
49
+ stored = "tampered" if Fake.tamper else content
50
+ return self._reply(201, {"id": "mem_1", "title": entry["body"]["title"], "content": stored,
51
+ "content_hash": content_hash(content),
52
+ "truth": {"status": "unverified"}, "provenance": {"status": "attested"}})
53
+ self._reply(404, {"detail": "not found"})
54
+
55
+ def do_GET(self):
56
+ self._record()
57
+ if self.path.startswith("/memory/api/search"):
58
+ return self._reply(200, {"items": [], "total": 0, "query": "x"})
59
+ if self.path == "/memory/api/memories/mem_1":
60
+ return self._reply(200, {"id": "mem_1", "content": "hello", "content_hash": content_hash("hello")})
61
+ self._reply(404, {"detail": "not found"})
62
+
63
+
64
+ @pytest.fixture()
65
+ def server():
66
+ Fake.seen, Fake.tamper = [], False
67
+ httpd = HTTPServer(("127.0.0.1", 0), Fake)
68
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
69
+ thread.start()
70
+ yield f"http://127.0.0.1:{httpd.server_port}"
71
+ httpd.shutdown()
72
+
73
+
74
+ def test_trial_then_write_read_search(server):
75
+ client = Client(Actor.generate(), base_url=server)
76
+ assert client.start_trial()["plan"] == "personal.trial.7d" and client.api_key == "ask_trial"
77
+ unit = client.write("Supplier terms", "Net 30", tags=["finance"], source_refs=["contract-17"])
78
+ assert unit["id"] == "mem_1" and client.read("mem_1")["content"] == "hello"
79
+ assert client.search("supplier", limit=500)["total"] == 0
80
+ assert "limit=100" in Fake.seen[-1]["path"] # clamped like the server
81
+ nonces = [s["headers"]["x-actor-nonce"] for s in Fake.seen]
82
+ assert len(set(nonces)) == len(nonces), "a proof was reused"
83
+ assert Fake.seen[1]["body"]["source_refs"] == ["contract-17"]
84
+ assert "x-saas-key" not in Fake.seen[0]["headers"] # the trial is claimed by the actor alone
85
+
86
+
87
+ def test_a_memory_whose_content_does_not_match_its_hash_is_refused(server):
88
+ Fake.tamper = True
89
+ client = Client(Actor.generate(), "ask_trial", base_url=server)
90
+ with pytest.raises(IntegrityError):
91
+ client.write("t", "original")
92
+
93
+
94
+ def test_errors_carry_the_status_and_the_services_reason(server):
95
+ client = Client(Actor.generate(), "wrong", base_url=server)
96
+ with pytest.raises(AttestedMemoryError) as caught:
97
+ client.write("t", "x")
98
+ assert caught.value.status == 401 and "invalid or expired" in caught.value.detail
99
+ with pytest.raises(AttestedMemoryError, match="no API key"):
100
+ Client(Actor.generate(), base_url=server).search()
101
+
102
+
103
+ def test_the_key_file_is_private_and_never_overwritten(tmp_path):
104
+ actor = Actor.generate()
105
+ path = actor.save(tmp_path / "actor.json")
106
+ assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
107
+ assert Actor.load(path).actor_id == actor.actor_id
108
+ with pytest.raises(FileExistsError):
109
+ Actor.generate().save(path)
110
+
111
+
112
+ def test_cli_init_trial_write(server, tmp_path, monkeypatch, capsys):
113
+ monkeypatch.setenv("ATTESTED_MEMORY_HOME", str(tmp_path))
114
+ monkeypatch.delenv("ATTESTED_MEMORY_API_KEY", raising=False)
115
+ assert main(["--url", server, "init"]) == 0
116
+ assert main(["--url", server, "trial"]) == 0
117
+ assert stat.S_IMODE(os.stat(tmp_path / "key.json").st_mode) == 0o600
118
+ assert main(["--url", server, "write", "Title", "Body", "--tag", "a"]) == 0
119
+ assert '"id": "mem_1"' in capsys.readouterr().out