memwal 0.2.1.dev0__py3-none-any.whl → 0.2.2.dev1__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.
memwal/__init__.py CHANGED
@@ -128,4 +128,4 @@ __all__ = [
128
128
  "RecallManualResult",
129
129
  ]
130
130
 
131
- __version__ = "0.2.1.dev0"
131
+ __version__ = "0.2.2.dev1"
memwal/client.py CHANGED
@@ -142,6 +142,24 @@ def _reject_blank_text(text: object, message: str = "Text cannot be empty") -> N
142
142
  raise ValueError(message)
143
143
 
144
144
 
145
+ def _require(data: object, field: str, endpoint: str) -> Any:
146
+ """``data[field]`` from a parsed 2xx body, or a ``MemWalError`` naming both.
147
+
148
+ A bare ``KeyError`` is not a ``MemWalError``, carries only the field name,
149
+ and is raised far from the response that caused it (#1250). Optional
150
+ fields keep using ``.get()`` with a documented default.
151
+ """
152
+
153
+ if not isinstance(data, dict):
154
+ raise MemWalError(
155
+ f"{endpoint} response was {type(data).__name__}, expected an object "
156
+ f"with {field!r}"
157
+ )
158
+ if field not in data:
159
+ raise MemWalError(f"{endpoint} response missing {field!r}")
160
+ return data[field]
161
+
162
+
145
163
  IDEMPOTENCY_BUCKET_MS = 30 * 60 * 1000
146
164
 
147
165
 
@@ -437,6 +455,12 @@ def _check_vector(vector: Any) -> None:
437
455
  ):
438
456
  raise ValueError("vector must be a non-empty list of finite numbers")
439
457
 
458
+ def _destroyed_client_error() -> "MemWalError":
459
+ return MemWalError(
460
+ "Walrus Memory client was destroyed; its keys are zeroed. Create a new one."
461
+ )
462
+
463
+
440
464
  class MemWal:
441
465
  """Async-native Walrus Memory client.
442
466
 
@@ -445,8 +469,13 @@ class MemWal:
445
469
  """
446
470
 
447
471
  def __init__(self, config: MemWalConfig) -> None:
448
- self._private_key_hex = normalize_private_key(config.key)
449
- self._signing_key = build_signing_key(self._private_key_hex)
472
+ self._destroyed: bool = False
473
+ self._private_key_hex: str = normalize_private_key(config.key)
474
+ signing_key = build_signing_key(self._private_key_hex)
475
+ self._signing_key: Optional[nacl.signing.SigningKey] = signing_key
476
+ # Mutable copy of the 32-byte seed so destroy() can zero-fill it in
477
+ # place without mutating any caller-supplied bytearray (#975).
478
+ self._private_key_bytes: bytearray = bytearray(bytes(signing_key))
450
479
  self._account_id = config.account_id
451
480
  self._server_url = normalize_server_url(config.server_url)
452
481
  self._namespace = config.namespace
@@ -473,7 +502,7 @@ class MemWal:
473
502
  @classmethod
