nxd 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nxd/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ NXD — encrypted agent operating system.
3
+
4
+ Three guarantees:
5
+ 1. The agent works fully
6
+ 2. The agent sees nothing
7
+ 3. The operator holds the keys
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Literal
13
+
14
+ from nxd.audit import export as audit_export
15
+ from nxd.audit import log as audit_log
16
+ from nxd.audit import verify as audit_verify
17
+ from nxd.bind import BindingError, bind, open
18
+ from nxd.blur import blur, blur_dataset, blur_guide, blur_report
19
+ from nxd.channel import Channel, ChannelClosedError, channel
20
+ from nxd.checkpoint import checkpoint
21
+ from nxd.fhe import aggregate, match, score
22
+ from nxd.handoff import Handoff
23
+ from nxd.redact import deredact, redact
24
+ from nxd.search import SearchIndex, build_index, retrieve, search
25
+ from nxd.seal import seal, seal_text, unseal, unseal_text
26
+ from nxd.shield import send_to_model, shield, unshield
27
+ from nxd.sign import sign, sign_record, verify_record, verify_signature
28
+ from nxd.split import InsufficientSharesError, distribute, reconstruct_strict, split
29
+ from nxd.tokenize import detokenize, tokenize, tokenize_record
30
+ from nxd.vault import Vault
31
+ from nxd.verify import LockedError, register, unlock, verify
32
+
33
+ Mode = Literal["auto", "fast", "secure"]
34
+
35
+ __all__ = [
36
+ "score", "match", "aggregate", "receive",
37
+ "Vault", "Handoff",
38
+ "shield", "unshield", "send_to_model",
39
+ "build_index", "search", "retrieve", "SearchIndex",
40
+ "register", "verify", "unlock", "LockedError",
41
+ "tokenize", "detokenize", "tokenize_record",
42
+ "redact", "deredact",
43
+ "seal", "unseal", "seal_text", "unseal_text",
44
+ "channel", "Channel", "ChannelClosedError",
45
+ "checkpoint",
46
+ "sign", "verify_signature", "sign_record", "verify_record",
47
+ "blur", "blur_dataset", "blur_report", "blur_guide",
48
+ "split", "reconstruct_strict", "distribute", "InsufficientSharesError",
49
+ "bind", "open", "BindingError",
50
+ "audit_log", "audit_verify", "audit_export",
51
+ "Mode",
52
+ ]
53
+
54
+
55
+ def receive(model: Any, token: str, handoff: Handoff, mode: Mode = "auto") -> list[int]:
56
+ """Agent B scores encrypted handoff context."""
57
+ context = handoff.unpack(token)
58
+ clients = context if isinstance(context, list) else [context]
59
+ return score(model, clients, mode=mode)
60
+
61
+
62
+ class audit:
63
+ log = staticmethod(audit_log)
64
+ verify = staticmethod(audit_verify)
65
+ export = staticmethod(audit_export)
nxd/_crypto.py ADDED
@@ -0,0 +1,46 @@
1
+ """Shared cryptography utilities for NXD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from cryptography.fernet import Fernet
10
+
11
+ DEFAULT_DATA_DIR = Path.home() / ".nxd"
12
+ MASTER_KEY_FILE = "master.key"
13
+
14
+
15
+ def data_dir() -> Path:
16
+ import os
17
+ path = Path(os.environ.get("NXD_DATA_DIR", DEFAULT_DATA_DIR))
18
+ path.mkdir(parents=True, exist_ok=True)
19
+ return path
20
+
21
+
22
+ def master_key_path() -> Path:
23
+ return data_dir() / MASTER_KEY_FILE
24
+
25
+
26
+ def load_fernet(key_path: Path | None = None) -> Fernet:
27
+ path = key_path or master_key_path()
28
+ if path.exists():
29
+ key = path.read_bytes()
30
+ else:
31
+ key = Fernet.generate_key()
32
+ path.write_bytes(key)
33
+ os.chmod(path, 0o600)
34
+ return Fernet(key)
35
+
36
+
37
+ def load_hmac_key() -> bytes:
38
+ return hashlib.sha256(master_key_path().read_bytes()).digest()
39
+
40
+
41
+ def encrypt_bytes(data: bytes, fernet: Fernet | None = None) -> bytes:
42
+ return (fernet or load_fernet()).encrypt(data)
43
+
44
+
45
+ def decrypt_bytes(token: bytes, fernet: Fernet | None = None) -> bytes:
46
+ return (fernet or load_fernet()).decrypt(token)
nxd/audit.py ADDED
@@ -0,0 +1,86 @@
1
+ """Tamper-proof audit chain for all NXD operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from nxd._crypto import data_dir
12
+
13
+ AUDIT_FILE = "audit_chain.jsonl"
14
+
15
+
16
+ class AuditChain:
17
+ def __init__(self, path: Path | None = None):
18
+ self.path = path or (data_dir() / AUDIT_FILE)
19
+ self.path.parent.mkdir(parents=True, exist_ok=True)
20
+ if not self.path.exists():
21
+ self.path.write_text("")
22
+
23
+ def _last_hash(self) -> str:
24
+ lines = [l for l in self.path.read_text().splitlines() if l.strip()]
25
+ if not lines:
26
+ return "genesis"
27
+ return json.loads(lines[-1])["hash"]
28
+
29
+ def log(self, operation: str, **meta: Any) -> dict:
30
+ prev = self._last_hash()
31
+ entry = {
32
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
33
+ "operation": operation,
34
+ "meta": meta,
35
+ "prev_hash": prev,
36
+ }
37
+ payload = json.dumps(entry, sort_keys=True)
38
+ entry["hash"] = hashlib.sha256((prev + payload).encode()).hexdigest()
39
+ with self.path.open("a") as f:
40
+ f.write(json.dumps(entry) + "\n")
41
+ return entry
42
+
43
+ def entries(self) -> list[dict]:
44
+ return [json.loads(l) for l in self.path.read_text().splitlines() if l.strip()]
45
+
46
+ def verify(self) -> tuple[bool, int | None]:
47
+ lines = self.entries()
48
+ prev = "genesis"
49
+ for i, entry in enumerate(lines):
50
+ stored = entry.pop("hash")
51
+ payload = json.dumps(entry, sort_keys=True)
52
+ expected = hashlib.sha256((prev + payload).encode()).hexdigest()
53
+ if stored != expected:
54
+ entry["hash"] = stored
55
+ return False, i
56
+ prev = stored
57
+ entry["hash"] = stored
58
+ return True, None
59
+
60
+ def export(self, out_path: str | Path) -> Path:
61
+ out = Path(out_path)
62
+ report = {"chain_valid": self.verify()[0], "entries": self.entries()}
63
+ out.write_text(json.dumps(report, indent=2))
64
+ return out
65
+
66
+
67
+ _audit = AuditChain()
68
+
69
+
70
+ def log(operation: str, **meta: Any) -> dict:
71
+ return _audit.log(operation, **meta)
72
+
73
+
74
+ def verify() -> tuple[bool, int | None]:
75
+ return _audit.verify()
76
+
77
+
78
+ def export(out_path: str | Path) -> Path:
79
+ return _audit.export(out_path)
80
+
81
+
82
+ def reset_for_tests(path: Path) -> None:
83
+ global _audit
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ path.write_text("")
86
+ _audit = AuditChain(path)
nxd/bind.py ADDED
@@ -0,0 +1,50 @@
1
+ """Data binding — encrypt locked to specific recipient and purpose."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+
8
+ from cryptography.fernet import Fernet
9
+ from cryptography.hazmat.primitives import hashes
10
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
11
+
12
+ from nxd._crypto import master_key_path
13
+
14
+
15
+ class BindingError(Exception):
16
+ pass
17
+
18
+
19
+ def _derive_key(recipient: str, purpose: str = "") -> Fernet:
20
+ seed = master_key_path().read_bytes()
21
+ derived = HKDF(
22
+ algorithm=hashes.SHA256(),
23
+ length=32,
24
+ salt=b"nxd-bind",
25
+ info=f"{recipient}:{purpose}".encode(),
26
+ ).derive(seed)
27
+ key = base64.urlsafe_b64encode(derived)
28
+ return Fernet(key)
29
+
30
+
31
+ def bind(data: str, recipient: str, purpose: str = "") -> str:
32
+ from nxd import audit
33
+ payload = json.dumps({"data": data, "recipient": recipient, "purpose": purpose})
34
+ token = _derive_key(recipient, purpose).encrypt(payload.encode()).decode()
35
+ audit.log("bind", recipient=recipient, purpose=purpose)
36
+ return token
37
+
38
+
39
+ def open(locked: str, caller: str, purpose: str = "") -> str:
40
+ from nxd import audit
41
+ try:
42
+ payload = json.loads(_derive_key(caller, purpose).decrypt(locked.encode()).decode())
43
+ except Exception as e:
44
+ raise BindingError(f"Access denied for {caller}") from e
45
+ if payload["recipient"] != caller:
46
+ raise BindingError(f"Bound to {payload['recipient']}, not {caller}")
47
+ if purpose and payload.get("purpose") and payload["purpose"] != purpose:
48
+ raise BindingError(f"Bound to purpose '{payload['purpose']}', not '{purpose}'")
49
+ audit.log("open", caller=caller, purpose=purpose)
50
+ return payload["data"]
nxd/blur.py ADDED
@@ -0,0 +1,41 @@
1
+ """Differential privacy — blur aggregates for safe sharing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def blur(value: float, sensitivity: float = 1.0, epsilon: float = 1.0) -> float:
9
+ from nxd import audit
10
+ scale = sensitivity / epsilon
11
+ noise = np.random.laplace(0, scale)
12
+ result = float(value + noise)
13
+ audit.log("blur", epsilon=epsilon)
14
+ return result
15
+
16
+
17
+ def blur_dataset(values: list[float], sensitivity: float = 1.0, epsilon: float = 1.0) -> list[float]:
18
+ from nxd import audit
19
+ result = [blur(v, sensitivity, epsilon) for v in values]
20
+ audit.log("blur_dataset", count=len(values))
21
+ return result
22
+
23
+
24
+ def blur_report(records: list[dict], fields: list[str], epsilon: float = 1.0) -> dict:
25
+ from nxd import audit
26
+ report = {}
27
+ for field in fields:
28
+ vals = [float(r[field]) for r in records if field in r]
29
+ if vals:
30
+ noisy = blur_dataset(vals, sensitivity=max(vals) * 0.1, epsilon=epsilon)
31
+ report[field] = {"mean": float(np.mean(noisy)), "std": float(np.std(noisy))}
32
+ audit.log("blur_report", fields=fields)
33
+ return report
34
+
35
+
36
+ def blur_guide() -> str:
37
+ return (
38
+ "epsilon=0.1: very strong privacy, less accuracy\n"
39
+ "epsilon=1.0: balanced\n"
40
+ "epsilon=10.0: weak privacy, high accuracy"
41
+ )
nxd/channel.py ADDED
@@ -0,0 +1,63 @@
1
+ """Encrypted channels — E2E messaging between agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import time
8
+
9
+ from cryptography.fernet import Fernet
10
+ from cryptography.hazmat.primitives import hashes
11
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
12
+
13
+ from nxd._crypto import master_key_path
14
+
15
+
16
+ class ChannelClosedError(Exception):
17
+ pass
18
+
19
+
20
+ def _session_fernet(sender: str, receiver: str) -> Fernet:
21
+ seed = master_key_path().read_bytes()
22
+ derived = HKDF(
23
+ algorithm=hashes.SHA256(),
24
+ length=32,
25
+ salt=b"nxd-channel",
26
+ info=f"{sender}:{receiver}".encode(),
27
+ ).derive(seed)
28
+ key = base64.urlsafe_b64encode(derived)
29
+ return Fernet(key)
30
+
31
+
32
+ class Channel:
33
+ def __init__(self, sender: str, receiver: str):
34
+ self.sender = sender
35
+ self.receiver = receiver
36
+ self._closed = False
37
+ self._fernet = _session_fernet(sender, receiver)
38
+
39
+ def send(self, message: str) -> str:
40
+ from nxd import audit
41
+ if self._closed:
42
+ raise ChannelClosedError("Channel is closed")
43
+ packet = json.dumps({"ts": time.time(), "msg": message}).encode()
44
+ token = self._fernet.encrypt(packet).decode()
45
+ audit.log("channel_send", sender=self.sender, receiver=self.receiver)
46
+ return token
47
+
48
+ def receive(self, packet: str) -> str:
49
+ from nxd import audit
50
+ if self._closed:
51
+ raise ChannelClosedError("Channel is closed")
52
+ payload = json.loads(self._fernet.decrypt(packet.encode()).decode())
53
+ audit.log("channel_receive", receiver=self.receiver)
54
+ return payload["msg"]
55
+
56
+ def close(self) -> None:
57
+ from nxd import audit
58
+ self._closed = True
59
+ audit.log("channel_close", sender=self.sender, receiver=self.receiver)
60
+
61
+
62
+ def channel(sender: str, receiver: str) -> Channel:
63
+ return Channel(sender, receiver)
nxd/checkpoint.py ADDED
@@ -0,0 +1,75 @@
1
+ """Agent state persistence — encrypted checkpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from nxd._crypto import data_dir, load_fernet
11
+
12
+
13
+ class CheckpointStore:
14
+ def __init__(self, path: Path | None = None):
15
+ self.dir = path or (data_dir() / "checkpoints")
16
+ self.dir.mkdir(parents=True, exist_ok=True)
17
+
18
+ def _file(self, agent_id: str) -> Path:
19
+ return self.dir / f"{agent_id}.nxd"
20
+
21
+ def save(self, agent_id: str, state: dict) -> Path:
22
+ from nxd import audit
23
+ f = load_fernet()
24
+ meta = {"agent_id": agent_id, "timestamp": time.time(), "keys": list(state.keys())}
25
+ payload = gzip.compress(json.dumps({"meta": meta, "state": state}).encode())
26
+ out = self._file(agent_id)
27
+ out.write_bytes(f.encrypt(payload))
28
+ audit.log("checkpoint_save", agent_id=agent_id)
29
+ return out
30
+
31
+ def load(self, agent_id: str) -> dict:
32
+ from nxd import audit
33
+ out = self._file(agent_id)
34
+ if not out.exists():
35
+ raise FileNotFoundError(f"No checkpoint for {agent_id}")
36
+ f = load_fernet()
37
+ payload = gzip.decompress(f.decrypt(out.read_bytes()))
38
+ data = json.loads(payload.decode())
39
+ audit.log("checkpoint_load", agent_id=agent_id)
40
+ return data["state"]
41
+
42
+ def list_checkpoints(self) -> list[str]:
43
+ entries = []
44
+ for p in self.dir.glob("*.nxd"):
45
+ try:
46
+ f = load_fernet()
47
+ payload = gzip.decompress(f.decrypt(p.read_bytes()))
48
+ meta = json.loads(payload.decode())["meta"]
49
+ ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(meta["timestamp"]))
50
+ entries.append(f"{meta['agent_id']} | {ts} | {len(meta['keys'])} keys")
51
+ except Exception:
52
+ entries.append(p.stem)
53
+ return entries
54
+
55
+ def purge(self, agent_id: str) -> None:
56
+ from nxd import audit
57
+ out = self._file(agent_id)
58
+ if out.exists():
59
+ out.unlink()
60
+ audit.log("checkpoint_purge", agent_id=agent_id)
61
+
62
+
63
+ _store = CheckpointStore()
64
+
65
+
66
+ class checkpoint:
67
+ save = staticmethod(lambda agent_id, state: _store.save(agent_id, state))
68
+ load = staticmethod(lambda agent_id: _store.load(agent_id))
69
+ list = staticmethod(lambda: _store.list_checkpoints())
70
+ purge = staticmethod(lambda agent_id: _store.purge(agent_id))
71
+
72
+
73
+ def reset_for_tests(path: Path) -> None:
74
+ global _store
75
+ _store = CheckpointStore(path)
nxd/fhe.py ADDED
@@ -0,0 +1,106 @@
1
+ """FHE compute primitives — score, match, aggregate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import multiprocessing as mp
6
+ import os
7
+ from typing import Any, Literal
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ Mode = Literal["auto", "fast", "secure"]
13
+ PARALLEL_THRESHOLD = 64
14
+ MIN_COMPILE_ROWS = 64
15
+
16
+ _compiled_models: set[int] = set()
17
+ _worker_model: Any = None
18
+ _worker_fhe: str = "execute"
19
+
20
+
21
+ def _to_float64(data: Any) -> np.ndarray:
22
+ if isinstance(data, pd.DataFrame):
23
+ arr = data.values
24
+ elif isinstance(data, np.ndarray):
25
+ arr = data
26
+ elif isinstance(data, dict):
27
+ arr = np.array(list(data.values()), dtype=np.float64)
28
+ elif isinstance(data, list):
29
+ if not data:
30
+ return np.empty((0, 0), dtype=np.float64)
31
+ if isinstance(data[0], dict):
32
+ arr = pd.DataFrame(data).values
33
+ else:
34
+ arr = np.array(data, dtype=np.float64)
35
+ else:
36
+ arr = np.asarray(data, dtype=np.float64)
37
+ return np.asarray(arr, dtype=np.float64)
38
+
39
+
40
+ def _compile_set(X: np.ndarray) -> np.ndarray:
41
+ if len(X) >= MIN_COMPILE_ROWS:
42
+ return X
43
+ repeats = (MIN_COMPILE_ROWS // max(len(X), 1)) + 1
44
+ return np.tile(X, (repeats, 1))[:MIN_COMPILE_ROWS]
45
+
46
+
47
+ def _resolve_fhe(mode: Mode) -> str:
48
+ return "simulate" if mode == "fast" else "execute"
49
+
50
+
51
+ def _ensure_compiled(model: Any, X: np.ndarray) -> None:
52
+ key = id(model)
53
+ if key not in _compiled_models:
54
+ model.compile(_compile_set(X))
55
+ _compiled_models.add(key)
56
+
57
+
58
+ def _worker_predict(X_batch: np.ndarray) -> np.ndarray:
59
+ return _worker_model.predict(X_batch, fhe=_worker_fhe)
60
+
61
+
62
+ def _predict(model: Any, X: np.ndarray, mode: Mode) -> np.ndarray:
63
+ _ensure_compiled(model, X)
64
+ fhe = _resolve_fhe(mode)
65
+ use_parallel = mode == "auto" and fhe == "execute" and len(X) >= PARALLEL_THRESHOLD
66
+ if use_parallel:
67
+ global _worker_model, _worker_fhe
68
+ _worker_model = model
69
+ _worker_fhe = fhe
70
+ n_workers = min(os.cpu_count() or 8, len(X))
71
+ batches = [b for b in np.array_split(X, n_workers) if len(b) > 0]
72
+ ctx = mp.get_context("fork")
73
+ with ctx.Pool(len(batches)) as pool:
74
+ parts = pool.map(_worker_predict, batches)
75
+ return np.concatenate(parts)
76
+ return model.predict(X, fhe=fhe)
77
+
78
+
79
+ def score(model: Any, clients: Any, mode: Mode = "auto") -> list[int]:
80
+ from nxd import audit
81
+ X = _to_float64(clients)
82
+ if X.ndim == 1:
83
+ X = X.reshape(1, -1)
84
+ result = [int(v) for v in _predict(model, X, mode)]
85
+ audit.log("score", count=len(result), mode=mode)
86
+ return result
87
+
88
+
89
+ def match(model: Any, record_a: Any, record_b: Any) -> bool:
90
+ from nxd import audit
91
+ a = _to_float64(record_a).ravel()
92
+ b = _to_float64(record_b).ravel()
93
+ X = np.concatenate([a, b]).reshape(1, -1)
94
+ result = bool(int(_predict(model, X, mode="secure")[0]) == 1)
95
+ audit.log("match", result=result)
96
+ return result
97
+
98
+
99
+ def aggregate(model: Any, records: Any) -> float:
100
+ from nxd import audit
101
+ X = _to_float64(records)
102
+ if X.ndim == 1:
103
+ X = X.reshape(-1, 1)
104
+ result = float(np.mean(_predict(model, X, mode="auto")))
105
+ audit.log("aggregate", count=len(X))
106
+ return result
nxd/handoff.py ADDED
@@ -0,0 +1,30 @@
1
+ """Encrypted context handoff between agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from cryptography.fernet import Fernet
9
+
10
+
11
+ class Handoff:
12
+ def __init__(self, key: bytes | None = None):
13
+ self._key = key or Fernet.generate_key()
14
+ self._fernet = Fernet(self._key)
15
+
16
+ @property
17
+ def key(self) -> bytes:
18
+ return self._key
19
+
20
+ def pack(self, context: Any) -> str:
21
+ from nxd import audit
22
+ token = self._fernet.encrypt(json.dumps(context).encode()).decode()
23
+ audit.log("handoff_pack")
24
+ return token
25
+
26
+ def unpack(self, token: str) -> Any:
27
+ from nxd import audit
28
+ data = json.loads(self._fernet.decrypt(token.encode()).decode())
29
+ audit.log("handoff_unpack")
30
+ return data
nxd/redact.py ADDED
@@ -0,0 +1,42 @@
1
+ """PII redaction — mask sensitive text before AI models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ _PATTERNS = [
9
+ (r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "EMAIL"),
10
+ (r"\b\d{3}-\d{2}-\d{4}\b", "SSN"),
11
+ (r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b", "CARD"),
12
+ (r"\bsk_live_[A-Za-z0-9]+\b", "APIKEY"),
13
+ (r"\bBearer\s+[A-Za-z0-9._-]+\b", "BEARER"),
14
+ (r"\b\d{2}/\d{2}/\d{4}\b", "DATE"),
15
+ (r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b", "PHONE"),
16
+ (r"\b[A-Z][a-z]+ [A-Z][a-z]+\b", "NAME"),
17
+ ]
18
+
19
+
20
+ def redact(text: str) -> tuple[str, dict[str, str]]:
21
+ from nxd import audit
22
+ mapping: dict[str, str] = {}
23
+ result = text
24
+ counters: dict[str, int] = {}
25
+ for pattern, label in _PATTERNS:
26
+ for match in re.finditer(pattern, result):
27
+ val = match.group()
28
+ counters[label] = counters.get(label, 0) + 1
29
+ placeholder = f"[{label}_{counters[label]}]"
30
+ mapping[placeholder] = val
31
+ result = result.replace(val, placeholder, 1)
32
+ audit.log("redact", placeholders=len(mapping))
33
+ return result, mapping
34
+
35
+
36
+ def deredact(text: str, mapping: dict[str, str]) -> str:
37
+ from nxd import audit
38
+ result = text
39
+ for placeholder, value in mapping.items():
40
+ result = result.replace(placeholder, value)
41
+ audit.log("deredact", restored=len(mapping))
42
+ return result
nxd/seal.py ADDED
@@ -0,0 +1,49 @@
1
+ """Document sealing — encrypt files for storage and transit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from nxd._crypto import load_fernet
10
+
11
+
12
+ def seal(path: str | Path, operator_id: str = "operator") -> Path:
13
+ from nxd import audit
14
+ src = Path(path)
15
+ f = load_fernet()
16
+ header = {"filename": src.name, "timestamp": time.time(), "operator": operator_id}
17
+ payload = json.dumps(header).encode() + b"\n---\n" + src.read_bytes()
18
+ out = src.with_suffix(src.suffix + ".nxd")
19
+ out.write_bytes(f.encrypt(payload))
20
+ audit.log("seal", file=src.name)
21
+ return out
22
+
23
+
24
+ def unseal(path: str | Path, out_dir: str | Path | None = None) -> Path:
25
+ from nxd import audit
26
+ src = Path(path)
27
+ f = load_fernet()
28
+ payload = f.decrypt(src.read_bytes())
29
+ header_raw, data = payload.split(b"\n---\n", 1)
30
+ header = json.loads(header_raw.decode())
31
+ dest_dir = Path(out_dir) if out_dir else src.parent
32
+ out = dest_dir / header["filename"]
33
+ out.write_bytes(data)
34
+ audit.log("unseal", file=header["filename"])
35
+ return out
36
+
37
+
38
+ def seal_text(text: str) -> str:
39
+ from nxd import audit
40
+ token = load_fernet().encrypt(text.encode()).decode()
41
+ audit.log("seal_text", length=len(text))
42
+ return token
43
+
44
+
45
+ def unseal_text(token: str) -> str:
46
+ from nxd import audit
47
+ text = load_fernet().decrypt(token.encode()).decode()
48
+ audit.log("unseal_text", length=len(text))
49
+ return text