Skip to content

Commit 8e82476

Browse files
authored
Merge pull request #49 from Indicio-tech/feature/7
Implement a file-based secrets manager
2 parents 5dec651 + a419a62 commit 8e82476

File tree

10 files changed

+345
-7
lines changed

10 files changed

+345
-7
lines changed

.github/workflows/tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,5 @@ jobs:
2222
run: uv sync --all-extras
2323
- name: Run pytest
2424
run: uv run pytest
25+
- name: Run examples
26+
run: uv run pytest examples/
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
11
"""Cryptography and Secrets Management backends."""
2+
3+
from didcomm_messaging.crypto.backend.basic import (
4+
FileBasedSecretsManager,
5+
InMemorySecretsManager,
6+
)
7+
8+
__all__ = ["FileBasedSecretsManager", "InMemorySecretsManager"]

didcomm_messaging/crypto/backend/authlib.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,17 @@ def from_verification_method(cls, vm: VerificationMethod) -> "AuthlibKey":
104104
key = cls.multikey_to_key(multikey)
105105
return cls(key, kid)
106106

107+
if vm.type == "JsonWebKey2020":
108+
jwk = vm.public_key_jwk
109+
if not jwk:
110+
raise ValueError("JWK verification method missing key")
111+
112+
try:
113+
key = JsonWebKey.import_key(jwk)
114+
except Exception as err:
115+
raise ValueError("Invalid JWK") from err
116+
return cls(key, kid)
117+
107118
codec = cls.type_to_codec.get(vm.type)
108119
if not codec:
109120
raise ValueError("Unsupported verification method type: {vm_type}")

didcomm_messaging/crypto/backend/basic.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
"""Basic Crypto Implementations."""
22

3-
from typing import Optional
3+
import atexit
4+
import json
5+
import shutil
6+
from pathlib import Path
7+
from typing import Callable, Dict, Optional
8+
49
from didcomm_messaging.crypto.base import S, SecretsManager
510

611

@@ -18,3 +23,77 @@ async def get_secret_by_kid(self, kid: str) -> Optional[S]:
1823
async def add_secret(self, secret: S) -> None:
1924
"""Add a secret to the secrets manager."""
2025
self.secrets[secret.kid] = secret
26+
27+
28+
class FileBasedSecretsManager(SecretsManager[S]):
29+
"""File-based Secrets Manager with in-memory caching and auto-save.
30+
31+
Secrets are stored in memory for fast access and persisted to a JSONL file.
32+
The file is saved automatically on program exit via atexit, and can also
33+
be flushed explicitly using the flush() method.
34+
35+
Requires serializer and deserializer callbacks to convert between SecretKey
36+
objects and their JSON-serializable representation.
37+
"""
38+
39+
def __init__(
40+
self,
41+
path: str,
42+
serializer: Callable[[S], Dict],
43+
deserializer: Callable[[str, Dict], S],
44+
secrets: Optional[dict] = None,
45+
):
46+
"""Initialize the FileBasedSecretsManager.
47+
48+
Args:
49+
path: Full path to the JSONL file for storing secrets.
50+
serializer: Callback to serialize a SecretKey to a dict.
51+
deserializer: Callback to deserialize a dict to a SecretKey.
52+
Takes (kid, serialized_dict) as arguments.
53+
secrets: Optional initial secrets to load (file takes precedence).
54+
"""
55+
self._path = Path(path)
56+
self._serializer = serializer
57+
self._deserializer = deserializer
58+
self._secrets: Dict[str, S] = secrets or {}
59+
60+
if self._path.exists():
61+
with open(self._path) as f:
62+
for line in f:
63+
line = line.strip()
64+
if not line:
65+
continue
66+
data = json.loads(line)
67+
kid = data.get("kid")
68+
if kid:
69+
self._secrets[kid] = self._deserializer(kid, data)
70+
71+
atexit.register(self._sync)
72+
73+
@property
74+
def path(self) -> str:
75+
"""Return the path to the secrets file."""
76+
return str(self._path)
77+
78+
async def get_secret_by_kid(self, kid: str) -> Optional[S]:
79+
"""Get a secret by its kid."""
80+
return self._secrets.get(kid)
81+
82+
async def add_secret(self, secret: S) -> None:
83+
"""Add a secret to the secrets manager."""
84+
self._secrets[secret.kid] = secret
85+
86+
async def flush(self) -> None:
87+
"""Explicitly save secrets to the file."""
88+
self._sync()
89+
90+
def _sync(self) -> None:
91+
"""Write secrets to file (called on atexit and flush)."""
92+
tmp_path = self._path.with_suffix(".tmp")
93+
self._path.parent.mkdir(parents=True, exist_ok=True)
94+
with open(tmp_path, "w") as f:
95+
for kid, secret in self._secrets.items():
96+
data = self._serializer(secret)
97+
data["kid"] = kid
98+
f.write(json.dumps(data) + "\n")
99+
shutil.move(tmp_path, self._path)