474
503
  def create(
475
504
  cls,
476
- key: str,
505
+ key: Union[str, bytes, bytearray],
477
506
  account_id: str,
478
507
  server_url: Optional[str] = None,
479
508
  namespace: str = "default",
@@ -482,7 +511,10 @@ class MemWal:
482
511
  """Create a new Walrus Memory client instance.
483
512
 
484
513
  Args:
485
- key: Ed25519 private key hex string (the delegate key).
514
+ key: Ed25519 private key (hex string, ``suiprivkey1...``, or 32-byte
515
+ ``bytes`` / ``bytearray``). When passed as ``bytearray``, the
516
+ bytes are copied into an internal buffer so :meth:`destroy`
517
+ zeroes only the client's copy and leaves the caller's buffer intact.
486
518
  account_id: Walrus Memory account object ID on Sui.
487
519
  server_url: Server URL (default: ``https://relayer.memory.walrus.xyz``).
488
520
  namespace: Default namespace for memory isolation (default: ``"default"``).
@@ -510,8 +542,65 @@ class MemWal:
510
542
  self._client = httpx.AsyncClient(timeout=60.0)
511
543
  return self._client
512
544
 
513
- async def close(self) -> None:
514
- """Close the underlying HTTP client."""
545
+ def destroy(self) -> None:
546
+ """Wipe internal key buffers, drop signing credentials, and invalidate the client.
547
+
548
+ Provides parity with the TypeScript SDK's ``destroy()`` method (#975):
549
+
550
+ - Zeroes ``_private_key_bytes`` (the client's internal ``bytearray`` seed
551
+ copy) in place.
552
+ - Clears ``_private_key_hex``, drops the ``_signing_key`` reference, and
553
+ clears cached SEAL session tokens, server config, owner address, and
554
+ pending idempotency keys.
555
+ - Marks the client as destroyed so any subsequent or in-flight operation
556
+ raises :class:`MemWalError`.
557
+
558
+ Note on CPython memory semantics:
559
+ Python ``str`` and ``bytes`` objects (and PyNaCl's internal C-level
560
+ ``SigningKey`` buffers) are immutable in CPython and cannot be zeroed
561
+ in place from pure Python. ``destroy()`` zeroes the client's mutable
562
+ ``bytearray`` seed copy in place and drops all references to immutable
563
+ key objects so they become eligible for garbage collection once the
564
+ caller also releases its references. Callers that want to minimize
565
+ long-lived plaintext ``str`` keys in memory should pass ``key`` as a
566
+ 32-byte ``bytearray`` and zero their own buffer after ``create()``.
567
+ """
568
+ self._destroyed = True
569
+ if self._private_key_bytes:
570
+ self._private_key_bytes[:] = b"\x00" * len(self._private_key_bytes)
571
+ self._private_key_hex = ""
572
+ self._signing_key = None
573
+ self._session_cache = None
574
+ self._server_config = None
575
+ self._relayer_version_metadata = None
576
+ self._owner_address = None
577
+ self._pending_remember_keys.clear()
578
+ for attr in ("_session_build_task", "_owner_task"):
579
+ task = getattr(self, attr, None)
580
+ setattr(self, attr, None)
581
+ if task is not None and not task.done():
582
+ task.cancel()
583
+
584
+ async def close(self, clear_keys: bool = False) -> None:
585
+ """Close the underlying HTTP client and cancel any in-flight initialization tasks.
586
+
587
+ Args:
588
+ clear_keys: If ``True``, also call :meth:`destroy` to wipe internal
589
+ key buffers, drop signing credentials, and invalidate the client.
590
+ """
591
+ for attr in ("_session_build_task", "_owner_task"):
592
+ task = getattr(self, attr, None)
593
+ setattr(self, attr, None)
594
+ if task is not None and not task.done():
595
+ task.cancel()
596
+ try:
597
+ await task
598
+ except (asyncio.CancelledError, Exception):
599
+ # Ignore cancellation or transport errors from in-flight
600
+ # session tasks during teardown.
601
+ pass
602
+ if clear_keys:
603
+ self.destroy()
515
604
  if self._client is not None and not self._client.is_closed:
516
605
  await self._client.aclose()
517
606
  self._client = None
@@ -1118,9 +1207,9 @@ class MemWal:
1118
1207
  data = await self._signed_request("POST", "/api/recall", body)
1119
1208
  memories = [
1120
1209
  RecallMemory(
1121
- blob_id=m["blob_id"],
1122
- text=m["text"],
1123
- distance=m["distance"],
1210
+ blob_id=_require(m, "blob_id", "POST /api/recall"),
1211
+ text=_require(m, "text", "POST /api/recall"),
1212
+ distance=_require(m, "distance", "POST /api/recall"),
1124
1213
  score=m.get("score"),
1125
1214
  created_at=m.get("created_at"),
1126
1215
  )
@@ -1288,12 +1377,16 @@ class MemWal:
1288
1377
  "namespace": self._resolve_namespace(namespace),
1289
1378
  })
1290
1379
  memories = [
1291
- AskMemory(blob_id=m["blob_id"], text=m["text"], distance=m["distance"])
1380
+ AskMemory(
1381
+ blob_id=_require(m, "blob_id", "POST /api/ask"),
1382
+ text=_require(m, "text", "POST /api/ask"),
1383
+ distance=_require(m, "distance", "POST /api/ask"),
1384
+ )
1292
1385
  for m in data.get("memories", [])
1293
1386
  ]
1294
1387
  return AskResult(
1295
- answer=data["answer"],
1296
- memories_used=data["memories_used"],
1388
+ answer=_require(data, "answer", "POST /api/ask"),
1389
+ memories_used=_require(data, "memories_used", "POST /api/ask"),
1297
1390
  memories=memories,
1298
1391
  )
1299
1392
 
@@ -1353,12 +1446,13 @@ class MemWal:
1353
1446
  "namespace": namespace,
1354
1447
  "limit": limit,
1355
1448
  })
1449
+ endpoint = "POST /api/restore"
1356
1450
  return RestoreResult(
1357
- restored=data["restored"],
1358
- skipped=data["skipped"],
1359
- total=data["total"],
1360
- namespace=data["namespace"],
1361
- owner=data["owner"],
1451
+ restored=_require(data, "restored", endpoint),
1452
+ skipped=_require(data, "skipped", endpoint),
1453
+ total=_require(data, "total", endpoint),
1454
+ namespace=_require(data, "namespace", endpoint),
1455
+ owner=_require(data, "owner", endpoint),
1362
1456
  # Relayers older than WALM-319 omit `truncated` entirely --
1363
1457
  # treat "not present" as "not known to be truncated" rather
1364
1458
  # than require every relayer version to send it.
@@ -1421,20 +1515,21 @@ class MemWal:
1421
1515
  # against `path_and_query`, not `path`.
1422
1516
  path = f"/v1/owners/{owner}/namespaces" + (f"?{query}" if query else "")
1423
1517
  data = await self._signed_request("GET", path, {}, include_seal_session=False)
1518
+ endpoint = "GET /v1/owners/{owner}/namespaces"
1424
1519
  return NamespacesResult(
1425
1520
  namespaces=[
1426
1521
  NamespaceSummary(
1427
- id=ns["id"],
1428
- name=ns["name"],
1429
- memory_count=ns["memory_count"],
1430
- storage_used=ns["storage_used"],
1431
- updated_at=ns["updated_at"],
1522
+ id=_require(ns, "id", endpoint),
1523
+ name=_require(ns, "name", endpoint),
1524
+ memory_count=_require(ns, "memory_count", endpoint),
1525
+ storage_used=_require(ns, "storage_used", endpoint),
1526
+ updated_at=_require(ns, "updated_at", endpoint),
1432
1527
  )
1433
- for ns in data["namespaces"]
1528
+ for ns in _require(data, "namespaces", endpoint)
1434
1529
  ],
1435
1530
  next_cursor=data.get("next_cursor"),
1436
- has_more=data["has_more"],
1437
- snapshot_version=data["snapshot_version"],
1531
+ has_more=_require(data, "has_more", endpoint),
1532
+ snapshot_version=_require(data, "snapshot_version", endpoint),
1438
1533
  )
1439
1534
 
1440
1535
  async def health(self) -> HealthResult:
@@ -1443,13 +1538,17 @@ class MemWal:
1443
1538
  Returns:
1444
1539
  :class:`HealthResult` with status and version.
1445
1540
  """
1541
+ if self._destroyed:
1542
+ raise _destroyed_client_error()
1446
1543
  response = await self._http.get(f"{self._server_url}/health")
1544
+ if self._destroyed:
1545
+ raise _destroyed_client_error()
1447
1546
  if response.status_code != 200:
1448
1547
  raise MemWalError(f"Health check failed: {response.status_code}")
1449
1548
  data = response.json()
1450
1549
  return HealthResult(
1451
- status=data["status"],
1452
- version=data["version"],
1550
+ status=_require(data, "status", "GET /health"),
1551
+ version=_require(data, "version", "GET /health"),
1453
1552
  relayer_version=data.get("relayerVersion"),
1454
1553
  api_version=data.get("apiVersion"),
1455
1554
  min_supported_sdk=data.get("minSupportedSdk"),
@@ -1462,7 +1561,8 @@ class MemWal:
1462
1561
 
1463
1562
  async def compatibility(self) -> Dict[str, Any]:
1464
1563
  """Fetch and validate the relayer compatibility contract."""
1465
-
1564
+ if self._destroyed:
1565
+ raise _destroyed_client_error()
1466
1566
  return await self._ensure_compatible_relayer()
1467
1567
 
1468
1568
  # ============================================================
@@ -1493,11 +1593,12 @@ class MemWal:
1493
1593
  },
1494
1594
  include_seal_session=False,
1495
1595
  )