didcomm_messaging/crypto/base.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ async def ecdh_es_encrypt(self, to_keys: Sequence[P], message: bytes) -> bytes:
9191
"""Encode a message into DIDComm v2 anonymous encryption."""
9292

9393
@abstractmethod
94-
async def ecdh_es_decrypt(self, wrapper: Union[str, bytes], recip_key: S) -> bytes:
94+
async def ecdh_es_decrypt(
95+
self, enc_message: Union[str, bytes], recip_key: S
96+
) -> bytes:
9597
"""Decode a message from DIDComm v2 anonymous encryption."""
9698

9799
@abstractmethod
@@ -106,7 +108,7 @@ async def ecdh_1pu_encrypt(
106108
@abstractmethod
107109
async def ecdh_1pu_decrypt(
108110
self,
109-
wrapper: Union[str, bytes],
111+
enc_message: Union[str, bytes],
110112
recip_key: S,
111113
sender_key: P,
112114
) -> bytes:
@@ -121,7 +123,7 @@ def verification_method_to_public_key(cls, vm: VerificationMethod) -> P:
121123
class SecretsManager(ABC, Generic[S]):
122124
"""Secrets Resolver interface.
123125
124-
Thie secrets resolver may be used to supplement the CryptoService backend to provide
126+
The secrets resolver may be used to supplement the CryptoService backend to provide
125127
greater flexibility.
126128
"""
127129

didcomm_messaging/legacy/crypto.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,7 @@ def sign_message_field(field_value: Dict, signer: str, secret: bytes) -> Dict:
160160
)
161161