1596
+ endpoint = "POST /api/remember/manual"
1496
1597
  return RememberManualResult(
1497
- id=data["id"],
1498
- blob_id=data["blob_id"],
1499
- owner=data["owner"],
1500
- namespace=data["namespace"],
1598
+ id=_require(data, "id", endpoint),
1599
+ blob_id=_require(data, "blob_id", endpoint),
1600
+ owner=_require(data, "owner", endpoint),
1601
+ namespace=_require(data, "namespace", endpoint),
1501
1602
  )
1502
1603
 
1503
1604
  async def recall_manual(self, opts: RecallManualOptions) -> RecallManualResult:
@@ -1545,6 +1646,8 @@ class MemWal:
1545
1646
  Returns:
1546
1647
  Public key hex string.
1547
1648
  """
1649
+ if self._destroyed or self._signing_key is None:
1650
+ raise _destroyed_client_error()
1548
1651
  return bytes_to_hex(bytes(self._signing_key.verify_key))
1549
1652
 
1550
1653
  # ============================================================
@@ -1552,6 +1655,8 @@ class MemWal:
1552
1655
  # ============================================================
1553
1656
 
1554
1657
  async def _ensure_compatible_relayer(self) -> Dict[str, Any]:
1658
+ if self._destroyed:
1659
+ raise _destroyed_client_error()
1555
1660
  if self._relayer_version_metadata is not None:
1556
1661
  return self._relayer_version_metadata
1557
1662
 
@@ -1559,14 +1664,20 @@ class MemWal:
1559
1664
  self._compatibility_lock = asyncio.Lock()
1560
1665
 
1561
1666
  async with self._compatibility_lock:
1667
+ if self._destroyed:
1668
+ raise _destroyed_client_error()
1562
1669
  if self._relayer_version_metadata is not None:
1563
1670
  return self._relayer_version_metadata
1564
1671
 
1565
1672
  version_response = await self._http.get(f"{self._server_url}/version")
1673
+ if self._destroyed:
1674
+ raise _destroyed_client_error()
1566
1675
  if version_response.status_code == 200:
1567
1676
  metadata = version_response.json()
1568
1677
  elif version_response.status_code in (404, 405):
1569
1678
  health_response = await self._http.get(f"{self._server_url}/health")
1679
+ if self._destroyed:
1680
+ raise _destroyed_client_error()
1570
1681
  if health_response.status_code != 200:
1571
1682
  raise MemWalError(
1572
1683
  "Walrus Memory compatibility check failed: "
@@ -1684,10 +1795,16 @@ class MemWal:
1684
1795
  )
1685
1796
 
1686
1797
  async def _build_seal_session_inner(self) -> str:
1798
+ if self._destroyed or self._signing_key is None:
1799
+ raise _destroyed_client_error()
1687
1800
  cfg = await self._fetch_server_config()
1801
+ if self._destroyed or self._signing_key is None:
1802
+ raise _destroyed_client_error()
1688
1803
  await self._assert_first_package_version(
1689
1804
  cfg["network"], cfg["suiRpcUrl"], cfg["packageId"]
1690
1805
  )
1806
+ if self._destroyed or self._signing_key is None:
1807
+ raise _destroyed_client_error()
1691
1808
 
1692
1809
  session_signing_key = nacl.signing.SigningKey.generate()
1693
1810
  session_public_key = bytes(session_signing_key.verify_key)
@@ -1716,6 +1833,8 @@ class MemWal:
1716
1833
  separators=(",", ":"),
1717
1834
  )
1718
1835
  session_bytes = base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
1836
+ if self._destroyed:
1837
+ raise _destroyed_client_error()
1719
1838
  self._session_cache = (
1720
1839
  session_bytes,
1721
1840
  int(time.time() * 1000) + SEAL_SESSION_TTL_MIN * 60_000 - SEAL_SESSION_SAFETY_MARGIN_MS,
@@ -1723,22 +1842,40 @@ class MemWal:
1723
1842
  return session_bytes
1724
1843
 
1725
1844
  async def _build_seal_session(self) -> str:
1845
+ if self._destroyed:
1846
+ raise _destroyed_client_error()
1726
1847
  now_ms = int(time.time() * 1000)
1727
1848
  if self._session_cache is not None:
1728
1849
  cached_bytes, expires_at_ms = self._session_cache
1729
1850
  if now_ms < expires_at_ms:
1730
1851
  return cached_bytes
1731
1852
 
1732
- if self._session_build_task is not None:
1733
- return await self._session_build_task
1853
+ task = self._session_build_task
1854
+ if task is None or task.done():
1855
+ task = asyncio.create_task(self._build_seal_session_inner())
1856
+ self._session_build_task = task
1857
+
1858
+ def _on_session_build_done(done_task: "asyncio.Task[str]") -> None:
1859
+ if self._session_build_task is done_task:
1860
+ self._session_build_task = None
1861
+ if not done_task.cancelled():
1862
+ done_task.exception()
1863
+
1864
+ task.add_done_callback(_on_session_build_done)
1734
1865
 
1735
- self._session_build_task = asyncio.create_task(self._build_seal_session_inner())
1736
1866
  try:
1737
- return await self._session_build_task
1738
- finally:
1739
- self._session_build_task = None
1867
+ result = await asyncio.shield(task)
1868
+ except asyncio.CancelledError:
1869
+ if self._destroyed:
1870
+ raise _destroyed_client_error() from None
1871
+ raise
1872
+ if self._destroyed:
1873
+ raise _destroyed_client_error()
1874
+ return result
1740
1875
 
1741
1876
  async def _resolve_owner_inner(self) -> str:
1877
+ if self._destroyed:
1878
+ raise _destroyed_client_error()
1742
1879
  # POST /api/stats authenticates with the same delegate scheme and
1743
1880
  # returns the owner the relayer resolved from our key. Same approach
1744
1881
  # as the TypeScript SDK's resolveOwner().
@@ -1748,6 +1885,8 @@ class MemWal:
1748
1885
  {"namespace": self._namespace},
1749
1886
  include_seal_session=False,
1750
1887
  )
1888
+ if self._destroyed:
1889
+ raise _destroyed_client_error()
1751
1890
  owner = data.get("owner")
1752
1891
  if not owner:
1753
1892
  raise MemWalError(
@@ -1763,17 +1902,33 @@ class MemWal:
1763
1902
  The owner-scoped read routes take the address in the path, but the
1764
1903
  client is configured with only a delegate key and account id.
1765
1904
  """