162162
return {
163-
"@type": "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec"
164-
"/signature/1.0/ed25519Sha512_single",
163+
"@type": "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/signature/1.0/ed25519Sha512_single",
165164
"signer": signer,
166165
"sig_data": sig_data,
167166
"signature": signature,

examples/authlib_file_secrets.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Example of using authlib crypto with file-based secrets storage."""
2+
3+
import asyncio
4+
import json
5+
import tempfile
6+
from pathlib import Path
7+
8+
from authlib.jose import OKPKey
9+
10+
from didcomm_messaging.crypto.backend.authlib import (
11+
AuthlibCryptoService,
12+
AuthlibSecretKey,
13+
)
14+
from didcomm_messaging.crypto.backend.basic import FileBasedSecretsManager
15+
from didcomm_messaging.multiformats.multibase import Base64UrlEncoder
16+
from didcomm_messaging.packaging import PackagingService
17+
from didcomm_messaging.resolver import PrefixResolver
18+
from didcomm_messaging.resolver.jwk import JWKResolver
19+
20+
b64 = Base64UrlEncoder()
21+
22+
23+
def create_jwk_did(jwk: dict) -> str:
24+
"""Create a did:jwk from a JWK dict."""
25+
encoded = b64.encode(json.dumps(jwk).encode())
26+
return f"did:jwk:{encoded}"
27+
28+
29+
async def main():
30+
"""Run the example."""
31+
# Create a temporary file for secrets storage
32+
secrets_file = tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False)
33+
secrets_path = secrets_file.name
34+
secrets_file.close()
35+
36+
# Serializer: Convert AuthlibSecretKey to JWK dict
37+
def serialize_secret(secret: AuthlibSecretKey) -> dict:
38+
return secret.key.as_dict(is_private=True)
39+
40+
# Deserializer: Convert JWK dict back to AuthlibSecretKey
41+
def deserialize_secret(kid: str, data: dict) -> AuthlibSecretKey:
42+
key = OKPKey.import_key(data)
43+
return AuthlibSecretKey(key, kid)
44+
45+
# Create the file-based secrets manager
46+
secrets = FileBasedSecretsManager(secrets_path, serialize_secret, deserialize_secret)
47+
48+
# Generate keys for sender and recipient
49+
# Using X25519 for both since it supports key agreement (encryption)
50+
sender_sk = OKPKey.generate_key("X25519", is_private=True)
51+
recipient_sk = OKPKey.generate_key("X25519", is_private=True)
52+
53+
# Get JWKs and create DIDs
54+
# For 1PU (authenticated encryption), we need key agreement keys
55+
sender_jwk = {**sender_sk.as_dict(), "use": "enc"}
56+
recipient_jwk = {**recipient_sk.as_dict(), "use": "enc"}
57+
58+
sender_did = create_jwk_did(sender_jwk)
59+
recipient_did = create_jwk_did(recipient_jwk)
60+
61+
# Add keys to secrets manager with proper kids
62+
sender_secret = AuthlibSecretKey(sender_sk, f"{sender_did}#0")
63+
recipient_secret = AuthlibSecretKey(recipient_sk, f"{recipient_did}#0")
64+
65+
await secrets.add_secret(sender_secret)
66+
await secrets.add_secret(recipient_secret)
67+
68+
# Set up crypto and resolver
69+
crypto = AuthlibCryptoService()
70+
resolver = PrefixResolver({"did:jwk": JWKResolver()})
71+
packer = PackagingService()
72+
73+
message = b"Hello, secure world!"
74+
75+
# Pack the message using authenticated encryption (ECDH-1PU)
76+
# Requires both sender and recipient to have key agreement keys
77+
packed = await packer.pack(
78+
crypto=crypto,
79+
resolver=resolver,
80+
secrets=secrets,
81+
message=message,
82+
to=[recipient_did],
83+
frm=sender_did,
84+
)
85+
print("Packed message:")
86+
print(json.dumps(json.loads(packed), indent=2))
87+
88+
# Flush secrets to file
89+
await secrets.flush()
90+
91+
# Show the contents of the secrets file
92+
print("\nSecrets file contents:")
93+
with open(secrets_path) as f:
94+
for line in f:
95+
print(line.strip())
96+
97+
# Create a new secrets manager that loads from the file
98+
# This exercises the deserializer
99+
print("\n--- Creating new secrets manager from file ---")
100+
secrets2 = FileBasedSecretsManager(secrets_path, serialize_secret, deserialize_secret)
101+
102+
# Unpack the message using the newly loaded secrets
103+
plaintext, metadata = await packer.unpack(
104+
crypto=crypto,
105+
resolver=resolver,
106+
secrets=secrets2,
107+
enc_message=packed,
108+
)
109+
print("\nUnpacked message:")
110+
print(plaintext)
111+
112+
# Verify the message matches
113+
assert plaintext == message
114+
print("\nSuccess! Round-trip completed with deserialized secrets.")
115+
116+
# Clean up
117+
Path(secrets_path).unlink()
118+
119+
120+
if __name__ == "__main__":
121+
asyncio.run(main())

examples/conftest.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import subprocess
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
7+
def pytest_collect_file(parent, file_path: Path):
8+
"""Collect Python files in examples/ as test items."""
9+
if file_path.suffix == ".py" and file_path.name not in ("conftest.py",):
10+
return ExampleFile.from_parent(parent, path=file_path)
11+
12+
13+
class ExampleFile(pytest.File):
14+
"""pytest collector for example scripts."""
15+
16+
def collect(self):
17+
yield ExampleItem.from_parent(self, name=self.path.stem)
18+
19+
20+
class ExampleItem(pytest.Item):
21+
"""pytest item that runs an example script."""
22+
23+
def runtest(self):
24+
result = subprocess.run(
25+
["python", str(self.path)],
26+
capture_output=True,
27+
text=True,
28+
cwd=self.path.parent,
29+
)
30+
if result.returncode != 0:
31+
raise ExampleFailedError(
32+
f"Example {self.name} failed with code {result.returncode}\n"
33+
f"stdout: {result.stdout}\n"
34+
f"stderr: {result.stderr}"
35+
)
36+
37+
def reportinfo(self):
38+
return self.path, 0, f"Example: {self.path.name}"
39+
40+
41+
class ExampleFailedError(Exception):
42+
"""Raised when an example script fails to run."""
File renamed without changes.

0 commit comments

Comments
 (0)