1905
+ if self._destroyed:
1906
+ raise _destroyed_client_error()
1766
1907
  if self._owner_address is not None:
1767
1908
  return self._owner_address
1768
1909
 
1769
- if self._owner_task is not None:
1770
- return await self._owner_task
1910
+ task = self._owner_task
1911
+ if task is None or task.done():
1912
+ task = asyncio.create_task(self._resolve_owner_inner())
1913
+ self._owner_task = task
1914
+
1915
+ def _on_owner_done(done_task: "asyncio.Task[str]") -> None:
1916
+ if self._owner_task is done_task:
1917
+ self._owner_task = None
1918
+ if not done_task.cancelled():
1919
+ done_task.exception()
1920
+
1921
+ task.add_done_callback(_on_owner_done)
1771
1922
 
1772
- self._owner_task = asyncio.create_task(self._resolve_owner_inner())
1773
1923
  try:
1774
- return await self._owner_task
1775
- finally:
1776
- self._owner_task = None
1924
+ result = await asyncio.shield(task)
1925
+ except asyncio.CancelledError:
1926
+ if self._destroyed:
1927
+ raise _destroyed_client_error() from None
1928
+ raise
1929
+ if self._destroyed:
1930
+ raise _destroyed_client_error()
1931
+ return result
1777
1932
 
1778
1933
  async def _signed_request(
1779
1934
  self,
@@ -1803,8 +1958,12 @@ class MemWal:
1803
1958
  - ``x-account-id``: Walrus Memory account object ID
1804
1959
  - ``Content-Type``: application/json
1805
1960
  """
1961
+ if self._destroyed or self._signing_key is None:
1962
+ raise _destroyed_client_error()
1806
1963
 
1807
1964
  await self._ensure_compatible_relayer()
1965
+ if self._destroyed or self._signing_key is None:
1966
+ raise _destroyed_client_error()
1808
1967
 
1809
1968
  method_upper = method.upper()
1810
1969
  timestamp = str(int(time.time()))
@@ -1838,6 +1997,8 @@ class MemWal:
1838
1997
  }
1839
1998
  if include_seal_session:
1840
1999
  headers["x-seal-session"] = await self._build_seal_session()
2000
+ if self._destroyed or self._signing_key is None:
2001
+ raise _destroyed_client_error()
1841
2002
 
1842
2003
  response = await self._http.request(
1843
2004
  method=method_upper,
@@ -1846,6 +2007,8 @@ class MemWal:
1846
2007
  content=None if method_upper == "GET" else body_str,
1847
2008
  timeout=timeout,
1848
2009
  )
2010
+ if self._destroyed or self._signing_key is None:
2011
+ raise _destroyed_client_error()
1849
2012
 
1850
2013
  if response.status_code not in accepted_statuses:
1851
2014
  err_text = response.text
@@ -2073,7 +2236,7 @@ class MemWalSync:
2073
2236
  @classmethod
2074
2237
  def create(
2075
2238
  cls,
2076
- key: str,
2239
+ key: Union[str, bytes, bytearray],
2077
2240
  account_id: str,
2078
2241
  server_url: Optional[str] = None,
2079
2242
  namespace: str = "default",
@@ -2278,9 +2441,18 @@ class MemWalSync:
2278
2441
  """Synchronous version of :meth:`MemWal.get_public_key_hex`."""
2279
2442
  return self._run(self._inner.get_public_key_hex())
2280
2443
 
2281
- def close(self) -> None:
2282
- """Close the underlying HTTP client."""
2283
- self._run(self._inner.close())
2444
+ def destroy(self) -> None:
2445
+ """Synchronous version of :meth:`MemWal.destroy`."""
2446
+ self._inner.destroy()
2447
+
2448
+ def close(self, clear_keys: bool = False) -> None:
2449
+ """Close the underlying HTTP client.
2450
+
2451
+ Args:
2452
+ clear_keys: If ``True``, also call :meth:`destroy` to wipe internal
2453
+ key buffers, drop signing credentials, and invalidate the client.
2454
+ """
2455
+ self._run(self._inner.close(clear_keys=clear_keys))
2284
2456
 
2285
2457
  def __enter__(self) -> "MemWalSync":
2286
2458
  return self
memwal/mock.py CHANGED
@@ -129,9 +129,13 @@ class MemWalMock:
129
129
  ) -> "MemWalMock":
130
130
  return cls(namespace, owner, initial_memories)
131
131
 
132
- async def close(self) -> None:
132
+ def destroy(self) -> None:
133
133
  """No-op for API parity with :class:`MemWal`."""
134
134
 
135
+ async def close(self, clear_keys: bool = False) -> None:
136
+ """No-op for API parity with :class:`MemWal`."""
137
+ del clear_keys
138
+
135
139
  async def __aenter__(self) -> "MemWalMock":
136
140
  return self
137
141
 
@@ -714,8 +718,11 @@ class MemWalMockSync:
714
718
  def clear(self, namespace: Optional[str] = None) -> int:
715
719
  return self._inner.clear(namespace)
716
720
 
717
- def close(self) -> None:
718
- self._run(self._inner.close())
721
+ def destroy(self) -> None:
722
+ self._inner.destroy()
723
+
724
+ def close(self, clear_keys: bool = False) -> None:
725
+ self._run(self._inner.close(clear_keys=clear_keys))
719
726
 
720
727
  def __enter__(self) -> "MemWalMockSync":
721
728
  return self
memwal/types.py CHANGED
@@ -9,7 +9,7 @@ the Walrus Memory Rust server (TEE).
9
9
  from __future__ import annotations
10
10
 
11
11
  from dataclasses import dataclass
12
- from typing import Any, Dict, List, Optional
12
+ from typing import Any, Dict, List, Optional, Union
13
13
 
14
14
  # ============================================================
15
15
  # Config
@@ -34,8 +34,11 @@ class MemWalConfig:
34
34
  """Configuration for creating a Walrus Memory client.
35
35
 
36
36
  Attributes:
37
- key: Ed25519 private key (hex string). This is the delegate key from the
38
- Walrus Memory dashboard.
37
+ key: Ed25519 private key (hex string, ``suiprivkey1...``, or 32-byte
38
+ ``bytes`` / ``bytearray``). This is the delegate key from the
39
+ Walrus Memory dashboard. When passed as ``bytearray``, the client
40
+ copies the bytes into its own internal buffer so :meth:`MemWal.destroy`
41
+ zeroes only the client's copy.
39
42
  account_id: Walrus Memory account object ID on Sui.
40
43
  server_url: Server URL. ``None`` means omitted. Any explicit URL,
41
44
  including the hosted default, wins over ``env``.
@@ -46,7 +49,7 @@ class MemWalConfig:
46
49
  > ``DEFAULT_SERVER_URL``.
47
50
  """
48
51
 
49
- key: str
52
+ key: Union[str, bytes, bytearray]
50
53
  account_id: str
51
54
  server_url: Optional[str] = None
52
55
  namespace: str = "default"
memwal/utils.py CHANGED
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import base64
11
11
  import hashlib
12
12
  from datetime import datetime, timezone
13
- from typing import Tuple
13
+ from typing import Tuple, Union
14
14
 
15
15
  import nacl.signing
16
16
 
@@ -51,7 +51,7 @@ def hex_to_bytes(hex_str: str) -> bytes:
51
51
  return bytes.fromhex(clean)
52
52
 
53
53
 
54
- def bytes_to_hex(b: bytes) -> str:
54
+ def bytes_to_hex(b: Union[bytes, bytearray]) -> str:
55
55
  """Convert bytes to a lowercase hex string (no ``0x`` prefix).
56
56
 
57
57
  Args:
@@ -75,11 +75,14 @@ def sha256_hex(data: str) -> str:
75
75
  return hashlib.sha256(data.encode("utf-8")).hexdigest()
76
76
 
77
77
 
78
- def build_signing_key(private_key_hex: str) -> nacl.signing.SigningKey:
79
- """Build a PyNaCl ``SigningKey`` from an Ed25519 private key hex string.
78
+ def build_signing_key(
79
+ private_key: Union[str, bytes, bytearray]
80
+ ) -> nacl.signing.SigningKey:
81
+ """Build a PyNaCl ``SigningKey`` from an Ed25519 private key hex string or 32-byte buffer.
80
82
 
81
83
  Args:
82
- private_key_hex: 32-byte Ed25519 seed as hex (64 hex chars), optionally ``0x``-prefixed.
84
+ private_key: 32-byte Ed25519 seed as hex (64 hex chars, optionally ``0x``-prefixed)
85
+ or raw 32-byte ``bytes`` / ``bytearray``.
83
86
 
84
87
  Returns:
85
88
  A ``nacl.signing.SigningKey`` instance.
@@ -87,7 +90,10 @@ def build_signing_key(private_key_hex: str) -> nacl.signing.SigningKey:
87
90
  Raises:
88
91
  ValueError: If the decoded seed is not exactly 32 bytes.
89
92
  """
90
- seed_bytes = hex_to_bytes(private_key_hex)
93
+ if isinstance(private_key, (bytes, bytearray)):
94
+ seed_bytes = bytes(private_key)
95
+ else:
96
+ seed_bytes = hex_to_bytes(private_key)
91
97
  if len(seed_bytes) != 32:
92
98
  raise ValueError(
93
99
  f"Ed25519 seed must be exactly 32 bytes, got {len(seed_bytes)}"
@@ -290,12 +296,19 @@ def decode_sui_private_key(encoded: str) -> bytes:
290
296
  return seed
291
297
 
292
298
 
293
- def normalize_private_key(key: str) -> str:
294
- """Accept either a hex seed or a Sui ``suiprivkey1...`` string, return hex.
299
+ def normalize_private_key(key: Union[str, bytes, bytearray]) -> str:
300
+ """Accept a hex seed, ``suiprivkey1...`` string, or 32-byte ``bytes``/``bytearray``; return hex.
295
301
 
296
- The TypeScript SDK takes both, and the dashboard hands out the bech32 form,
297
- so hex-only input would reject a key a user reasonably expects to work.
302
+ The TypeScript SDK accepts hex, ``suiprivkey1...``, and 32-byte ``Uint8Array``.
298
303
  """
304
+ if isinstance(key, (bytes, bytearray)):
305
+ if len(key) != 32:
306
+ raise ValueError(f"Ed25519 seed must be exactly 32 bytes, got {len(key)}")
307
+ return bytes_to_hex(key)
308
+ if not isinstance(key, str):
309
+ raise TypeError(
310
+ f"key must be str, bytes, or bytearray, got {type(key).__name__}"
311
+ )
299
312
  candidate = key.strip()
300
313
  if candidate.lower().startswith("suiprivkey1"):
301
314
  return bytes_to_hex(decode_sui_private_key(candidate))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.2.1.dev0
3
+ Version: 0.2.2.dev1
4
4
  Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
5
5
  Project-URL: Homepage, https://memory.walrus.xyz
6
6
  Project-URL: Documentation, https://memory.walrus.xyz
@@ -259,6 +259,16 @@ Create a new async client.
259
259
  | `await remember_manual(opts)` | Store encrypted payload + pre-computed vector |
260
260
  | `await recall_manual(opts)` | Search with pre-computed vector |
261
261
  | `await get_public_key_hex()` | Get Ed25519 public key |
262
+ | `destroy()` | Zero internal key seed buffer, drop signing credentials/session cache, and permanently invalidate the client |
263
+ | `await close(clear_keys=False)` | Close the underlying HTTP client (pass `clear_keys=True` to also run `destroy()`) |
264
+
265
+ ### Key Lifecycle & `destroy()`
266
+
267
+ For parity with the TypeScript SDK's `destroy()`, `MemWal` and `MemWalSync` expose `destroy()` and `close(clear_keys=True)`:
268
+
269
+ - `key` may be passed as a hex string, `suiprivkey1...` string, or a 32-byte `bytes` / `bytearray`. When passed as `bytearray`, the SDK copies the bytes into its own internal buffer so `destroy()` zeroes only the client's copy and leaves the caller's `bytearray` untouched.
270
+ - Calling `destroy()` (or `await client.close(clear_keys=True)` / `sync_client.close(clear_keys=True)`) zero-fills the client's internal `bytearray` seed copy in place, clears `_private_key_hex`, drops the `_signing_key` and cached SEAL session references, cancels any in-flight initialization tasks, and causes subsequent or in-flight calls to raise `MemWalError`.
271
+ - **CPython memory semantics**: Python `str` and `bytes` objects (and PyNaCl's internal C-level `SigningKey` buffers) are immutable in CPython and cannot be overwritten in place from pure Python. `destroy()` zeroes the mutable `bytearray` copy and drops all references to immutable key objects so they become eligible for garbage collection once the caller also releases its references. To minimize plaintext `str` copies in process memory, pass a 32-byte `bytearray` to `MemWal.create()` and zero your own buffer after creation.
262
272
 
263
273
  ## Authentication
264
274
 
@@ -0,0 +1,10 @@
1
+ memwal/__init__.py,sha256=MGoDtX1cNKJndeJGln3gaFCfk3ueuHt0Mfs-CF2JyyE,3142
2
+ memwal/client.py,sha256=6YExSuMZ9RGeIShakk8UxSXgVY8pKwDexExPgnqhH4I,96563
3
+ memwal/compatibility.py,sha256=FsKApCcHOFZeIeNOph3YydWVnZtMJdT0KCt0e6jylho,2799
4
+ memwal/middleware.py,sha256=z2xdbvaobBg4byJXnrX7amWdVYE4jfeO4t31DoDBv_o,38413
5
+ memwal/mock.py,sha256=cB-mu2yqycvMCVMmgqTTsIj_pGSkHufCyLNbfkPdHEE,24262
6
+ memwal/types.py,sha256=fPvxU4k14qmj577sMtGZyVaGhwXxORtyXL-TxD0P6LI,14002
7
+ memwal/utils.py,sha256=YyLuxPjKB0RksL1ZGPORpW7QrSID0vQQABq3L_64zKw,12596
8
+ memwal-0.2.2.dev1.dist-info/METADATA,sha256=XLsl3x9kG7nJN5MyuQ_hg7te9FZbu59qLwlbUyyQQEM,11923
9
+ memwal-0.2.2.dev1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ memwal-0.2.2.dev1.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- memwal/__init__.py,sha256=IAJWdWRnUVTD5bZN7EVWQ6Q511j27npZgD5aG_DMltk,3142
2
- memwal/client.py,sha256=GryxHSCyGjDmlWOTBSJp7J7ItopwUs0D1JdynrgjYTk,88398
3
- memwal/compatibility.py,sha256=FsKApCcHOFZeIeNOph3YydWVnZtMJdT0KCt0e6jylho,2799
4
- memwal/middleware.py,sha256=z2xdbvaobBg4byJXnrX7amWdVYE4jfeO4t31DoDBv_o,38413
5
- memwal/mock.py,sha256=5bXVej1Xs1a3o1tLL0Vvr1QB9IXyRUTBM7qooTEtysQ,24015
6
- memwal/types.py,sha256=xvs9BqFLXDsfkHp3-8Bd_Pv5RhDDWOKr0-bUsvic1P4,13732
7
- memwal/utils.py,sha256=Dyv-DWIMwivtYLxruHXlNXE9i8hZu8QDv1TapVKMeOQ,12064
8
- memwal-0.2.1.dev0.dist-info/METADATA,sha256=OiHVIIM8Tbpf4Mj0dsKObxQfxZD_1NqshTsKGthp9hE,10346
9
- memwal-0.2.1.dev0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
- memwal-0.2.1.dev0.dist-info/RECORD,,